RAG_Chatbot / frontend /components /chat-interface.tsx
senlinyy's picture
feat: UI updated
65afa7b
Raw
History Blame Contribute Delete
20.1 kB
"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>
);
}