"use client"; import { ArrowUp, Loader2, Square } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { readSSEStream } from "@/lib/chat/streaming-state"; import { cn } from "@/lib/utils"; import { QuotaBar } from "./quota-bar"; import { QuotaWarningBanner } from "./quota-warning-banner"; interface Message { id: string; role: "user" | "assistant"; content: string; } interface AnonymousChatProps { model: AnonModel; } export function AnonymousChat({ model }: AnonymousChatProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [quota, setQuota] = useState(null); const abortRef = useRef(null); const messagesEndRef = useRef(null); const textareaRef = useRef(null); useEffect(() => { anonymousChatApiService.getQuota().then(setQuota).catch(console.error); }, []); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); const autoResizeTextarea = useCallback(() => { const textarea = textareaRef.current; if (textarea) { textarea.style.height = "auto"; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; } }, []); const handleSubmit = useCallback(async () => { const trimmed = input.trim(); if (!trimmed || isStreaming) return; if (quota && quota.used >= quota.limit) return; const userMsg: Message = { id: crypto.randomUUID(), role: "user", content: trimmed }; const assistantId = crypto.randomUUID(); const assistantMsg: Message = { id: assistantId, role: "assistant", content: "" }; setMessages((prev) => [...prev, userMsg, assistantMsg]); setInput(""); setIsStreaming(true); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } const controller = new AbortController(); abortRef.current = controller; try { const chatHistory = [...messages, userMsg].map((m) => ({ role: m.role, content: m.content, })); const response = await fetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"}/api/v1/public/anon-chat/stream`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ model_slug: model.seo_slug, messages: chatHistory, }), signal: controller.signal, } ); if (!response.ok) { if (response.status === 429) { const errorData = await response.json(); setQuota({ used: errorData.detail?.used ?? quota?.limit ?? 1000000, limit: errorData.detail?.limit ?? quota?.limit ?? 1000000, remaining: 0, status: "exceeded", warning_threshold: quota?.warning_threshold ?? 800000, }); setMessages((prev) => prev.filter((m) => m.id !== assistantId)); return; } throw new Error(`Stream error: ${response.status}`); } for await (const event of readSSEStream(response)) { if (controller.signal.aborted) break; if (event.type === "text-delta") { setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + event.delta } : m)) ); } else if (event.type === "error") { setMessages((prev) => prev.map((m) => m.id === assistantId ? { ...m, content: m.content || event.errorText } : m ) ); } else if ("type" in event && event.type === "data-token-usage") { // After streaming completes, refresh quota anonymousChatApiService.getQuota().then(setQuota).catch(console.error); } } } catch (err) { if (err instanceof DOMException && err.name === "AbortError") return; console.error("Chat stream error:", err); setMessages((prev) => prev.map((m) => m.id === assistantId && !m.content ? { ...m, content: "An error occurred. Please try again." } : m ) ); } finally { setIsStreaming(false); abortRef.current = null; anonymousChatApiService.getQuota().then(setQuota).catch(console.error); } }, [input, isStreaming, messages, model.seo_slug, quota]); const handleCancel = useCallback(() => { abortRef.current?.abort(); }, []); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }; const isExceeded = quota ? quota.used >= quota.limit : false; return (
{quota && ( )}
{messages.length === 0 && (
{model.name.charAt(0).toUpperCase()}

{model.name}

{model.description && (

{model.description}

)}

Free to use · No login required · Start typing below

)} {messages.map((msg) => (
{msg.role === "assistant" && (
{model.name.charAt(0).toUpperCase()}
)}
{msg.role === "assistant" && !msg.content && isStreaming ? ( ) : (
{msg.content}
)}
))}
{quota && ( )}