"use client"; import { useState, useRef, useEffect, type ChangeEvent } from "react"; import { CheckCircle2, ClipboardList, FileText, Loader2, Plus, RefreshCw, Send, Trash2, UploadCloud, X, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; 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; const STUDENT_FILE_ACCEPT = ".pdf,.docx,.txt,.md,.ipynb"; const FEEDBACK_FILE_ACCEPT = ".pdf,.docx,.txt,.md"; const SUGGESTED_PROMPTS = [ "Summarise my tutor feedback", "What should I improve first?", "Explain this comment in plain English", "How does this relate to the rubric?", ]; type ParsedStudentFile = { id: string; name: string; text: string; }; 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 [rubrics, setRubrics] = useState(null); const [rubricsError, setRubricsError] = 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 [feedbackText, setFeedbackText] = useState(""); const [feedbackFiles, setFeedbackFiles] = useState([]); const [courseworkFiles, setCourseworkFiles] = useState([]); const [studentUploadBusy, setStudentUploadBusy] = useState(null); const [studentUploadError, setStudentUploadError] = useState(null); const [feedbackDialogOpen, setFeedbackDialogOpen] = useState(false); const scrollRef = useRef(null); const inputRef = 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]; const hasTutorFeedback = feedbackText.trim().length > 0 || feedbackFiles.length > 0; const hasCoursework = courseworkFiles.length > 0; const courseworkFeedbackReady = hasTutorFeedback && hasCoursework; const contextSummary = courseworkFeedbackReady ? `${feedbackFiles.length + (feedbackText.trim() ? 1 : 0)} feedback source${ feedbackFiles.length + (feedbackText.trim() ? 1 : 0) === 1 ? "" : "s" } ยท ${courseworkFiles.length} coursework file${courseworkFiles.length === 1 ? "" : "s"}` : "Add feedback and coursework"; 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 loadRubrics() { setRubricsError(null); try { const response = await fetch("/api/rubrics"); if (!response.ok) throw new Error(await response.text()); const data = await response.json(); setRubrics(Array.isArray(data.files) ? data.files : []); } catch (error) { setRubrics([]); setRubricsError((error as Error).message); } } useEffect(() => { loadRubrics(); }, []); 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) { if (busy) return; setActivePersonaId(nextPersona.id); personaIdRef.current = nextPersona.id; saveChatPreferences({ personaId: nextPersona.id, temperature: temperatureRef.current }); if (messages.length > 0) { activeIdRef.current = null; setActiveId(null); setMessages([]); setInput(""); return; } 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; } function contextText(pasted: string, files: ParsedStudentFile[]) { return [ pasted.trim() ? `[Pasted tutor feedback]\n${pasted.trim()}` : "", ...files.map((file) => `[${file.name}]\n${file.text}`), ] .filter(Boolean) .join("\n\n---\n\n"); } function courseworkText() { return courseworkFiles.map((file) => `[${file.name}]\n${file.text}`).join("\n\n---\n\n"); } function removeParsedFile(kind: "feedback" | "coursework", id: string) { if (kind === "feedback") { setFeedbackFiles((prev) => prev.filter((item) => item.id !== id)); } else { setCourseworkFiles((prev) => prev.filter((item) => item.id !== id)); } } function useSuggestedPrompt(prompt: string) { setInput(prompt); window.requestAnimationFrame(() => inputRef.current?.focus()); } async function parseStudentFile(file: File, kind: "feedback" | "coursework") { setStudentUploadError(null); setStudentUploadBusy(`${kind}:${file.name}`); try { const formData = new FormData(); formData.append("file", file); const response = await fetch("/api/student/parse-file", { method: "POST", body: formData, }); const payload = await response.json().catch(() => null); if (!response.ok) { throw new Error(payload?.detail ?? `Could not parse ${file.name}`); } const parsed: ParsedStudentFile = { id: `${Date.now()}-${file.name}`, name: payload.filename ?? file.name, text: payload.text ?? "", }; if (kind === "feedback") { setFeedbackFiles((prev) => [...prev, parsed]); } else { setCourseworkFiles((prev) => [...prev, parsed]); } } catch (error) { setStudentUploadError((error as Error).message); } finally { setStudentUploadBusy(null); } } function handleStudentFileChange( event: ChangeEvent, kind: "feedback" | "coursework" ) { const files = Array.from(event.target.files ?? []); event.target.value = ""; files.forEach((file) => { void parseStudentFile(file, kind); }); } async function send() { const text = input.trim(); if (!text || busy) return; if (!courseworkFeedbackReady) { setFeedbackDialogOpen(true); 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, tutor_feedback_text: contextText(feedbackText, feedbackFiles), coursework_text: courseworkText(), }), }); 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 { continue; } } } } 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 ( <> Coursework feedback Add the feedback and coursework the assistant should use before you ask a question.
Tutor feedback
Required
{hasTutorFeedback && }
Paste feedback