Run biome check and add ChatError component

This commit is contained in:
James Blair 2026-05-08 20:33:24 +12:00
parent 6a5041be43
commit 08f45451bb
Signed by: james
SSH key fingerprint: SHA256:hsDR3ENI2ZMgh+CNb+0PrFsj29DI007R+BMKUVGi6zQ
13 changed files with 228 additions and 136 deletions

View file

@ -1,6 +1,5 @@
import { Chat } from "@/components/chat"; import { Chat } from "@/components/chat";
import { Container } from "@/components/container"; import { Container } from "@/components/container";
import { Prose } from "@/components/prose";
export default function ChatPage() { export default function ChatPage() {
return ( return (

View file

@ -6,125 +6,125 @@
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@theme inline { @theme inline {
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-background: var(--background); --color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6); --radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8); --radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4); --radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2); --radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.514 0.222 16.935); --primary: oklch(0.514 0.222 16.935);
--primary-foreground: oklch(0.969 0.015 12.422); --primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.967 0.001 286.375); --secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.81 0.117 11.638); --chart-1: oklch(0.81 0.117 11.638);
--chart-2: oklch(0.645 0.246 16.439); --chart-2: oklch(0.645 0.246 16.439);
--chart-3: oklch(0.586 0.253 17.585); --chart-3: oklch(0.586 0.253 17.585);
--chart-4: oklch(0.514 0.222 16.935); --chart-4: oklch(0.514 0.222 16.935);
--chart-5: oklch(0.455 0.188 13.697); --chart-5: oklch(0.455 0.188 13.697);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.586 0.253 17.585); --sidebar-primary: oklch(0.586 0.253 17.585);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422); --sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.455 0.188 13.697); --primary: oklch(0.455 0.188 13.697);
--primary-foreground: oklch(0.969 0.015 12.422); --primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.274 0.006 286.033); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.81 0.117 11.638); --chart-1: oklch(0.81 0.117 11.638);
--chart-2: oklch(0.645 0.246 16.439); --chart-2: oklch(0.645 0.246 16.439);
--chart-3: oklch(0.586 0.253 17.585); --chart-3: oklch(0.586 0.253 17.585);
--chart-4: oklch(0.514 0.222 16.935); --chart-4: oklch(0.514 0.222 16.935);
--chart-5: oklch(0.455 0.188 13.697); --chart-5: oklch(0.455 0.188 13.697);
--sidebar: oklch(0.21 0.006 285.885); --sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.645 0.246 16.439); --sidebar-primary: oklch(0.645 0.246 16.439);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422); --sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html { html {
@apply font-sans; @apply font-sans;
} }
} }

View file

@ -19,7 +19,8 @@
"rules": { "rules": {
"recommended": true, "recommended": true,
"suspicious": { "suspicious": {
"noUnknownAtRules": "off" "noUnknownAtRules": "off",
"noArrayIndexKey": "off"
}, },
"complexity": { "complexity": {
"noStaticOnlyClass": "off" "noStaticOnlyClass": "off"

View file

@ -0,0 +1,14 @@
import { AlertTriangleIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
export function ChatError({ error }: { error?: Error }) {
if (!error) return null;
return (
<Alert variant="destructive">
<AlertTriangleIcon />
<AlertTitle>{error.name}</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
);
}

View file

@ -1,7 +1,7 @@
import { useState, type SubmitEvent } from "react"; import { ArrowUpIcon } from "lucide-react";
import { type SubmitEvent, useState } from "react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea"; import { Textarea } from "../ui/textarea";
import { ArrowUpIcon, SendIcon } from "lucide-react";
interface ChatFormProps { interface ChatFormProps {
onMessage: (message: { text: string }) => void; onMessage: (message: { text: string }) => void;

View file

@ -1,5 +1,5 @@
import type { UIMessage } from "ai";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { UIMessage } from "ai";
interface ChatMessagesProps { interface ChatMessagesProps {
messages: UIMessage[]; messages: UIMessage[];

View file

@ -1,9 +1,10 @@
"use client"; "use client";
import { ChatMessages } from "./chat-messages";
import { ChatForm } from "./chat-form";
import { DefaultChatTransport } from "ai";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { ChatError } from "./chat-error";
import { ChatForm } from "./chat-form";
import { ChatMessages } from "./chat-messages";
export function Chat() { export function Chat() {
const { messages, sendMessage, status, error } = useChat({ const { messages, sendMessage, status, error } = useChat({
@ -13,7 +14,8 @@ export function Chat() {
}); });
return ( return (
<div className="max-w-2xl flex flex-col gap-2"> <div className="max-w-2xl flex flex-col gap-4">
<ChatError error={error} />
<ChatMessages messages={messages} /> <ChatMessages messages={messages} />
<ChatForm <ChatForm
onMessage={sendMessage} onMessage={sendMessage}

76
components/ui/alert.tsx Normal file
View file

@ -0,0 +1,76 @@
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className,
)}
{...props}
/>
);
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction };

View file

@ -1,8 +1,8 @@
import * as React from "react" import { cva, type VariantProps } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui";
import { Slot } from "radix-ui" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@ -38,8 +38,8 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function Button({ function Button({
className, className,
@ -49,9 +49,9 @@ function Button({
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
@ -61,7 +61,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

View file

@ -1,6 +1,6 @@
import * as React from "react" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return ( return (
@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Textarea } export { Textarea };

View file

@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View file

@ -1,5 +1,5 @@
import Elysia from "elysia"; import Elysia from "elysia";
import { health } from "./modules/health";
import { chat } from "./modules/chat"; import { chat } from "./modules/chat";
import { health } from "./modules/health";
export const app = new Elysia({ prefix: "/api" }).use(health).use(chat); export const app = new Elysia({ prefix: "/api" }).use(health).use(chat);

View file

@ -1,6 +1,6 @@
import { openai } from "@ai-sdk/openai";
import { convertToModelMessages, streamText } from "ai"; import { convertToModelMessages, streamText } from "ai";
import type { ChatModel } from "./model"; import type { ChatModel } from "./model";
import { openai } from "@ai-sdk/openai";
export abstract class Chat { export abstract class Chat {
static async streamText(chat: ChatModel["chat"]) { static async streamText(chat: ChatModel["chat"]) {