senlinyy commited on
Commit
697c853
·
1 Parent(s): 69698fb

feat: persistent chat

Browse files
backend/main.py CHANGED
@@ -241,6 +241,14 @@ async def list_files(_: None = Depends(auth.require_admin)) -> dict:
241
  return {"files": hf_sync.list_remote_pdfs()}
242
 
243
 
 
 
 
 
 
 
 
 
244
  # ---------------- Admin auth ----------------
245
  @app.post("/admin/login")
246
  async def admin_login(body: LoginRequest, response: Response) -> dict:
 
241
  return {"files": hf_sync.list_remote_pdfs()}
242
 
243
 
244
+ @app.get("/documents")
245
+ async def list_documents() -> dict:
246
+ """Public read-only list of uploaded course materials."""
247
+ if not hf_sync.is_configured():
248
+ return {"files": []}
249
+ return {"files": hf_sync.list_remote_pdfs()}
250
+
251
+
252
  # ---------------- Admin auth ----------------
253
  @app.post("/admin/login")
254
  async def admin_login(body: LoginRequest, response: Response) -> dict:
frontend/components/chat-interface.tsx CHANGED
@@ -1,20 +1,31 @@
1
  "use client";
2
  import { useState, useRef, useEffect } from "react";
3
- import { Send, Loader2, BookOpen } from "lucide-react";
4
  import { Button } from "@/components/ui/button";
5
  import { Input } from "@/components/ui/input";
6
  import { Card, CardContent } from "@/components/ui/card";
7
  import { PersonaSelector } from "@/components/persona-selector";
8
  import { TemperatureSlider } from "@/components/temperature-slider";
9
- import { DEFAULT_PERSONAS, DEFAULT_PERSONA_ID, fetchPersonas, type Persona } from "@/lib/personas";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- type Msg = {
12
- role: "user" | "assistant";
13
- content: string;
14
- reasoning?: string;
15
- sources?: string[];
16
- status?: string;
17
- };
18
 
19
  function latestThinkingPreview(reasoning: string) {
20
  const text = reasoning.trim();
@@ -22,40 +33,249 @@ function latestThinkingPreview(reasoning: string) {
22
  return text.length > 420 ? `...${text.slice(-420)}` : text;
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
25
  export function ChatInterface() {
26
  const [personas, setPersonas] = useState<Persona[]>(DEFAULT_PERSONAS);
27
- const [persona, setPersona] = useState<Persona>(
28
- DEFAULT_PERSONAS.find((p) => p.id === DEFAULT_PERSONA_ID) ?? DEFAULT_PERSONAS[0]
29
- );
30
- const [temperature, setTemperature] = useState(0.4);
31
- const [messages, setMessages] = useState<Msg[]>([]);
 
 
32
  const [input, setInput] = useState("");
33
  const [busy, setBusy] = useState(false);
 
 
34
  const scrollRef = useRef<HTMLDivElement>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
- // Load live personas on mount
37
  useEffect(() => {
38
  fetchPersonas().then((list) => {
39
  setPersonas(list);
40
- // Re-select same id if it still exists, otherwise pick default
41
- setPersona((prev) => list.find((p) => p.id === prev.id) ?? list[0] ?? prev);
42
  });
43
  }, []);
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  useEffect(() => {
46
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
47
  }, [messages, busy]);
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  async function send() {
50
  const text = input.trim();
51
  if (!text || busy) return;
 
 
52
  setInput("");
53
- const next: Msg[] = [
 
54
  ...messages,
55
  { role: "user", content: text },
56
  { role: "assistant", content: "" },
57
  ];
58
- setMessages(next);
59
  setBusy(true);
60
 
61
  try {
@@ -63,11 +283,11 @@ export function ChatInterface() {
63
  method: "POST",
64
  headers: { "Content-Type": "application/json" },
65
  body: JSON.stringify({
66
- messages: next
67
  .slice(0, -1)
68
  .map(({ role, content }) => ({ role, content })),
69
  persona_prompt: persona.prompt,
70
- temperature,
71
  }),
72
  });
73
 
@@ -107,31 +327,28 @@ export function ChatInterface() {
107
  } else if (evt.type === "error") {
108
  acc += `\n\n*[error: ${evt.message}]*`;
109
  }
110
- setMessages((prev) => {
111
- const copy = [...prev];
112
- copy[copy.length - 1] = {
113
- role: "assistant",
114
- content: acc,
115
- reasoning,
116
- sources,
117
- status,
118
- };
119
- return copy;
120
- });
121
  } catch {
122
  // partial JSON, ignore
123
  }
124
  }
125
  }
126
  } catch (e) {
127
- setMessages((prev) => {
128
- const copy = [...prev];
129
- copy[copy.length - 1] = {
130
- role: "assistant",
131
- content: `*Failed to reach the model: ${(e as Error).message}*`,
132
- };
133
- return copy;
134
- });
135
  } finally {
136
  setBusy(false);
137
  }
@@ -162,44 +379,44 @@ export function ChatInterface() {
162
  key={i}
163
  className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}
164
  >
165
- <div
166
- className={`max-w-[85%] rounded-lg px-4 py-2.5 text-sm leading-relaxed ${
167
- m.role === "user"
168
- ? "bg-primary text-primary-foreground"
169
- : "bg-muted text-foreground"
170
- }`}
171
- >
172
- <div className="whitespace-pre-wrap">
173
- {m.content ||
174
- thinkingPreview ||
175
- (isStreamingAssistant ? "Thinking" : "")}
176
- </div>
177
- {thinkingPreview && (
178
- <div className="mt-2 text-xs italic opacity-70">
179
- Thinking stream
180
  </div>
181
- )}
182
- {m.reasoning && (
183
- <details className="mt-3 rounded-md border border-border/60 bg-background/60 p-3 text-xs text-muted-foreground">
184
- <summary className="cursor-pointer select-none font-medium text-foreground">
185
- Thinking
186
- </summary>
187
- <div className="mt-2 whitespace-pre-wrap leading-relaxed">
188
- {m.reasoning}
189
  </div>
190
- </details>
191
- )}
192
- {m.status && (
193
- <div className="mt-2 text-xs italic opacity-80">{m.status}</div>
194
- )}
195
- {m.sources && m.sources.length > 0 && (
196
- <div className="mt-2 border-t border-border/50 pt-2 text-xs opacity-80">
197
- <span className="font-medium">Sources: </span>
198
- {m.sources.join(", ")}
199
- </div>
200
- )}
 
 
 
 
 
 
 
 
 
 
201
  </div>
202
- </div>
203
  );
204
  })}
205
  </div>
@@ -212,7 +429,7 @@ export function ChatInterface() {
212
  }}
213
  >
214
  <Input
215
- placeholder="Ask about the course material"
216
  value={input}
217
  onChange={(e) => setInput(e.target.value)}
218
  disabled={busy}
@@ -230,12 +447,108 @@ export function ChatInterface() {
230
 
231
  <Card>
232
  <CardContent className="space-y-6 p-6">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  <div className="space-y-2">
234
  <div className="text-sm font-medium">Persona</div>
235
- <PersonaSelector personas={personas} value={persona.id} onChange={setPersona} />
236
  <p className="text-xs text-muted-foreground">{persona.description}</p>
237
  </div>
238
- <TemperatureSlider value={temperature} onChange={setTemperature} />
239
  </CardContent>
240
  </Card>
241
  </div>
 
1
  "use client";
2
  import { useState, useRef, useEffect } from "react";
3
+ import { BookOpen, FileText, Loader2, Plus, RefreshCw, Send, Trash2 } from "lucide-react";
4
  import { Button } from "@/components/ui/button";
5
  import { Input } from "@/components/ui/input";
6
  import { Card, CardContent } from "@/components/ui/card";
7
  import { PersonaSelector } from "@/components/persona-selector";
8
  import { TemperatureSlider } from "@/components/temperature-slider";
9
+ import {
10
+ createConversation,
11
+ loadChatPreferences,
12
+ loadChatStore,
13
+ removeConversation,
14
+ saveChatPreferences,
15
+ saveChatStore,
16
+ titleFromMessages,
17
+ upsertConversation,
18
+ type ChatConversation,
19
+ type ChatMessage,
20
+ } from "@/lib/chat-storage";
21
+ import {
22
+ DEFAULT_PERSONAS,
23
+ DEFAULT_PERSONA_ID,
24
+ fetchPersonas,
25
+ type Persona,
26
+ } from "@/lib/personas";
27
 
28
+ const DEFAULT_TEMPERATURE = 0.4;
 
 
 
 
 
 
29
 
30
  function latestThinkingPreview(reasoning: string) {
31
  const text = reasoning.trim();
 
33
  return text.length > 420 ? `...${text.slice(-420)}` : text;
34
  }
35
 
36
+ function displayDate(value: string) {
37
+ const date = new Date(value);
38
+ if (Number.isNaN(date.getTime())) return "";
39
+ return date.toLocaleString(undefined, {
40
+ month: "short",
41
+ day: "numeric",
42
+ hour: "2-digit",
43
+ minute: "2-digit",
44
+ });
45
+ }
46
+
47
  export function ChatInterface() {
48
  const [personas, setPersonas] = useState<Persona[]>(DEFAULT_PERSONAS);
49
+ const [documents, setDocuments] = useState<string[] | null>(null);
50
+ const [documentsError, setDocumentsError] = useState<string | null>(null);
51
+ const [activePersonaId, setActivePersonaId] = useState(DEFAULT_PERSONA_ID);
52
+ const [temperature, setTemperature] = useState(DEFAULT_TEMPERATURE);
53
+ const [conversations, setConversations] = useState<ChatConversation[]>([]);
54
+ const [activeId, setActiveId] = useState<string | null>(null);
55
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
56
  const [input, setInput] = useState("");
57
  const [busy, setBusy] = useState(false);
58
+ const [hydrated, setHydrated] = useState(false);
59
+
60
  const scrollRef = useRef<HTMLDivElement>(null);
61
+ const activeIdRef = useRef<string | null>(null);
62
+ const personaIdRef = useRef(activePersonaId);
63
+ const temperatureRef = useRef(temperature);
64
+
65
+ const persona =
66
+ personas.find((p) => p.id === activePersonaId) ??
67
+ personas.find((p) => p.id === DEFAULT_PERSONA_ID) ??
68
+ personas[0] ??
69
+ DEFAULT_PERSONAS[0];
70
+
71
+ useEffect(() => {
72
+ const store = loadChatStore();
73
+ const preferences = loadChatPreferences();
74
+ const active = store.conversations.find((conversation) => conversation.id === store.activeId);
75
+ const personaId = active?.personaId ?? preferences.personaId ?? DEFAULT_PERSONA_ID;
76
+ const nextTemperature = active?.temperature ?? preferences.temperature ?? DEFAULT_TEMPERATURE;
77
+
78
+ setConversations(store.conversations);
79
+ setActiveId(store.activeId);
80
+ setMessages(active?.messages ?? []);
81
+ setActivePersonaId(personaId);
82
+ setTemperature(nextTemperature);
83
+ activeIdRef.current = store.activeId;
84
+ personaIdRef.current = personaId;
85
+ temperatureRef.current = nextTemperature;
86
+ setHydrated(true);
87
+ }, []);
88
 
 
89
  useEffect(() => {
90
  fetchPersonas().then((list) => {
91
  setPersonas(list);
 
 
92
  });
93
  }, []);
94
 
95
+ async function loadDocuments() {
96
+ setDocumentsError(null);
97
+ try {
98
+ const response = await fetch("/api/documents");
99
+ if (!response.ok) throw new Error(await response.text());
100
+ const data = await response.json();
101
+ setDocuments(Array.isArray(data.files) ? data.files : []);
102
+ } catch (error) {
103
+ setDocuments([]);
104
+ setDocumentsError((error as Error).message);
105
+ }
106
+ }
107
+
108
+ useEffect(() => {
109
+ loadDocuments();
110
+ }, []);
111
+
112
+ useEffect(() => {
113
+ activeIdRef.current = activeId;
114
+ }, [activeId]);
115
+
116
+ useEffect(() => {
117
+ personaIdRef.current = activePersonaId;
118
+ }, [activePersonaId]);
119
+
120
+ useEffect(() => {
121
+ temperatureRef.current = temperature;
122
+ }, [temperature]);
123
+
124
+ useEffect(() => {
125
+ if (hydrated) saveChatStore({ activeId, conversations });
126
+ }, [activeId, conversations, hydrated]);
127
+
128
  useEffect(() => {
129
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
130
  }, [messages, busy]);
131
 
132
+ function ensureActiveConversation() {
133
+ const existing = conversations.find((conversation) => conversation.id === activeIdRef.current);
134
+ if (existing) return existing.id;
135
+
136
+ const conversation = createConversation(personaIdRef.current, temperatureRef.current);
137
+ activeIdRef.current = conversation.id;
138
+ setActiveId(conversation.id);
139
+ setConversations((prev) => upsertConversation(prev, conversation));
140
+ return conversation.id;
141
+ }
142
+
143
+ function commitMessages(conversationId: string, nextMessages: ChatMessage[]) {
144
+ setMessages(nextMessages);
145
+ setConversations((prev) => {
146
+ const existing =
147
+ prev.find((conversation) => conversation.id === conversationId) ??
148
+ createConversation(personaIdRef.current, temperatureRef.current);
149
+ return upsertConversation(prev, {
150
+ ...existing,
151
+ id: conversationId,
152
+ title: titleFromMessages(nextMessages),
153
+ updatedAt: new Date().toISOString(),
154
+ personaId: personaIdRef.current,
155
+ temperature: temperatureRef.current,
156
+ messages: nextMessages,
157
+ });
158
+ });
159
+ }
160
+
161
+ function updateActiveSettings(personaId: string, nextTemperature: number) {
162
+ if (!activeIdRef.current) return;
163
+ setConversations((prev) =>
164
+ prev.map((conversation) =>
165
+ conversation.id === activeIdRef.current
166
+ ? {
167
+ ...conversation,
168
+ personaId,
169
+ temperature: nextTemperature,
170
+ updatedAt: new Date().toISOString(),
171
+ }
172
+ : conversation
173
+ )
174
+ );
175
+ }
176
+
177
+ function handlePersonaChange(nextPersona: Persona) {
178
+ setActivePersonaId(nextPersona.id);
179
+ personaIdRef.current = nextPersona.id;
180
+ saveChatPreferences({ personaId: nextPersona.id, temperature: temperatureRef.current });
181
+ updateActiveSettings(nextPersona.id, temperatureRef.current);
182
+ }
183
+
184
+ function handleTemperatureChange(nextTemperature: number) {
185
+ setTemperature(nextTemperature);
186
+ temperatureRef.current = nextTemperature;
187
+ saveChatPreferences({ personaId: personaIdRef.current, temperature: nextTemperature });
188
+ updateActiveSettings(personaIdRef.current, nextTemperature);
189
+ }
190
+
191
+ function newChat() {
192
+ if (busy) return;
193
+ activeIdRef.current = null;
194
+ setActiveId(null);
195
+ setMessages([]);
196
+ setInput("");
197
+ const preferences = loadChatPreferences();
198
+ const personaId = preferences.personaId ?? personaIdRef.current;
199
+ const nextTemperature = preferences.temperature ?? temperatureRef.current;
200
+ setActivePersonaId(personaId);
201
+ setTemperature(nextTemperature);
202
+ personaIdRef.current = personaId;
203
+ temperatureRef.current = nextTemperature;
204
+ }
205
+
206
+ function openConversation(conversation: ChatConversation) {
207
+ if (busy) return;
208
+ activeIdRef.current = conversation.id;
209
+ setActiveId(conversation.id);
210
+ setMessages(conversation.messages);
211
+ setActivePersonaId(conversation.personaId);
212
+ setTemperature(conversation.temperature);
213
+ personaIdRef.current = conversation.personaId;
214
+ temperatureRef.current = conversation.temperature;
215
+ saveChatPreferences({
216
+ personaId: conversation.personaId,
217
+ temperature: conversation.temperature,
218
+ });
219
+ setInput("");
220
+ }
221
+
222
+ function deleteConversation(id: string) {
223
+ if (busy) return;
224
+ const remaining = removeConversation(conversations, id);
225
+ const nextActive = activeId === id ? remaining[0] : conversations.find((item) => item.id === activeId);
226
+
227
+ setConversations(remaining);
228
+ if (nextActive) {
229
+ activeIdRef.current = nextActive.id;
230
+ setActiveId(nextActive.id);
231
+ setMessages(nextActive.messages);
232
+ setActivePersonaId(nextActive.personaId);
233
+ setTemperature(nextActive.temperature);
234
+ personaIdRef.current = nextActive.personaId;
235
+ temperatureRef.current = nextActive.temperature;
236
+ } else {
237
+ activeIdRef.current = null;
238
+ setActiveId(null);
239
+ setMessages([]);
240
+ const preferences = loadChatPreferences();
241
+ const personaId = preferences.personaId ?? personaIdRef.current;
242
+ const nextTemperature = preferences.temperature ?? temperatureRef.current;
243
+ setActivePersonaId(personaId);
244
+ setTemperature(nextTemperature);
245
+ personaIdRef.current = personaId;
246
+ temperatureRef.current = nextTemperature;
247
+ }
248
+ }
249
+
250
+ function clearHistory() {
251
+ if (busy) return;
252
+ setConversations([]);
253
+ activeIdRef.current = null;
254
+ setActiveId(null);
255
+ setMessages([]);
256
+ setInput("");
257
+ const preferences = loadChatPreferences();
258
+ const personaId = preferences.personaId ?? personaIdRef.current;
259
+ const nextTemperature = preferences.temperature ?? temperatureRef.current;
260
+ setActivePersonaId(personaId);
261
+ setTemperature(nextTemperature);
262
+ personaIdRef.current = personaId;
263
+ temperatureRef.current = nextTemperature;
264
+ }
265
+
266
  async function send() {
267
  const text = input.trim();
268
  if (!text || busy) return;
269
+
270
+ const conversationId = ensureActiveConversation();
271
  setInput("");
272
+
273
+ let currentMessages: ChatMessage[] = [
274
  ...messages,
275
  { role: "user", content: text },
276
  { role: "assistant", content: "" },
277
  ];
278
+ commitMessages(conversationId, currentMessages);
279
  setBusy(true);
280
 
281
  try {
 
283
  method: "POST",
284
  headers: { "Content-Type": "application/json" },
285
  body: JSON.stringify({
286
+ messages: currentMessages
287
  .slice(0, -1)
288
  .map(({ role, content }) => ({ role, content })),
289
  persona_prompt: persona.prompt,
290
+ temperature: temperatureRef.current,
291
  }),
292
  });
293
 
 
327
  } else if (evt.type === "error") {
328
  acc += `\n\n*[error: ${evt.message}]*`;
329
  }
330
+
331
+ currentMessages = [...currentMessages];
332
+ currentMessages[currentMessages.length - 1] = {
333
+ role: "assistant",
334
+ content: acc,
335
+ reasoning,
336
+ sources,
337
+ status,
338
+ };
339
+ commitMessages(conversationId, currentMessages);
 
340
  } catch {
341
  // partial JSON, ignore
342
  }
343
  }
344
  }
345
  } catch (e) {
346
+ currentMessages = [...currentMessages];
347
+ currentMessages[currentMessages.length - 1] = {
348
+ role: "assistant",
349
+ content: `*Failed to reach the model: ${(e as Error).message}*`,
350
+ };
351
+ commitMessages(conversationId, currentMessages);
 
 
352
  } finally {
353
  setBusy(false);
354
  }
 
379
  key={i}
380
  className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}
381
  >
382
+ <div
383
+ className={`max-w-[85%] rounded-lg px-4 py-2.5 text-sm leading-relaxed ${
384
+ m.role === "user"
385
+ ? "bg-primary text-primary-foreground"
386
+ : "bg-muted text-foreground"
387
+ }`}
388
+ >
389
+ <div className="whitespace-pre-wrap">
390
+ {m.content ||
391
+ thinkingPreview ||
392
+ (isStreamingAssistant ? "Thinking..." : "")}
 
 
 
 
393
  </div>
394
+ {thinkingPreview && (
395
+ <div className="mt-2 text-xs italic opacity-70">
396
+ Thinking stream
 
 
 
 
 
397
  </div>
398
+ )}
399
+ {m.reasoning && (
400
+ <details className="mt-3 rounded-md border border-border/60 bg-background/60 p-3 text-xs text-muted-foreground">
401
+ <summary className="cursor-pointer select-none font-medium text-foreground">
402
+ Thinking
403
+ </summary>
404
+ <div className="mt-2 whitespace-pre-wrap leading-relaxed">
405
+ {m.reasoning}
406
+ </div>
407
+ </details>
408
+ )}
409
+ {m.status && (
410
+ <div className="mt-2 text-xs italic opacity-80">{m.status}</div>
411
+ )}
412
+ {m.sources && m.sources.length > 0 && (
413
+ <div className="mt-2 border-t border-border/50 pt-2 text-xs opacity-80">
414
+ <span className="font-medium">Sources: </span>
415
+ {m.sources.join(", ")}
416
+ </div>
417
+ )}
418
+ </div>
419
  </div>
 
420
  );
421
  })}
422
  </div>
 
429
  }}
430
  >
431
  <Input
432
+ placeholder="Ask about the course material..."
433
  value={input}
434
  onChange={(e) => setInput(e.target.value)}
435
  disabled={busy}
 
447
 
448
  <Card>
449
  <CardContent className="space-y-6 p-6">
450
+ <div className="space-y-2">
451
+ <div className="flex items-center justify-between gap-2">
452
+ <div className="text-sm font-medium">Conversations</div>
453
+ <Button variant="outline" size="sm" onClick={newChat} disabled={busy}>
454
+ <Plus className="h-4 w-4" /> New
455
+ </Button>
456
+ </div>
457
+ <div className="max-h-56 space-y-1 overflow-y-auto rounded-md border bg-background p-1">
458
+ {conversations.length === 0 ? (
459
+ <div className="px-2 py-6 text-center text-xs text-muted-foreground">
460
+ No saved chats yet.
461
+ </div>
462
+ ) : (
463
+ conversations.map((conversation) => (
464
+ <div
465
+ key={conversation.id}
466
+ className={`group flex items-center gap-1 rounded-md ${
467
+ conversation.id === activeId ? "bg-accent" : "hover:bg-accent/60"
468
+ }`}
469
+ >
470
+ <button
471
+ type="button"
472
+ className="min-w-0 flex-1 px-2 py-2 text-left"
473
+ onClick={() => openConversation(conversation)}
474
+ disabled={busy}
475
+ >
476
+ <div className="truncate text-sm font-medium">
477
+ {conversation.title}
478
+ </div>
479
+ <div className="text-xs text-muted-foreground">
480
+ {displayDate(conversation.updatedAt)}
481
+ </div>
482
+ </button>
483
+ <Button
484
+ type="button"
485
+ variant="ghost"
486
+ size="icon"
487
+ className="h-8 w-8 shrink-0 opacity-70 group-hover:opacity-100"
488
+ onClick={() => deleteConversation(conversation.id)}
489
+ disabled={busy}
490
+ aria-label={`Delete ${conversation.title}`}
491
+ >
492
+ <Trash2 className="h-4 w-4" />
493
+ </Button>
494
+ </div>
495
+ ))
496
+ )}
497
+ </div>
498
+ {conversations.length > 0 && (
499
+ <Button variant="ghost" size="sm" onClick={clearHistory} disabled={busy}>
500
+ <Trash2 className="h-4 w-4" /> Clear history
501
+ </Button>
502
+ )}
503
+ </div>
504
+
505
+ <div className="space-y-2">
506
+ <div className="flex items-center justify-between gap-2">
507
+ <div className="text-sm font-medium">Course Materials</div>
508
+ <Button
509
+ type="button"
510
+ variant="ghost"
511
+ size="sm"
512
+ onClick={loadDocuments}
513
+ disabled={documents === null}
514
+ >
515
+ <RefreshCw className="h-4 w-4" /> Refresh
516
+ </Button>
517
+ </div>
518
+ <div className="max-h-44 overflow-y-auto rounded-md border bg-background">
519
+ {documents === null ? (
520
+ <div className="px-3 py-6 text-center text-xs text-muted-foreground">
521
+ Loading materials...
522
+ </div>
523
+ ) : documentsError ? (
524
+ <div className="px-3 py-3 text-xs text-destructive">
525
+ {documentsError}
526
+ </div>
527
+ ) : documents.length === 0 ? (
528
+ <div className="px-3 py-6 text-center text-xs text-muted-foreground">
529
+ No materials uploaded yet.
530
+ </div>
531
+ ) : (
532
+ <ul className="divide-y">
533
+ {documents.map((name) => (
534
+ <li key={name} className="flex min-w-0 items-center gap-2 px-3 py-2">
535
+ <FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
536
+ <span className="truncate text-sm" title={name}>
537
+ {name}
538
+ </span>
539
+ </li>
540
+ ))}
541
+ </ul>
542
+ )}
543
+ </div>
544
+ </div>
545
+
546
  <div className="space-y-2">
547
  <div className="text-sm font-medium">Persona</div>
548
+ <PersonaSelector personas={personas} value={persona.id} onChange={handlePersonaChange} />
549
  <p className="text-xs text-muted-foreground">{persona.description}</p>
550
  </div>
551
+ <TemperatureSlider value={temperature} onChange={handleTemperatureChange} />
552
  </CardContent>
553
  </Card>
554
  </div>
frontend/components/persona-selector.tsx CHANGED
@@ -4,7 +4,6 @@ import {
4
  SelectContent,
5
  SelectItem,
6
  SelectTrigger,
7
- SelectValue,
8
  } from "@/components/ui/select";
9
  import { type Persona } from "@/lib/personas";
10
 
@@ -17,6 +16,8 @@ export function PersonaSelector({
17
  value: string;
18
  onChange: (p: Persona) => void;
19
  }) {
 
 
20
  return (
21
  <Select
22
  value={value}
@@ -25,8 +26,10 @@ export function PersonaSelector({
25
  if (p) onChange(p);
26
  }}
27
  >
28
- <SelectTrigger className="w-full">
29
- <SelectValue placeholder="Choose a persona" />
 
 
30
  </SelectTrigger>
31
  <SelectContent>
32
  {personas.map((p) => (
 
4
  SelectContent,
5
  SelectItem,
6
  SelectTrigger,
 
7
  } from "@/components/ui/select";
8
  import { type Persona } from "@/lib/personas";
9
 
 
16
  value: string;
17
  onChange: (p: Persona) => void;
18
  }) {
19
+ const selected = personas.find((p) => p.id === value);
20
+
21
  return (
22
  <Select
23
  value={value}
 
26
  if (p) onChange(p);
27
  }}
28
  >
29
+ <SelectTrigger className="h-11 w-full">
30
+ <span className="truncate text-left font-medium">
31
+ {selected?.name ?? "Choose a persona"}
32
+ </span>
33
  </SelectTrigger>
34
  <SelectContent>
35
  {personas.map((p) => (
frontend/lib/chat-storage.ts ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type ChatMessage = {
2
+ role: "user" | "assistant";
3
+ content: string;
4
+ reasoning?: string;
5
+ sources?: string[];
6
+ status?: string;
7
+ };
8
+
9
+ export type ChatConversation = {
10
+ id: string;
11
+ title: string;
12
+ createdAt: string;
13
+ updatedAt: string;
14
+ personaId: string;
15
+ temperature: number;
16
+ messages: ChatMessage[];
17
+ };
18
+
19
+ type ChatStore = {
20
+ activeId: string | null;
21
+ conversations: ChatConversation[];
22
+ };
23
+
24
+ type ChatPreferences = {
25
+ personaId?: string;
26
+ temperature?: number;
27
+ };
28
+
29
+ const STORAGE_KEY = "iamearth.chat.v1";
30
+ const PREFERENCES_KEY = "iamearth.chat.preferences.v1";
31
+ const MAX_CONVERSATIONS = 20;
32
+ const MAX_MESSAGES_PER_CONVERSATION = 80;
33
+ const MAX_MESSAGE_CHARS = 12000;
34
+
35
+ const emptyStore: ChatStore = {
36
+ activeId: null,
37
+ conversations: [],
38
+ };
39
+
40
+ function now() {
41
+ return new Date().toISOString();
42
+ }
43
+
44
+ function id() {
45
+ return typeof crypto !== "undefined" && "randomUUID" in crypto
46
+ ? crypto.randomUUID()
47
+ : `chat-${Date.now()}-${Math.random().toString(16).slice(2)}`;
48
+ }
49
+
50
+ function isBrowser() {
51
+ return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
52
+ }
53
+
54
+ function sanitizeMessage(message: ChatMessage): ChatMessage {
55
+ return {
56
+ ...message,
57
+ content: message.content.slice(0, MAX_MESSAGE_CHARS),
58
+ reasoning: message.reasoning?.slice(0, MAX_MESSAGE_CHARS),
59
+ };
60
+ }
61
+
62
+ function sanitizeConversation(conversation: ChatConversation): ChatConversation {
63
+ return {
64
+ ...conversation,
65
+ messages: conversation.messages
66
+ .slice(-MAX_MESSAGES_PER_CONVERSATION)
67
+ .map(sanitizeMessage),
68
+ };
69
+ }
70
+
71
+ function sorted(conversations: ChatConversation[]) {
72
+ return [...conversations].sort(
73
+ (a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)
74
+ );
75
+ }
76
+
77
+ export function titleFromMessages(messages: ChatMessage[]) {
78
+ const firstUser = messages.find((message) => message.role === "user")?.content.trim();
79
+ if (!firstUser) return "New chat";
80
+ return firstUser.length > 48 ? `${firstUser.slice(0, 45)}...` : firstUser;
81
+ }
82
+
83
+ export function createConversation(personaId: string, temperature: number): ChatConversation {
84
+ const timestamp = now();
85
+ return {
86
+ id: id(),
87
+ title: "New chat",
88
+ createdAt: timestamp,
89
+ updatedAt: timestamp,
90
+ personaId,
91
+ temperature,
92
+ messages: [],
93
+ };
94
+ }
95
+
96
+ export function loadChatStore(): ChatStore {
97
+ if (!isBrowser()) return emptyStore;
98
+
99
+ try {
100
+ const raw = window.localStorage.getItem(STORAGE_KEY);
101
+ if (!raw) return emptyStore;
102
+ const parsed = JSON.parse(raw) as Partial<ChatStore>;
103
+ if (!Array.isArray(parsed.conversations)) return emptyStore;
104
+
105
+ const conversations = sorted(
106
+ parsed.conversations
107
+ .filter((conversation): conversation is ChatConversation => {
108
+ return Boolean(
109
+ conversation &&
110
+ typeof conversation.id === "string" &&
111
+ typeof conversation.title === "string" &&
112
+ Array.isArray(conversation.messages)
113
+ );
114
+ })
115
+ .map(sanitizeConversation)
116
+ ).slice(0, MAX_CONVERSATIONS);
117
+
118
+ const activeId =
119
+ parsed.activeId === null
120
+ ? null
121
+ : conversations.find((conversation) => conversation.id === parsed.activeId)?.id ??
122
+ conversations[0]?.id ??
123
+ null;
124
+
125
+ return { activeId, conversations };
126
+ } catch {
127
+ return emptyStore;
128
+ }
129
+ }
130
+
131
+ export function saveChatStore(store: ChatStore) {
132
+ if (!isBrowser()) return;
133
+
134
+ try {
135
+ const conversations = sorted(store.conversations)
136
+ .map(sanitizeConversation)
137
+ .slice(0, MAX_CONVERSATIONS);
138
+ const activeId =
139
+ store.activeId === null
140
+ ? null
141
+ : conversations.find((conversation) => conversation.id === store.activeId)?.id ??
142
+ conversations[0]?.id ??
143
+ null;
144
+
145
+ window.localStorage.setItem(
146
+ STORAGE_KEY,
147
+ JSON.stringify({ activeId, conversations })
148
+ );
149
+ } catch {
150
+ // Storage can fail in private browsing or when the quota is full.
151
+ }
152
+ }
153
+
154
+ export function loadChatPreferences(): ChatPreferences {
155
+ if (!isBrowser()) return {};
156
+
157
+ try {
158
+ const raw = window.localStorage.getItem(PREFERENCES_KEY);
159
+ if (!raw) return {};
160
+ const parsed = JSON.parse(raw) as Partial<ChatPreferences>;
161
+ return {
162
+ personaId: typeof parsed.personaId === "string" ? parsed.personaId : undefined,
163
+ temperature: typeof parsed.temperature === "number" ? parsed.temperature : undefined,
164
+ };
165
+ } catch {
166
+ return {};
167
+ }
168
+ }
169
+
170
+ export function saveChatPreferences(preferences: ChatPreferences) {
171
+ if (!isBrowser()) return;
172
+
173
+ try {
174
+ window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences));
175
+ } catch {
176
+ // Storage can fail in private browsing or when the quota is full.
177
+ }
178
+ }
179
+
180
+ export function upsertConversation(
181
+ conversations: ChatConversation[],
182
+ conversation: ChatConversation
183
+ ) {
184
+ return sorted([
185
+ sanitizeConversation(conversation),
186
+ ...conversations.filter((item) => item.id !== conversation.id),
187
+ ]).slice(0, MAX_CONVERSATIONS);
188
+ }
189
+
190
+ export function removeConversation(conversations: ChatConversation[], idToRemove: string) {
191
+ return conversations.filter((conversation) => conversation.id !== idToRemove);
192
+ }