Spaces:
Sleeping
Sleeping
| "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<Persona[]>(DEFAULT_PERSONAS); | |
| const [rubrics, setRubrics] = useState<string[] | null>(null); | |
| const [rubricsError, setRubricsError] = 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 [feedbackText, setFeedbackText] = useState(""); | |
| const [feedbackFiles, setFeedbackFiles] = useState<ParsedStudentFile[]>([]); | |
| const [courseworkFiles, setCourseworkFiles] = useState<ParsedStudentFile[]>([]); | |
| const [studentUploadBusy, setStudentUploadBusy] = useState<string | null>(null); | |
| const [studentUploadError, setStudentUploadError] = useState<string | null>(null); | |
| const [feedbackDialogOpen, setFeedbackDialogOpen] = useState(false); | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const inputRef = useRef<HTMLInputElement>(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]; | |
| 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<HTMLInputElement>, | |
| 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 ( | |
| <> | |
| <Dialog open={feedbackDialogOpen} onOpenChange={setFeedbackDialogOpen}> | |
| <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl"> | |
| <DialogHeader> | |
| <DialogTitle>Coursework feedback</DialogTitle> | |
| <DialogDescription> | |
| Add the feedback and coursework the assistant should use before you ask a question. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <div className="grid gap-4"> | |
| <section className="space-y-3 rounded-md border p-4"> | |
| <div className="flex items-center justify-between gap-3"> | |
| <div> | |
| <div className="text-sm font-medium">Tutor feedback</div> | |
| <div className="text-xs text-muted-foreground">Required</div> | |
| </div> | |
| {hasTutorFeedback && <CheckCircle2 className="h-4 w-4 text-emerald-600" />} | |
| </div> | |
| <div className="space-y-2"> | |
| <div className="text-xs font-medium text-muted-foreground">Paste feedback</div> | |
| <textarea | |
| value={feedbackText} | |
| onChange={(event) => setFeedbackText(event.target.value)} | |
| placeholder="Paste tutor feedback here" | |
| className="min-h-32 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" | |
| /> | |
| </div> | |
| <div className="flex items-center gap-3 text-xs text-muted-foreground"> | |
| <div className="h-px flex-1 bg-border" /> | |
| <span>or</span> | |
| <div className="h-px flex-1 bg-border" /> | |
| </div> | |
| <label className="flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground hover:bg-accent/50"> | |
| {studentUploadBusy?.startsWith("feedback:") ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : ( | |
| <UploadCloud className="h-4 w-4" /> | |
| )} | |
| Upload feedback | |
| <input | |
| type="file" | |
| className="hidden" | |
| accept={FEEDBACK_FILE_ACCEPT} | |
| disabled={Boolean(studentUploadBusy)} | |
| onChange={(event) => handleStudentFileChange(event, "feedback")} | |
| /> | |
| </label> | |
| {feedbackFiles.length > 0 && ( | |
| <ul className="space-y-1"> | |
| {feedbackFiles.map((file) => ( | |
| <li key={file.id} className="flex items-center gap-2 rounded-md border px-2 py-1.5 text-xs"> | |
| <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> | |
| <span className="min-w-0 flex-1 truncate" title={file.name}> | |
| {file.name} | |
| </span> | |
| <Button | |
| type="button" | |
| variant="ghost" | |
| size="icon" | |
| className="h-6 w-6" | |
| aria-label={`Remove ${file.name}`} | |
| onClick={() => removeParsedFile("feedback", file.id)} | |
| > | |
| <X className="h-3.5 w-3.5" /> | |
| </Button> | |
| </li> | |
| ))} | |
| </ul> | |
| )} | |
| </section> | |
| <section className="space-y-3 rounded-md border p-4"> | |
| <div className="flex items-center justify-between gap-3"> | |
| <div> | |
| <div className="text-sm font-medium">Coursework</div> | |
| <div className="text-xs text-muted-foreground">Required</div> | |
| </div> | |
| {hasCoursework && <CheckCircle2 className="h-4 w-4 text-emerald-600" />} | |
| </div> | |
| <label className="flex cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground hover:bg-accent/50"> | |
| {studentUploadBusy?.startsWith("coursework:") ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : ( | |
| <UploadCloud className="h-4 w-4" /> | |
| )} | |
| Upload coursework | |
| <input | |
| type="file" | |
| className="hidden" | |
| accept={STUDENT_FILE_ACCEPT} | |
| disabled={Boolean(studentUploadBusy)} | |
| onChange={(event) => handleStudentFileChange(event, "coursework")} | |
| /> | |
| </label> | |
| {courseworkFiles.length > 0 && ( | |
| <ul className="space-y-1"> | |
| {courseworkFiles.map((file) => ( | |
| <li key={file.id} className="flex items-center gap-2 rounded-md border px-2 py-1.5 text-xs"> | |
| <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> | |
| <span className="min-w-0 flex-1 truncate" title={file.name}> | |
| {file.name} | |
| </span> | |
| <Button | |
| type="button" | |
| variant="ghost" | |
| size="icon" | |
| className="h-6 w-6" | |
| aria-label={`Remove ${file.name}`} | |
| onClick={() => removeParsedFile("coursework", file.id)} | |
| > | |
| <X className="h-3.5 w-3.5" /> | |
| </Button> | |
| </li> | |
| ))} | |
| </ul> | |
| )} | |
| </section> | |
| {studentUploadError && ( | |
| <div className="rounded-md border border-destructive/30 px-3 py-2 text-xs text-destructive"> | |
| {studentUploadError} | |
| </div> | |
| )} | |
| </div> | |
| <DialogFooter> | |
| <Button | |
| type="button" | |
| onClick={() => setFeedbackDialogOpen(false)} | |
| disabled={!courseworkFeedbackReady} | |
| > | |
| Start asking | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| <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 about your tutor feedback. | |
| </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(); | |
| }} | |
| > | |
| <div className="flex w-full flex-col gap-2"> | |
| <div className={`flex flex-col gap-2 ${courseworkFeedbackReady ? "sm:flex-row" : ""}`}> | |
| <Button | |
| type="button" | |
| variant={courseworkFeedbackReady ? "outline" : "default"} | |
| className={courseworkFeedbackReady ? "justify-start sm:w-56" : "w-full"} | |
| onClick={() => setFeedbackDialogOpen(true)} | |
| > | |
| {courseworkFeedbackReady ? ( | |
| <CheckCircle2 className="h-4 w-4" /> | |
| ) : ( | |
| <ClipboardList className="h-4 w-4" /> | |
| )} | |
| Coursework feedback | |
| </Button> | |
| {courseworkFeedbackReady && ( | |
| <> | |
| <Input | |
| ref={inputRef} | |
| placeholder="Ask a follow-up about your feedback" | |
| 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> | |
| </> | |
| )} | |
| </div> | |
| {courseworkFeedbackReady && ( | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <span className="text-xs text-muted-foreground">{contextSummary}</span> | |
| {SUGGESTED_PROMPTS.map((prompt) => ( | |
| <button | |
| key={prompt} | |
| type="button" | |
| className="rounded-full border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground" | |
| onClick={() => useSuggestedPrompt(prompt)} | |
| > | |
| {prompt} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </form> | |
| </div> | |
| </Card> | |
| <Card className="min-h-0 overflow-hidden"> | |
| <CardContent className="h-full space-y-6 overflow-y-auto 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">Rubrics</div> | |
| <Button | |
| type="button" | |
| variant="ghost" | |
| size="sm" | |
| onClick={loadRubrics} | |
| disabled={rubrics === null} | |
| > | |
| <RefreshCw className="h-4 w-4" /> Refresh | |
| </Button> | |
| </div> | |
| <div className="max-h-44 overflow-y-auto rounded-md border bg-background"> | |
| {rubrics === null ? ( | |
| <div className="px-3 py-6 text-center text-xs text-muted-foreground"> | |
| Loading rubrics... | |
| </div> | |
| ) : rubricsError ? ( | |
| <div className="px-3 py-3 text-xs text-destructive"> | |
| {rubricsError} | |
| </div> | |
| ) : rubrics.length === 0 ? ( | |
| <div className="px-3 py-6 text-center text-xs text-muted-foreground"> | |
| No rubrics uploaded yet. | |
| </div> | |
| ) : ( | |
| <ul className="divide-y"> | |
| {rubrics.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> | |
| </> | |
| ); | |
| } | |