Add AI chat feature

This commit is contained in:
James Blair 2026-05-08 20:24:34 +12:00
parent 80056b3728
commit 6a5041be43
Signed by: james
SSH key fingerprint: SHA256:hsDR3ENI2ZMgh+CNb+0PrFsj29DI007R+BMKUVGi6zQ
15 changed files with 1259 additions and 2 deletions

11
app/chat/page.tsx Normal file
View file

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

View file

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

852
bun.lock

File diff suppressed because it is too large Load diff

25
components.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View file

@ -0,0 +1,34 @@
import { useState, type SubmitEvent } from "react";
import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { ArrowUpIcon, SendIcon } from "lucide-react";
interface ChatFormProps {
onMessage: (message: { text: string }) => void;
disabled?: boolean;
}
export function ChatForm({ onMessage, disabled }: ChatFormProps) {
const [text, setText] = useState("");
function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (!text.trim()) return;
setText("");
onMessage({ text });
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<Textarea
name="text"
value={text}
onChange={(e) => setText(e.target.value)}
className="min-h-18"
/>
<Button type="submit" disabled={disabled} size="icon-lg">
<ArrowUpIcon />
</Button>
</form>
);
}

View file

@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
import { UIMessage } from "ai";
interface ChatMessagesProps {
messages: UIMessage[];
}
export function ChatMessages({ messages }: ChatMessagesProps) {
return (
<div className="flex flex-col gap-2">
{messages.map((message, index) => (
<div
key={index}
className={cn(
"bg-primary text-primary-foreground px-3 py-2 rounded-lg whitespace-pre-wrap wrap-break-word break-all",
message.role === "user"
? "self-end bg-primary text-primary-foreground"
: "self-start bg-accent text-accent-foreground",
)}
>
{message.parts.map((part, index) => {
if (part.type === "text") {
return <span key={index}>{part.text}</span>;
}
return null;
})}
</div>
))}
</div>
);
}

24
components/chat/index.tsx Normal file
View file

@ -0,0 +1,24 @@
"use client";
import { ChatMessages } from "./chat-messages";
import { ChatForm } from "./chat-form";
import { DefaultChatTransport } from "ai";
import { useChat } from "@ai-sdk/react";
export function Chat() {
const { messages, sendMessage, status, error } = useChat({
transport: new DefaultChatTransport({
api: "http://localhost:3000/api/chat",
}),
});
return (
<div className="max-w-2xl flex flex-col gap-2">
<ChatMessages messages={messages} />
<ChatForm
onMessage={sendMessage}
disabled={status === "submitted" || status === "streaming"}
/>
</div>
);
}

67
components/ui/button.tsx Normal file
View file

@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
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",
className
)}
{...props}
/>
)
}
export { Textarea }

6
lib/utils.ts Normal file
View file

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

View file

@ -10,11 +10,21 @@
"format": "biome format --write" "format": "biome format --write"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^3.0.63",
"@ai-sdk/react": "^3.0.178",
"@elysia/eden": "^1.4.10", "@elysia/eden": "^1.4.10",
"ai": "^6.0.176",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"elysia": "^1.4.28", "elysia": "^1.4.28",
"lucide-react": "^1.14.0",
"next": "16.2.6", "next": "16.2.6",
"radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.2.0",

View file

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

View file

@ -0,0 +1,15 @@
import { Elysia } from "elysia";
import { ChatModel } from "./model";
import { Chat } from "./service";
export const chat = new Elysia({ prefix: "/chat" }).post(
"/",
async ({ body }) => {
const stream = await Chat.streamText(body);
return stream.toUIMessageStreamResponse();
},
{
body: ChatModel.chat,
},
);

View file

@ -0,0 +1,21 @@
import { t, type UnwrapSchema } from "elysia";
export const ChatModel = {
chat: t.Object({
messages: t.Array(
t.Object({
id: t.String(),
role: t.Union([
t.Literal("user"),
t.Literal("assistant"),
t.Literal("system"),
]),
parts: t.Array(t.Any()),
}),
),
}),
} as const;
export type ChatModel = {
[k in keyof typeof ChatModel]: UnwrapSchema<(typeof ChatModel)[k]>;
};

View file

@ -0,0 +1,13 @@
import { convertToModelMessages, streamText } from "ai";
import type { ChatModel } from "./model";
import { openai } from "@ai-sdk/openai";
export abstract class Chat {
static async streamText(chat: ChatModel["chat"]) {
return streamText({
model: openai("gpt-5.5"),
system: "You are Yae Miko from Genshin Impact",
messages: await convertToModelMessages(chat.messages),
});
}
}