ishaq101 Claude Sonnet 4.6 (1M context) commited on
Commit
8fa19d9
Β·
1 Parent(s): 91cd4b2

feat: integrate room & chat API contract updates

Browse files

## API Integration
- Align LoginResponse type with full backend schema (id, fullname, company, role, etc.)
- Fix Login.tsx field mapping: res.data.user_id β†’ res.data.id, res.data.name β†’ res.data.fullname
- Add RoomMessage and RoomDetail interfaces for GET /api/v1/room/{room_id}
- Add getRoom() to fetch room detail with full chat history
- Add deleteRoom() for soft-delete via DELETE /api/v1/room/{room_id}?user_id=...

## Chat History Loading
- Add messagesLoaded flag to ChatRoom interface to track fetch state
- Add useEffect watching currentChatId to auto-load messages when a room is selected
- Covers both auto-selected room on page load and manual sidebar clicks
- Newly created rooms are marked messagesLoaded: true (no fetch needed)

## Delete Room
- deleteChat() now calls deleteRoom API before removing from local state
- If API fails (403/404), deletion is cancelled β€” room stays in sidebar
- deleteAllChats() calls deleteRoom in parallel via Promise.allSettled
- Deleted rooms disappear permanently after refresh (backend filters inactive rooms)

## Chat Stream Fixes
- Fix SSE parsing: split buffer by /\r?\n/ instead of \n to strip \r from chunks
- Prevents mid-word spacing artifacts in rendered AI responses
- Handle done SSE event with break to terminate stream cleanly
- Fix data line regex: /^data: ?/ to preserve intentional content spacing

## UI & Rendering
- Add ReactMarkdown with remark-gfm, remark-math, rehype-katex for rich AI responses
- Add TypingIndicator component with animated loading messages from loading-messages.yaml
- Add Database icon to Knowledge button, Bot avatar for assistant messages
- Add favicon: public/logo.png as browser tab icon
- Refresh chat UI: bubble styles, input area shadow, sidebar gradient polish
- Add streamingMsgId state to correctly show typing indicator per message

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

index.html CHANGED
@@ -4,6 +4,7 @@
4
  <head>
5
  <meta charset="UTF-8" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
7
  <title>Chatbot application</title>
8
  </head>
9
 
 
4
  <head>
5
  <meta charset="UTF-8" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="icon" type="image/png" href="/logo.png" />
8
  <title>Chatbot application</title>
9
  </head>
10
 
package.json CHANGED
@@ -36,8 +36,8 @@
36
  "@radix-ui/react-slot": "1.1.2",
37
  "@radix-ui/react-switch": "1.1.3",
38
  "@radix-ui/react-tabs": "1.1.3",
39
- "@radix-ui/react-toggle-group": "1.1.2",
40
  "@radix-ui/react-toggle": "1.1.2",
 
41
  "@radix-ui/react-tooltip": "1.1.8",
42
  "canvas-confetti": "1.9.4",
43
  "class-variance-authority": "0.7.1",
@@ -46,6 +46,7 @@
46
  "date-fns": "3.6.0",
47
  "embla-carousel-react": "8.6.0",
48
  "input-otp": "1.4.2",
 
49
  "lucide-react": "0.487.0",
50
  "motion": "12.23.24",
51
  "next-themes": "0.4.6",
@@ -53,12 +54,16 @@
53
  "react-dnd": "16.0.1",
54
  "react-dnd-html5-backend": "16.0.1",
55
  "react-hook-form": "7.55.0",
 
56
  "react-popper": "2.3.0",
57
  "react-resizable-panels": "2.1.7",
58
  "react-responsive-masonry": "2.7.1",
59
  "react-router": "7.13.0",
60
  "react-slick": "0.31.0",
61
  "recharts": "2.15.2",
 
 
 
62
  "sonner": "2.0.3",
63
  "tailwind-merge": "3.2.0",
64
  "tw-animate-css": "1.3.8",
 
36
  "@radix-ui/react-slot": "1.1.2",
37
  "@radix-ui/react-switch": "1.1.3",
38
  "@radix-ui/react-tabs": "1.1.3",
 
39
  "@radix-ui/react-toggle": "1.1.2",
40
+ "@radix-ui/react-toggle-group": "1.1.2",
41
  "@radix-ui/react-tooltip": "1.1.8",
42
  "canvas-confetti": "1.9.4",
43
  "class-variance-authority": "0.7.1",
 
46
  "date-fns": "3.6.0",
47
  "embla-carousel-react": "8.6.0",
48
  "input-otp": "1.4.2",
49
+ "katex": "^0.16.45",
50
  "lucide-react": "0.487.0",
51
  "motion": "12.23.24",
52
  "next-themes": "0.4.6",
 
54
  "react-dnd": "16.0.1",
55
  "react-dnd-html5-backend": "16.0.1",
56
  "react-hook-form": "7.55.0",
57
+ "react-markdown": "^10.1.0",
58
  "react-popper": "2.3.0",
59
  "react-resizable-panels": "2.1.7",
60
  "react-responsive-masonry": "2.7.1",
61
  "react-router": "7.13.0",
62
  "react-slick": "0.31.0",
63
  "recharts": "2.15.2",
64
+ "rehype-katex": "^7.0.1",
65
+ "remark-gfm": "^4.0.1",
66
+ "remark-math": "^6.0.0",
67
  "sonner": "2.0.3",
68
  "tailwind-merge": "3.2.0",
69
  "tw-animate-css": "1.3.8",
pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
public/loading-messages.yaml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ messages:
2
+ - Routing
3
+ - Parsing
4
+ - Mapping
5
+ - Tracing
6
+ - Linking
7
+ - Binding
8
+ - Buffering
9
+ - Merging
10
+ - Resolving
11
+ - Scanning
12
+ - Collating
13
+ - Filtering
14
+ - Balancing
15
+ - Shaping
16
+ - Aligning
17
+ - Weaving
18
+ - Framing
19
+ - Tuning
20
+ - Grounding
21
+ - Composing
public/logo.png ADDED
src/app/components/Login.tsx CHANGED
@@ -28,9 +28,9 @@ export default function Login() {
28
  try {
29
  const res = await login(email, password);
30
  const user = {
31
- user_id: res.data.user_id,
32
  email: res.data.email,
33
- name: res.data.name,
34
  loginTime: new Date().toISOString(),
35
  };
36
  localStorage.setItem("chatbot_user", JSON.stringify(user));
@@ -43,11 +43,11 @@ export default function Login() {
43
  };
44
 
45
  return (
46
- <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#4FC3F7] via-[#00C853] to-[#FF8F00]">
47
  <div className="w-full max-w-md px-4">
48
  <div className="bg-white rounded-xl shadow-2xl p-6 border border-slate-200">
49
  <div className="flex items-center justify-center mb-6">
50
- <div className="bg-gradient-to-br from-[#00C853] to-[#00A843] p-2.5 rounded-lg">
51
  <LogIn className="w-6 h-6 text-white" />
52
  </div>
53
  </div>
@@ -105,7 +105,7 @@ export default function Login() {
105
  <button
106
  type="submit"
107
  disabled={isLoading}
108
- className="w-full flex items-center justify-center gap-2 bg-gradient-to-r from-[#00C853] to-[#00A843] text-white py-2.5 text-sm rounded-lg hover:from-[#00A843] hover:to-[#00962B] transition font-medium disabled:opacity-60 disabled:cursor-not-allowed"
109
  >
110
  {isLoading ? (
111
  <Loader2 className="w-4 h-4 animate-spin" />
 
28
  try {
29
  const res = await login(email, password);
30
  const user = {
31
+ user_id: res.data.id,
32
  email: res.data.email,
33
+ name: res.data.fullname,
34
  loginTime: new Date().toISOString(),
35
  };
36
  localStorage.setItem("chatbot_user", JSON.stringify(user));
 
43
  };
44
 
45
  return (
46
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#BAE6FD] via-[#A7F3D0] to-[#FDE68A]">
47
  <div className="w-full max-w-md px-4">
48
  <div className="bg-white rounded-xl shadow-2xl p-6 border border-slate-200">
49
  <div className="flex items-center justify-center mb-6">
50
+ <div className="bg-gradient-to-br from-[#059669] to-[#047857] p-2.5 rounded-lg">
51
  <LogIn className="w-6 h-6 text-white" />
52
  </div>
53
  </div>
 
105
  <button
106
  type="submit"
107
  disabled={isLoading}
108
+ className="w-full flex items-center justify-center gap-2 bg-gradient-to-r from-[#059669] to-[#047857] text-white py-2.5 text-sm rounded-lg hover:from-[#047857] hover:to-[#065F46] transition font-medium disabled:opacity-60 disabled:cursor-not-allowed"
109
  >
110
  {isLoading ? (
111
  <Loader2 className="w-4 h-4 animate-spin" />
src/app/components/Main.tsx CHANGED
@@ -9,13 +9,21 @@ import {
9
  X,
10
  MessageSquare,
11
  User,
12
- Database,
13
  Loader2,
 
14
  } from "lucide-react";
 
 
 
 
 
15
  import KnowledgeManagement from "./KnowledgeManagement";
16
  import {
17
  getRooms,
 
18
  createRoom,
 
19
  streamChat,
20
  type ChatSource,
21
  } from "../../services/api";
@@ -41,6 +49,138 @@ interface ChatRoom {
41
  messages: Message[];
42
  createdAt: string;
43
  updatedAt: string | null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
45
 
46
  export default function Main() {
@@ -50,6 +190,7 @@ export default function Main() {
50
  const [currentChatId, setCurrentChatId] = useState<string | null>(null);
51
  const [input, setInput] = useState("");
52
  const [isStreaming, setIsStreaming] = useState(false);
 
53
  const [roomsLoading, setRoomsLoading] = useState(false);
54
  const [roomsError, setRoomsError] = useState<string | null>(null);
55
  const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -70,6 +211,15 @@ export default function Main() {
70
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
71
  }, [currentChatId, chats]);
72
 
 
 
 
 
 
 
 
 
 
73
  const loadRooms = async (userId: string) => {
74
  setRoomsLoading(true);
75
  setRoomsError(null);
@@ -81,6 +231,7 @@ export default function Main() {
81
  messages: [],
82
  createdAt: r.created_at,
83
  updatedAt: r.updated_at,
 
84
  }));
85
  setChats(mapped);
86
  if (mapped.length > 0) {
@@ -95,13 +246,44 @@ export default function Main() {
95
  }
96
  };
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  const currentChat = chats.find((chat) => chat.id === currentChatId);
99
 
100
  const createNewChat = () => {
101
  setCurrentChatId(null);
102
  };
103
 
104
- const deleteChat = (chatId: string) => {
 
 
 
 
 
 
105
  const updatedChats = chats.filter((chat) => chat.id !== chatId);
106
  setChats(updatedChats);
107
  if (currentChatId === chatId) {
@@ -109,7 +291,11 @@ export default function Main() {
109
  }
110
  };
111
 
112
- const deleteAllChats = () => {
 
 
 
 
113
  setChats([]);
114
  setCurrentChatId(null);
115
  };
@@ -133,6 +319,7 @@ export default function Main() {
133
  messages: [],
134
  createdAt: res.data.created_at,
135
  updatedAt: res.data.updated_at,
 
136
  };
137
  setChats((prev) => [newRoom, ...prev]);
138
  roomId = newRoom.id;
@@ -166,6 +353,7 @@ export default function Main() {
166
  setIsStreaming(true);
167
 
168
  const assistantMsgId = crypto.randomUUID();
 
169
 
170
  setChats((prev) =>
171
  prev.map((chat) =>
@@ -204,14 +392,14 @@ export default function Main() {
204
  if (done) break;
205
 
206
  buffer += decoder.decode(value, { stream: true });
207
- const lines = buffer.split("\n");
208
  buffer = lines.pop() ?? "";
209
 
210
  for (const line of lines) {
211
  if (line.startsWith("event:")) {
212
  currentEvent = line.replace("event:", "").trim();
213
  } else if (line.startsWith("data:")) {
214
- const data = line.replace("data:", "").trim();
215
 
216
  if (currentEvent === "sources" && data) {
217
  const sources: ChatSource[] = JSON.parse(data);
@@ -257,6 +445,8 @@ export default function Main() {
257
  : chat
258
  )
259
  );
 
 
260
  }
261
  }
262
  }
@@ -273,7 +463,7 @@ export default function Main() {
273
  ? {
274
  ...m,
275
  content:
276
- "Error: Failed to get response. Please try again.",
277
  }
278
  : m
279
  ),
@@ -284,6 +474,7 @@ export default function Main() {
284
  }
285
  } finally {
286
  setIsStreaming(false);
 
287
  abortControllerRef.current = null;
288
  }
289
  };
@@ -301,7 +492,7 @@ export default function Main() {
301
  <div
302
  className={`${
303
  sidebarOpen ? "w-64" : "w-0"
304
- } bg-gradient-to-br from-[#00C853] to-[#00A843] text-white transition-all duration-300 flex flex-col overflow-hidden`}
305
  >
306
  <div className="p-3 border-b border-white/20">
307
  <button
@@ -386,7 +577,7 @@ export default function Main() {
386
  </div>
387
 
388
  {/* Main Content */}
389
- <div className="flex-1 flex flex-col">
390
  {/* Header */}
391
  <div className="bg-white border-b border-slate-200 p-3 flex items-center gap-3">
392
  <button
@@ -399,12 +590,12 @@ export default function Main() {
399
  <Menu className="w-5 h-5" />
400
  )}
401
  </button>
402
- <h1 className="text-base text-slate-900 flex-1">
403
  {currentChat?.title || "Chatbot"}
404
  </h1>
405
  <button
406
  onClick={() => setKnowledgeOpen(true)}
407
- className="flex items-center gap-2 bg-gradient-to-r from-[#FF8F00] to-[#FF6F00] text-white px-3 py-2 rounded-lg hover:from-[#FF6F00] hover:to-[#F57C00] transition text-sm"
408
  >
409
  <Database className="w-4 h-4" />
410
  Knowledge
@@ -434,35 +625,60 @@ export default function Main() {
434
  message.role === "user" ? "justify-end" : "justify-start"
435
  }`}
436
  >
 
 
 
 
 
 
 
437
  <div
438
- className={`max-w-2xl px-4 py-2.5 rounded-xl ${
439
  message.role === "user"
440
- ? "bg-gradient-to-r from-[#4FC3F7] to-[#29B6F6] text-white"
441
- : "bg-white border border-slate-200 text-slate-900"
442
  }`}
443
  >
444
- <p className="whitespace-pre-wrap break-words text-sm">
445
- {message.content}
446
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  {message.role === "assistant" &&
448
  message.sources &&
449
  message.sources.length > 0 && (
450
  <div className="mt-2 pt-2 border-t border-slate-100">
451
- <p className="text-[10px] text-slate-400 mb-1">
452
  Sources:
453
  </p>
454
  <div className="flex flex-wrap gap-1">
455
  {message.sources.map((src, i) => (
456
  <span
457
  key={i}
458
- className="text-[10px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded"
459
  title={
460
  src.page_label
461
  ? `Page ${src.page_label}`
462
  : undefined
463
  }
464
  >
465
- {src.filename}
466
  {src.page_label ? ` p.${src.page_label}` : ""}
467
  </span>
468
  ))}
@@ -491,24 +707,28 @@ export default function Main() {
491
  </div>
492
 
493
  {/* Input Area */}
494
- <div className="bg-white border-t border-slate-200 p-3">
495
  <div className="max-w-4xl mx-auto">
496
  <div className="flex gap-2 items-end">
497
  <textarea
498
  value={input}
499
  onChange={(e) => setInput(e.target.value)}
500
  onKeyDown={handleKeyPress}
501
- placeholder="Type your message..."
502
  rows={1}
503
- className="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-[#4FC3F7] focus:border-transparent resize-none max-h-32"
504
  disabled={isStreaming}
505
  />
506
  <button
507
  onClick={handleSend}
508
  disabled={!input.trim() || isStreaming}
509
- className="bg-gradient-to-r from-[#4FC3F7] to-[#29B6F6] text-white p-2.5 rounded-lg hover:from-[#29B6F6] hover:to-[#039BE5] transition disabled:opacity-50 disabled:cursor-not-allowed"
510
  >
511
- <Send className="w-4 h-4" />
 
 
 
 
512
  </button>
513
  </div>
514
  </div>
 
9
  X,
10
  MessageSquare,
11
  User,
12
+ Bot,
13
  Loader2,
14
+ Database,
15
  } from "lucide-react";
16
+ import ReactMarkdown from "react-markdown";
17
+ import remarkGfm from "remark-gfm";
18
+ import remarkMath from "remark-math";
19
+ import rehypeKatex from "rehype-katex";
20
+ import type { Components } from "react-markdown";
21
  import KnowledgeManagement from "./KnowledgeManagement";
22
  import {
23
  getRooms,
24
+ getRoom,
25
  createRoom,
26
+ deleteRoom,
27
  streamChat,
28
  type ChatSource,
29
  } from "../../services/api";
 
49
  messages: Message[];
50
  createdAt: string;
51
  updatedAt: string | null;
52
+ messagesLoaded: boolean;
53
+ }
54
+
55
+ // Markdown component overrides for clean rendering inside chat bubbles
56
+ const markdownComponents: Components = {
57
+ p: ({ children }) => (
58
+ <p className="text-sm mb-2 last:mb-0 leading-relaxed">{children}</p>
59
+ ),
60
+ h1: ({ children }) => (
61
+ <h1 className="text-lg font-bold mb-3 mt-4 first:mt-0">{children}</h1>
62
+ ),
63
+ h2: ({ children }) => (
64
+ <h2 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h2>
65
+ ),
66
+ h3: ({ children }) => (
67
+ <h3 className="text-sm font-semibold mb-2 mt-2 first:mt-0">{children}</h3>
68
+ ),
69
+ ul: ({ children }) => (
70
+ <ul className="list-disc pl-5 mb-2 space-y-1 text-sm">{children}</ul>
71
+ ),
72
+ ol: ({ children }) => (
73
+ <ol className="list-decimal pl-5 mb-2 space-y-1 text-sm">{children}</ol>
74
+ ),
75
+ li: ({ children }) => <li className="text-sm leading-relaxed">{children}</li>,
76
+ code: ({ children, className }) => {
77
+ const isBlock = className?.startsWith("language-");
78
+ if (isBlock) {
79
+ return (
80
+ <code className="block text-xs font-mono text-slate-100 leading-relaxed">
81
+ {children}
82
+ </code>
83
+ );
84
+ }
85
+ return (
86
+ <code className="bg-slate-100 text-pink-600 px-1.5 py-0.5 rounded text-xs font-mono">
87
+ {children}
88
+ </code>
89
+ );
90
+ },
91
+ pre: ({ children }) => (
92
+ <pre className="bg-slate-900 rounded-lg p-3 mb-2 mt-1 overflow-x-auto text-xs">
93
+ {children}
94
+ </pre>
95
+ ),
96
+ blockquote: ({ children }) => (
97
+ <blockquote className="border-l-4 border-slate-300 pl-3 text-slate-500 italic mb-2 text-sm">
98
+ {children}
99
+ </blockquote>
100
+ ),
101
+ table: ({ children }) => (
102
+ <div className="overflow-x-auto mb-2">
103
+ <table className="w-full text-sm border-collapse">{children}</table>
104
+ </div>
105
+ ),
106
+ th: ({ children }) => (
107
+ <th className="border border-slate-200 px-3 py-1.5 bg-slate-100 font-medium text-left text-xs">
108
+ {children}
109
+ </th>
110
+ ),
111
+ td: ({ children }) => (
112
+ <td className="border border-slate-200 px-3 py-1.5 text-xs">{children}</td>
113
+ ),
114
+ a: ({ children, href }) => (
115
+ <a
116
+ href={href}
117
+ target="_blank"
118
+ rel="noopener noreferrer"
119
+ className="text-blue-600 underline hover:text-blue-800"
120
+ >
121
+ {children}
122
+ </a>
123
+ ),
124
+ strong: ({ children }) => (
125
+ <strong className="font-semibold">{children}</strong>
126
+ ),
127
+ hr: () => <hr className="border-slate-200 my-3" />,
128
+ };
129
+
130
+ // Typing indicator β€” three bouncing dots
131
+ function useLoadingMessages() {
132
+ const [messages, setMessages] = useState<string[]>([]);
133
+
134
+ useEffect(() => {
135
+ fetch("/loading-messages.yaml")
136
+ .then((r) => r.text())
137
+ .then((text) => {
138
+ const parsed = text
139
+ .split("\n")
140
+ .filter((l) => l.trimStart().startsWith("- "))
141
+ .map((l) => l.replace(/^\s*- /, "").trim())
142
+ .filter(Boolean);
143
+ if (parsed.length > 0) setMessages(parsed);
144
+ })
145
+ .catch(() => {});
146
+ }, []);
147
+
148
+ return messages;
149
+ }
150
+
151
+ function TypingIndicator() {
152
+ const messages = useLoadingMessages();
153
+ const [index, setIndex] = useState(0);
154
+
155
+ useEffect(() => {
156
+ if (messages.length === 0) return;
157
+ setIndex(Math.floor(Math.random() * messages.length));
158
+ const id = setInterval(() => {
159
+ setIndex((prev) => {
160
+ let next: number;
161
+ do { next = Math.floor(Math.random() * messages.length); } while (messages.length > 1 && next === prev);
162
+ return next;
163
+ });
164
+ }, 300);
165
+ return () => clearInterval(id);
166
+ }, [messages]);
167
+
168
+ if (messages.length === 0) {
169
+ return (
170
+ <div className="flex gap-1.5 items-center py-1 px-0.5">
171
+ <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "0ms", animationDuration: "1s" }} />
172
+ <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "200ms", animationDuration: "1s" }} />
173
+ <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "400ms", animationDuration: "1s" }} />
174
+ </div>
175
+ );
176
+ }
177
+
178
+ return (
179
+ <div className="flex items-center gap-2 py-1 px-0.5 text-sm text-slate-400 italic">
180
+ <span className="inline-block w-2 h-2 rounded-full bg-slate-400 animate-pulse" />
181
+ <span>{messages[index]}…</span>
182
+ </div>
183
+ );
184
  }
185
 
186
  export default function Main() {
 
190
  const [currentChatId, setCurrentChatId] = useState<string | null>(null);
191
  const [input, setInput] = useState("");
192
  const [isStreaming, setIsStreaming] = useState(false);
193
+ const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null);
194
  const [roomsLoading, setRoomsLoading] = useState(false);
195
  const [roomsError, setRoomsError] = useState<string | null>(null);
196
  const messagesEndRef = useRef<HTMLDivElement>(null);
 
211
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
212
  }, [currentChatId, chats]);
213
 
214
+ useEffect(() => {
215
+ if (!currentChatId) return;
216
+ const chat = chats.find((c) => c.id === currentChatId);
217
+ if (chat && !chat.messagesLoaded) {
218
+ loadRoomMessages(currentChatId);
219
+ }
220
+ // eslint-disable-next-line react-hooks/exhaustive-deps
221
+ }, [currentChatId]);
222
+
223
  const loadRooms = async (userId: string) => {
224
  setRoomsLoading(true);
225
  setRoomsError(null);
 
231
  messages: [],
232
  createdAt: r.created_at,
233
  updatedAt: r.updated_at,
234
+ messagesLoaded: false,
235
  }));
236
  setChats(mapped);
237
  if (mapped.length > 0) {
 
246
  }
247
  };
248
 
249
+ const loadRoomMessages = async (roomId: string) => {
250
+ try {
251
+ const detail = await getRoom(roomId);
252
+ const messages: Message[] = detail.messages.map((m) => ({
253
+ id: m.id,
254
+ role: m.role,
255
+ content: m.content,
256
+ timestamp: new Date(m.created_at).getTime(),
257
+ }));
258
+ setChats((prev) =>
259
+ prev.map((chat) =>
260
+ chat.id === roomId
261
+ ? { ...chat, messages, messagesLoaded: true }
262
+ : chat
263
+ )
264
+ );
265
+ } catch {
266
+ setChats((prev) =>
267
+ prev.map((chat) =>
268
+ chat.id === roomId ? { ...chat, messagesLoaded: true } : chat
269
+ )
270
+ );
271
+ }
272
+ };
273
+
274
  const currentChat = chats.find((chat) => chat.id === currentChatId);
275
 
276
  const createNewChat = () => {
277
  setCurrentChatId(null);
278
  };
279
 
280
+ const deleteChat = async (chatId: string) => {
281
+ if (!user) return;
282
+ try {
283
+ await deleteRoom(chatId, user.user_id);
284
+ } catch {
285
+ return;
286
+ }
287
  const updatedChats = chats.filter((chat) => chat.id !== chatId);
288
  setChats(updatedChats);
289
  if (currentChatId === chatId) {
 
291
  }
292
  };
293
 
294
+ const deleteAllChats = async () => {
295
+ if (!user) return;
296
+ await Promise.allSettled(
297
+ chats.map((chat) => deleteRoom(chat.id, user.user_id))
298
+ );
299
  setChats([]);
300
  setCurrentChatId(null);
301
  };
 
319
  messages: [],
320
  createdAt: res.data.created_at,
321
  updatedAt: res.data.updated_at,
322
+ messagesLoaded: true,
323
  };
324
  setChats((prev) => [newRoom, ...prev]);
325
  roomId = newRoom.id;
 
353
  setIsStreaming(true);
354
 
355
  const assistantMsgId = crypto.randomUUID();
356
+ setStreamingMsgId(assistantMsgId);
357
 
358
  setChats((prev) =>
359
  prev.map((chat) =>
 
392
  if (done) break;
393
 
394
  buffer += decoder.decode(value, { stream: true });
395
+ const lines = buffer.split(/\r?\n/);
396
  buffer = lines.pop() ?? "";
397
 
398
  for (const line of lines) {
399
  if (line.startsWith("event:")) {
400
  currentEvent = line.replace("event:", "").trim();
401
  } else if (line.startsWith("data:")) {
402
+ const data = line.replace(/^data: ?/, "");
403
 
404
  if (currentEvent === "sources" && data) {
405
  const sources: ChatSource[] = JSON.parse(data);
 
445
  : chat
446
  )
447
  );
448
+ } else if (currentEvent === "done") {
449
+ break;
450
  }
451
  }
452
  }
 
463
  ? {
464
  ...m,
465
  content:
466
+ "Sorry, I couldn't get a response. Please try again.",
467
  }
468
  : m
469
  ),
 
474
  }
475
  } finally {
476
  setIsStreaming(false);
477
+ setStreamingMsgId(null);
478
  abortControllerRef.current = null;
479
  }
480
  };
 
492
  <div
493
  className={`${
494
  sidebarOpen ? "w-64" : "w-0"
495
+ } bg-gradient-to-b from-[#059669] to-[#047857] text-white transition-all duration-300 flex flex-col overflow-hidden`}
496
  >
497
  <div className="p-3 border-b border-white/20">
498
  <button
 
577
  </div>
578
 
579
  {/* Main Content */}
580
+ <div className="flex-1 flex flex-col min-w-0">
581
  {/* Header */}
582
  <div className="bg-white border-b border-slate-200 p-3 flex items-center gap-3">
583
  <button
 
590
  <Menu className="w-5 h-5" />
591
  )}
592
  </button>
593
+ <h1 className="text-base text-slate-900 flex-1 truncate">
594
  {currentChat?.title || "Chatbot"}
595
  </h1>
596
  <button
597
  onClick={() => setKnowledgeOpen(true)}
598
+ className="flex items-center gap-2 bg-[#F59E0B] hover:bg-[#D97706] text-white px-3 py-2 rounded-lg transition-all duration-200 hover:scale-105 text-sm flex-shrink-0"
599
  >
600
  <Database className="w-4 h-4" />
601
  Knowledge
 
625
  message.role === "user" ? "justify-end" : "justify-start"
626
  }`}
627
  >
628
+ {/* Avatar for assistant */}
629
+ {message.role === "assistant" && (
630
+ <div className="w-7 h-7 rounded-full bg-gradient-to-br from-[#059669] to-[#047857] flex items-center justify-center flex-shrink-0 mt-0.5 mr-2">
631
+ <Bot className="w-3.5 h-3.5 text-white" />
632
+ </div>
633
+ )}
634
+
635
  <div
636
+ className={`max-w-2xl px-4 py-3 rounded-2xl ${
637
  message.role === "user"
638
+ ? "bg-[#3B82F6] text-white rounded-tr-sm shadow-sm"
639
+ : "bg-[#F3F4F6] border-0 text-slate-900 rounded-tl-sm shadow-sm"
640
  }`}
641
  >
642
+ {message.role === "user" ? (
643
+ <p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
644
+ {message.content}
645
+ </p>
646
+ ) : message.content === "" && streamingMsgId === message.id ? (
647
+ // Waiting for first chunk β€” show typing indicator
648
+ <TypingIndicator />
649
+ ) : (
650
+ // Render markdown for assistant messages
651
+ <div className="text-slate-900">
652
+ <ReactMarkdown
653
+ remarkPlugins={[remarkGfm, remarkMath]}
654
+ rehypePlugins={[rehypeKatex]}
655
+ components={markdownComponents}
656
+ >
657
+ {message.content}
658
+ </ReactMarkdown>
659
+ </div>
660
+ )}
661
+
662
+ {/* Sources */}
663
  {message.role === "assistant" &&
664
  message.sources &&
665
  message.sources.length > 0 && (
666
  <div className="mt-2 pt-2 border-t border-slate-100">
667
+ <p className="text-[10px] text-slate-400 mb-1.5">
668
  Sources:
669
  </p>
670
  <div className="flex flex-wrap gap-1">
671
  {message.sources.map((src, i) => (
672
  <span
673
  key={i}
674
+ className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full border border-slate-200"
675
  title={
676
  src.page_label
677
  ? `Page ${src.page_label}`
678
  : undefined
679
  }
680
  >
681
+ πŸ“„ {src.filename}
682
  {src.page_label ? ` p.${src.page_label}` : ""}
683
  </span>
684
  ))}
 
707
  </div>
708
 
709
  {/* Input Area */}
710
+ <div className="bg-white border-t border-slate-200 p-3 shadow-[0_-2px_10px_rgba(0,0,0,0.06)]">
711
  <div className="max-w-4xl mx-auto">
712
  <div className="flex gap-2 items-end">
713
  <textarea
714
  value={input}
715
  onChange={(e) => setInput(e.target.value)}
716
  onKeyDown={handleKeyPress}
717
+ placeholder="Ask me anything... (Enter to send, Shift+Enter for newline)"
718
  rows={1}
719
+ className="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-300 focus:outline-none focus:ring-2 focus:ring-[#3B82F6] focus:border-transparent resize-none max-h-32"
720
  disabled={isStreaming}
721
  />
722
  <button
723
  onClick={handleSend}
724
  disabled={!input.trim() || isStreaming}
725
+ className="bg-[#3B82F6] hover:bg-[#2563EB] text-white p-2.5 rounded-lg transition-all duration-200 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
726
  >
727
+ {isStreaming ? (
728
+ <Loader2 className="w-4 h-4 animate-spin" />
729
+ ) : (
730
+ <Send className="w-4 h-4" />
731
+ )}
732
  </button>
733
  </div>
734
  </div>
src/main.tsx CHANGED
@@ -2,6 +2,6 @@
2
  import { createRoot } from "react-dom/client";
3
  import App from "./app/App.tsx";
4
  import "./styles/index.css";
 
5
 
6
  createRoot(document.getElementById("root")!).render(<App />);
7
-
 
2
  import { createRoot } from "react-dom/client";
3
  import App from "./app/App.tsx";
4
  import "./styles/index.css";
5
+ import "katex/dist/katex.min.css";
6
 
7
  createRoot(document.getElementById("root")!).render(<App />);
 
src/services/api.ts CHANGED
@@ -3,7 +3,18 @@
3
  export interface LoginResponse {
4
  status: string;
5
  message: string;
6
- data: { user_id: string; email: string; name: string };
 
 
 
 
 
 
 
 
 
 
 
7
  }
8
 
9
  export interface Room {
@@ -19,6 +30,17 @@ export interface CreateRoomResponse {
19
  data: Room;
20
  }
21
 
 
 
 
 
 
 
 
 
 
 
 
22
  export type DocumentStatus = "pending" | "processing" | "completed" | "failed";
23
 
24
  export interface ApiDocument {
@@ -44,7 +66,7 @@ export interface ChatSource {
44
 
45
  // ─── Base Client ──────────────────────────────────────────────────────────────
46
 
47
- const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string) ?? "";
48
 
49
  async function request<T>(path: string, options?: RequestInit): Promise<T> {
50
  const res = await fetch(`${BASE_URL}${path}`, {
@@ -73,6 +95,15 @@ export const login = (email: string, password: string) =>
73
  export const getRooms = (userId: string) =>
74
  request<Room[]>(`/api/v1/rooms/${userId}`);
75
 
 
 
 
 
 
 
 
 
 
76
  export const createRoom = (userId: string, title?: string) =>
77
  request<CreateRoomResponse>("/api/v1/room/create", {
78
  method: "POST",
 
3
  export interface LoginResponse {
4
  status: string;
5
  message: string;
6
+ data: {
7
+ id: string;
8
+ fullname: string;
9
+ email: string;
10
+ company: string;
11
+ company_size: string;
12
+ function: string;
13
+ site: string;
14
+ role: string;
15
+ status: string;
16
+ created_at: string;
17
+ };
18
  }
19
 
20
  export interface Room {
 
30
  data: Room;
31
  }
32
 
33
+ export interface RoomMessage {
34
+ id: string;
35
+ role: "user" | "assistant";
36
+ content: string;
37
+ created_at: string;
38
+ }
39
+
40
+ export interface RoomDetail extends Room {
41
+ messages: RoomMessage[];
42
+ }
43
+
44
  export type DocumentStatus = "pending" | "processing" | "completed" | "failed";
45
 
46
  export interface ApiDocument {
 
66
 
67
  // ─── Base Client ──────────────────────────────────────────────────────────────
68
 
69
+ const BASE_URL = ((import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_URL) ?? "";
70
 
71
  async function request<T>(path: string, options?: RequestInit): Promise<T> {
72
  const res = await fetch(`${BASE_URL}${path}`, {
 
95
  export const getRooms = (userId: string) =>
96
  request<Room[]>(`/api/v1/rooms/${userId}`);
97
 
98
+ export const getRoom = (roomId: string) =>
99
+ request<RoomDetail>(`/api/v1/room/${roomId}`);
100
+
101
+ export const deleteRoom = (roomId: string, userId: string) =>
102
+ request<{ status: string; message: string }>(
103
+ `/api/v1/room/${roomId}?user_id=${userId}`,
104
+ { method: "DELETE" }
105
+ );
106
+
107
  export const createRoom = (userId: string, title?: string) =>
108
  request<CreateRoomResponse>("/api/v1/room/create", {
109
  method: "POST",