FeedbackChatbot / frontend /components /chat-interface.tsx
senlinyy's picture
feat: change persona logic
b044041
Raw
History Blame Contribute Delete
32.5 kB
"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>
</>
);
}