diff --git a/examples/next-js-demos/chat-with-your-assistants/.env.example b/examples/next-js-demos/chat-with-your-assistants/.env.example new file mode 100644 index 00000000..fb6ddb4a --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/.env.example @@ -0,0 +1,6 @@ +# Required - OpenAI API key for chat +OPENAI_API_KEY='' + +# Optional - Vercel KV for rate limiting +KV_REST_API_URL='' +KV_REST_API_TOKEN='' \ No newline at end of file diff --git a/examples/next-js-demos/chat-with-your-assistants/.eslintrc.json b/examples/next-js-demos/chat-with-your-assistants/.eslintrc.json new file mode 100644 index 00000000..bffb357a --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/examples/next-js-demos/chat-with-your-assistants/.gitignore b/examples/next-js-demos/chat-with-your-assistants/.gitignore new file mode 100644 index 00000000..fd3dbb57 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/next-js-demos/chat-with-your-assistants/README.md b/examples/next-js-demos/chat-with-your-assistants/README.md new file mode 100644 index 00000000..83296f8c --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/README.md @@ -0,0 +1,47 @@ +# 🤖 AI Chat App - Chat with your assistants + +![Chat with your assistants demo app](app/opengraph-image.png) + +This demo app is a chat app that allows you to chat with different assistants. + +It uses [NLUX](https://docs.nlkit.com/nlux/) for the AIChat component and the communication with the LLM provider. + +## 🌟 Key Features + +- 🤖 Chat with Multiple AI Assistants +- 💾 Persistent Conversations via Local Storage +- 📅 Conversation Sorting by Recent Activity +- 🔍 Search for Conversations +- 🗃️ Conversation history as context + +## 🛠️ Tech Stack + +- 🔥 Next.js: For blazing-fast, SEO-friendly React applications +- 🎨 TailwindCSS: Utility-first CSS framework for rapid UI development +- 🖌️ Shadcn UI: Beautiful, customizable UI components +- 🧠 NLUX: Powerful AI integration for natural language processing +- 🪄 OpenAI: OpenAI LLM provider + +## 🚀 Getting Started + +Install the npm packages + +```bash +npm install +# or +yarn +# or +pnpm install +``` + +Run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. diff --git a/examples/next-js-demos/chat-with-your-assistants/adapter/openai.ts b/examples/next-js-demos/chat-with-your-assistants/adapter/openai.ts new file mode 100644 index 00000000..f00fa9f8 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/adapter/openai.ts @@ -0,0 +1,71 @@ +import { clipChatMessagesUpToNTokens } from "@/lib/conversations"; +import { + ChatAdapter, + ChatAdapterExtras, + ChatItem, + StreamingAdapterObserver, +} from "@nlux/react"; + +const demoProxyServerUrl = "/api/chat"; + +function chatHistoryMessageInSingleString( + chatHistory: (ChatItem | ChatItem)[] +): ChatItem[] { + return chatHistory.map((m) => { + return { + role: m.role, + message: typeof m.message === "string" ? m.message : m.message.join(""), + }; + }); +} + +// Adapter to send query to the server and receive a stream of chunks as response +export const openAiAdapter: () => ChatAdapter = () => ({ + streamText: async ( + prompt: string, + observer: StreamingAdapterObserver, + extras: ChatAdapterExtras + ) => { + const body = { + prompt, + messages: clipChatMessagesUpToNTokens( + chatHistoryMessageInSingleString(extras.conversationHistory || []), + 200 + ).map((m) => ({ role: m.role, content: m.message })), + }; + const response = await fetch(demoProxyServerUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (response.status !== 200) { + observer.error(new Error("Failed to connect to the server")); + return; + } + + if (!response.body) { + return; + } + + // Read a stream of server-sent events + // and feed them to the observer as they are being generated + const reader = response.body.getReader(); + const textDecoder = new TextDecoder(); + + let doneStream = false; + while (!doneStream) { + const { value, done } = await reader.read(); + if (done) { + doneStream = true; + } else { + const content = textDecoder.decode(value); + if (content) { + observer.next(content); + } + } + } + + observer.complete(); + }, +}); diff --git a/examples/next-js-demos/chat-with-your-assistants/app/api/chat/route.ts b/examples/next-js-demos/chat-with-your-assistants/app/api/chat/route.ts new file mode 100644 index 00000000..476a7ccf --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/app/api/chat/route.ts @@ -0,0 +1,53 @@ +import { openai } from "@ai-sdk/openai"; +import { streamText } from "ai"; +import { kv } from "@vercel/kv"; +import { Ratelimit } from "@upstash/ratelimit"; +import { env } from "process"; + +export async function POST(req: Request) { + if (env.KV_REST_API_URL && env.KV_REST_API_TOKEN) { + // Rate limiting + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(50, "1 d"), + }); + const ip = req.headers.get("x-forwarded-for"); + + const { success, limit, reset, remaining } = await ratelimit.limit( + `chat_app_ratelimit_${ip}` + ); + + if (!success) { + return new Response("You have reached your request limit for the day.", { + status: 429, + headers: { + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString(), + }, + }); + } + } + + const { prompt, messages } = await req.json(); + + // API KEY defaults to the OPENAI_API_KEY environment variable. + const result = await streamText({ + model: openai("gpt-3.5-turbo"), + messages: [ + ...messages, + { + role: "user", + content: prompt, + }, + ], + maxTokens: 1000, + + async onFinish({ text, toolCalls, toolResults, usage, finishReason }) { + // implement your own logic here, e.g. for storing messages + // or recording token usage + }, + }); + + return result.toTextStreamResponse(); +} diff --git a/examples/next-js-demos/chat-with-your-assistants/app/favicon.ico b/examples/next-js-demos/chat-with-your-assistants/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/examples/next-js-demos/chat-with-your-assistants/app/favicon.ico differ diff --git a/examples/next-js-demos/chat-with-your-assistants/app/globals.css b/examples/next-js-demos/chat-with-your-assistants/app/globals.css new file mode 100644 index 00000000..df87d48b --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/app/globals.css @@ -0,0 +1,73 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --border: 0 0% 94.8%; + --input: 0 0% 89.8%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --ring: 0 0% 3.9%; + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --border: 0 0% 11.9%; + --input: 0 0% 14.9%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --ring: 0 0% 83.1%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +.nlux-AiChat-root > .nlux-chatRoom-container > .nlux-launchPad-container, +.nlux-AiChat-root > .nlux-chatRoom-container > .nlux-conversation-container { + z-index: 30; +} + +/* .nlux-comp-composer > textarea, +.nlux-comp-composer > textarea:hover { + background-color: transparent; + +} */ + +.nlux-AiChat-root.nlux-theme-nova.nlux-colorScheme-dark.nlux-AiChat-style { + --nlux-ChatRoom--BackgroundColor: transparent; +} diff --git a/examples/next-js-demos/chat-with-your-assistants/app/layout.tsx b/examples/next-js-demos/chat-with-your-assistants/app/layout.tsx new file mode 100644 index 00000000..040794a7 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/app/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { ThemeProvider } from "@/components/theme-provider"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Chat App", + description: "Chat with your assistants", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/examples/next-js-demos/chat-with-your-assistants/app/opengraph-image.png b/examples/next-js-demos/chat-with-your-assistants/app/opengraph-image.png new file mode 100644 index 00000000..35b523eb Binary files /dev/null and b/examples/next-js-demos/chat-with-your-assistants/app/opengraph-image.png differ diff --git a/examples/next-js-demos/chat-with-your-assistants/app/page.tsx b/examples/next-js-demos/chat-with-your-assistants/app/page.tsx new file mode 100644 index 00000000..9eabbc28 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/app/page.tsx @@ -0,0 +1,348 @@ +"use client"; +import { useCallback, useMemo, useState, useEffect } from "react"; +import { useTheme } from "@/components/theme-provider"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Search } from "lucide-react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; + +import { Conversation, conversationsHistory } from "@/data/history"; + +import { + AiChat, + EventsConfig, + MessageReceivedCallback, + MessageSentCallback, +} from "@nlux/react"; +import "@nlux/themes/nova.css"; +import { Separator } from "@/components/ui/separator"; +import { formatDate } from "@/lib/utils"; +import { GithubIcon } from "@/components/github-icon"; +import { Input } from "@/components/ui/input"; +import { produce } from "immer"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import Link from "next/link"; +import { LastMessageSummary } from "@/components/last-message-summary"; +import { + getConversationLastTimestamp, + parsedToObjects, + sortConversationsByLastMessageDate, +} from "@/lib/conversations"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { SimpleAvatar } from "@/components/simple-avatar"; +import { UserSettingsForm } from "@/components/user-settings"; +import useLocalStorage from "@/lib/localstorage"; +import { openAiAdapter } from "@/adapter/openai"; + +const initialData = { + conversations: conversationsHistory, + imageUrl: "https://github.com/nlkitai.png", +}; +function App() { + const { theme } = useTheme(); + const [query, setQuery] = useState(""); + const [currentConversationId, setCurrentConversationId] = + useState(""); + + const [storage, setStorage] = useLocalStorage({ + key: "chat_app", // TODO: Change name + defaultValue: initialData, + }); + const [conversations, setConversations] = useState(() => []); + const [userImgUrl, setUserImgUrl] = useState(""); + + useEffect(() => { + // To prevent mismatch with pre-render, first show no conversations and then load them + setConversations(parsedToObjects(storage.conversations)); + setUserImgUrl(storage.imageUrl); + }, []); + + // Persist conversations to local storage with debounce + useEffect(() => { + const timer = setTimeout(() => { + setStorage({ + conversations: conversations, + imageUrl: userImgUrl, + }); + }, 1000); + return () => clearTimeout(timer); + }, [conversations, userImgUrl]); + + const userName = useMemo( + () => conversations[0]?.personas.user?.name, + [conversations] + ); + + // Sort conversations by last message date + const sortedConversations = useMemo( + () => sortConversationsByLastMessageDate(conversations), + [conversations] + ); + + const currentConversation = useMemo( + () => + conversations.find( + (conversation) => conversation.id === currentConversationId + ), + [conversations, currentConversationId] + ); + + const filteredConversations = useMemo( + () => + sortedConversations.filter((conversation) => + conversation.personas.assistant?.name + .toLowerCase() + .includes(query.toLowerCase()) + ), + [sortedConversations, query] + ); + + const finalConversations = filteredConversations; + + const messageReceivedCallback = useCallback< + MessageReceivedCallback + >( + (eventDetails) => { + setConversations( + produce((draft) => { + const conversation = draft.find( + (conversation) => conversation.id === currentConversationId + ); + if (conversation) { + conversation.chat?.push({ + message: eventDetails.message.join(""), + timestamp: new Date(), + role: "assistant", + }); + } + }) + ); + }, + [currentConversationId, conversations, setConversations] + ); + + const messageSentCallback = useCallback( + (eventDetails) => { + setConversations( + produce((draft) => { + const conversation = draft.find( + (conversation) => conversation.id === currentConversationId + ); + if (conversation) { + conversation.chat?.push({ + message: eventDetails.message, + timestamp: new Date(), + role: "user", + }); + } + }) + ); + }, + [currentConversationId, conversations, setConversations] + ); + + const initialConversation = useMemo(() => { + const chatClone = currentConversation?.chat?.slice() || []; + return chatClone; + }, [currentConversationId]); + + const eventCallbacks: EventsConfig = { + // @ts-expect-error lib type error + messageReceived: messageReceivedCallback, + messageSent: messageSentCallback, + }; + + return ( +
+
+
+
+ + + + + + + User Settings + + { + setUserImgUrl(data.image_url); + // Update user name in conversations + setConversations( + produce((draft) => { + draft.forEach((conversation) => { + if (conversation && conversation.personas.user) { + conversation.personas.user.name = data.username; + } + }); + }) + ); + }} + /> + + + + + + +
+ {/* Height is calculated as screen height - header - footer */} + +
+ + setQuery(e.target.value)} + value={query} + /> +
+ +
+ +
+
+
+
+
+ {currentConversation ? ( +
+ + +
+

+ {currentConversation.personas.assistant?.name} +

+

+ {currentConversation.personas.assistant?.tagline} +

+
+
+ ) : null} +
+
+ + + + +
+
+ {currentConversation ? ( +
+ +
+ ) : ( +
+

+ Chat with your assistants +

+

+ This demo uses{" "} + + NLUX + + , a conversational AI library +

+
+ )} +
+
+ ); +} + +export default App; diff --git a/examples/next-js-demos/chat-with-your-assistants/components.json b/examples/next-js-demos/chat-with-your-assistants/components.json new file mode 100644 index 00000000..63bdcbe8 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/examples/next-js-demos/chat-with-your-assistants/components/github-icon.tsx b/examples/next-js-demos/chat-with-your-assistants/components/github-icon.tsx new file mode 100644 index 00000000..7b59d658 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components/github-icon.tsx @@ -0,0 +1,21 @@ +import { LucideProps } from "lucide-react"; + +export function GithubIcon({ ...props }: LucideProps) { + return ( + + ); +} diff --git a/examples/next-js-demos/chat-with-your-assistants/components/last-message-summary.tsx b/examples/next-js-demos/chat-with-your-assistants/components/last-message-summary.tsx new file mode 100644 index 00000000..e2b54870 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components/last-message-summary.tsx @@ -0,0 +1,16 @@ +"use client"; +export function LastMessageSummary({ + lastMessage, + maxLength = 34, +}: { + lastMessage: string; + maxLength?: number; +}) { + return ( +

+ {lastMessage.length > maxLength + ? lastMessage.slice(0, maxLength - 3) + "..." + : lastMessage} +

+ ); +} diff --git a/examples/next-js-demos/chat-with-your-assistants/components/simple-avatar.tsx b/examples/next-js-demos/chat-with-your-assistants/components/simple-avatar.tsx new file mode 100644 index 00000000..7a661caf --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components/simple-avatar.tsx @@ -0,0 +1,26 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +export function SimpleAvatar({ + avatar, + name, + className, +}: { + avatar: string; + name: string; + className?: string; +}) { + return ( + + + + + {!name + ? "" + : name + .split(" ") + .slice(0, 2) + .reduce((a, b) => a + b[0], "")} + + + ); +} diff --git a/examples/next-js-demos/chat-with-your-assistants/components/theme-provider.tsx b/examples/next-js-demos/chat-with-your-assistants/components/theme-provider.tsx new file mode 100644 index 00000000..af7a2323 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components/theme-provider.tsx @@ -0,0 +1,72 @@ +'use client' +import { createContext, useContext, useEffect, useState } from "react"; + +type Theme = "dark" | "light" | "auto"; + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; + storageKey?: string; +}; + +type ThemeProviderState = { + theme: Theme; + setTheme: (theme: Theme) => void; +}; + +const initialState: ThemeProviderState = { + theme: "auto", + setTheme: () => null, +}; + +const ThemeProviderContext = createContext(initialState); + +export function ThemeProvider({ + children, + defaultTheme = "auto", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => defaultTheme + ); + + useEffect(() => { + const root = window.document.documentElement; + + root.classList.remove("light", "dark"); + + if (theme === "auto") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + + root.classList.add(systemTheme); + return; + } + + root.classList.add(theme); + }, [theme]); + + const value = { + theme, + setTheme: (theme: Theme) => { + setTheme(theme); + }, + }; + + return ( + + {children} + + ); +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext); + + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider"); + + return context; +}; diff --git a/examples/next-js-demos/chat-with-your-assistants/components/theme-toggle.tsx b/examples/next-js-demos/chat-with-your-assistants/components/theme-toggle.tsx new file mode 100644 index 00000000..da193873 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components/theme-toggle.tsx @@ -0,0 +1,38 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Check, Search, Moon, Sun, Monitor } from "lucide-react"; +import { useTheme } from "@/components/theme-provider"; + +export function ThemeToggle() { + const { setTheme } = useTheme(); + return ( + + + + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("auto")}> + + System + + + + ); +} diff --git a/examples/next-js-demos/chat-with-your-assistants/components/ui/avatar.tsx b/examples/next-js-demos/chat-with-your-assistants/components/ui/avatar.tsx new file mode 100644 index 00000000..222f6ca8 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/examples/next-js-demos/chat-with-your-assistants/components/ui/badge.tsx b/examples/next-js-demos/chat-with-your-assistants/components/ui/badge.tsx new file mode 100644 index 00000000..e87d62bf --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/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-md 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 shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow 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/examples/next-js-demos/chat-with-your-assistants/components/ui/button.tsx b/examples/next-js-demos/chat-with-your-assistants/components/ui/button.tsx new file mode 100644 index 00000000..0270f644 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/examples/next-js-demos/chat-with-your-assistants/components/ui/card.tsx b/examples/next-js-demos/chat-with-your-assistants/components/ui/card.tsx new file mode 100644 index 00000000..77e9fb78 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/examples/next-js-demos/chat-with-your-assistants/components/ui/dropdown-menu.tsx b/examples/next-js-demos/chat-with-your-assistants/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..0e4dccfd --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components/ui/dropdown-menu.tsx @@ -0,0 +1,203 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/examples/next-js-demos/chat-with-your-assistants/components/ui/form.tsx b/examples/next-js-demos/chat-with-your-assistants/components/ui/form.tsx new file mode 100644 index 00000000..f6afdaf2 --- /dev/null +++ b/examples/next-js-demos/chat-with-your-assistants/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +