"use client"; import { useState, useRef, useEffect } from "react"; import { FileText, Loader2, Plus, RefreshCw, Send, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; import { PersonaSelector } from "@/components/persona-selector"; import { TemperatureSlider } from "@/components/temperature-slider"; import { createConversation, loadChatPreferences, loadChatStore, removeConversation, saveChatPreferences, saveChatStore, titleFromMessages, upsertConversation, type ChatConversation, type ChatMessage, } from "@/lib/chat-storage"; import { DEFAULT_PERSONAS, DEFAULT_PERSONA_ID, fetchPersonas, type Persona, } from "@/lib/personas"; const DEFAULT_TEMPERATURE = 0.4; function latestThinkingPreview(reasoning: string) { const text = reasoning.trim(); if (!text) return ""; return text.length > 420 ? `...${text.slice(-420)}` : text; } function displayDate(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } export function ChatInterface() { const [personas, setPersonas] = useState(DEFAULT_PERSONAS); const [documents, setDocuments] = useState(null); const [documentsError, setDocumentsError] = useState(null); const [activePersonaId, setActivePersonaId] = useState(DEFAULT_PERSONA_ID); const [temperature, setTemperature] = useState(DEFAULT_TEMPERATURE); const [conversations, setConversations] = useState([]); const [activeId, setActiveId] = useState(null); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const [hydrated, setHydrated] = useState(false); const scrollRef = useRef(null); const activeIdRef = useRef(null); const personaIdRef = useRef(activePersonaId); const temperatureRef = useRef(temperature); const persona = personas.find((p) => p.id === activePersonaId) ?? personas.find((p) => p.id === DEFAULT_PERSONA_ID) ?? personas[0] ?? DEFAULT_PERSONAS[0]; useEffect(() => { const store = loadChatStore(); const preferences = loadChatPreferences(); const active = store.conversations.find((conversation) => conversation.id === store.activeId); const personaId = active?.personaId ?? preferences.personaId ?? DEFAULT_PERSONA_ID; const nextTemperature = active?.temperature ?? preferences.temperature ?? DEFAULT_TEMPERATURE; setConversations(store.conversations); setActiveId(store.activeId); setMessages(active?.messages ?? []); setActivePersonaId(personaId); setTemperature(nextTemperature); activeIdRef.current = store.activeId; personaIdRef.current = personaId; temperatureRef.current = nextTemperature; setHydrated(true); }, []); useEffect(() => { fetchPersonas().then((list) => { setPersonas(list); }); }, []); async function loadDocuments() { setDocumentsError(null); try { const response = await fetch("/api/documents"); if (!response.ok) throw new Error(await response.text()); const data = await response.json(); setDocuments(Array.isArray(data.files) ? data.files : []); } catch (error) { setDocuments([]); setDocumentsError((error as Error).message); } } useEffect(() => { loadDocuments(); }, []); useEffect(() => { activeIdRef.current = activeId; }, [activeId]); useEffect(() => { personaIdRef.current = activePersonaId; }, [activePersonaId]); useEffect(() => { temperatureRef.current = temperature; }, [temperature]); useEffect(() => { if (hydrated) saveChatStore({ activeId, conversations }); }, [activeId, conversations, hydrated]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); }, [messages, busy]); function ensureActiveConversation() { const existing = conversations.find((conversation) => conversation.id === activeIdRef.current); if (existing) return existing.id; const conversation = createConversation(personaIdRef.current, temperatureRef.current); activeIdRef.current = conversation.id; setActiveId(conversation.id); setConversations((prev) => upsertConversation(prev, conversation)); return conversation.id; } function commitMessages(conversationId: string, nextMessages: ChatMessage[]) { setMessages(nextMessages); setConversations((prev) => { const existing = prev.find((conversation) => conversation.id === conversationId) ?? createConversation(personaIdRef.current, temperatureRef.current); return upsertConversation(prev, { ...existing, id: conversationId, title: titleFromMessages(nextMessages), updatedAt: new Date().toISOString(), personaId: personaIdRef.current, temperature: temperatureRef.current, messages: nextMessages, }); }); } function updateActiveSettings(personaId: string, nextTemperature: number) { if (!activeIdRef.current) return; setConversations((prev) => prev.map((conversation) => conversation.id === activeIdRef.current ? { ...conversation, personaId, temperature: nextTemperature, updatedAt: new Date().toISOString(), } : conversation ) ); } function handlePersonaChange(nextPersona: Persona) { setActivePersonaId(nextPersona.id); personaIdRef.current = nextPersona.id; saveChatPreferences({ personaId: nextPersona.id, temperature: temperatureRef.current }); updateActiveSettings(nextPersona.id, temperatureRef.current); } function handleTemperatureChange(nextTemperature: number) { setTemperature(nextTemperature); temperatureRef.current = nextTemperature; saveChatPreferences({ personaId: personaIdRef.current, temperature: nextTemperature }); updateActiveSettings(personaIdRef.current, nextTemperature); } function newChat() { if (busy) return; activeIdRef.current = null; setActiveId(null); setMessages([]); setInput(""); const preferences = loadChatPreferences(); const personaId = preferences.personaId ?? personaIdRef.current; const nextTemperature = preferences.temperature ?? temperatureRef.current; setActivePersonaId(personaId); setTemperature(nextTemperature); personaIdRef.current = personaId; temperatureRef.current = nextTemperature; } function openConversation(conversation: ChatConversation) { if (busy) return; activeIdRef.current = conversation.id; setActiveId(conversation.id); setMessages(conversation.messages); setActivePersonaId(conversation.personaId); setTemperature(conversation.temperature); personaIdRef.current = conversation.personaId; temperatureRef.current = conversation.temperature; saveChatPreferences({ personaId: conversation.personaId, temperature: conversation.temperature, }); setInput(""); } function deleteConversation(id: string) { if (busy) return; const remaining = removeConversation(conversations, id); const nextActive = activeId === id ? remaining[0] : conversations.find((item) => item.id === activeId); setConversations(remaining); if (nextActive) { activeIdRef.current = nextActive.id; setActiveId(nextActive.id); setMessages(nextActive.messages); setActivePersonaId(nextActive.personaId); setTemperature(nextActive.temperature); personaIdRef.current = nextActive.personaId; temperatureRef.current = nextActive.temperature; } else { activeIdRef.current = null; setActiveId(null); setMessages([]); const preferences = loadChatPreferences(); const personaId = preferences.personaId ?? personaIdRef.current; const nextTemperature = preferences.temperature ?? temperatureRef.current; setActivePersonaId(personaId); setTemperature(nextTemperature); personaIdRef.current = personaId; temperatureRef.current = nextTemperature; } } function clearHistory() { if (busy) return; setConversations([]); activeIdRef.current = null; setActiveId(null); setMessages([]); setInput(""); const preferences = loadChatPreferences(); const personaId = preferences.personaId ?? personaIdRef.current; const nextTemperature = preferences.temperature ?? temperatureRef.current; setActivePersonaId(personaId); setTemperature(nextTemperature); personaIdRef.current = personaId; temperatureRef.current = nextTemperature; } async function send() { const text = input.trim(); if (!text || busy) return; const conversationId = ensureActiveConversation(); setInput(""); let currentMessages: ChatMessage[] = [ ...messages, { role: "user", content: text }, { role: "assistant", content: "" }, ]; commitMessages(conversationId, currentMessages); setBusy(true); try { const resp = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: currentMessages .slice(0, -1) .map(({ role, content }) => ({ role, content })), persona_prompt: persona.prompt, temperature: temperatureRef.current, }), }); if (!resp.ok) { const message = (await resp.text()).trim() || `HTTP ${resp.status}`; throw new Error(message); } if (!resp.body) throw new Error("No response stream"); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let acc = ""; let reasoning = ""; let sources: string[] | undefined; let status: string | undefined; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split("\n"); buf = lines.pop() ?? ""; for (const line of lines) { if (!line.trim()) continue; try { const evt = JSON.parse(line); if (evt.type === "delta") { acc += evt.text; status = undefined; } else if (evt.type === "reasoning") { reasoning += evt.text; } else if (evt.type === "phase") { status = evt.message || evt.value; } else if (evt.type === "sources") { sources = evt.sources; } else if (evt.type === "error") { acc += `\n\n*[error: ${evt.message}]*`; } currentMessages = [...currentMessages]; currentMessages[currentMessages.length - 1] = { role: "assistant", content: acc, reasoning, sources, status, }; commitMessages(conversationId, currentMessages); } catch { // partial JSON, ignore } } } } catch (e) { currentMessages = [...currentMessages]; currentMessages[currentMessages.length - 1] = { role: "assistant", content: `*Failed to reach the model: ${(e as Error).message}*`, }; commitMessages(conversationId, currentMessages); } finally { setBusy(false); } } return (
{messages.length === 0 && (
Luminaria holding a light

Ask a question or share a thought.

)} {messages.map((m, i) => { const isStreamingAssistant = busy && i === messages.length - 1 && m.role === "assistant"; const thinkingPreview = isStreamingAssistant && !m.content && m.reasoning ? latestThinkingPreview(m.reasoning) : ""; return (
{m.content || thinkingPreview || (isStreamingAssistant ? "Thinking..." : "")}
{thinkingPreview && (
Thinking stream
)} {m.reasoning && (
Thinking
{m.reasoning}
)} {m.status && (
{m.status}
)} {m.sources && m.sources.length > 0 && (
Sources: {m.sources.join(", ")}
)}
); })}
{ e.preventDefault(); send(); }} > setInput(e.target.value)} disabled={busy} />
Conversations
{conversations.length === 0 ? (
No saved chats yet.
) : ( conversations.map((conversation) => (
)) )}
{conversations.length > 0 && ( )}
Course Materials
{documents === null ? (
Loading materials...
) : documentsError ? (
{documentsError}
) : documents.length === 0 ? (
No materials uploaded yet.
) : (
    {documents.map((name) => (
  • {name}
  • ))}
)}
Persona

{persona.description}

); }