2026-01-03 17:13:15 +01:00

256 lines
9.5 KiB
TypeScript

"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Field,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { useChat } from "@ai-sdk/react";
import { ArrowUp, Eraser, Loader2, Plus, PlusIcon, Square } from "lucide-react";
import { MessageWall } from "@/components/messages/message-wall";
import { ChatHeader } from "@/app/parts/chat-header";
import { ChatHeaderBlock } from "@/app/parts/chat-header";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { UIMessage } from "ai";
import { useEffect, useState, useRef } from "react";
import { AI_NAME, CLEAR_CHAT_TEXT, OWNER_NAME, WELCOME_MESSAGE } from "@/config";
import Image from "next/image";
import Link from "next/link";
const formSchema = z.object({
message: z
.string()
.min(1, "Message cannot be empty.")
.max(2000, "Message must be at most 2000 characters."),
});
const STORAGE_KEY = 'chat-messages';
type StorageData = {
messages: UIMessage[];
durations: Record<string, number>;
};
const loadMessagesFromStorage = (): { messages: UIMessage[]; durations: Record<string, number> } => {
if (typeof window === 'undefined') return { messages: [], durations: {} };
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return { messages: [], durations: {} };
const parsed = JSON.parse(stored);
return {
messages: parsed.messages || [],
durations: parsed.durations || {},
};
} catch (error) {
console.error('Failed to load messages from localStorage:', error);
return { messages: [], durations: {} };
}
};
const saveMessagesToStorage = (messages: UIMessage[], durations: Record<string, number>) => {
if (typeof window === 'undefined') return;
try {
const data: StorageData = { messages, durations };
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (error) {
console.error('Failed to save messages to localStorage:', error);
}
};
export default function Chat() {
const [isClient, setIsClient] = useState(false);
const [durations, setDurations] = useState<Record<string, number>>({});
const welcomeMessageShownRef = useRef<boolean>(false);
const stored = typeof window !== 'undefined' ? loadMessagesFromStorage() : { messages: [], durations: {} };
const [initialMessages] = useState<UIMessage[]>(stored.messages);
const { messages, sendMessage, status, stop, setMessages } = useChat({
messages: initialMessages,
});
useEffect(() => {
setIsClient(true);
setDurations(stored.durations);
setMessages(stored.messages);
}, []);
useEffect(() => {
if (isClient) {
saveMessagesToStorage(messages, durations);
}
}, [durations, messages, isClient]);
const handleDurationChange = (key: string, duration: number) => {
setDurations((prevDurations) => {
const newDurations = { ...prevDurations };
newDurations[key] = duration;
return newDurations;
});
};
useEffect(() => {
if (isClient && initialMessages.length === 0 && !welcomeMessageShownRef.current) {
const welcomeMessage: UIMessage = {
id: `welcome-${Date.now()}`,
role: "assistant",
parts: [
{
type: "text",
text: WELCOME_MESSAGE,
},
],
};
setMessages([welcomeMessage]);
saveMessagesToStorage([welcomeMessage], {});
welcomeMessageShownRef.current = true;
}
}, [isClient, initialMessages.length, setMessages]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
message: "",
},
});
function onSubmit(data: z.infer<typeof formSchema>) {
sendMessage({ text: data.message });
form.reset();
}
function clearChat() {
const newMessages: UIMessage[] = [];
const newDurations = {};
setMessages(newMessages);
setDurations(newDurations);
saveMessagesToStorage(newMessages, newDurations);
toast.success("Chat cleared");
}
return (
<div className="flex h-screen items-center justify-center font-sans dark:bg-black">
<main className="w-full dark:bg-black h-screen relative">
<div className="fixed top-0 left-0 right-0 z-50 bg-linear-to-b from-background via-background/50 to-transparent dark:bg-black overflow-visible pb-16">
<div className="relative overflow-visible">
<ChatHeader>
<ChatHeaderBlock />
<ChatHeaderBlock className="justify-center items-center">
<Avatar
className="size-8 ring-1 ring-primary"
>
<AvatarImage src="/logo.png" />
<AvatarFallback>
<Image src="/logo.png" alt="Logo" width={36} height={36} />
</AvatarFallback>
</Avatar>
<p className="tracking-tight">Chat with {AI_NAME}</p>
</ChatHeaderBlock>
<ChatHeaderBlock className="justify-end">
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={clearChat}
>
<Plus className="size-4" />
{CLEAR_CHAT_TEXT}
</Button>
</ChatHeaderBlock>
</ChatHeader>
</div>
</div>
<div className="h-screen overflow-y-auto px-5 py-4 w-full pt-[88px] pb-[150px]">
<div className="flex flex-col items-center justify-end min-h-full">
{isClient ? (
<>
<MessageWall messages={messages} status={status} durations={durations} onDurationChange={handleDurationChange} />
{status === "submitted" && (
<div className="flex justify-start max-w-3xl w-full">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
</>
) : (
<div className="flex justify-center max-w-2xl w-full">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</div>
<div className="fixed bottom-0 left-0 right-0 z-50 bg-linear-to-t from-background via-background/50 to-transparent dark:bg-black overflow-visible pt-13">
<div className="w-full px-5 pt-5 pb-1 items-center flex justify-center relative overflow-visible">
<div className="message-fade-overlay" />
<div className="max-w-3xl w-full">
<form id="chat-form" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name="message"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="chat-form-message" className="sr-only">
Message
</FieldLabel>
<div className="relative h-13">
<Input
{...field}
id="chat-form-message"
className="h-15 pr-15 pl-5 bg-card rounded-[20px]"
placeholder="Type your message here..."
disabled={status === "streaming"}
aria-invalid={fieldState.invalid}
autoComplete="off"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
}}
/>
{(status == "ready" || status == "error") && (
<Button
className="absolute right-3 top-3 rounded-full"
type="submit"
disabled={!field.value.trim()}
size="icon"
>
<ArrowUp className="size-4" />
</Button>
)}
{(status == "streaming" || status == "submitted") && (
<Button
className="absolute right-2 top-2 rounded-full"
size="icon"
onClick={() => {
stop();
}}
>
<Square className="size-4" />
</Button>
)}
</div>
</Field>
)}
/>
</FieldGroup>
</form>
</div>
</div>
<div className="w-full px-5 py-3 items-center flex justify-center text-xs text-muted-foreground">
© {new Date().getFullYear()} {OWNER_NAME}&nbsp;<Link href="/terms" className="underline">Terms of Use</Link>&nbsp;Powered by&nbsp;<Link href="https://ringel.ai/" className="underline">Ringel.AI</Link>
</div>
</div>
</main>
</div >
);
}