Spaces:
Sleeping
Sleeping
| "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<Persona[]>(DEFAULT_PERSONAS); | |
| const [documents, setDocuments] = useState<string[] | null>(null); | |
| const [documentsError, setDocumentsError] = useState<string | null>(null); | |
| const [activePersonaId, setActivePersonaId] = useState(DEFAULT_PERSONA_ID); | |
| const [temperature, setTemperature] = useState(DEFAULT_TEMPERATURE); | |
| const [conversations, setConversations] = useState<ChatConversation[]>([]); | |
| const [activeId, setActiveId] = useState<string | null>(null); | |
| const [messages, setMessages] = useState<ChatMessage[]>([]); | |
| const [input, setInput] = useState(""); | |
| const [busy, setBusy] = useState(false); | |
| const [hydrated, setHydrated] = useState(false); | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const activeIdRef = useRef<string | null>(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 ( | |
| <div className="grid h-full grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]"> | |
| <Card className="flex flex-col overflow-hidden"> | |
| <div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-6"> | |
| {messages.length === 0 && ( | |
| <div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground"> | |
| <img | |
| src="/luminaria.svg" | |
| alt="Luminaria holding a light" | |
| className="mb-4 h-28 w-28 rounded-lg object-contain" | |
| /> | |
| <p className="text-sm"> | |
| Ask a question or share a thought. | |
| </p> | |
| </div> | |
| )} | |
| {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 ( | |
| <div | |
| key={i} | |
| className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`} | |
| > | |
| <div | |
| className={`max-w-[85%] rounded-lg px-4 py-2.5 text-sm leading-relaxed ${ | |
| m.role === "user" | |
| ? "bg-primary text-primary-foreground" | |
| : "bg-muted text-foreground" | |
| }`} | |
| > | |
| <div className="whitespace-pre-wrap"> | |
| {m.content || | |
| thinkingPreview || | |
| (isStreamingAssistant ? "Thinking..." : "")} | |
| </div> | |
| {thinkingPreview && ( | |
| <div className="mt-2 text-xs italic opacity-70"> | |
| Thinking stream | |
| </div> | |
| )} | |
| {m.reasoning && ( | |
| <details className="mt-3 rounded-md border border-border/60 bg-background/60 p-3 text-xs text-muted-foreground"> | |
| <summary className="cursor-pointer select-none font-medium text-foreground"> | |
| Thinking | |
| </summary> | |
| <div className="mt-2 whitespace-pre-wrap leading-relaxed"> | |
| {m.reasoning} | |
| </div> | |
| </details> | |
| )} | |
| {m.status && ( | |
| <div className="mt-2 text-xs italic opacity-80">{m.status}</div> | |
| )} | |
| {m.sources && m.sources.length > 0 && ( | |
| <div className="mt-2 border-t border-border/50 pt-2 text-xs opacity-80"> | |
| <span className="font-medium">Sources: </span> | |
| {m.sources.join(", ")} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div className="border-t p-4"> | |
| <form | |
| className="flex gap-2" | |
| onSubmit={(e) => { | |
| e.preventDefault(); | |
| send(); | |
| }} | |
| > | |
| <Input | |
| placeholder="Ask a question or share a thought" | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| disabled={busy} | |
| /> | |
| <Button type="submit" disabled={busy || !input.trim()} size="icon"> | |
| {busy ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : ( | |
| <Send className="h-4 w-4" /> | |
| )} | |
| </Button> | |
| </form> | |
| </div> | |
| </Card> | |
| <Card> | |
| <CardContent className="space-y-6 p-6"> | |
| <div className="space-y-2"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <div className="text-sm font-medium">Conversations</div> | |
| <Button variant="outline" size="sm" onClick={newChat} disabled={busy}> | |
| <Plus className="h-4 w-4" /> New | |
| </Button> | |
| </div> | |
| <div className="max-h-56 space-y-1 overflow-y-auto rounded-md border bg-background p-1"> | |
| {conversations.length === 0 ? ( | |
| <div className="px-2 py-6 text-center text-xs text-muted-foreground"> | |
| No saved chats yet. | |
| </div> | |
| ) : ( | |
| conversations.map((conversation) => ( | |
| <div | |
| key={conversation.id} | |
| className={`group flex items-center gap-1 rounded-md ${ | |
| conversation.id === activeId ? "bg-accent" : "hover:bg-accent/60" | |
| }`} | |
| > | |
| <button | |
| type="button" | |
| className="min-w-0 flex-1 px-2 py-2 text-left" | |
| onClick={() => openConversation(conversation)} | |
| disabled={busy} | |
| > | |
| <div className="truncate text-sm font-medium"> | |
| {conversation.title} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| {displayDate(conversation.updatedAt)} | |
| </div> | |
| </button> | |
| <Button | |
| type="button" | |
| variant="ghost" | |
| size="icon" | |
| className="h-8 w-8 shrink-0 opacity-70 group-hover:opacity-100" | |
| onClick={() => deleteConversation(conversation.id)} | |
| disabled={busy} | |
| aria-label={`Delete ${conversation.title}`} | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| {conversations.length > 0 && ( | |
| <Button variant="ghost" size="sm" onClick={clearHistory} disabled={busy}> | |
| <Trash2 className="h-4 w-4" /> Clear history | |
| </Button> | |
| )} | |
| </div> | |
| <div className="space-y-2"> | |
| <div className="flex items-center justify-between gap-2"> | |
| <div className="text-sm font-medium">Course Materials</div> | |
| <Button | |
| type="button" | |
| variant="ghost" | |
| size="sm" | |
| onClick={loadDocuments} | |
| disabled={documents === null} | |
| > | |
| <RefreshCw className="h-4 w-4" /> Refresh | |
| </Button> | |
| </div> | |
| <div className="max-h-44 overflow-y-auto rounded-md border bg-background"> | |
| {documents === null ? ( | |
| <div className="px-3 py-6 text-center text-xs text-muted-foreground"> | |
| Loading materials... | |
| </div> | |
| ) : documentsError ? ( | |
| <div className="px-3 py-3 text-xs text-destructive"> | |
| {documentsError} | |
| </div> | |
| ) : documents.length === 0 ? ( | |
| <div className="px-3 py-6 text-center text-xs text-muted-foreground"> | |
| No materials uploaded yet. | |
| </div> | |
| ) : ( | |
| <ul className="divide-y"> | |
| {documents.map((name) => ( | |
| <li key={name} className="flex min-w-0 items-center gap-2 px-3 py-2"> | |
| <FileText className="h-4 w-4 shrink-0 text-muted-foreground" /> | |
| <span className="truncate text-sm" title={name}> | |
| {name} | |
| </span> | |
| </li> | |
| ))} | |
| </ul> | |
| )} | |
| </div> | |
| </div> | |
| <div className="space-y-2"> | |
| <div className="text-sm font-medium">Persona</div> | |
| <PersonaSelector personas={personas} value={persona.id} onChange={handlePersonaChange} /> | |
| <p className="text-xs text-muted-foreground">{persona.description}</p> | |
| </div> | |
| <TemperatureSlider value={temperature} onChange={handleTemperatureChange} /> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| ); | |
| } | |