``` ├── .github/ ├── FUNDING.yml ├── .gitignore (100 tokens) ├── LICENSE (omitted) ├── README.md (300 tokens) ├── app/ ├── api/ ├── chat/ ├── route.ts (600 tokens) ├── completion/ ├── route.ts (300 tokens) ├── favicon.ico ├── globals.css (1300 tokens) ├── layout.tsx (200 tokens) ├── static-app-shell/ ├── page.tsx ├── components.json (100 tokens) ├── eslint.config.mjs (100 tokens) ├── frontend/ ├── ChatLayout.tsx (100 tokens) ├── app.tsx (100 tokens) ├── components/ ├── APIKeyForm.tsx (800 tokens) ├── Chat.tsx (700 tokens) ├── ChatInput.tsx (1600 tokens) ├── ChatNavigator.tsx (500 tokens) ├── ChatSidebar.tsx (800 tokens) ├── Error.tsx (100 tokens) ├── KeyPrompt.tsx (200 tokens) ├── MemoizedMarkdown.tsx (800 tokens) ├── Message.tsx (700 tokens) ├── MessageControls.tsx (500 tokens) ├── MessageEditor.tsx (600 tokens) ├── MessageReasoning.tsx (200 tokens) ├── Messages.tsx (300 tokens) ├── ui/ ├── MessageLoading.tsx (200 tokens) ├── ThemeProvider.tsx (100 tokens) ├── ThemeToggler.tsx (200 tokens) ├── badge.tsx (300 tokens) ├── button.tsx (500 tokens) ├── card.tsx (400 tokens) ├── dialog.tsx (800 tokens) ├── dropdown-menu.tsx (1700 tokens) ├── icons.tsx (100 tokens) ├── input.tsx (200 tokens) ├── separator.tsx (100 tokens) ├── sheet.tsx (800 tokens) ├── sidebar.tsx (4.4k tokens) ├── skeleton.tsx (100 tokens) ├── sonner.tsx (100 tokens) ├── textarea.tsx (200 tokens) ├── tooltip.tsx (400 tokens) ├── dexie/ ├── db.ts (200 tokens) ├── queries.ts (600 tokens) ├── hooks/ ├── useChatNavigator.ts (200 tokens) ├── useMessageSummary.ts (300 tokens) ├── routes/ ├── Home.tsx (200 tokens) ├── Index.tsx ├── Settings.tsx (200 tokens) ├── Thread.tsx (200 tokens) ├── stores/ ├── APIKeyStore.ts (300 tokens) ├── ModelStore.ts (300 tokens) ├── hooks/ ├── use-mobile.ts (100 tokens) ├── useAutoResizeTextArea.ts (200 tokens) ├── lib/ ├── models.ts (300 tokens) ├── utils.ts ├── next.config.ts ├── open-next.config.ts ├── package.json (400 tokens) ├── pnpm-lock.yaml (omitted) ├── postcss.config.mjs ├── public/ ├── file.svg (100 tokens) ├── globe.svg (200 tokens) ├── next.svg (300 tokens) ├── vercel.svg ├── window.svg (100 tokens) ├── todos.md (100 tokens) ├── tsconfig.json (100 tokens) ├── wrangler.jsonc ``` ## /.github/FUNDING.yml ```yml path="/.github/FUNDING.yml" github: [senbo1] ``` ## /.gitignore ```gitignore path="/.gitignore" # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) .env* # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts #cloudflare .open-next ``` ## /README.md # Chat0 Blazingly-Fast, Open-source, and Free AI Chat App. ## Features - Open Source - Privacy first (all data stored locally in your browser) - Bring your own API keys (we don't store them) - Chat Navigator - Easily Navigate to any message in the chat - Multi-model support - Google Gemini, OpenAI, DeepSeek and more will be added soon - Optimized React codebase (No Unnecessary re-renders) ## 🤝 Contributing Contributions are welcome! Please feel free to submit a Pull Request. 1. Fork the project 2. Create your feature branch 3. Commit your changes 4. Push to the branch 5. Open a Pull Request ## 💻 Running Locally To run Chat0 locally, you'll need to follow these steps: 1. **Clone the repository:** ```bash git clone https://github.com/senbo1/chat0.git cd chat0 ``` 2. **Install dependencies:** We use `pnpm` for package management. ```bash pnpm install ``` 3. **Run the development server:** ```bash pnpm dev ``` 4. **Open your browser:** Navigate to [http://localhost:3000](http://localhost:3000) to see the application in action. ## 🐛 Issues & Support If you encounter any issues or have questions, please [open an issue](https://github.com/senbo1/chat0/issues) on GitHub. ## 💰 Buy me a coffee - [coff.ee/senbo](https://coff.ee/senbo) ## /app/api/chat/route.ts ```ts path="/app/api/chat/route.ts" import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { createOpenAI } from '@ai-sdk/openai'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { streamText, smoothStream } from 'ai'; import { headers } from 'next/headers'; import { getModelConfig, AIModel } from '@/lib/models'; import { NextRequest, NextResponse } from 'next/server'; export const maxDuration = 60; export async function POST(req: NextRequest) { try { const { messages, model } = await req.json(); const headersList = await headers(); const modelConfig = getModelConfig(model as AIModel); const apiKey = headersList.get(modelConfig.headerKey) as string; let aiModel; switch (modelConfig.provider) { case 'google': const google = createGoogleGenerativeAI({ apiKey }); aiModel = google(modelConfig.modelId); break; case 'openai': const openai = createOpenAI({ apiKey }); aiModel = openai(modelConfig.modelId); break; case 'openrouter': const openrouter = createOpenRouter({ apiKey }); aiModel = openrouter(modelConfig.modelId); break; default: return new Response( JSON.stringify({ error: 'Unsupported model provider' }), { status: 400, headers: { 'Content-Type': 'application/json' }, } ); } const result = streamText({ model: aiModel, messages, onError: (error) => { console.log('error', error); }, system: ` You are Chat0, an ai assistant that can answer questions and help with tasks. Be helpful and provide relevant information Be respectful and polite in all interactions. Be engaging and maintain a conversational tone. Always use LaTeX for mathematical expressions - Inline math must be wrapped in single dollar signs: $content$ Display math must be wrapped in double dollar signs: $content$ Display math should be placed on its own line, with nothing else on that line. Do not nest math delimiters or mix styles. Examples: - Inline: The equation $E = mc^2$ shows mass-energy equivalence. - Display: $\\frac{d}{dx}\\sin(x) = \\cos(x){{contextString}}#36; `, experimental_transform: [smoothStream({ chunking: 'word' })], abortSignal: req.signal, }); return result.toDataStreamResponse({ sendReasoning: true, getErrorMessage: (error) => { return (error as { message: string }).message; }, }); } catch (error) { console.log('error', error); return new NextResponse( JSON.stringify({ error: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, } ); } } ``` ## /app/api/completion/route.ts ```ts path="/app/api/completion/route.ts" import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { generateText } from 'ai'; import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; export async function POST(req: Request) { const headersList = await headers(); const googleApiKey = headersList.get('X-Google-API-Key'); if (!googleApiKey) { return NextResponse.json( { error: 'Google API key is required to enable chat title generation.', }, { status: 400 } ); } const google = createGoogleGenerativeAI({ apiKey: googleApiKey, }); const { prompt, isTitle, messageId, threadId } = await req.json(); try { const { text: title } = await generateText({ model: google('gemini-2.5-flash-preview-04-17'), system: `\n - you will generate a short title based on the first message a user begins a conversation with - ensure it is not more than 80 characters long - the title should be a summary of the user's message - you should NOT answer the user's message, you should only generate a summary/title - do not use quotes or colons`, prompt, }); return NextResponse.json({ title, isTitle, messageId, threadId }); } catch (error) { console.error('Failed to generate title:', error); return NextResponse.json( { error: 'Failed to generate title' }, { status: 500 } ); } } ``` ## /app/favicon.ico Binary file available at https://raw.githubusercontent.com/senbo1/chat0/refs/heads/main/app/favicon.ico ## /app/globals.css ```css path="/app/globals.css" @import 'tailwindcss'; @import 'tw-animate-css'; @plugin '@tailwindcss/typography'; @plugin 'tailwind-scrollbar'; @custom-variant dark (&:is(.dark *)); :root { --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(1 0 0); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --chart-1: oklch(0.81 0.1 252); --chart-2: oklch(0.62 0.19 260); --chart-3: oklch(0.55 0.22 263); --chart-4: oklch(0.49 0.22 264); --chart-5: oklch(0.42 0.18 266); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); --font-sans: Geist, sans-serif; --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; --font-mono: Geist Mono, monospace; --radius: 0.625rem; --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); } .dark { --background: oklch(0.2178 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.269 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.371 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); --border: oklch(0.275 0 0); --input: oklch(0.325 0 0); --ring: oklch(0.556 0 0); --chart-1: oklch(0.81 0.1 252); --chart-2: oklch(0.62 0.19 260); --chart-3: oklch(0.55 0.22 263); --chart-4: oklch(0.49 0.22 264); --chart-5: oklch(0.42 0.18 266); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(0.275 0 0); --sidebar-ring: oklch(0.439 0 0); --font-sans: Geist, sans-serif; --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif; --font-mono: Geist Mono, monospace; --radius: 0.625rem; --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1); --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1); --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1); --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1); --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); --font-sans: var(--font-sans); --font-mono: var(--font-mono); --font-serif: var(--font-serif); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --shadow-2xs: var(--shadow-2xs); --shadow-xs: var(--shadow-xs); --shadow-sm: var(--shadow-sm); --shadow: var(--shadow); --shadow-md: var(--shadow-md); --shadow-lg: var(--shadow-lg); --shadow-xl: var(--shadow-xl); --shadow-2xl: var(--shadow-2xl); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } @layer utilities { .no-scrollbar::-webkit-scrollbar { display: none; } .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } } .shiki { @apply !rounded-none; } ``` ## /app/layout.tsx ```tsx path="/app/layout.tsx" import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; import 'katex/dist/katex.min.css'; import { Toaster } from '@/frontend/components/ui/sonner'; import { ThemeProvider } from '@/frontend/components/ui/ThemeProvider'; const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'], }); const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'], }); export const metadata: Metadata = { title: 'Chat0', description: 'Fastest AI Chat App', }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en" suppressHydrationWarning> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`} > <ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {children} <Toaster richColors position="top-right" /> </ThemeProvider> </body> </html> ); } ``` ## /app/static-app-shell/page.tsx ```tsx path="/app/static-app-shell/page.tsx" 'use client'; import dynamic from 'next/dynamic'; const App = dynamic(() => import('@/frontend/app'), { ssr: false }); export default function Home() { return <App />; } ``` ## /components.json ```json path="/components.json" { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ``` ## /eslint.config.mjs ```mjs path="/eslint.config.mjs" import { dirname } from "path"; import { fileURLToPath } from "url"; import { FlatCompat } from "@eslint/eslintrc"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname, }); const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), ]; export default eslintConfig; ``` ## /frontend/ChatLayout.tsx ```tsx path="/frontend/ChatLayout.tsx" import { SidebarProvider } from '@/frontend/components/ui/sidebar'; import ChatSidebar from '@/frontend/components/ChatSidebar'; import { Outlet } from 'react-router'; export default function ChatLayout() { return ( <SidebarProvider> <ChatSidebar /> <div className="flex-1 relative"> <Outlet /> </div> </SidebarProvider> ); } ``` ## /frontend/app.tsx ```tsx path="/frontend/app.tsx" import { BrowserRouter, Route, Routes } from 'react-router'; import ChatLayout from './ChatLayout'; import Home from './routes/Home'; import Index from './routes/Index'; import Thread from './routes/Thread'; import Settings from './routes/Settings'; export default function App() { return ( <BrowserRouter> <Routes> <Route path="/" element={<Index />} /> <Route path="chat" element={<ChatLayout />}> <Route index element={<Home />} /> <Route path=":id" element={<Thread />} /> </Route> <Route path="settings" element={<Settings />} /> <Route path="*" element={<p> Not found </p>} /> </Routes> </BrowserRouter> ); } ``` ## /frontend/components/APIKeyForm.tsx ```tsx path="/frontend/components/APIKeyForm.tsx" import React, { useCallback, useEffect } from 'react'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { FieldError, useForm, UseFormRegister } from 'react-hook-form'; import { Button } from '@/frontend/components/ui/button'; import { Input } from '@/frontend/components/ui/input'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/frontend/components/ui/card'; import { Key } from 'lucide-react'; import { toast } from 'sonner'; import { useAPIKeyStore } from '@/frontend/stores/APIKeyStore'; import { Badge } from './ui/badge'; const formSchema = z.object({ google: z.string().trim().min(1, { message: 'Google API key is required for Title Generation', }), openrouter: z.string().trim().optional(), openai: z.string().trim().optional(), }); type FormValues = z.infer<typeof formSchema>; export default function APIKeyForm() { return ( <Card className="w-full max-w-2xl mx-auto"> <CardHeader> <div className="flex items-center gap-2"> <Key className="h-5 w-5" /> <CardTitle>Add Your API Keys To Start Chatting</CardTitle> </div> <CardDescription> Keys are stored locally in your browser. </CardDescription> </CardHeader> <CardContent className="space-y-6"> <Form /> </CardContent> </Card> ); } const Form = () => { const { keys, setKeys } = useAPIKeyStore(); const { register, handleSubmit, formState: { errors, isDirty }, reset, } = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: keys, }); useEffect(() => { reset(keys); }, [keys, reset]); const onSubmit = useCallback( (values: FormValues) => { setKeys(values); toast.success('API keys saved successfully'); }, [setKeys] ); return ( <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <ApiKeyField id="google" label="Google API Key" models={['Gemini 2.5 Flash', 'Gemini 2.5 Pro']} linkUrl="https://aistudio.google.com/apikey" placeholder="AIza..." register={register} error={errors.google} required /> <ApiKeyField id="openrouter" label="OpenRouter API Key" models={['DeepSeek R1 0538', 'DeepSeek-V3']} linkUrl="https://openrouter.ai/settings/keys" placeholder="sk-or-..." register={register} error={errors.openrouter} /> <ApiKeyField id="openai" label="OpenAI API Key" models={['GPT-4o', 'GPT-4.1-mini']} linkUrl="https://platform.openai.com/settings/organization/api-keys" placeholder="sk-..." register={register} error={errors.openai} /> <Button type="submit" className="w-full" disabled={!isDirty}> Save API Keys </Button> </form> ); }; interface ApiKeyFieldProps { id: string; label: string; linkUrl: string; models: string[]; placeholder: string; error?: FieldError | undefined; required?: boolean; register: UseFormRegister<FormValues>; } const ApiKeyField = ({ id, label, linkUrl, placeholder, models, error, required, register, }: ApiKeyFieldProps) => ( <div className="flex flex-col gap-2"> <label htmlFor={id} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex gap-1" > <span>{label}</span> {required && <span className="text-muted-foreground"> (Required)</span>} </label> <div className="flex gap-2"> {models.map((model) => ( <Badge key={model}>{model}</Badge> ))} </div> <Input id={id} placeholder={placeholder} {...register(id as keyof FormValues)} className={error ? 'border-red-500' : ''} /> <a href={linkUrl} target="_blank" className="text-sm text-blue-500 inline w-fit" > Create {label.split(' ')[0]} API Key </a> {error && ( <p className="text-[0.8rem] font-medium text-red-500">{error.message}</p> )} </div> ); ``` ## /frontend/components/Chat.tsx ```tsx path="/frontend/components/Chat.tsx" import { useChat } from '@ai-sdk/react'; import Messages from './Messages'; import ChatInput from './ChatInput'; import ChatNavigator from './ChatNavigator'; import { UIMessage } from 'ai'; import { v4 as uuidv4 } from 'uuid'; import { createMessage } from '@/frontend/dexie/queries'; import { useAPIKeyStore } from '@/frontend/stores/APIKeyStore'; import { useModelStore } from '@/frontend/stores/ModelStore'; import ThemeToggler from './ui/ThemeToggler'; import { SidebarTrigger, useSidebar } from './ui/sidebar'; import { Button } from './ui/button'; import { MessageSquareMore } from 'lucide-react'; import { useChatNavigator } from '@/frontend/hooks/useChatNavigator'; interface ChatProps { threadId: string; initialMessages: UIMessage[]; } export default function Chat({ threadId, initialMessages }: ChatProps) { const { getKey } = useAPIKeyStore(); const selectedModel = useModelStore((state) => state.selectedModel); const modelConfig = useModelStore((state) => state.getModelConfig()); const { isNavigatorVisible, handleToggleNavigator, closeNavigator, registerRef, scrollToMessage, } = useChatNavigator(); const { messages, input, status, setInput, setMessages, append, stop, reload, error, } = useChat({ id: threadId, initialMessages, experimental_throttle: 50, onFinish: async ({ parts }) => { const aiMessage: UIMessage = { id: uuidv4(), parts: parts as UIMessage['parts'], role: 'assistant', content: '', createdAt: new Date(), }; try { await createMessage(threadId, aiMessage); } catch (error) { console.error(error); } }, headers: { [modelConfig.headerKey]: getKey(modelConfig.provider) || '', }, body: { model: selectedModel, }, }); return ( <div className="relative w-full"> <ChatSidebarTrigger /> <main className={`flex flex-col w-full max-w-3xl pt-10 pb-44 mx-auto transition-all duration-300 ease-in-out`} > <Messages threadId={threadId} messages={messages} status={status} setMessages={setMessages} reload={reload} error={error} registerRef={registerRef} stop={stop} /> <ChatInput threadId={threadId} input={input} status={status} append={append} setInput={setInput} stop={stop} /> </main> <ThemeToggler /> <Button onClick={handleToggleNavigator} variant="outline" size="icon" className="fixed right-16 top-4 z-20" aria-label={ isNavigatorVisible ? 'Hide message navigator' : 'Show message navigator' } > <MessageSquareMore className="h-5 w-5" /> </Button> <ChatNavigator threadId={threadId} scrollToMessage={scrollToMessage} isVisible={isNavigatorVisible} onClose={closeNavigator} /> </div> ); } const ChatSidebarTrigger = () => { const { state } = useSidebar(); if (state === 'collapsed') { return <SidebarTrigger className="fixed left-4 top-4 z-100" />; } return null; }; ``` ## /frontend/components/ChatInput.tsx ```tsx path="/frontend/components/ChatInput.tsx" import { ChevronDown, Check, ArrowUpIcon } from 'lucide-react'; import { memo, useCallback, useMemo } from 'react'; import { Textarea } from '@/frontend/components/ui/textarea'; import { cn } from '@/lib/utils'; import { Button } from '@/frontend/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/frontend/components/ui/dropdown-menu'; import useAutoResizeTextarea from '@/hooks/useAutoResizeTextArea'; import { UseChatHelpers, useCompletion } from '@ai-sdk/react'; import { useParams } from 'react-router'; import { useNavigate } from 'react-router'; import { createMessage, createThread } from '@/frontend/dexie/queries'; import { useAPIKeyStore } from '@/frontend/stores/APIKeyStore'; import { useModelStore } from '@/frontend/stores/ModelStore'; import { AI_MODELS, AIModel, getModelConfig } from '@/lib/models'; import KeyPrompt from '@/frontend/components/KeyPrompt'; import { UIMessage } from 'ai'; import { v4 as uuidv4 } from 'uuid'; import { StopIcon } from './ui/icons'; import { toast } from 'sonner'; import { useMessageSummary } from '../hooks/useMessageSummary'; interface ChatInputProps { threadId: string; input: UseChatHelpers['input']; status: UseChatHelpers['status']; setInput: UseChatHelpers['setInput']; append: UseChatHelpers['append']; stop: UseChatHelpers['stop']; } interface StopButtonProps { stop: UseChatHelpers['stop']; } interface SendButtonProps { onSubmit: () => void; disabled: boolean; } const createUserMessage = (id: string, text: string): UIMessage => ({ id, parts: [{ type: 'text', text }], role: 'user', content: text, createdAt: new Date(), }); function PureChatInput({ threadId, input, status, setInput, append, stop, }: ChatInputProps) { const canChat = useAPIKeyStore((state) => state.hasRequiredKeys()); const { textareaRef, adjustHeight } = useAutoResizeTextarea({ minHeight: 72, maxHeight: 200, }); const navigate = useNavigate(); const { id } = useParams(); const isDisabled = useMemo( () => !input.trim() || status === 'streaming' || status === 'submitted', [input, status] ); const { complete } = useMessageSummary(); const handleSubmit = useCallback(async () => { const currentInput = textareaRef.current?.value || input; if ( !currentInput.trim() || status === 'streaming' || status === 'submitted' ) return; const messageId = uuidv4(); if (!id) { navigate(`/chat/${threadId}`); await createThread(threadId); complete(currentInput.trim(), { body: { threadId, messageId, isTitle: true }, }); } else { complete(currentInput.trim(), { body: { messageId, threadId } }); } const userMessage = createUserMessage(messageId, currentInput.trim()); await createMessage(threadId, userMessage); append(userMessage); setInput(''); adjustHeight(true); }, [ input, status, setInput, adjustHeight, append, id, textareaRef, threadId, complete, ]); if (!canChat) { return <KeyPrompt />; } const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }; const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { setInput(e.target.value); adjustHeight(); }; return ( <div className="fixed bottom-0 w-full max-w-3xl"> <div className="bg-secondary rounded-t-[20px] p-2 pb-0 w-full"> <div className="relative"> <div className="flex flex-col"> <div className="bg-secondary overflow-y-auto max-h-[300px]"> <Textarea id="chat-input" value={input} placeholder="What can I do for you?" className={cn( 'w-full px-4 py-3 border-none shadow-none dark:bg-transparent', 'placeholder:text-muted-foreground resize-none', 'focus-visible:ring-0 focus-visible:ring-offset-0', 'scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/30', 'scrollbar-thumb-rounded-full', 'min-h-[72px]' )} ref={textareaRef} onKeyDown={handleKeyDown} onChange={handleInputChange} aria-label="Chat message input" aria-describedby="chat-input-description" /> <span id="chat-input-description" className="sr-only"> Press Enter to send, Shift+Enter for new line </span> </div> <div className="h-14 flex items-center px-2"> <div className="flex items-center justify-between w-full"> <ChatModelDropdown /> {status === 'submitted' || status === 'streaming' ? ( <StopButton stop={stop} /> ) : ( <SendButton onSubmit={handleSubmit} disabled={isDisabled} /> )} </div> </div> </div> </div> </div> </div> ); } const ChatInput = memo(PureChatInput, (prevProps, nextProps) => { if (prevProps.input !== nextProps.input) return false; if (prevProps.status !== nextProps.status) return false; return true; }); const PureChatModelDropdown = () => { const getKey = useAPIKeyStore((state) => state.getKey); const { selectedModel, setModel } = useModelStore(); const isModelEnabled = useCallback( (model: AIModel) => { const modelConfig = getModelConfig(model); const apiKey = getKey(modelConfig.provider); return !!apiKey; }, [getKey] ); return ( <div className="flex items-center gap-2"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="flex items-center gap-1 h-8 pl-2 pr-2 text-xs rounded-md text-foreground hover:bg-primary/10 focus-visible:ring-1 focus-visible:ring-offset-0 focus-visible:ring-blue-500" aria-label={`Selected model: ${selectedModel}`} > <div className="flex items-center gap-1"> {selectedModel} <ChevronDown className="w-3 h-3 opacity-50" /> </div> </Button> </DropdownMenuTrigger> <DropdownMenuContent className={cn('min-w-[10rem]', 'border-border', 'bg-popover')} > {AI_MODELS.map((model) => { const isEnabled = isModelEnabled(model); return ( <DropdownMenuItem key={model} onSelect={() => isEnabled && setModel(model)} disabled={!isEnabled} className={cn( 'flex items-center justify-between gap-2', 'cursor-pointer' )} > <span>{model}</span> {selectedModel === model && ( <Check className="w-4 h-4 text-blue-500" aria-label="Selected" /> )} </DropdownMenuItem> ); })} </DropdownMenuContent> </DropdownMenu> </div> ); }; const ChatModelDropdown = memo(PureChatModelDropdown); function PureStopButton({ stop }: StopButtonProps) { return ( <Button variant="outline" size="icon" onClick={stop} aria-label="Stop generating response" > <StopIcon size={20} /> </Button> ); } const StopButton = memo(PureStopButton); const PureSendButton = ({ onSubmit, disabled }: SendButtonProps) => { return ( <Button onClick={onSubmit} variant="default" size="icon" disabled={disabled} aria-label="Send message" > <ArrowUpIcon size={18} /> </Button> ); }; const SendButton = memo(PureSendButton, (prevProps, nextProps) => { return prevProps.disabled === nextProps.disabled; }); export default ChatInput; ``` ## /frontend/components/ChatNavigator.tsx ```tsx path="/frontend/components/ChatNavigator.tsx" import { useLiveQuery } from 'dexie-react-hooks'; import { getMessageSummaries } from '@/frontend/dexie/queries'; import { memo } from 'react'; import { X } from 'lucide-react'; import { Button } from './ui/button'; interface MessageNavigatorProps { threadId: string; scrollToMessage: (id: string) => void; isVisible: boolean; onClose: () => void; } function PureChatNavigator({ threadId, scrollToMessage, isVisible, onClose, }: MessageNavigatorProps) { const messageSummaries = useLiveQuery( () => getMessageSummaries(threadId), [threadId] ); return ( <> {isVisible && ( <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={onClose} /> )} <aside className={`fixed right-0 top-0 h-full w-80 bg-background border-l z-50 transform transition-transform duration-300 ease-in-out ${ isVisible ? 'translate-x-0' : 'translate-x-full' }`} > <div className="flex flex-col h-full"> <div className="flex items-center justify-between p-4 border-b"> <h3 className="text-sm font-medium">Chat Navigator</h3> <Button onClick={onClose} variant="ghost" size="icon" className="h-8 w-8" aria-label="Close navigator" > <X className="h-4 w-4" /> </Button> </div> <div className="flex-1 overflow-hidden p-2"> <ul className="flex flex-col gap-2 p-4 prose prose-sm dark:prose-invert list-disc pl-5 h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/30 scrollbar-thumb-rounded-full"> {messageSummaries?.map((summary) => ( <li key={summary.id} onClick={() => { scrollToMessage(summary.messageId); }} className="cursor-pointer hover:text-foreground transition-colors" > {summary.content.slice(0, 100)} </li> ))} </ul> </div> </div> </aside> </> ); } export default memo(PureChatNavigator, (prevProps, nextProps) => { return ( prevProps.threadId === nextProps.threadId && prevProps.isVisible === nextProps.isVisible ); }); ``` ## /frontend/components/ChatSidebar.tsx ```tsx path="/frontend/components/ChatSidebar.tsx" import { Sidebar, SidebarHeader, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarFooter, SidebarTrigger, } from '@/frontend/components/ui/sidebar'; import { Button, buttonVariants } from './ui/button'; import { deleteThread, getThreads } from '@/frontend/dexie/queries'; import { useLiveQuery } from 'dexie-react-hooks'; import { Link, useNavigate, useParams } from 'react-router'; import { X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { memo, useEffect } from 'react'; export default function ChatSidebar() { const { id } = useParams(); const navigate = useNavigate(); const threads = useLiveQuery(() => getThreads(), []); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'o') { e.preventDefault(); navigate('/chat'); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, []); return ( <Sidebar> <div className="flex flex-col h-full p-2"> <Header /> <SidebarContent className="no-scrollbar"> <SidebarGroup> <SidebarGroupContent> <SidebarMenu> {threads?.map((thread) => { return ( <SidebarMenuItem key={thread.id}> <div className={cn( 'cursor-pointer group/thread h-9 flex items-center px-2 py-1 rounded-[8px] overflow-hidden w-full hover:bg-secondary', id === thread.id && 'bg-secondary' )} onClick={() => { if (id === thread.id) { return; } navigate(`/chat/${thread.id}`); }} > <span className="truncate block">{thread.title}</span> <Button variant="ghost" size="icon" className="hidden group-hover/thread:flex ml-auto h-7 w-7" onClick={async (event) => { event.preventDefault(); event.stopPropagation(); await deleteThread(thread.id); navigate(`/chat`); }} > <X size={16} /> </Button> </div> </SidebarMenuItem> ); })} </SidebarMenu> </SidebarGroupContent> </SidebarGroup> </SidebarContent> <Footer /> </div> </Sidebar> ); } function PureHeader() { return ( <SidebarHeader className="flex justify-between items-center gap-4 relative"> <SidebarTrigger className="absolute right-1 top-2.5" /> <h1 className="text-2xl font-bold"> Chat<span className="">0</span> </h1> <Link to="/chat" className={buttonVariants({ variant: 'default', className: 'w-full', })} > New Chat </Link> </SidebarHeader> ); } const Header = memo(PureHeader); const PureFooter = () => { const { id: chatId } = useParams(); return ( <SidebarFooter> <Link to={{ pathname: "/settings", search: chatId ? `?from=${encodeURIComponent(chatId)}` : "", }} className={buttonVariants({ variant: "outline" })} > Settings </Link> </SidebarFooter> ); }; const Footer = memo(PureFooter); ``` ## /frontend/components/Error.tsx ```tsx path="/frontend/components/Error.tsx" import { CircleAlert } from 'lucide-react'; export default function Error({ message }: { message: string }) { return ( <div className="rounded-md border border-red-500/50 px-4 py-3 text-red-600 flex items-center gap-4"> <CircleAlert size={24} aria-hidden="true" /> <p className="text-sm">{message}</p> </div> ); } ``` ## /frontend/components/KeyPrompt.tsx ```tsx path="/frontend/components/KeyPrompt.tsx" import { Button } from '@/frontend/components/ui/button'; import { Key } from 'lucide-react'; import { Link } from 'react-router'; export default function KeyPrompt() { return ( <div className="fixed bottom-6 left-1/2 z-50"> <div className="flex items-center p-4 pr-5 border rounded-lg bg-background shadow-lg gap-4 max-w-md"> <div className="bg-primary/10 p-2.5 rounded-full"> <Key className="h-5 w-5 text-primary" /> </div> <div> <p className="text-sm font-medium">API keys required</p> <p className="text-xs text-muted-foreground"> Add keys to enable chat </p> </div> <Link to="/settings"> <Button size="sm" variant="outline" className="ml-2 h-8 text-xs"> Configure </Button> </Link> </div> </div> ); } ``` ## /frontend/components/MemoizedMarkdown.tsx ```tsx path="/frontend/components/MemoizedMarkdown.tsx" import { memo, useMemo, useState, createContext, useContext } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import { marked } from 'marked'; import ShikiHighlighter from 'react-shiki'; import type { ComponentProps } from 'react'; import type { ExtraProps } from 'react-markdown'; import { Check, Copy } from 'lucide-react'; type CodeComponentProps = ComponentProps<'code'> & ExtraProps; type MarkdownSize = 'default' | 'small'; // Context to pass size down to components const MarkdownSizeContext = createContext<MarkdownSize>('default'); const components: Components = { code: CodeBlock as Components['code'], pre: ({ children }) => <>{children}</>, }; function CodeBlock({ children, className, ...props }: CodeComponentProps) { const size = useContext(MarkdownSizeContext); const match = /language-(\w+)/.exec(className || ''); if (match) { const lang = match[1]; return ( <div className="rounded-none"> <Codebar lang={lang} codeString={String(children)} /> <ShikiHighlighter language={lang} theme={'material-theme-darker'} className="text-sm font-mono rounded-full" showLanguage={false} > {String(children)} </ShikiHighlighter> </div> ); } const inlineCodeClasses = size === 'small' ? 'mx-0.5 overflow-auto rounded-md px-1 py-0.5 bg-primary/10 text-foreground font-mono text-xs' : 'mx-0.5 overflow-auto rounded-md px-2 py-1 bg-primary/10 text-foreground font-mono'; return ( <code className={inlineCodeClasses} {...props}> {children} </code> ); } function Codebar({ lang, codeString }: { lang: string; codeString: string }) { const [copied, setCopied] = useState(false); const copyToClipboard = async () => { try { await navigator.clipboard.writeText(codeString); setCopied(true); setTimeout(() => { setCopied(false); }, 2000); } catch (error) { console.error('Failed to copy code to clipboard:', error); } }; return ( <div className="flex justify-between items-center px-4 py-2 bg-secondary text-foreground rounded-t-md"> <span className="text-sm font-mono">{lang}</span> <button onClick={copyToClipboard} className="text-sm cursor-pointer"> {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} </button> </div> ); } function parseMarkdownIntoBlocks(markdown: string): string[] { const tokens = marked.lexer(markdown); return tokens.map((token) => token.raw); } function PureMarkdownRendererBlock({ content }: { content: string }) { return ( <ReactMarkdown remarkPlugins={[remarkGfm, [remarkMath]]} rehypePlugins={[rehypeKatex]} components={components} > {content} </ReactMarkdown> ); } const MarkdownRendererBlock = memo( PureMarkdownRendererBlock, (prevProps, nextProps) => { if (prevProps.content !== nextProps.content) return false; return true; } ); MarkdownRendererBlock.displayName = 'MarkdownRendererBlock'; const MemoizedMarkdown = memo( ({ content, id, size = 'default', }: { content: string; id: string; size?: MarkdownSize; }) => { const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]); const proseClasses = size === 'small' ? 'prose prose-sm dark:prose-invert bread-words max-w-none w-full prose-code:before:content-none prose-code:after:content-none' : 'prose prose-base dark:prose-invert bread-words max-w-none w-full prose-code:before:content-none prose-code:after:content-none'; return ( <MarkdownSizeContext.Provider value={size}> <div className={proseClasses}> {blocks.map((block, index) => ( <MarkdownRendererBlock content={block} key={`${id}-block-${index}`} /> ))} </div> </MarkdownSizeContext.Provider> ); } ); MemoizedMarkdown.displayName = 'MemoizedMarkdown'; export default MemoizedMarkdown; ``` ## /frontend/components/Message.tsx ```tsx path="/frontend/components/Message.tsx" import { memo, useState } from 'react'; import MarkdownRenderer from '@/frontend/components/MemoizedMarkdown'; import { cn } from '@/lib/utils'; import { UIMessage } from 'ai'; import equal from 'fast-deep-equal'; import MessageControls from './MessageControls'; import { UseChatHelpers } from '@ai-sdk/react'; import MessageEditor from './MessageEditor'; import MessageReasoning from './MessageReasoning'; function PureMessage({ threadId, message, setMessages, reload, isStreaming, registerRef, stop, }: { threadId: string; message: UIMessage; setMessages: UseChatHelpers['setMessages']; reload: UseChatHelpers['reload']; isStreaming: boolean; registerRef: (id: string, ref: HTMLDivElement | null) => void; stop: UseChatHelpers['stop']; }) { const [mode, setMode] = useState<'view' | 'edit'>('view'); return ( <div role="article" className={cn( 'flex flex-col', message.role === 'user' ? 'items-end' : 'items-start' )} > {message.parts.map((part, index) => { const { type } = part; const key = `message-${message.id}-part-${index}`; if (type === 'reasoning') { return ( <MessageReasoning key={key} reasoning={part.reasoning} id={message.id} /> ); } if (type === 'text') { return message.role === 'user' ? ( <div key={key} className="relative group px-4 py-3 rounded-xl bg-secondary border border-secondary-foreground/2 max-w-[80%]" ref={(el) => registerRef(message.id, el)} > {mode === 'edit' && ( <MessageEditor threadId={threadId} message={message} content={part.text} setMessages={setMessages} reload={reload} setMode={setMode} stop={stop} /> )} {mode === 'view' && <p>{part.text}</p>} {mode === 'view' && ( <MessageControls threadId={threadId} content={part.text} message={message} setMode={setMode} setMessages={setMessages} reload={reload} stop={stop} /> )} </div> ) : ( <div key={key} className="group flex flex-col gap-2 w-full"> <MarkdownRenderer content={part.text} id={message.id} /> {!isStreaming && ( <MessageControls threadId={threadId} content={part.text} message={message} setMessages={setMessages} reload={reload} stop={stop} /> )} </div> ); } })} </div> ); } const PreviewMessage = memo(PureMessage, (prevProps, nextProps) => { if (prevProps.isStreaming !== nextProps.isStreaming) return false; if (prevProps.message.id !== nextProps.message.id) return false; if (!equal(prevProps.message.parts, nextProps.message.parts)) return false; return true; }); PreviewMessage.displayName = 'PreviewMessage'; export default PreviewMessage; ``` ## /frontend/components/MessageControls.tsx ```tsx path="/frontend/components/MessageControls.tsx" import { Dispatch, SetStateAction, useState } from 'react'; import { Button } from './ui/button'; import { cn } from '@/lib/utils'; import { Check, Copy, RefreshCcw, SquarePen } from 'lucide-react'; import { UIMessage } from 'ai'; import { UseChatHelpers } from '@ai-sdk/react'; import { deleteTrailingMessages } from '@/frontend/dexie/queries'; import { useAPIKeyStore } from '@/frontend/stores/APIKeyStore'; interface MessageControlsProps { threadId: string; message: UIMessage; setMessages: UseChatHelpers['setMessages']; content: string; setMode?: Dispatch<SetStateAction<'view' | 'edit'>>; reload: UseChatHelpers['reload']; stop: UseChatHelpers['stop']; } export default function MessageControls({ threadId, message, setMessages, content, setMode, reload, stop, }: MessageControlsProps) { const [copied, setCopied] = useState(false); const hasRequiredKeys = useAPIKeyStore((state) => state.hasRequiredKeys()); const handleCopy = () => { navigator.clipboard.writeText(content); setCopied(true); setTimeout(() => { setCopied(false); }, 2000); }; const handleRegenerate = async () => { // stop the current request stop(); if (message.role === 'user') { await deleteTrailingMessages(threadId, message.createdAt as Date, false); setMessages((messages) => { const index = messages.findIndex((m) => m.id === message.id); if (index !== -1) { return [...messages.slice(0, index + 1)]; } return messages; }); } else { await deleteTrailingMessages(threadId, message.createdAt as Date); setMessages((messages) => { const index = messages.findIndex((m) => m.id === message.id); if (index !== -1) { return [...messages.slice(0, index)]; } return messages; }); } setTimeout(() => { reload(); }, 0); }; return ( <div className={cn( 'opacity-0 group-hover:opacity-100 transition-opacity duration-100 flex gap-1', { 'absolute mt-5 right-2': message.role === 'user', } )} > <Button variant="ghost" size="icon" onClick={handleCopy}> {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} </Button> {setMode && hasRequiredKeys && ( <Button variant="ghost" size="icon" onClick={() => setMode('edit')}> <SquarePen className="w-4 h-4" /> </Button> )} {hasRequiredKeys && ( <Button variant="ghost" size="icon" onClick={handleRegenerate}> <RefreshCcw className="w-4 h-4" /> </Button> )} </div> ); } ``` ## /frontend/components/MessageEditor.tsx ```tsx path="/frontend/components/MessageEditor.tsx" import { createMessage, deleteTrailingMessages, createMessageSummary, } from '@/frontend/dexie/queries'; import { UseChatHelpers, useCompletion } from '@ai-sdk/react'; import { useState } from 'react'; import { UIMessage } from 'ai'; import { Dispatch, SetStateAction } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { Textarea } from './ui/textarea'; import { Button } from './ui/button'; import { useAPIKeyStore } from '@/frontend/stores/APIKeyStore'; import { toast } from 'sonner'; export default function MessageEditor({ threadId, message, content, setMessages, reload, setMode, stop, }: { threadId: string; message: UIMessage; content: string; setMessages: UseChatHelpers['setMessages']; setMode: Dispatch<SetStateAction<'view' | 'edit'>>; reload: UseChatHelpers['reload']; stop: UseChatHelpers['stop']; }) { const [draftContent, setDraftContent] = useState(content); const getKey = useAPIKeyStore((state) => state.getKey); const { complete } = useCompletion({ api: '/api/completion', ...(getKey('google') && { headers: { 'X-Google-API-Key': getKey('google')! }, }), onResponse: async (response) => { try { const payload = await response.json(); if (response.ok) { const { title, messageId, threadId } = payload; await createMessageSummary(threadId, messageId, title); } else { toast.error( payload.error || 'Failed to generate a summary for the message' ); } } catch (error) { console.error(error); } }, }); const handleSave = async () => { try { await deleteTrailingMessages(threadId, message.createdAt as Date); const updatedMessage = { ...message, id: uuidv4(), content: draftContent, parts: [ { type: 'text' as const, text: draftContent, }, ], createdAt: new Date(), }; await createMessage(threadId, updatedMessage); setMessages((messages) => { const index = messages.findIndex((m) => m.id === message.id); if (index !== -1) { return [...messages.slice(0, index), updatedMessage]; } return messages; }); complete(draftContent, { body: { messageId: updatedMessage.id, threadId, }, }); setMode('view'); // stop the current stream if any stop(); setTimeout(() => { reload(); }, 0); } catch (error) { console.error('Failed to save message:', error); toast.error('Failed to save message'); } }; return ( <div> <Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave(); } }} /> <div className="flex gap-2 mt-2"> <Button onClick={handleSave}>Save</Button> <Button onClick={() => setMode('view')}>Cancel</Button> </div> </div> ); } ``` ## /frontend/components/MessageReasoning.tsx ```tsx path="/frontend/components/MessageReasoning.tsx" import { memo, useState } from 'react'; import MemoizedMarkdown from './MemoizedMarkdown'; import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; function PureMessageReasoning({ reasoning, id, }: { reasoning: string; id: string; }) { const [isExpanded, setIsExpanded] = useState(false); return ( <div className="flex flex-col gap-2 pb-2 max-w-3xl w-full"> <button onClick={() => setIsExpanded(!isExpanded)} className="flex items-center gap-2 text-muted-foreground cursor-pointer" > {isExpanded ? ( <span> <ChevronUpIcon className="w-4 h-4" /> </span> ) : ( <span> <ChevronDownIcon className="w-4 h-4" /> </span> )} <span>Reasoning</span> </button> {isExpanded && ( <div className="p-4 rounded-md bg-secondary/10 text-xs border"> <MemoizedMarkdown content={reasoning} id={id} size="small" /> </div> )} </div> ); } export default memo(PureMessageReasoning, (prev, next) => { return prev.reasoning === next.reasoning && prev.id === next.id; }); ``` ## /frontend/components/Messages.tsx ```tsx path="/frontend/components/Messages.tsx" import { memo } from 'react'; import PreviewMessage from './Message'; import { UIMessage } from 'ai'; import { UseChatHelpers } from '@ai-sdk/react'; import equal from 'fast-deep-equal'; import MessageLoading from './ui/MessageLoading'; import Error from './Error'; function PureMessages({ threadId, messages, status, setMessages, reload, error, stop, registerRef, }: { threadId: string; messages: UIMessage[]; setMessages: UseChatHelpers['setMessages']; reload: UseChatHelpers['reload']; status: UseChatHelpers['status']; error: UseChatHelpers['error']; stop: UseChatHelpers['stop']; registerRef: (id: string, ref: HTMLDivElement | null) => void; }) { return ( <section className="flex flex-col space-y-12"> {messages.map((message, index) => ( <PreviewMessage key={message.id} threadId={threadId} message={message} isStreaming={status === 'streaming' && messages.length - 1 === index} setMessages={setMessages} reload={reload} registerRef={registerRef} stop={stop} /> ))} {status === 'submitted' && <MessageLoading />} {error && <Error message={error.message} />} </section> ); } const Messages = memo(PureMessages, (prevProps, nextProps) => { if (prevProps.status !== nextProps.status) return false; if (prevProps.error !== nextProps.error) return false; if (prevProps.messages.length !== nextProps.messages.length) return false; if (!equal(prevProps.messages, nextProps.messages)) return false; return true; }); Messages.displayName = 'Messages'; export default Messages; ``` ## /frontend/components/ui/MessageLoading.tsx ```tsx path="/frontend/components/ui/MessageLoading.tsx" export default function MessageLoading() { return ( <svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" className="text-foreground" > <circle cx="4" cy="12" r="2" fill="currentColor"> <animate id="spinner_qFRN" begin="0;spinner_OcgL.end+0.25s" attributeName="cy" calcMode="spline" dur="0.6s" values="12;6;12" keySplines=".33,.66,.66,1;.33,0,.66,.33" /> </circle> <circle cx="12" cy="12" r="2" fill="currentColor"> <animate begin="spinner_qFRN.begin+0.1s" attributeName="cy" calcMode="spline" dur="0.6s" values="12;6;12" keySplines=".33,.66,.66,1;.33,0,.66,.33" /> </circle> <circle cx="20" cy="12" r="2" fill="currentColor"> <animate id="spinner_OcgL" begin="spinner_qFRN.begin+0.2s" attributeName="cy" calcMode="spline" dur="0.6s" values="12;6;12" keySplines=".33,.66,.66,1;.33,0,.66,.33" /> </circle> </svg> ); } ``` ## /frontend/components/ui/ThemeProvider.tsx ```tsx path="/frontend/components/ui/ThemeProvider.tsx" 'use client'; import * as React from 'react'; import { ThemeProvider as NextThemesProvider } from 'next-themes'; export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) { return <NextThemesProvider {...props}>{children}</NextThemesProvider>; } ``` ## /frontend/components/ui/ThemeToggler.tsx ```tsx path="/frontend/components/ui/ThemeToggler.tsx" 'use client'; import * as React from 'react'; import { Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; import { Button } from '@/frontend/components/ui/button'; export default function ThemeToggler() { const { setTheme, theme } = useTheme(); return ( <Button variant="outline" size="icon" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} className="fixed top-4 right-4" > <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" /> <span className="sr-only">Toggle theme</span> </Button> ); } ``` ## /frontend/components/ui/badge.tsx ```tsx path="/frontend/components/ui/badge.tsx" 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 badgeVariants = cva( "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", }, }, defaultVariants: { variant: "default", }, } ) function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) { const Comp = asChild ? Slot : "span" return ( <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} /> ) } export { Badge, badgeVariants } ``` ## /frontend/components/ui/button.tsx ```tsx path="/frontend/components/ui/button.tsx" 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( 'cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', { variants: { variant: { default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 disabled:hover:bg-primary', destructive: 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 disabled:hover:bg-destructive dark:disabled:hover:bg-destructive/60', outline: 'border border-white bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 disabled:hover:bg-background disabled:hover:text-foreground dark:disabled:hover:bg-input/30', secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 disabled:hover:bg-secondary', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 disabled:hover:bg-transparent disabled:hover:text-foreground', link: 'text-primary underline-offset-4 hover:underline disabled:hover:no-underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', }, }, defaultVariants: { variant: 'default', size: 'default', }, } ); function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<'button'> & VariantProps<typeof buttonVariants> & { asChild?: boolean; }) { const Comp = asChild ? Slot : 'button'; return ( <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> ); } export { Button, buttonVariants }; ``` ## /frontend/components/ui/card.tsx ```tsx path="/frontend/components/ui/card.tsx" import * as React from "react" import { cn } from "@/lib/utils" function Card({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card" className={cn( "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className )} {...props} /> ) } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-header" className={cn( "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className )} {...props} /> ) } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} /> ) } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} /> ) } function CardAction({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-action" className={cn( "col-start-2 row-span-2 row-start-1 self-start justify-self-end", className )} {...props} /> ) } function CardContent({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-content" className={cn("px-6", className)} {...props} /> ) } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} /> ) } export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, } ``` ## /frontend/components/ui/dialog.tsx ```tsx path="/frontend/components/ui/dialog.tsx" "use client" import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import { XIcon } from "lucide-react" import { cn } from "@/lib/utils" function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) { return <DialogPrimitive.Root data-slot="dialog" {...props} /> } function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) { return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> } function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) { return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> } function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) { return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> } function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) { return ( <DialogPrimitive.Overlay data-slot="dialog-overlay" className={cn( "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", className )} {...props} /> ) } function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }) { return ( <DialogPortal data-slot="dialog-portal"> <DialogOverlay /> <DialogPrimitive.Content data-slot="dialog-content" className={cn( "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", className )} {...props} > {children} {showCloseButton && ( <DialogPrimitive.Close data-slot="dialog-close" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" > <XIcon /> <span className="sr-only">Close</span> </DialogPrimitive.Close> )} </DialogPrimitive.Content> </DialogPortal> ) } function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} /> ) } function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="dialog-footer" className={cn( "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className )} {...props} /> ) } function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) { return ( <DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props} /> ) } function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) { return ( <DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props} /> ) } export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } ``` ## /frontend/components/ui/dropdown-menu.tsx ```tsx path="/frontend/components/ui/dropdown-menu.tsx" 'use client'; import * as React from 'react'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />; } function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { return ( <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> ); } function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) { return ( <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} /> ); } function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) { return ( <DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Content data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', className )} {...props} /> </DropdownMenuPrimitive.Portal> ); } function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { return ( <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> ); } function DropdownMenuItem({ className, inset, variant = 'default', ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean; variant?: 'default' | 'destructive'; }) { return ( <DropdownMenuPrimitive.Item data-slot="dropdown-menu-item" data-inset={inset} data-variant={variant} className={cn( "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} /> ); } function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) { return ( <DropdownMenuPrimitive.CheckboxItem data-slot="dropdown-menu-checkbox-item" className={cn( "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} checked={checked} {...props} > <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <DropdownMenuPrimitive.ItemIndicator> <CheckIcon className="size-4" /> </DropdownMenuPrimitive.ItemIndicator> </span> {children} </DropdownMenuPrimitive.CheckboxItem> ); } function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) { return ( <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} /> ); } function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) { return ( <DropdownMenuPrimitive.RadioItem data-slot="dropdown-menu-radio-item" className={cn( "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} > <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <DropdownMenuPrimitive.ItemIndicator> <CircleIcon className="size-2 fill-current" /> </DropdownMenuPrimitive.ItemIndicator> </span> {children} </DropdownMenuPrimitive.RadioItem> ); } function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean; }) { return ( <DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" data-inset={inset} className={cn( 'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className )} {...props} /> ); } function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) { return ( <DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" className={cn('bg-border -mx-1 my-1 h-px', className)} {...props} /> ); } function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { return ( <span data-slot="dropdown-menu-shortcut" className={cn( 'text-muted-foreground ml-auto text-xs tracking-widest', className )} {...props} /> ); } function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />; } function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean; }) { return ( <DropdownMenuPrimitive.SubTrigger data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn( 'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8', className )} {...props} > {children} <ChevronRightIcon className="ml-auto size-4" /> </DropdownMenuPrimitive.SubTrigger> ); } function DropdownMenuSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { return ( <DropdownMenuPrimitive.SubContent data-slot="dropdown-menu-sub-content" className={cn( 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', className )} {...props} /> ); } export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, }; ``` ## /frontend/components/ui/icons.tsx ```tsx path="/frontend/components/ui/icons.tsx" export const StopIcon = ({ size = 16 }: { size?: number }) => { return ( <svg height={size} viewBox="0 0 16 16" width={size} style={{ color: 'currentcolor' }} > <path fillRule="evenodd" clipRule="evenodd" d="M3 3H13V13H3V3Z" fill="currentColor" /> </svg> ); }; ``` ## /frontend/components/ui/input.tsx ```tsx path="/frontend/components/ui/input.tsx" import * as React from "react" import { cn } from "@/lib/utils" function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( <input type={type} data-slot="input" className={cn( "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className )} {...props} /> ) } export { Input } ``` ## /frontend/components/ui/separator.tsx ```tsx path="/frontend/components/ui/separator.tsx" "use client" import * as React from "react" import * as SeparatorPrimitive from "@radix-ui/react-separator" import { cn } from "@/lib/utils" function Separator({ className, orientation = "horizontal", decorative = true, ...props }: React.ComponentProps<typeof SeparatorPrimitive.Root>) { return ( <SeparatorPrimitive.Root data-slot="separator-root" decorative={decorative} orientation={orientation} className={cn( "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", className )} {...props} /> ) } export { Separator } ``` ## /frontend/components/ui/sheet.tsx ```tsx path="/frontend/components/ui/sheet.tsx" "use client" import * as React from "react" import * as SheetPrimitive from "@radix-ui/react-dialog" import { XIcon } from "lucide-react" import { cn } from "@/lib/utils" function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { return <SheetPrimitive.Root data-slot="sheet" {...props} /> } function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) { return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> } function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) { return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> } function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) { return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> } function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) { return ( <SheetPrimitive.Overlay data-slot="sheet-overlay" className={cn( "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", className )} {...props} /> ) } function SheetContent({ className, children, side = "right", ...props }: React.ComponentProps<typeof SheetPrimitive.Content> & { side?: "top" | "right" | "bottom" | "left" }) { return ( <SheetPortal> <SheetOverlay /> <SheetPrimitive.Content data-slot="sheet-content" className={cn( "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", side === "right" && "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", side === "left" && "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", side === "top" && "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", side === "bottom" && "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", className )} {...props} > {children} <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <XIcon className="size-4" /> <span className="sr-only">Close</span> </SheetPrimitive.Close> </SheetPrimitive.Content> </SheetPortal> ) } function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} /> ) } function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} /> ) } function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) { return ( <SheetPrimitive.Title data-slot="sheet-title" className={cn("text-foreground font-semibold", className)} {...props} /> ) } function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) { return ( <SheetPrimitive.Description data-slot="sheet-description" className={cn("text-muted-foreground text-sm", className)} {...props} /> ) } export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription, } ``` ## /frontend/components/ui/sidebar.tsx ```tsx path="/frontend/components/ui/sidebar.tsx" import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { VariantProps, cva } from 'class-variance-authority'; import { PanelLeftIcon } from 'lucide-react'; import { useIsMobile } from '@/hooks/use-mobile'; import { cn } from '@/lib/utils'; import { Button } from '@/frontend/components/ui/button'; import { Input } from '@/frontend/components/ui/input'; import { Separator } from '@/frontend/components/ui/separator'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from '@/frontend/components/ui/sheet'; import { Skeleton } from '@/frontend/components/ui/skeleton'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/frontend/components/ui/tooltip'; const SIDEBAR_COOKIE_NAME = 'sidebar_state'; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_WIDTH = '19rem'; const SIDEBAR_WIDTH_MOBILE = '18rem'; const SIDEBAR_WIDTH_ICON = '3rem'; const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; type SidebarContextProps = { state: 'expanded' | 'collapsed'; open: boolean; setOpen: (open: boolean) => void; openMobile: boolean; setOpenMobile: (open: boolean) => void; isMobile: boolean; toggleSidebar: () => void; }; const SidebarContext = React.createContext<SidebarContextProps | null>(null); function useSidebar() { const context = React.useContext(SidebarContext); if (!context) { throw new Error('useSidebar must be used within a SidebarProvider.'); } return context; } function SidebarProvider({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }: React.ComponentProps<'div'> & { defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; }) { const isMobile = useIsMobile(); const [openMobile, setOpenMobile] = React.useState(false); // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen); const open = openProp ?? _open; const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === 'function' ? value(open) : value; if (setOpenProp) { setOpenProp(openState); } else { _setOpen(openState); } // This sets the cookie to keep the sidebar state. document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; }, [setOpenProp, open] ); // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); }, [isMobile, setOpen, setOpenMobile]); // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { event.preventDefault(); toggleSidebar(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [toggleSidebar]); // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = open ? 'expanded' : 'collapsed'; const contextValue = React.useMemo<SidebarContextProps>( () => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, }), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] ); return ( <SidebarContext.Provider value={contextValue}> <TooltipProvider delayDuration={0}> <div data-slot="sidebar-wrapper" style={ { '--sidebar-width': SIDEBAR_WIDTH, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, ...style, } as React.CSSProperties } className={cn( 'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full', className )} {...props} > {children} </div> </TooltipProvider> </SidebarContext.Provider> ); } function Sidebar({ side = 'left', variant = 'sidebar', collapsible = 'offcanvas', className, children, ...props }: React.ComponentProps<'div'> & { side?: 'left' | 'right'; variant?: 'sidebar' | 'floating' | 'inset'; collapsible?: 'offcanvas' | 'icon' | 'none'; }) { const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); if (collapsible === 'none') { return ( <div data-slot="sidebar" className={cn( 'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col', className )} {...props} > {children} </div> ); } if (isMobile) { return ( <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> <SheetContent data-sidebar="sidebar" data-slot="sidebar" data-mobile="true" className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" style={ { '--sidebar-width': SIDEBAR_WIDTH_MOBILE, } as React.CSSProperties } side={side} > <SheetHeader className="sr-only"> <SheetTitle>Sidebar</SheetTitle> <SheetDescription>Displays the mobile sidebar.</SheetDescription> </SheetHeader> <div className="flex h-full w-full flex-col">{children}</div> </SheetContent> </Sheet> ); } return ( <div className="group peer text-sidebar-foreground hidden md:block" data-state={state} data-collapsible={state === 'collapsed' ? collapsible : ''} data-variant={variant} data-side={side} data-slot="sidebar" > {/* This is what handles the sidebar gap on desktop */} <div data-slot="sidebar-gap" className={cn( 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear', 'group-data-[collapsible=offcanvas]:w-0', 'group-data-[side=right]:rotate-180', variant === 'floating' || variant === 'inset' ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)' )} /> <div data-slot="sidebar-container" className={cn( 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex', side === 'left' ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', // Adjust the padding for floating and inset variants. variant === 'floating' || variant === 'inset' ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]' : 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l', className )} {...props} > <div data-sidebar="sidebar" data-slot="sidebar-inner" className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" > {children} </div> </div> </div> ); } function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) { const { toggleSidebar } = useSidebar(); return ( <Button data-sidebar="trigger" data-slot="sidebar-trigger" variant="ghost" size="icon" className={cn('size-7', className)} onClick={(event) => { onClick?.(event); toggleSidebar(); }} {...props} > <PanelLeftIcon className="size-4" /> <span className="sr-only">Toggle Sidebar</span> </Button> ); } function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { const { toggleSidebar } = useSidebar(); return ( <button data-sidebar="rail" data-slot="sidebar-rail" aria-label="Toggle Sidebar" tabIndex={-1} onClick={toggleSidebar} title="Toggle Sidebar" className={cn( 'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex', 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full', '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', className )} {...props} /> ); } function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) { return ( <main data-slot="sidebar-inset" className={cn( 'bg-background relative flex w-full flex-1 flex-col', 'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2', className )} {...props} /> ); } function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) { return ( <Input data-slot="sidebar-input" data-sidebar="input" className={cn('bg-background h-8 w-full shadow-none', className)} {...props} /> ); } function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="sidebar-header" data-sidebar="header" className={cn('flex flex-col gap-2 p-2', className)} {...props} /> ); } function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="sidebar-footer" data-sidebar="footer" className={cn('flex flex-col gap-2 p-2', className)} {...props} /> ); } function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) { return ( <Separator data-slot="sidebar-separator" data-sidebar="separator" className={cn('bg-sidebar-border mx-2 w-auto', className)} {...props} /> ); } function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="sidebar-content" data-sidebar="content" className={cn( 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', className )} {...props} /> ); } function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="sidebar-group" data-sidebar="group" className={cn('relative flex w-full min-w-0 flex-col p-2', className)} {...props} /> ); } function SidebarGroupLabel({ className, asChild = false, ...props }: React.ComponentProps<'div'> & { asChild?: boolean }) { const Comp = asChild ? Slot : 'div'; return ( <Comp data-slot="sidebar-group-label" data-sidebar="group-label" className={cn( 'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', className )} {...props} /> ); } function SidebarGroupAction({ className, asChild = false, ...props }: React.ComponentProps<'button'> & { asChild?: boolean }) { const Comp = asChild ? Slot : 'button'; return ( <Comp data-slot="sidebar-group-action" data-sidebar="group-action" className={cn( 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', // Increases the hit area of the button on mobile. 'after:absolute after:-inset-2 md:after:hidden', 'group-data-[collapsible=icon]:hidden', className )} {...props} /> ); } function SidebarGroupContent({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="sidebar-group-content" data-sidebar="group-content" className={cn('w-full text-sm', className)} {...props} /> ); } function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) { return ( <ul data-slot="sidebar-menu" data-sidebar="menu" className={cn('flex w-full min-w-0 flex-col gap-1', className)} {...props} /> ); } function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) { return ( <li data-slot="sidebar-menu-item" data-sidebar="menu-item" className={cn('group/menu-item relative', className)} {...props} /> ); } const sidebarMenuButtonVariants = cva( 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', { variants: { variant: { default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', outline: 'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]', }, size: { default: 'h-8 text-sm', sm: 'h-7 text-xs', lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!', }, }, defaultVariants: { variant: 'default', size: 'default', }, } ); function SidebarMenuButton({ asChild = false, isActive = false, variant = 'default', size = 'default', tooltip, className, ...props }: React.ComponentProps<'button'> & { asChild?: boolean; isActive?: boolean; tooltip?: string | React.ComponentProps<typeof TooltipContent>; } & VariantProps<typeof sidebarMenuButtonVariants>) { const Comp = asChild ? Slot : 'button'; const { isMobile, state } = useSidebar(); const button = ( <Comp data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size={size} data-active={isActive} className={cn(sidebarMenuButtonVariants({ variant, size }), className)} {...props} /> ); if (!tooltip) { return button; } if (typeof tooltip === 'string') { tooltip = { children: tooltip, }; } return ( <Tooltip> <TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipContent side="right" align="center" hidden={state !== 'collapsed' || isMobile} {...tooltip} /> </Tooltip> ); } function SidebarMenuAction({ className, asChild = false, showOnHover = false, ...props }: React.ComponentProps<'button'> & { asChild?: boolean; showOnHover?: boolean; }) { const Comp = asChild ? Slot : 'button'; return ( <Comp data-slot="sidebar-menu-action" data-sidebar="menu-action" className={cn( 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', // Increases the hit area of the button on mobile. 'after:absolute after:-inset-2 md:after:hidden', 'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden', showOnHover && 'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0', className )} {...props} /> ); } function SidebarMenuBadge({ className, ...props }: React.ComponentProps<'div'>) { return ( <div data-slot="sidebar-menu-badge" data-sidebar="menu-badge" className={cn( 'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none', 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', 'peer-data-[size=sm]/menu-button:top-1', 'peer-data-[size=default]/menu-button:top-1.5', 'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden', className )} {...props} /> ); } function SidebarMenuSkeleton({ className, showIcon = false, ...props }: React.ComponentProps<'div'> & { showIcon?: boolean; }) { // Random width between 50 to 90%. const width = React.useMemo(() => { return `${Math.floor(Math.random() * 40) + 50}%`; }, []); return ( <div data-slot="sidebar-menu-skeleton" data-sidebar="menu-skeleton" className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)} {...props} > {showIcon && ( <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" /> )} <Skeleton className="h-4 max-w-(--skeleton-width) flex-1" data-sidebar="menu-skeleton-text" style={ { '--skeleton-width': width, } as React.CSSProperties } /> </div> ); } function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) { return ( <ul data-slot="sidebar-menu-sub" data-sidebar="menu-sub" className={cn( 'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5', 'group-data-[collapsible=icon]:hidden', className )} {...props} /> ); } function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<'li'>) { return ( <li data-slot="sidebar-menu-sub-item" data-sidebar="menu-sub-item" className={cn('group/menu-sub-item relative', className)} {...props} /> ); } function SidebarMenuSubButton({ asChild = false, size = 'md', isActive = false, className, ...props }: React.ComponentProps<'a'> & { asChild?: boolean; size?: 'sm' | 'md'; isActive?: boolean; }) { const Comp = asChild ? Slot : 'a'; return ( <Comp data-slot="sidebar-menu-sub-button" data-sidebar="menu-sub-button" data-size={size} data-active={isActive} className={cn( 'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', size === 'sm' && 'text-xs', size === 'md' && 'text-sm', 'group-data-[collapsible=icon]:hidden', className )} {...props} /> ); } export { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, SidebarTrigger, useSidebar, }; ``` ## /frontend/components/ui/skeleton.tsx ```tsx path="/frontend/components/ui/skeleton.tsx" import { cn } from "@/lib/utils" function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="skeleton" className={cn("bg-accent animate-pulse rounded-md", className)} {...props} /> ) } export { Skeleton } ``` ## /frontend/components/ui/sonner.tsx ```tsx path="/frontend/components/ui/sonner.tsx" "use client" import { useTheme } from "next-themes" import { Toaster as Sonner, ToasterProps } from "sonner" const Toaster = ({ ...props }: ToasterProps) => { const { theme = "system" } = useTheme() return ( <Sonner theme={theme as ToasterProps["theme"]} className="toaster group" style={ { "--normal-bg": "var(--popover)", "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", } as React.CSSProperties } {...props} /> ) } export { Toaster } ``` ## /frontend/components/ui/textarea.tsx ```tsx path="/frontend/components/ui/textarea.tsx" import * as React from 'react'; import { cn } from '@/lib/utils'; function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) { return ( <textarea data-slot="textarea" className={cn( 'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', className )} {...props} /> ); } export { Textarea }; ``` ## /frontend/components/ui/tooltip.tsx ```tsx path="/frontend/components/ui/tooltip.tsx" "use client" import * as React from "react" import * as TooltipPrimitive from "@radix-ui/react-tooltip" import { cn } from "@/lib/utils" function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) { return ( <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} /> ) } function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) { return ( <TooltipProvider> <TooltipPrimitive.Root data-slot="tooltip" {...props} /> </TooltipProvider> ) } function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> } function TooltipContent({ className, sideOffset = 0, children, ...props }: React.ComponentProps<typeof TooltipPrimitive.Content>) { return ( <TooltipPrimitive.Portal> <TooltipPrimitive.Content data-slot="tooltip-content" sideOffset={sideOffset} className={cn( "bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", className )} {...props} > {children} <TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> </TooltipPrimitive.Content> </TooltipPrimitive.Portal> ) } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } ``` ## /frontend/dexie/db.ts ```ts path="/frontend/dexie/db.ts" import { UIMessage } from 'ai'; import Dexie, { type EntityTable } from 'dexie'; interface Thread { id: string; title: string; createdAt: Date; updatedAt: Date; lastMessageAt: Date; } interface DBMessage { id: string; threadId: string; parts: UIMessage['parts']; content: string; role: 'user' | 'assistant' | 'system' | 'data'; createdAt: Date; } interface MessageSummary { id: string; threadId: string; messageId: string; content: string; createdAt: Date; } const db = new Dexie('chat0') as Dexie & { threads: EntityTable<Thread, 'id'>; messages: EntityTable<DBMessage, 'id'>; messageSummaries: EntityTable<MessageSummary, 'id'>; }; db.version(1).stores({ threads: 'id, title, updatedAt, lastMessageAt', messages: 'id, threadId, createdAt, [threadId+createdAt]', messageSummaries: 'id, threadId, messageId, createdAt, [threadId+createdAt]', }); export type { Thread, DBMessage }; export { db }; ``` ## /frontend/dexie/queries.ts ```ts path="/frontend/dexie/queries.ts" import { db } from './db'; import { UIMessage } from 'ai'; import { v4 as uuidv4 } from 'uuid'; import Dexie from 'dexie'; export const getThreads = async () => { return await db.threads.orderBy('lastMessageAt').reverse().toArray(); }; export const createThread = async (id: string) => { return await db.threads.add({ id, title: 'New Chat', createdAt: new Date(), updatedAt: new Date(), lastMessageAt: new Date(), }); }; export const updateThread = async (id: string, title: string) => { return await db.threads.update(id, { title, updatedAt: new Date(), }); }; export const deleteThread = async (id: string) => { return await db.transaction( 'rw', [db.threads, db.messages, db.messageSummaries], async () => { await db.messages.where('threadId').equals(id).delete(); await db.messageSummaries.where('threadId').equals(id).delete(); return await db.threads.delete(id); } ); }; export const deleteAllThreads = async () => { return db.transaction( 'rw', [db.threads, db.messages, db.messageSummaries], async () => { await db.threads.clear(); await db.messages.clear(); await db.messageSummaries.clear(); } ); }; export const getMessagesByThreadId = async (threadId: string) => { return await db.messages .where('[threadId+createdAt]') .between([threadId, Dexie.minKey], [threadId, Dexie.maxKey]) .toArray(); }; export const createMessage = async (threadId: string, message: UIMessage) => { return await db.transaction('rw', [db.messages, db.threads], async () => { await db.messages.add({ id: message.id, threadId, parts: message.parts, role: message.role, content: message.content, createdAt: message.createdAt || new Date(), }); await db.threads.update(threadId, { lastMessageAt: message.createdAt || new Date(), }); }); }; export const deleteTrailingMessages = async ( threadId: string, createdAt: Date, gte: boolean = true ) => { const startKey = gte ? [threadId, createdAt] : [threadId, new Date(createdAt.getTime() + 1)]; const endKey = [threadId, Dexie.maxKey]; return await db.transaction( 'rw', [db.messages, db.messageSummaries], async () => { const messagesToDelete = await db.messages .where('[threadId+createdAt]') .between(startKey, endKey) .toArray(); const messageIds = messagesToDelete.map((msg) => msg.id); await db.messages .where('[threadId+createdAt]') .between(startKey, endKey) .delete(); if (messageIds.length > 0) { await db.messageSummaries.where('messageId').anyOf(messageIds).delete(); } } ); }; export const createMessageSummary = async ( threadId: string, messageId: string, content: string ) => { return await db.messageSummaries.add({ id: uuidv4(), threadId, messageId, content, createdAt: new Date(), }); }; export const getMessageSummaries = async (threadId: string) => { return await db.messageSummaries .where('[threadId+createdAt]') .between([threadId, Dexie.minKey], [threadId, Dexie.maxKey]) .toArray(); }; ``` ## /frontend/hooks/useChatNavigator.ts ```ts path="/frontend/hooks/useChatNavigator.ts" import { useCallback, useRef, useState } from 'react'; export const useChatNavigator = () => { const [isNavigatorVisible, setIsNavigatorVisible] = useState(false); const messageRefs = useRef<Record<string, HTMLDivElement | null>>({}); const registerRef = useCallback((id: string, ref: HTMLDivElement | null) => { messageRefs.current[id] = ref; }, []); const scrollToMessage = useCallback((id: string) => { const ref = messageRefs.current[id]; if (ref) { ref.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, []); const handleToggleNavigator = useCallback(() => { setIsNavigatorVisible((prev) => !prev); }, []); const closeNavigator = useCallback(() => { setIsNavigatorVisible(false); }, []); return { isNavigatorVisible, handleToggleNavigator, closeNavigator, registerRef, scrollToMessage, }; }; ``` ## /frontend/hooks/useMessageSummary.ts ```ts path="/frontend/hooks/useMessageSummary.ts" import { useCompletion } from '@ai-sdk/react'; import { useAPIKeyStore } from '@/frontend/stores/APIKeyStore'; import { toast } from 'sonner'; import { createMessageSummary, updateThread } from '@/frontend/dexie/queries'; interface MessageSummaryPayload { title: string; isTitle?: boolean; messageId: string; threadId: string; } export const useMessageSummary = () => { const getKey = useAPIKeyStore((state) => state.getKey); const { complete, isLoading } = useCompletion({ api: '/api/completion', ...(getKey('google') && { headers: { 'X-Google-API-Key': getKey('google')! }, }), onResponse: async (response) => { try { const payload: MessageSummaryPayload = await response.json(); if (response.ok) { const { title, isTitle, messageId, threadId } = payload; if (isTitle) { await updateThread(threadId, title); await createMessageSummary(threadId, messageId, title); } else { await createMessageSummary(threadId, messageId, title); } } else { toast.error('Failed to generate a summary for the message'); } } catch (error) { console.error(error); } }, }); return { complete, isLoading, }; }; ``` ## /frontend/routes/Home.tsx ```tsx path="/frontend/routes/Home.tsx" import APIKeyManager from '@/frontend/components/APIKeyForm'; import Chat from '@/frontend/components/Chat'; import { v4 as uuidv4 } from 'uuid'; import { useAPIKeyStore } from '../stores/APIKeyStore'; import { useModelStore } from '../stores/ModelStore'; export default function Home() { const hasRequiredKeys = useAPIKeyStore((state) => state.hasRequiredKeys()); const isAPIKeysHydrated = useAPIKeyStore.persist?.hasHydrated(); const isModelStoreHydrated = useModelStore.persist?.hasHydrated(); if (!isAPIKeysHydrated || !isModelStoreHydrated) return null; if (!hasRequiredKeys) return ( <div className="flex flex-col items-center justify-center w-full h-full max-w-3xl pt-10 pb-44 mx-auto"> <APIKeyManager /> </div> ); return <Chat threadId={uuidv4()} initialMessages={[]} />; } ``` ## /frontend/routes/Index.tsx ```tsx path="/frontend/routes/Index.tsx" import { useEffect } from 'react'; import { useNavigate } from 'react-router'; export default function Index() { const navigate = useNavigate(); useEffect(() => { navigate('/chat'); }, [navigate]); return null; } ``` ## /frontend/routes/Settings.tsx ```tsx path="/frontend/routes/Settings.tsx" import APIKeyForm from '@/frontend/components/APIKeyForm'; import { Link, useSearchParams } from 'react-router'; import { buttonVariants } from '../components/ui/button'; import { ArrowLeftIcon } from 'lucide-react'; export default function Settings() { const [searchParams] = useSearchParams(); const chatId = searchParams.get('from'); return ( <section className="flex w-full h-full"> <Link to={{ pathname: `/chat${chatId ? `/${chatId}` : ''}`, }} className={buttonVariants({ variant: 'default', className: 'w-fit fixed top-10 left-40 z-10', })} > <ArrowLeftIcon className="w-4 h-4" /> Back to Chat </Link> <div className="flex items-center justify-center w-full h-full pt-24 pb-44 mx-auto"> <APIKeyForm /> </div> </section> ); } ``` ## /frontend/routes/Thread.tsx ```tsx path="/frontend/routes/Thread.tsx" import Chat from '@/frontend/components/Chat'; import { useParams } from 'react-router'; import { useLiveQuery } from 'dexie-react-hooks'; import { getMessagesByThreadId } from '../dexie/queries'; import { type DBMessage } from '../dexie/db'; import { UIMessage } from 'ai'; export default function Thread() { const { id } = useParams(); if (!id) throw new Error('Thread ID is required'); const messages = useLiveQuery(() => getMessagesByThreadId(id), [id]); const convertToUIMessages = (messages?: DBMessage[]) => { return messages?.map((message) => ({ id: message.id, role: message.role, parts: message.parts as UIMessage['parts'], content: message.content || '', createdAt: message.createdAt, })); }; return ( <Chat key={id} threadId={id} initialMessages={convertToUIMessages(messages) || []} /> ); } ``` ## /frontend/stores/APIKeyStore.ts ```ts path="/frontend/stores/APIKeyStore.ts" import { create, Mutate, StoreApi } from 'zustand'; import { persist } from 'zustand/middleware'; export const PROVIDERS = ['google', 'openrouter', 'openai'] as const; export type Provider = (typeof PROVIDERS)[number]; type APIKeys = Record<Provider, string>; type APIKeyStore = { keys: APIKeys; setKeys: (newKeys: Partial<APIKeys>) => void; hasRequiredKeys: () => boolean; getKey: (provider: Provider) => string | null; }; type StoreWithPersist = Mutate< StoreApi<APIKeyStore>, [['zustand/persist', { keys: APIKeys }]] >; export const withStorageDOMEvents = (store: StoreWithPersist) => { const storageEventCallback = (e: StorageEvent) => { if (e.key === store.persist.getOptions().name && e.newValue) { store.persist.rehydrate(); } }; window.addEventListener('storage', storageEventCallback); return () => { window.removeEventListener('storage', storageEventCallback); }; }; export const useAPIKeyStore = create<APIKeyStore>()( persist( (set, get) => ({ keys: { google: '', openrouter: '', openai: '', }, setKeys: (newKeys) => { set((state) => ({ keys: { ...state.keys, ...newKeys }, })); }, hasRequiredKeys: () => { return !!get().keys.google; }, getKey: (provider) => { const key = get().keys[provider]; return key ? key : null; }, }), { name: 'api-keys', partialize: (state) => ({ keys: state.keys }), } ) ); withStorageDOMEvents(useAPIKeyStore); ``` ## /frontend/stores/ModelStore.ts ```ts path="/frontend/stores/ModelStore.ts" import { create, Mutate, StoreApi } from 'zustand'; import { persist } from 'zustand/middleware'; import { AIModel, getModelConfig, ModelConfig } from '@/lib/models'; type ModelStore = { selectedModel: AIModel; setModel: (model: AIModel) => void; getModelConfig: () => ModelConfig; }; type StoreWithPersist = Mutate< StoreApi<ModelStore>, [['zustand/persist', { selectedModel: AIModel }]] >; export const withStorageDOMEvents = (store: StoreWithPersist) => { const storageEventCallback = (e: StorageEvent) => { if (e.key === store.persist.getOptions().name && e.newValue) { store.persist.rehydrate(); } }; window.addEventListener('storage', storageEventCallback); return () => { window.removeEventListener('storage', storageEventCallback); }; }; export const useModelStore = create<ModelStore>()( persist( (set, get) => ({ selectedModel: 'Gemini 2.5 Flash', setModel: (model) => { set({ selectedModel: model }); }, getModelConfig: () => { const { selectedModel } = get(); return getModelConfig(selectedModel); }, }), { name: 'selected-model', partialize: (state) => ({ selectedModel: state.selectedModel }), } ) ); withStorageDOMEvents(useModelStore); ``` ## /hooks/use-mobile.ts ```ts path="/hooks/use-mobile.ts" import * as React from "react" const MOBILE_BREAKPOINT = 768 export function useIsMobile() { const [isMobile, setIsMobile] = React.useState<boolean | undefined>(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 } ``` ## /hooks/useAutoResizeTextArea.ts ```ts path="/hooks/useAutoResizeTextArea.ts" import { useCallback, useEffect, useRef } from 'react'; interface UseAutoResizeTextareaProps { minHeight: number; maxHeight?: number; } export default function useAutoResizeTextarea({ minHeight, maxHeight, }: UseAutoResizeTextareaProps) { const textareaRef = useRef<HTMLTextAreaElement>(null); const adjustHeight = useCallback( (reset?: boolean) => { const textarea = textareaRef.current; if (!textarea) return; if (reset) { textarea.style.height = `${minHeight}px`; return; } textarea.style.height = `${minHeight}px`; const newHeight = Math.max( minHeight, Math.min(textarea.scrollHeight, maxHeight ?? Number.POSITIVE_INFINITY) ); textarea.style.height = `${newHeight}px`; }, [minHeight, maxHeight] ); useEffect(() => { const textarea = textareaRef.current; if (textarea) { textarea.style.height = `${minHeight}px`; } }, [minHeight]); useEffect(() => { const handleResize = () => adjustHeight(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [adjustHeight]); return { textareaRef, adjustHeight }; } ``` ## /lib/models.ts ```ts path="/lib/models.ts" import { Provider } from '@/frontend/stores/APIKeyStore'; export const AI_MODELS = [ 'Deepseek R1 0528', 'Deepseek V3', 'Gemini 2.5 Pro', 'Gemini 2.5 Flash', 'GPT-4o', 'GPT-4.1-mini', ] as const; export type AIModel = (typeof AI_MODELS)[number]; export type ModelConfig = { modelId: string; provider: Provider; headerKey: string; }; export const MODEL_CONFIGS = { 'Deepseek R1 0528': { modelId: 'deepseek/deepseek-r1-0528:free', provider: 'openrouter', headerKey: 'X-OpenRouter-API-Key', }, 'Deepseek V3': { modelId: 'deepseek/deepseek-chat-v3-0324:free', provider: 'openrouter', headerKey: 'X-OpenRouter-API-Key', }, 'Gemini 2.5 Pro': { modelId: 'gemini-2.5-pro-preview-05-06', provider: 'google', headerKey: 'X-Google-API-Key', }, 'Gemini 2.5 Flash': { modelId: 'gemini-2.5-flash-preview-04-17', provider: 'google', headerKey: 'X-Google-API-Key', }, 'GPT-4o': { modelId: 'gpt-4o', provider: 'openai', headerKey: 'X-OpenAI-API-Key', }, 'GPT-4.1-mini': { modelId: 'gpt-4.1-mini', provider: 'openai', headerKey: 'X-OpenAI-API-Key', }, } as const satisfies Record<AIModel, ModelConfig>; export const getModelConfig = (modelName: AIModel): ModelConfig => { return MODEL_CONFIGS[modelName]; }; ``` ## /lib/utils.ts ```ts path="/lib/utils.ts" import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` ## /next.config.ts ```ts path="/next.config.ts" import type { NextConfig } from 'next'; const nextConfig: NextConfig = { rewrites: async () => { return [ { source: '/((?!api/).*)', destination: '/static-app-shell', }, ]; }, }; export default nextConfig; ``` ## /open-next.config.ts ```ts path="/open-next.config.ts" import { defineCloudflareConfig } from '@opennextjs/cloudflare'; export default defineCloudflareConfig(); ``` ## /package.json ```json path="/package.json" { "name": "chat0", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" }, "dependencies": { "@ai-sdk/google": "^1.2.19", "@ai-sdk/openai": "^1.3.22", "@ai-sdk/react": "^1.2.12", "@hookform/resolvers": "^5.0.1", "@opennextjs/cloudflare": "^1.2.1", "@openrouter/ai-sdk-provider": "^0.4.6", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.7", "ai": "^4.3.16", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dexie": "^4.0.11", "dexie-react-hooks": "^1.1.7", "fast-deep-equal": "^3.1.3", "katex": "^0.16.22", "lucide-react": "^0.510.0", "marked": "^15.0.12", "next": "15.3.2", "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.57.0", "react-markdown": "^10.1.0", "react-router": "^7.6.2", "react-shiki": "^0.6.0", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "sonner": "^2.0.5", "swr": "^2.3.3", "tailwind-merge": "^3.3.0", "tailwind-scrollbar": "^4.0.2", "usehooks-ts": "^3.1.1", "uuid": "^11.1.0", "zod": "^3.25.56", "zustand": "^5.0.5" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@tailwindcss/postcss": "^4.1.8", "@tailwindcss/typography": "^0.5.16", "@types/katex": "^0.16.7", "@types/node": "^20.19.0", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", "@types/react-syntax-highlighter": "^15.5.13", "eslint": "^9.28.0", "eslint-config-next": "15.3.2", "tailwindcss": "^4.1.8", "tw-animate-css": "^1.3.4", "typescript": "^5.8.3", "wrangler": "^4.19.1" } } ``` ## /postcss.config.mjs ```mjs path="/postcss.config.mjs" const config = { plugins: ["@tailwindcss/postcss"], }; export default config; ``` ## /public/file.svg ```svg path="/public/file.svg" <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> ``` ## /public/globe.svg ```svg path="/public/globe.svg" <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> ``` ## /public/next.svg ```svg path="/public/next.svg" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> ``` ## /public/vercel.svg ```svg path="/public/vercel.svg" <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> ``` ## /public/window.svg ```svg path="/public/window.svg" <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> ``` ## /todos.md ## TODOS - [ ] Dexie - Add Sync across multiple tabs in Dexie - [ ] Dexie - Add Error Handling - [ ] Chat - Add Chat History Search - [ ] Chat - Add Attachments (Image, PDF) - [ ] Chat - Add History Card View - [ ] Chat - Add Scroll to Bottom Button ## Additional Features - [ ] Resumeable Stream with redis ## /tsconfig.json ```json path="/tsconfig.json" { "compilerOptions": { "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ``` ## /wrangler.jsonc ```jsonc path="/wrangler.jsonc" { "main": ".open-next/worker.js", "name": "chat0", "compatibility_date": "2025-03-25", "compatibility_flags": ["nodejs_compat"], "assets": { "directory": ".open-next/assets", "binding": "ASSETS" } } ``` The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.