ishaq101 commited on
Commit
5ca6cf1
·
1 Parent(s): 10d1f27

[NOTICKET] Branding Styling and Integration (Chat & Voice)

Browse files

- update styling for product branding (logo, wording, color, style)
- integration with chatbot backend
- integration with voice backend

.gitignore CHANGED
@@ -36,8 +36,9 @@ Thumbs.db
36
  *.local
37
  vite.config.ts.timestamp-*
38
 
39
- API_CONTRACT.md
40
  API_CONTRACT_VOICE.md
 
41
 
42
  # Database logos (served via CDN)
43
  public/databases/
 
36
  *.local
37
  vite.config.ts.timestamp-*
38
 
39
+ API_CONTRACT_CHATBOT.md
40
  API_CONTRACT_VOICE.md
41
+ STYLE.md
42
 
43
  # Database logos (served via CDN)
44
  public/databases/
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🐠
4
  colorFrom: green
5
  colorTo: purple
6
  sdk: docker
7
- pinned: false
8
  short_description: Demo Frontend Voice Agent
9
  ---
10
 
 
4
  colorFrom: green
5
  colorTo: purple
6
  sdk: docker
7
+ pinned: true
8
  short_description: Demo Frontend Voice Agent
9
  ---
10
 
index.html CHANGED
@@ -4,8 +4,8 @@
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
 
11
  <body>
 
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/jpeg" href="/maintiva-logo.jpg" />
8
+ <title>Maintiva Agent</title>
9
  </head>
10
 
11
  <body>
public/maintiva-logo.jpg ADDED
src/app/components/KnowledgeManagement.tsx CHANGED
@@ -158,7 +158,7 @@ export default function KnowledgeManagement({
158
  const newDoc: ApiDocument = {
159
  id: uploadRes.data.id,
160
  filename: uploadRes.data.filename,
161
- status: "pending",
162
  file_size: file.size,
163
  file_type: file.name.split(".").pop() ?? "",
164
  created_at: new Date().toISOString(),
 
158
  const newDoc: ApiDocument = {
159
  id: uploadRes.data.id,
160
  filename: uploadRes.data.filename,
161
+ status: uploadRes.data.status,
162
  file_size: file.size,
163
  file_type: file.name.split(".").pop() ?? "",
164
  created_at: new Date().toISOString(),
src/app/components/Login.tsx CHANGED
@@ -1,14 +1,15 @@
1
  import { useState } from "react";
2
  import { useNavigate } from "react-router";
3
- import { LogIn, Loader2 } from "lucide-react";
4
  import { login } from "../../services/api";
5
- import logoUrl from "../../assets/logo.png";
6
 
7
  export default function Login() {
8
  const [email, setEmail] = useState("");
9
  const [password, setPassword] = useState("");
10
  const [error, setError] = useState("");
11
  const [isLoading, setIsLoading] = useState(false);
 
12
  const navigate = useNavigate();
13
 
14
  const handleLogin = async (e: React.FormEvent) => {
@@ -44,7 +45,7 @@ export default function Login() {
44
  };
45
 
46
  return (
47
- <div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-[#1e2d45]">
48
 
49
  {/* ── Dot grid texture ── */}
50
  <div
@@ -124,9 +125,6 @@ export default function Login() {
124
  <circle cx="30" cy="25" r="2.5" fill="#f97316" fillOpacity="0.5"/>
125
  </svg>
126
 
127
- {/* ── Large ring — top right ── */}
128
- <div className="absolute -top-24 -right-24 w-80 h-80 rounded-full border border-slate-700 animate-float-slow opacity-50" />
129
- <div className="absolute -top-10 -right-10 w-48 h-48 rounded-full border border-slate-800 opacity-60" />
130
 
131
  {/* ── Rotated square — bottom left ── */}
132
  <div
@@ -155,15 +153,6 @@ export default function Login() {
155
  />
156
  </svg>
157
 
158
- {/* ── Rotated square — bottom left ── */}
159
- <div
160
- className="absolute bottom-16 left-16 w-24 h-24 border border-slate-700 opacity-50 animate-float"
161
- style={{ transform: "rotate(20deg)", "--float-rotate": "20deg" } as React.CSSProperties}
162
- />
163
- <div
164
- className="absolute bottom-28 left-28 w-12 h-12 border border-slate-700 opacity-30"
165
- style={{ transform: "rotate(45deg)" }}
166
- />
167
 
168
  {/* ── Small floating dots ── */}
169
  <div className="absolute top-1/4 left-12 w-2 h-2 rounded-full bg-sky-400 opacity-60 animate-float" style={{ animationDelay: "1s" }} />
@@ -172,21 +161,21 @@ export default function Login() {
172
  <div className="absolute top-2/3 right-1/3 w-1 h-1 rounded-full bg-violet-400 opacity-50 animate-float-slow" style={{ animationDelay: "4s" }} />
173
 
174
  {/* ── Login card ── */}
175
- <div className="relative z-10 w-full max-w-md px-4">
176
- <div className="bg-white rounded-2xl shadow-2xl shadow-black/40 p-7 border border-white/10">
177
 
178
  {/* Brand logo */}
179
- <div className="flex flex-col items-center gap-3 mb-6">
180
- <img src={logoUrl} alt="DataEyond" className="w-12 h-12 object-contain" />
181
  <div className="text-center">
182
- <h1 className="text-xl font-semibold text-slate-900">Welcome Back</h1>
183
- <p className="text-slate-400 text-sm mt-0.5">Sign in to continue to your chatbot</p>
184
  </div>
185
  </div>
186
 
187
- <form onSubmit={handleLogin} className="space-y-4">
188
  <div>
189
- <label htmlFor="email" className="block text-xs font-medium mb-1.5 text-slate-600">
190
  Email Address
191
  </label>
192
  <input
@@ -194,29 +183,40 @@ export default function Login() {
194
  type="email"
195
  value={email}
196
  onChange={(e) => setEmail(e.target.value)}
197
- className="w-full px-3 py-2 text-sm rounded-xl border border-slate-200 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-sky-400/30 focus:border-sky-400 placeholder:text-slate-300 transition"
198
  placeholder="you@example.com"
199
  disabled={isLoading}
200
  />
201
  </div>
202
 
203
  <div>
204
- <label htmlFor="password" className="block text-xs font-medium mb-1.5 text-slate-600">
205
  Password
206
  </label>
207
- <input
208
- id="password"
209
- type="password"
210
- value={password}
211
- onChange={(e) => setPassword(e.target.value)}
212
- className="w-full px-3 py-2 text-sm rounded-xl border border-slate-200 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-sky-400/30 focus:border-sky-400 placeholder:text-slate-300 transition"
213
- placeholder="Enter your password"
214
- disabled={isLoading}
215
- />
 
 
 
 
 
 
 
 
 
 
 
216
  </div>
217
 
218
  {error && (
219
- <div className="bg-red-50 border border-red-100 text-red-600 px-3 py-2 rounded-xl text-xs">
220
  {error}
221
  </div>
222
  )}
@@ -224,12 +224,12 @@ export default function Login() {
224
  <button
225
  type="submit"
226
  disabled={isLoading}
227
- className="w-full flex items-center justify-center gap-2 bg-[#059669] hover:bg-[#047857] active:bg-[#065F46] text-white py-2.5 text-sm rounded-xl transition font-medium disabled:opacity-50 disabled:cursor-not-allowed mt-1"
228
  >
229
  {isLoading ? (
230
- <Loader2 className="w-4 h-4 animate-spin" />
231
  ) : (
232
- <LogIn className="w-4 h-4" />
233
  )}
234
  {isLoading ? "Signing in…" : "Sign In"}
235
  </button>
 
1
  import { useState } from "react";
2
  import { useNavigate } from "react-router";
3
+ import { LogIn, Loader2, Eye, EyeOff } from "lucide-react";
4
  import { login } from "../../services/api";
5
+ import logoUrl from "../../assets/maintiva-logo.jpg";
6
 
7
  export default function Login() {
8
  const [email, setEmail] = useState("");
9
  const [password, setPassword] = useState("");
10
  const [error, setError] = useState("");
11
  const [isLoading, setIsLoading] = useState(false);
12
+ const [showPassword, setShowPassword] = useState(false);
13
  const navigate = useNavigate();
14
 
15
  const handleLogin = async (e: React.FormEvent) => {
 
45
  };
46
 
47
  return (
48
+ <div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-[#fcfdfd]">
49
 
50
  {/* ── Dot grid texture ── */}
51
  <div
 
125
  <circle cx="30" cy="25" r="2.5" fill="#f97316" fillOpacity="0.5"/>
126
  </svg>
127
 
 
 
 
128
 
129
  {/* ── Rotated square — bottom left ── */}
130
  <div
 
153
  />
154
  </svg>
155
 
 
 
 
 
 
 
 
 
 
156
 
157
  {/* ── Small floating dots ── */}
158
  <div className="absolute top-1/4 left-12 w-2 h-2 rounded-full bg-sky-400 opacity-60 animate-float" style={{ animationDelay: "1s" }} />
 
161
  <div className="absolute top-2/3 right-1/3 w-1 h-1 rounded-full bg-violet-400 opacity-50 animate-float-slow" style={{ animationDelay: "4s" }} />
162
 
163
  {/* ── Login card ── */}
164
+ <div className="relative z-10 w-full max-w-md xl:max-w-lg 2xl:max-w-xl px-4">
165
+ <div className="bg-white rounded-2xl shadow-2xl shadow-black/40 p-7 xl:p-10 border border-white/10">
166
 
167
  {/* Brand logo */}
168
+ <div className="flex flex-col items-center gap-2 xl:gap-4 mb-6 xl:mb-8">
169
+ <img src={logoUrl} alt="Maintiva" className="w-12 h-12 xl:w-20 xl:h-20 object-contain" />
170
  <div className="text-center">
171
+ <h1 className="text-xl xl:text-3xl font-semibold text-slate-900">Maintiva Agent</h1>
172
+ <p className="text-slate-400 text-sm xl:text-base mt-0.5 xl:mt-1">Welcome back to your AI Based Virtual Agent for Analysis, Learning & RCA</p>
173
  </div>
174
  </div>
175
 
176
+ <form onSubmit={handleLogin} className="space-y-4 xl:space-y-5">
177
  <div>
178
+ <label htmlFor="email" className="block text-xs xl:text-sm font-medium mb-1.5 text-slate-600">
179
  Email Address
180
  </label>
181
  <input
 
183
  type="email"
184
  value={email}
185
  onChange={(e) => setEmail(e.target.value)}
186
+ className="w-full px-3 xl:px-4 py-2 xl:py-3 text-sm xl:text-base rounded-xl border border-slate-200 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-sky-400/30 focus:border-sky-400 placeholder:text-slate-300 transition"
187
  placeholder="you@example.com"
188
  disabled={isLoading}
189
  />
190
  </div>
191
 
192
  <div>
193
+ <label htmlFor="password" className="block text-xs xl:text-sm font-medium mb-1.5 text-slate-600">
194
  Password
195
  </label>
196
+ <div className="relative">
197
+ <input
198
+ id="password"
199
+ type={showPassword ? "text" : "password"}
200
+ value={password}
201
+ onChange={(e) => setPassword(e.target.value)}
202
+ className="w-full px-3 xl:px-4 py-2 xl:py-3 pr-10 xl:pr-12 text-sm xl:text-base rounded-xl border border-slate-200 bg-slate-50 focus:outline-none focus:ring-2 focus:ring-sky-400/30 focus:border-sky-400 placeholder:text-slate-300 transition"
203
+ placeholder="Enter your password"
204
+ disabled={isLoading}
205
+ />
206
+ <button
207
+ type="button"
208
+ onClick={() => setShowPassword((v) => !v)}
209
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition"
210
+ tabIndex={-1}
211
+ aria-label={showPassword ? "Hide password" : "Show password"}
212
+ >
213
+ {showPassword ? <EyeOff className="w-4 h-4 xl:w-5 xl:h-5" /> : <Eye className="w-4 h-4 xl:w-5 xl:h-5" />}
214
+ </button>
215
+ </div>
216
  </div>
217
 
218
  {error && (
219
+ <div className="bg-red-50 border border-red-100 text-red-600 px-3 py-2 rounded-xl text-xs xl:text-sm">
220
  {error}
221
  </div>
222
  )}
 
224
  <button
225
  type="submit"
226
  disabled={isLoading}
227
+ className="w-full flex items-center justify-center gap-2 bg-[#059669] hover:bg-[#047857] active:bg-[#065F46] text-white py-2.5 xl:py-3.5 text-sm xl:text-base rounded-xl transition font-medium disabled:opacity-50 disabled:cursor-not-allowed mt-1"
228
  >
229
  {isLoading ? (
230
+ <Loader2 className="w-4 h-4 xl:w-5 xl:h-5 animate-spin" />
231
  ) : (
232
+ <LogIn className="w-4 h-4 xl:w-5 xl:h-5" />
233
  )}
234
  {isLoading ? "Signing in…" : "Sign In"}
235
  </button>
src/app/components/Main.tsx CHANGED
@@ -1,23 +1,7 @@
1
- import { useState, useEffect, useRef } from "react";
2
  import { useNavigate } from "react-router";
3
- import {
4
- Send,
5
- Plus,
6
- Trash2,
7
- LogOut,
8
- Menu,
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,
@@ -27,21 +11,15 @@ import {
27
  streamChat,
28
  type ChatSource,
29
  } from "../../services/api";
30
-
31
- interface StoredUser {
32
- user_id: string;
33
- email: string;
34
- name: string;
35
- loginTime: string;
36
- }
37
-
38
- interface Message {
39
- id: string;
40
- role: "user" | "assistant";
41
- content: string;
42
- timestamp: number;
43
- sources?: ChatSource[];
44
- }
45
 
46
  interface ChatRoom {
47
  id: string;
@@ -52,177 +30,26 @@ interface ChatRoom {
52
  messagesLoaded: boolean;
53
  }
54
 
55
- // Preprocess markdown to ensure tables are properly separated from surrounding text
56
- function preprocessMarkdown(content: string): string {
57
- // Step 1: Split concatenated table rows — "| val ||" means end of row + start of next
58
- let result = content.replace(/\|\|/g, "|\n|");
59
-
60
- // Step 2: Per-line — if a line has non-pipe text before a pipe table row, split them
61
- const lines = result.split("\n");
62
- const processed = lines.map((line) => {
63
- const tableStart = line.indexOf("|");
64
- if (tableStart > 0) {
65
- const tableContent = line.slice(tableStart);
66
- // Confirm it looks like a table row (at least 2 pipes)
67
- if ((tableContent.match(/\|/g) ?? []).length >= 2) {
68
- return line.slice(0, tableStart).trimEnd() + "\n\n" + tableContent;
69
- }
70
- }
71
- return line;
72
- });
73
- result = processed.join("\n");
74
-
75
- // Step 3: Ensure blank line before table rows preceded by non-table text (not another row ending with |)
76
- result = result.replace(/([^|\n])\n(\|)/g, "$1\n\n$2");
77
-
78
- return result;
79
- }
80
-
81
- // Markdown component overrides for clean rendering inside chat bubbles
82
- const markdownComponents: Components = {
83
- p: ({ children }) => (
84
- <p className="text-sm mb-2 last:mb-0 leading-relaxed">{children}</p>
85
- ),
86
- h1: ({ children }) => (
87
- <h1 className="text-lg font-bold mb-3 mt-4 first:mt-0">{children}</h1>
88
- ),
89
- h2: ({ children }) => (
90
- <h2 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h2>
91
- ),
92
- h3: ({ children }) => (
93
- <h3 className="text-sm font-semibold mb-2 mt-2 first:mt-0">{children}</h3>
94
- ),
95
- ul: ({ children }) => (
96
- <ul className="list-disc pl-5 mb-2 space-y-1 text-sm">{children}</ul>
97
- ),
98
- ol: ({ children }) => (
99
- <ol className="list-decimal pl-5 mb-2 space-y-1 text-sm">{children}</ol>
100
- ),
101
- li: ({ children }) => <li className="text-sm leading-relaxed">{children}</li>,
102
- code: ({ children, className }) => {
103
- const isBlock = className?.startsWith("language-");
104
- if (isBlock) {
105
- return (
106
- <code className="block text-xs font-mono text-slate-100 leading-relaxed">
107
- {children}
108
- </code>
109
- );
110
- }
111
- return (
112
- <code className="bg-slate-100 text-pink-600 px-1.5 py-0.5 rounded text-xs font-mono">
113
- {children}
114
- </code>
115
- );
116
- },
117
- pre: ({ children }) => (
118
- <pre className="bg-slate-900 rounded-lg p-3 mb-2 mt-1 overflow-x-auto text-xs">
119
- {children}
120
- </pre>
121
- ),
122
- blockquote: ({ children }) => (
123
- <blockquote className="border-l-4 border-slate-300 pl-3 text-slate-500 italic mb-2 text-sm">
124
- {children}
125
- </blockquote>
126
- ),
127
- table: ({ children }) => (
128
- <div className="overflow-x-auto mb-2">
129
- <table className="w-full text-sm border-collapse">{children}</table>
130
- </div>
131
- ),
132
- th: ({ children }) => (
133
- <th className="border border-slate-200 px-3 py-1.5 bg-slate-100 font-medium text-left text-xs">
134
- {children}
135
- </th>
136
- ),
137
- td: ({ children }) => (
138
- <td className="border border-slate-200 px-3 py-1.5 text-xs">{children}</td>
139
- ),
140
- a: ({ children, href }) => (
141
- <a
142
- href={href}
143
- target="_blank"
144
- rel="noopener noreferrer"
145
- className="text-blue-600 underline hover:text-blue-800"
146
- >
147
- {children}
148
- </a>
149
- ),
150
- strong: ({ children }) => (
151
- <strong className="font-semibold">{children}</strong>
152
- ),
153
- hr: () => <hr className="border-slate-200 my-3" />,
154
- };
155
-
156
- // Typing indicator — three bouncing dots
157
- function useLoadingMessages() {
158
- const [messages, setMessages] = useState<string[]>([]);
159
-
160
- useEffect(() => {
161
- fetch("/loading-messages.yaml")
162
- .then((r) => r.text())
163
- .then((text) => {
164
- const parsed = text
165
- .split("\n")
166
- .filter((l) => l.trimStart().startsWith("- "))
167
- .map((l) => l.replace(/^\s*- /, "").trim())
168
- .filter(Boolean);
169
- if (parsed.length > 0) setMessages(parsed);
170
- })
171
- .catch(() => {});
172
- }, []);
173
-
174
- return messages;
175
- }
176
-
177
- function TypingIndicator() {
178
- const messages = useLoadingMessages();
179
- const [index, setIndex] = useState(0);
180
-
181
- useEffect(() => {
182
- if (messages.length === 0) return;
183
- setIndex(Math.floor(Math.random() * messages.length));
184
- const id = setInterval(() => {
185
- setIndex((prev) => {
186
- let next: number;
187
- do { next = Math.floor(Math.random() * messages.length); } while (messages.length > 1 && next === prev);
188
- return next;
189
- });
190
- }, 300);
191
- return () => clearInterval(id);
192
- }, [messages]);
193
-
194
- if (messages.length === 0) {
195
- return (
196
- <div className="flex gap-1.5 items-center py-1 px-0.5">
197
- <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "0ms", animationDuration: "1s" }} />
198
- <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "200ms", animationDuration: "1s" }} />
199
- <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: "400ms", animationDuration: "1s" }} />
200
- </div>
201
- );
202
- }
203
-
204
- return (
205
- <div className="flex items-center gap-2 py-1 px-0.5 text-sm text-slate-400 italic">
206
- <span className="inline-block w-2 h-2 rounded-full bg-slate-400 animate-pulse" />
207
- <span>{messages[index]}…</span>
208
- </div>
209
- );
210
- }
211
-
212
  export default function Main() {
213
  const navigate = useNavigate();
214
- const [sidebarOpen, setSidebarOpen] = useState(true);
215
  const [chats, setChats] = useState<ChatRoom[]>([]);
216
  const [currentChatId, setCurrentChatId] = useState<string | null>(null);
217
- const [input, setInput] = useState("");
218
  const [isStreaming, setIsStreaming] = useState(false);
219
  const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null);
220
  const [roomsLoading, setRoomsLoading] = useState(false);
221
- const [roomsError, setRoomsError] = useState<string | null>(null);
222
- const messagesEndRef = useRef<HTMLDivElement>(null);
223
  const [user, setUser] = useState<StoredUser | null>(null);
224
  const [knowledgeOpen, setKnowledgeOpen] = useState(false);
 
225
  const abortControllerRef = useRef<AbortController | null>(null);
 
 
 
 
 
 
 
 
 
226
 
227
  useEffect(() => {
228
  const storedUser = localStorage.getItem("chatbot_user");
@@ -233,10 +60,6 @@ export default function Main() {
233
  }
234
  }, []);
235
 
236
- useEffect(() => {
237
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
238
- }, [currentChatId, chats]);
239
-
240
  useEffect(() => {
241
  if (!currentChatId) return;
242
  const chat = chats.find((c) => c.id === currentChatId);
@@ -248,7 +71,6 @@ export default function Main() {
248
 
249
  const loadRooms = async (userId: string) => {
250
  setRoomsLoading(true);
251
- setRoomsError(null);
252
  try {
253
  const apiRooms = await getRooms(userId);
254
  const mapped: ChatRoom[] = apiRooms.map((r) => ({
@@ -263,10 +85,8 @@ export default function Main() {
263
  if (mapped.length > 0) {
264
  setCurrentChatId(mapped[0].id);
265
  }
266
- } catch (err) {
267
- setRoomsError(
268
- err instanceof Error ? err.message : "Failed to load chats"
269
- );
270
  } finally {
271
  setRoomsLoading(false);
272
  }
@@ -284,9 +104,7 @@ export default function Main() {
284
  }));
285
  setChats((prev) =>
286
  prev.map((chat) =>
287
- chat.id === roomId
288
- ? { ...chat, messages, messagesLoaded: true }
289
- : chat
290
  )
291
  );
292
  } catch {
@@ -298,68 +116,66 @@ export default function Main() {
298
  }
299
  };
300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  const currentChat = chats.find((chat) => chat.id === currentChatId);
302
 
303
- const createNewChat = () => {
304
- setCurrentChatId(null);
305
- };
306
 
307
- const deleteChat = async (chatId: string) => {
308
  if (!user) return;
309
  try {
310
  await deleteRoom(chatId, user.user_id);
311
  } catch {
312
  return;
313
  }
314
- const updatedChats = chats.filter((chat) => chat.id !== chatId);
315
- setChats(updatedChats);
316
  if (currentChatId === chatId) {
317
- setCurrentChatId(updatedChats.length > 0 ? updatedChats[0].id : null);
318
  }
319
  };
320
 
321
- const deleteAllChats = async () => {
322
- if (!user) return;
323
- await Promise.allSettled(
324
- chats.map((chat) => deleteRoom(chat.id, user.user_id))
325
- );
326
- setChats([]);
327
- setCurrentChatId(null);
328
- };
329
-
330
  const handleLogout = () => {
331
  localStorage.removeItem("chatbot_user");
332
  navigate("/login");
333
  };
334
 
335
- const handleSend = async () => {
336
- if (!input.trim() || isStreaming || !user) return;
337
-
338
- let roomId = currentChatId;
339
-
340
- if (!roomId) {
341
- try {
342
- const res = await createRoom(user.user_id, input.slice(0, 50));
343
- const newRoom: ChatRoom = {
344
- id: res.data.id,
345
- title: res.data.title,
346
- messages: [],
347
- createdAt: res.data.created_at,
348
- updatedAt: res.data.updated_at,
349
- messagesLoaded: true,
350
- };
351
- setChats((prev) => [newRoom, ...prev]);
352
- roomId = newRoom.id;
353
- setCurrentChatId(roomId);
354
- } catch {
355
- return;
356
- }
357
- }
358
 
359
  const userMessage: Message = {
360
  id: crypto.randomUUID(),
361
  role: "user",
362
- content: input,
363
  timestamp: Date.now(),
364
  };
365
 
@@ -375,10 +191,7 @@ export default function Main() {
375
  )
376
  );
377
 
378
- const sentMessage = input;
379
- setInput("");
380
  setIsStreaming(true);
381
-
382
  const assistantMsgId = crypto.randomUUID();
383
  setStreamingMsgId(assistantMsgId);
384
 
@@ -394,7 +207,7 @@ export default function Main() {
394
  role: "assistant",
395
  content: "",
396
  timestamp: Date.now(),
397
- sources: [],
398
  },
399
  ],
400
  }
@@ -404,9 +217,13 @@ export default function Main() {
404
 
405
  abortControllerRef.current = new AbortController();
406
 
407
- try {
408
- const response = await streamChat(user.user_id, roomId, sentMessage);
 
 
409
 
 
 
410
  if (!response.body) throw new Error("No response body");
411
 
412
  const reader = response.body.getReader();
@@ -464,23 +281,58 @@ export default function Main() {
464
  ? {
465
  ...chat,
466
  messages: chat.messages.map((m) =>
467
- m.id === assistantMsgId
468
- ? { ...m, content: data }
469
- : m
470
  ),
471
  }
472
  : chat
473
  )
474
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  } else if (currentEvent === "done") {
476
  break;
477
  }
478
  }
479
  }
480
  }
481
- // Re-fetch from API after stream ends to get the authoritative
482
- // stored content guarantees formatting matches page-refresh behavior
 
 
 
483
  await loadRoomMessages(roomId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  } catch (err: unknown) {
485
  if ((err as Error).name !== "AbortError") {
486
  setChats((prev) =>
@@ -492,8 +344,7 @@ export default function Main() {
492
  m.id === assistantMsgId
493
  ? {
494
  ...m,
495
- content:
496
- "Sorry, I couldn't get a response. Please try again.",
497
  }
498
  : m
499
  ),
@@ -506,342 +357,133 @@ export default function Main() {
506
  setIsStreaming(false);
507
  setStreamingMsgId(null);
508
  abortControllerRef.current = null;
 
509
  }
510
- };
 
 
 
 
 
 
 
 
 
 
 
 
511
 
512
- const handleKeyPress = (e: React.KeyboardEvent) => {
513
- if (e.key === "Enter" && !e.shiftKey) {
514
- e.preventDefault();
515
- handleSend();
516
- }
517
- };
518
 
519
- return (
520
- <div className="flex h-screen bg-slate-50">
521
- {/* Sidebar */}
522
- <div
523
- className={`${
524
- sidebarOpen ? "w-64" : "w-0"
525
- } bg-gradient-to-b from-[#059669] to-[#047857] text-white transition-all duration-300 flex flex-col overflow-hidden`}
526
- >
527
- <div className="p-3 border-b border-white/20">
528
- <button
529
- onClick={createNewChat}
530
- className="w-full flex items-center justify-center gap-2 bg-white/20 hover:bg-white/30 px-3 py-2 rounded-lg transition text-sm"
531
- >
532
- <Plus className="w-4 h-4" />
533
- New Chat
534
- </button>
535
- </div>
536
 
537
- <div className="flex-1 overflow-y-auto p-2 space-y-1">
538
- {roomsLoading ? (
539
- <div className="flex justify-center py-4">
540
- <Loader2 className="w-4 h-4 animate-spin text-white/70" />
541
- </div>
542
- ) : roomsError ? (
543
- <p className="text-xs text-red-200 text-center px-2 py-2">
544
- {roomsError}
545
- </p>
546
- ) : (
547
- chats.map((chat) => (
548
- <div
549
- key={chat.id}
550
- className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition group ${
551
- currentChatId === chat.id
552
- ? "bg-white/25"
553
- : "hover:bg-white/15"
554
- }`}
555
- >
556
- <MessageSquare className="w-3.5 h-3.5 flex-shrink-0" />
557
- <div
558
- className="flex-1 truncate text-sm"
559
- onClick={() => setCurrentChatId(chat.id)}
560
- >
561
- {chat.title}
562
- </div>
563
- <button
564
- onClick={(e) => {
565
- e.stopPropagation();
566
- deleteChat(chat.id);
567
- }}
568
- className="opacity-0 group-hover:opacity-100 transition"
569
- >
570
- <Trash2 className="w-3.5 h-3.5 text-red-100 hover:text-white" />
571
- </button>
572
- </div>
573
- ))
574
- )}
575
- </div>
576
 
577
- <div className="border-t border-white/20 p-3 space-y-2">
578
- {chats.length > 0 && (
579
- <button
580
- onClick={deleteAllChats}
581
- className="w-full flex items-center justify-center gap-2 text-red-100 hover:text-white px-3 py-2 rounded-lg hover:bg-white/15 transition text-xs"
582
- >
583
- <Trash2 className="w-3.5 h-3.5" />
584
- Clear All Chats
585
- </button>
586
- )}
587
-
588
- <div className="flex items-center gap-2 p-2 rounded-lg bg-white/20">
589
- <div className="w-7 h-7 bg-white/30 rounded-full flex items-center justify-center">
590
- <User className="w-3.5 h-3.5" />
591
- </div>
592
- <div className="flex-1 min-w-0">
593
- <div className="text-xs truncate">{user?.name}</div>
594
- <div className="text-[10px] text-white/70 truncate">
595
- {user?.email}
596
- </div>
597
- </div>
598
- <button
599
- onClick={handleLogout}
600
- className="text-white/70 hover:text-white transition"
601
- title="Logout"
602
- >
603
- <LogOut className="w-3.5 h-3.5" />
604
- </button>
605
- </div>
606
- </div>
 
 
 
 
 
 
 
 
607
  </div>
608
 
609
- {/* Main Content */}
610
- <div className="flex-1 flex flex-col min-w-0 relative">
611
-
612
- {/* ── Subtle background — harmonizes with Login page ── */}
613
- <div className="absolute inset-0 pointer-events-none z-0 overflow-hidden">
614
- {/* Dot grid */}
615
- <div
616
- className="absolute inset-0 opacity-[0.13]"
617
- style={{
618
- backgroundImage: "radial-gradient(circle, #94a3b8 1px, transparent 1px)",
619
- backgroundSize: "28px 28px",
620
- }}
621
- />
622
- {/* Cyan orb — top right */}
623
- <div
624
- className="absolute -top-28 -right-28 w-80 h-80 rounded-full blur-3xl opacity-[0.08]"
625
- style={{ background: "#0ea5e9" }}
626
- />
627
- {/* Orange orb — bottom left */}
628
- <div
629
- className="absolute -bottom-28 -left-28 w-96 h-96 rounded-full blur-3xl opacity-[0.07]"
630
- style={{ background: "#f97316" }}
631
- />
632
- {/* Green orb — center left */}
633
- <div
634
- className="absolute top-1/2 -left-32 w-64 h-64 rounded-full blur-3xl opacity-[0.05]"
635
- style={{ background: "#10b981" }}
636
- />
637
- {/* Neural network — bottom right */}
638
- <svg
639
- className="absolute bottom-20 right-4 opacity-[0.07]"
640
- width="160" height="160" viewBox="0 0 200 200"
641
- fill="none" xmlns="http://www.w3.org/2000/svg"
642
- >
643
- <line x1="20" y1="150" x2="65" y2="90" stroke="#0ea5e9" strokeWidth="1.2"/>
644
- <line x1="20" y1="150" x2="55" y2="170" stroke="#0ea5e9" strokeWidth="1.2"/>
645
- <line x1="65" y1="90" x2="110" y2="125" stroke="#0ea5e9" strokeWidth="1.2"/>
646
- <line x1="65" y1="90" x2="130" y2="55" stroke="#10b981" strokeWidth="1.2"/>
647
- <line x1="110" y1="125" x2="170" y2="105" stroke="#0ea5e9" strokeWidth="1.2"/>
648
- <line x1="130" y1="55" x2="170" y2="105" stroke="#10b981" strokeWidth="1.2"/>
649
- <line x1="130" y1="55" x2="175" y2="30" stroke="#10b981" strokeWidth="1.2"/>
650
- <line x1="55" y1="170" x2="110" y2="125" stroke="#0ea5e9" strokeWidth="1.2"/>
651
- <line x1="175" y1="30" x2="170" y2="105" stroke="#10b981" strokeWidth="1.2"/>
652
- <circle cx="20" cy="150" r="4" fill="#0ea5e9"/>
653
- <circle cx="55" cy="170" r="3" fill="#0ea5e9"/>
654
- <circle cx="65" cy="90" r="5.5" fill="#0ea5e9"/>
655
- <circle cx="110" cy="125" r="4" fill="#0ea5e9"/>
656
- <circle cx="130" cy="55" r="5.5" fill="#10b981"/>
657
- <circle cx="170" cy="105" r="4" fill="#10b981"/>
658
- <circle cx="175" cy="30" r="3" fill="#10b981"/>
659
- </svg>
660
- {/* Neural network — top left (small) */}
661
- <svg
662
- className="absolute top-16 left-4 opacity-[0.06]"
663
- width="110" height="110" viewBox="0 0 180 180"
664
- fill="none" xmlns="http://www.w3.org/2000/svg"
665
- >
666
- <line x1="160" y1="30" x2="115" y2="80" stroke="#f97316" strokeWidth="1.2"/>
667
- <line x1="115" y1="80" x2="70" y2="55" stroke="#f97316" strokeWidth="1.2"/>
668
- <line x1="115" y1="80" x2="130" y2="135" stroke="#0ea5e9" strokeWidth="1.2"/>
669
- <line x1="70" y1="55" x2="25" y2="90" stroke="#f97316" strokeWidth="1.2"/>
670
- <line x1="25" y1="90" x2="80" y2="155" stroke="#0ea5e9" strokeWidth="1.2"/>
671
- <line x1="80" y1="155" x2="130" y2="135" stroke="#0ea5e9" strokeWidth="1.2"/>
672
- <circle cx="160" cy="30" r="4" fill="#f97316"/>
673
- <circle cx="115" cy="80" r="5.5" fill="#f97316"/>
674
- <circle cx="70" cy="55" r="4" fill="#f97316"/>
675
- <circle cx="130" cy="135" r="3" fill="#0ea5e9"/>
676
- <circle cx="25" cy="90" r="4" fill="#0ea5e9"/>
677
- <circle cx="80" cy="155" r="3" fill="#0ea5e9"/>
678
- </svg>
679
- </div>
680
-
681
- {/* Header */}
682
- <div className="relative z-10 bg-white border-b border-slate-200 p-3 flex items-center gap-3">
683
- <button
684
- onClick={() => setSidebarOpen(!sidebarOpen)}
685
- className="text-slate-600 hover:text-slate-900 transition"
686
- >
687
- {sidebarOpen ? (
688
- <X className="w-5 h-5" />
689
- ) : (
690
- <Menu className="w-5 h-5" />
691
- )}
692
- </button>
693
- <h1 className="text-base text-slate-900 flex-1 truncate">
694
- {currentChat?.title || "Chatbot"}
695
- </h1>
696
- <button
697
- onClick={() => setKnowledgeOpen(true)}
698
- 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"
699
- >
700
- <Database className="w-4 h-4" />
701
- Knowledge
702
- </button>
703
- </div>
704
 
705
- {/* Messages */}
706
- <div className="relative z-10 flex-1 overflow-y-auto p-4 space-y-4">
707
- {currentChat?.messages.length === 0 && (
708
- <div className="flex items-center justify-center h-full">
709
- <div className="text-center">
710
- <MessageSquare className="w-12 h-12 text-slate-300 mx-auto mb-3" />
711
- <h2 className="text-base text-slate-600 mb-1">
712
- Start a conversation
713
- </h2>
714
- <p className="text-sm text-slate-400">
715
- Send a message to begin chatting
716
- </p>
717
- </div>
718
- </div>
719
- )}
720
-
721
- {currentChat?.messages.map((message) => (
722
- <div
723
- key={message.id}
724
- className={`flex ${
725
- message.role === "user" ? "justify-end" : "justify-start"
726
- }`}
727
- >
728
- {/* Avatar for assistant */}
729
- {message.role === "assistant" && (
730
- <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">
731
- <Bot className="w-3.5 h-3.5 text-white" />
732
- </div>
733
- )}
734
-
735
- <div
736
- className={`max-w-2xl px-4 py-3 rounded-2xl ${
737
- message.role === "user"
738
- ? "bg-[#3B82F6] text-white rounded-tr-sm shadow-sm"
739
- : "bg-[#F3F4F6] border-0 text-slate-900 rounded-tl-sm shadow-sm"
740
- }`}
741
- >
742
- {message.role === "user" ? (
743
- <p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
744
- {message.content}
745
- </p>
746
- ) : message.content === "" && streamingMsgId === message.id ? (
747
- // Waiting for first chunk — show typing indicator
748
- <TypingIndicator />
749
- ) : (
750
- // Render markdown for assistant messages
751
- <div className="text-slate-900">
752
- <ReactMarkdown
753
- remarkPlugins={[remarkGfm, remarkMath]}
754
- rehypePlugins={[rehypeKatex]}
755
- components={markdownComponents}
756
- >
757
- {streamingMsgId === message.id
758
- ? message.content
759
- : preprocessMarkdown(message.content)}
760
- </ReactMarkdown>
761
- </div>
762
- )}
763
-
764
- {/* Sources */}
765
- {message.role === "assistant" &&
766
- message.sources &&
767
- message.sources.length > 0 && (
768
- <div className="mt-2 pt-2 border-t border-slate-100">
769
- <p className="text-[10px] text-slate-400 mb-1.5">
770
- Sources:
771
- </p>
772
- <div className="flex flex-wrap gap-1">
773
- {message.sources.map((src, i) => (
774
- <span
775
- key={i}
776
- className="text-[10px] bg-slate-100 text-slate-600 px-2 py-0.5 rounded-full border border-slate-200"
777
- title={
778
- src.page_label
779
- ? `Page ${src.page_label}`
780
- : undefined
781
- }
782
- >
783
- 📄 {src.filename}
784
- {src.page_label ? ` p.${src.page_label}` : ""}
785
- </span>
786
- ))}
787
- </div>
788
- </div>
789
- )}
790
- </div>
791
- </div>
792
- ))}
793
-
794
- {!currentChat && chats.length === 0 && !roomsLoading && (
795
- <div className="flex items-center justify-center h-full">
796
- <div className="text-center">
797
- <MessageSquare className="w-12 h-12 text-slate-300 mx-auto mb-3" />
798
- <h2 className="text-base text-slate-600 mb-1">
799
- Welcome to Chatbot
800
- </h2>
801
- <p className="text-sm text-slate-400">
802
- Create a new chat to get started
803
- </p>
804
- </div>
805
- </div>
806
- )}
807
-
808
- <div ref={messagesEndRef} />
809
- </div>
810
 
811
- {/* Input Area */}
812
- <div className="relative z-10 bg-white border-t border-slate-200 p-3 shadow-[0_-2px_10px_rgba(0,0,0,0.06)]">
813
- <div className="max-w-4xl mx-auto">
814
- <div className="flex gap-2 items-end">
815
- <textarea
816
- value={input}
817
- onChange={(e) => setInput(e.target.value)}
818
- onKeyDown={handleKeyPress}
819
- placeholder="Ask me anything... (Enter to send, Shift+Enter for newline)"
820
- rows={1}
821
- 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"
822
- disabled={isStreaming}
823
- />
824
- <button
825
- onClick={handleSend}
826
- disabled={!input.trim() || isStreaming}
827
- 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"
828
- >
829
- {isStreaming ? (
830
- <Loader2 className="w-4 h-4 animate-spin" />
831
- ) : (
832
- <Send className="w-4 h-4" />
833
- )}
834
- </button>
835
- </div>
836
- </div>
837
  </div>
838
  </div>
839
 
840
- {/* Knowledge Management Modal */}
841
  <KnowledgeManagement
842
  open={knowledgeOpen}
843
  onClose={() => setKnowledgeOpen(false)}
844
  />
845
- </div>
846
  );
847
  }
 
1
+ import { useState, useEffect, useRef, useCallback } from "react";
2
  import { useNavigate } from "react-router";
3
+ import { Database, Menu } from "lucide-react";
4
+ import { AnimatePresence } from "motion/react";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import KnowledgeManagement from "./KnowledgeManagement";
6
  import {
7
  getRooms,
 
11
  streamChat,
12
  type ChatSource,
13
  } from "../../services/api";
14
+ import { textToSpeech } from "../../services/voiceApi";
15
+ import { AudioPlayer } from "../../audio/AudioPlayer";
16
+ import ChatLayout from "./chat/ChatLayout";
17
+ import Sidebar from "./chat/Sidebar";
18
+ import ChatWindow from "./chat/ChatWindow";
19
+ import ChatInput from "./chat/ChatInput";
20
+ import VoiceStatusBar from "./chat/VoiceStatusBar";
21
+ import { useVoiceSession } from "../../hooks/useVoiceSession";
22
+ import type { Message, ChatSession, StoredUser } from "./chat/types";
 
 
 
 
 
 
23
 
24
  interface ChatRoom {
25
  id: string;
 
30
  messagesLoaded: boolean;
31
  }
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  export default function Main() {
34
  const navigate = useNavigate();
 
35
  const [chats, setChats] = useState<ChatRoom[]>([]);
36
  const [currentChatId, setCurrentChatId] = useState<string | null>(null);
 
37
  const [isStreaming, setIsStreaming] = useState(false);
38
  const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null);
39
  const [roomsLoading, setRoomsLoading] = useState(false);
 
 
40
  const [user, setUser] = useState<StoredUser | null>(null);
41
  const [knowledgeOpen, setKnowledgeOpen] = useState(false);
42
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
43
  const abortControllerRef = useRef<AbortController | null>(null);
44
+ const ttsPlayerRef = useRef<AudioPlayer | null>(null);
45
+ const isVoiceActiveRef = useRef(false);
46
+
47
+ // Stable refs so voice callbacks always see the latest values
48
+ const currentChatIdRef = useRef<string | null>(null);
49
+ useEffect(() => { currentChatIdRef.current = currentChatId; }, [currentChatId]);
50
+
51
+ const userRef = useRef<StoredUser | null>(null);
52
+ useEffect(() => { userRef.current = user; }, [user]);
53
 
54
  useEffect(() => {
55
  const storedUser = localStorage.getItem("chatbot_user");
 
60
  }
61
  }, []);
62
 
 
 
 
 
63
  useEffect(() => {
64
  if (!currentChatId) return;
65
  const chat = chats.find((c) => c.id === currentChatId);
 
71
 
72
  const loadRooms = async (userId: string) => {
73
  setRoomsLoading(true);
 
74
  try {
75
  const apiRooms = await getRooms(userId);
76
  const mapped: ChatRoom[] = apiRooms.map((r) => ({
 
85
  if (mapped.length > 0) {
86
  setCurrentChatId(mapped[0].id);
87
  }
88
+ } catch {
89
+ // silently fail — UI shows empty state
 
 
90
  } finally {
91
  setRoomsLoading(false);
92
  }
 
104
  }));
105
  setChats((prev) =>
106
  prev.map((chat) =>
107
+ chat.id === roomId ? { ...chat, messages, messagesLoaded: true } : chat
 
 
108
  )
109
  );
110
  } catch {
 
116
  }
117
  };
118
 
119
+ // Ensures a chat room exists; creates one for voice if needed.
120
+ const ensureRoom = useCallback(async (title?: string): Promise<string | null> => {
121
+ if (currentChatIdRef.current) return currentChatIdRef.current;
122
+ const currentUser = userRef.current;
123
+ if (!currentUser) return null;
124
+ try {
125
+ const res = await createRoom(currentUser.user_id, title ?? "Voice Session");
126
+ const newRoom: ChatRoom = {
127
+ id: res.data.id,
128
+ title: res.data.title,
129
+ messages: [],
130
+ createdAt: res.data.created_at,
131
+ updatedAt: res.data.updated_at,
132
+ messagesLoaded: true,
133
+ };
134
+ setChats((prev) => [newRoom, ...prev]);
135
+ setCurrentChatId(newRoom.id);
136
+ return newRoom.id;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }, []);
141
+
142
  const currentChat = chats.find((chat) => chat.id === currentChatId);
143
 
144
+ const handleNewChat = () => setCurrentChatId(null);
 
 
145
 
146
+ const handleDeleteSession = async (chatId: string) => {
147
  if (!user) return;
148
  try {
149
  await deleteRoom(chatId, user.user_id);
150
  } catch {
151
  return;
152
  }
153
+ const updated = chats.filter((c) => c.id !== chatId);
154
+ setChats(updated);
155
  if (currentChatId === chatId) {
156
+ setCurrentChatId(updated.length > 0 ? updated[0].id : null);
157
  }
158
  };
159
 
 
 
 
 
 
 
 
 
 
160
  const handleLogout = () => {
161
  localStorage.removeItem("chatbot_user");
162
  navigate("/login");
163
  };
164
 
165
+ const handleStop = () => {
166
+ abortControllerRef.current?.abort();
167
+ };
168
+
169
+ const handleSend = useCallback(async (text: string) => {
170
+ if (!user) return;
171
+
172
+ let roomId = await ensureRoom(text.slice(0, 50));
173
+ if (!roomId) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  const userMessage: Message = {
176
  id: crypto.randomUUID(),
177
  role: "user",
178
+ content: text,
179
  timestamp: Date.now(),
180
  };
181
 
 
191
  )
192
  );
193
 
 
 
194
  setIsStreaming(true);
 
195
  const assistantMsgId = crypto.randomUUID();
196
  setStreamingMsgId(assistantMsgId);
197
 
 
207
  role: "assistant",
208
  content: "",
209
  timestamp: Date.now(),
210
+ sources: [] as ChatSource[],
211
  },
212
  ],
213
  }
 
217
 
218
  abortControllerRef.current = new AbortController();
219
 
220
+ // TTS state — only used when voice is active
221
+ const audioChunks: ArrayBuffer[] = [];
222
+ let audioSampleRate = 24000;
223
+ let ttsChain = Promise.resolve();
224
 
225
+ try {
226
+ const response = await streamChat(user.user_id, roomId, text);
227
  if (!response.body) throw new Error("No response body");
228
 
229
  const reader = response.body.getReader();
 
281
  ? {
282
  ...chat,
283
  messages: chat.messages.map((m) =>
284
+ m.id === assistantMsgId ? { ...m, content: data } : m
 
 
285
  ),
286
  }
287
  : chat
288
  )
289
  );
290
+ } else if (currentEvent === "audio" && data && isVoiceActiveRef.current) {
291
+ // TTS: only during active voice session
292
+ const sentence = data;
293
+ ttsChain = ttsChain.then(async () => {
294
+ try {
295
+ const { pcm, sampleRate } = await textToSpeech(sentence);
296
+ audioChunks.push(pcm);
297
+ audioSampleRate = sampleRate;
298
+ if (!ttsPlayerRef.current) {
299
+ ttsPlayerRef.current = new AudioPlayer();
300
+ }
301
+ ttsPlayerRef.current.init(sampleRate);
302
+ ttsPlayerRef.current.enqueue(pcm);
303
+ } catch {
304
+ // TTS failure is non-fatal; chat continues
305
+ }
306
+ });
307
  } else if (currentEvent === "done") {
308
  break;
309
  }
310
  }
311
  }
312
  }
313
+
314
+ // Wait for all queued TTS calls to finish
315
+ await ttsChain;
316
+
317
+ // Reload messages from server (may reassign IDs)
318
  await loadRoomMessages(roomId);
319
+
320
+ // Re-attach audio chunks to the last assistant message after server sync
321
+ if (audioChunks.length > 0) {
322
+ setChats((prev) =>
323
+ prev.map((chat) => {
324
+ if (chat.id !== roomId) return chat;
325
+ const msgs = [...chat.messages];
326
+ for (let i = msgs.length - 1; i >= 0; i--) {
327
+ if (msgs[i].role === "assistant") {
328
+ msgs[i] = { ...msgs[i], audioChunks, audioSampleRate };
329
+ break;
330
+ }
331
+ }
332
+ return { ...chat, messages: msgs };
333
+ })
334
+ );
335
+ }
336
  } catch (err: unknown) {
337
  if ((err as Error).name !== "AbortError") {
338
  setChats((prev) =>
 
344
  m.id === assistantMsgId
345
  ? {
346
  ...m,
347
+ content: "Sorry, I couldn't get a response. Please try again.",
 
348
  }
349
  : m
350
  ),
 
357
  setIsStreaming(false);
358
  setStreamingMsgId(null);
359
  abortControllerRef.current = null;
360
+ ttsPlayerRef.current = null;
361
  }
362
+ }, [user, ensureRoom, loadRoomMessages]);
363
+
364
+ // Voice callbacks
365
+ const handleVoiceTranscript = useCallback(async (text: string) => {
366
+ // Route the STT transcript through the chatbot (same as typing)
367
+ await handleSend(text);
368
+ }, [handleSend]);
369
+
370
+ const { voiceState, start, stop, isActive: isVoiceActive } = useVoiceSession({
371
+ onTranscript: handleVoiceTranscript,
372
+ onReply: () => { /* WebSocket reply ignored — chatbot SSE handles response */ },
373
+ bypassWsTts: true,
374
+ });
375
 
376
+ // Keep isVoiceActiveRef in sync so handleSend can read it synchronously
377
+ useEffect(() => { isVoiceActiveRef.current = isVoiceActive; }, [isVoiceActive]);
 
 
 
 
378
 
379
+ const handleVoiceToggle = useCallback(() => {
380
+ if (isVoiceActive) stop();
381
+ else start();
382
+ }, [isVoiceActive, start, stop]);
 
 
 
 
 
 
 
 
 
 
 
 
 
383
 
384
+ const sessions: ChatSession[] = chats.map((c) => ({
385
+ id: c.id,
386
+ title: c.title,
387
+ createdAt: c.createdAt,
388
+ updatedAt: c.updatedAt,
389
+ }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
+ return (
392
+ <ChatLayout
393
+ sidebar={
394
+ <Sidebar
395
+ sessions={sessions}
396
+ activeSessionId={currentChatId}
397
+ isLoadingSessions={roomsLoading}
398
+ user={user}
399
+ onNewChat={handleNewChat}
400
+ onSelectSession={setCurrentChatId}
401
+ onDeleteSession={handleDeleteSession}
402
+ onLogout={handleLogout}
403
+ mobileOpen={mobileSidebarOpen}
404
+ onMobileClose={() => setMobileSidebarOpen(false)}
405
+ />
406
+ }
407
+ >
408
+ {/* Background decoration */}
409
+ <div className="absolute inset-0 pointer-events-none z-0 overflow-hidden">
410
+ <div
411
+ className="absolute inset-0 opacity-[0.13]"
412
+ style={{
413
+ backgroundImage: "radial-gradient(circle, #94a3b8 1px, transparent 1px)",
414
+ backgroundSize: "28px 28px",
415
+ }}
416
+ />
417
+ <div
418
+ className="absolute -top-28 -right-28 w-80 h-80 rounded-full blur-3xl opacity-[0.08]"
419
+ style={{ background: "#0ea5e9" }}
420
+ />
421
+ <div
422
+ className="absolute -bottom-28 -left-28 w-96 h-96 rounded-full blur-3xl opacity-[0.07]"
423
+ style={{ background: "#f97316" }}
424
+ />
425
+ <div
426
+ className="absolute top-1/2 -left-32 w-64 h-64 rounded-full blur-3xl opacity-[0.05]"
427
+ style={{ background: "#10b981" }}
428
+ />
429
  </div>
430
 
431
+ {/* Header */}
432
+ <div className="relative z-10 bg-white border-b border-neutral-100 px-4 py-3 flex items-center gap-3">
433
+ {/* Hamburger — mobile only */}
434
+ <button
435
+ className="md:hidden flex-shrink-0 p-1.5 rounded-lg text-neutral-500 hover:text-brand-green hover:bg-brand-green-50 transition-all"
436
+ onClick={() => setMobileSidebarOpen(true)}
437
+ aria-label="Open menu"
438
+ >
439
+ <Menu className="w-5 h-5" />
440
+ </button>
441
+
442
+ <h1 className="text-base font-bold text-neutral-900 flex-1 truncate">
443
+ {currentChat?.title ?? "New Chat"}
444
+ </h1>
445
+
446
+ <button
447
+ onClick={() => setKnowledgeOpen(true)}
448
+ className="flex items-center gap-2 bg-brand-amber text-white px-3 py-2 rounded-lg text-sm font-semibold hover:brightness-105 hover:scale-105 transition-all duration-200 flex-shrink-0"
449
+ >
450
+ <Database className="w-4 h-4" />
451
+ <span className="hidden sm:inline">Knowledge</span>
452
+ </button>
453
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
+ {/* Chat window */}
456
+ <div className="relative z-10 flex-1 flex flex-col min-h-0">
457
+ <ChatWindow
458
+ messages={currentChat?.messages ?? []}
459
+ isLoading={isStreaming}
460
+ streamingMsgId={streamingMsgId}
461
+ userName={user?.name}
462
+ />
463
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
 
465
+ {/* Input area */}
466
+ <div className="relative z-10 border-t border-neutral-100 bg-white/80 backdrop-blur-sm">
467
+ <div className="w-full max-w-4xl xl:max-w-5xl mx-auto flex flex-col gap-2 px-3 sm:px-4 pt-3 pb-4">
468
+ <AnimatePresence>
469
+ {isVoiceActive && (
470
+ <VoiceStatusBar voiceState={voiceState} onStop={stop} />
471
+ )}
472
+ </AnimatePresence>
473
+ <ChatInput
474
+ onSend={handleSend}
475
+ onStop={handleStop}
476
+ isLoading={isStreaming}
477
+ voiceState={voiceState}
478
+ onVoiceToggle={handleVoiceToggle}
479
+ />
 
 
 
 
 
 
 
 
 
 
 
480
  </div>
481
  </div>
482
 
 
483
  <KnowledgeManagement
484
  open={knowledgeOpen}
485
  onClose={() => setKnowledgeOpen(false)}
486
  />
487
+ </ChatLayout>
488
  );
489
  }
src/app/components/chat/ChatInput.tsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from "react";
2
+ import { SendHorizontal, Square } from "lucide-react";
3
+ import type { VoiceState } from "../../../hooks/useVoiceSession";
4
+ import VoiceMicButton from "./VoiceMicButton";
5
+
6
+ interface ChatInputProps {
7
+ onSend: (text: string) => void;
8
+ onStop?: () => void;
9
+ isLoading: boolean;
10
+ disabled?: boolean;
11
+ voiceState: VoiceState;
12
+ onVoiceToggle: () => void;
13
+ }
14
+
15
+ export default function ChatInput({
16
+ onSend,
17
+ onStop,
18
+ isLoading,
19
+ disabled,
20
+ voiceState,
21
+ onVoiceToggle,
22
+ }: ChatInputProps) {
23
+ const [value, setValue] = useState("");
24
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
25
+
26
+ const isVoiceActive = voiceState !== "IDLE" && voiceState !== "ERROR";
27
+
28
+ useEffect(() => {
29
+ const el = textareaRef.current;
30
+ if (!el) return;
31
+ el.style.height = "auto";
32
+ el.style.height = Math.min(el.scrollHeight, 50) + "px";
33
+ }, [value]);
34
+
35
+ const handleSend = () => {
36
+ const trimmed = value.trim();
37
+ if (!trimmed || isLoading || disabled || isVoiceActive) return;
38
+ onSend(trimmed);
39
+ setValue("");
40
+ };
41
+
42
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
43
+ if (e.key === "Enter" && !e.shiftKey) {
44
+ e.preventDefault();
45
+ handleSend();
46
+ }
47
+ };
48
+
49
+ const canSend = value.trim().length > 0 && !disabled && !isVoiceActive;
50
+
51
+ return (
52
+ <div
53
+ className={`bg-white border rounded-2xl shadow-sm px-4 py-3 transition-all ${
54
+ disabled || isVoiceActive
55
+ ? "opacity-80 border-neutral-200"
56
+ : "border-neutral-200 focus-within:border-brand-green focus-within:shadow-md focus-within:shadow-brand-green/10"
57
+ }`}
58
+ >
59
+ <div className="flex items-end gap-3">
60
+ <VoiceMicButton
61
+ voiceState={voiceState}
62
+ onToggle={onVoiceToggle}
63
+ disabled={isLoading}
64
+ />
65
+
66
+ <textarea
67
+ ref={textareaRef}
68
+ value={value}
69
+ onChange={(e) => setValue(e.target.value)}
70
+ onKeyDown={handleKeyDown}
71
+ placeholder={
72
+ isVoiceActive
73
+ ? "Voice session active — speak to interact"
74
+ : "Ask me anything… (Enter to send, Shift+Enter for newline)"
75
+ }
76
+ disabled={isLoading || disabled || isVoiceActive}
77
+ rows={1}
78
+ className="flex-1 resize-none bg-transparent text-sm text-neutral-900 leading-6 placeholder:text-neutral-400 outline-none overflow-y-auto"
79
+ style={{ minHeight: "24px", maxHeight: "50px" }}
80
+ />
81
+
82
+ {isLoading ? (
83
+ <button
84
+ onClick={onStop}
85
+ className="w-9 h-9 flex-shrink-0 rounded-xl bg-gradient-to-br from-brand-green-light to-brand-green text-white shadow-md shadow-brand-green/25 hover:brightness-105 flex items-center justify-center transition-all"
86
+ aria-label="Stop"
87
+ >
88
+ <Square className="h-3.5 w-3.5" />
89
+ </button>
90
+ ) : (
91
+ <button
92
+ onClick={handleSend}
93
+ disabled={!canSend}
94
+ className={`w-9 h-9 flex-shrink-0 rounded-xl flex items-center justify-center transition-all ${
95
+ canSend
96
+ ? "bg-gradient-to-br from-brand-green-light to-brand-green text-white shadow-md shadow-brand-green/25 hover:brightness-105"
97
+ : "bg-neutral-100 text-neutral-300 cursor-not-allowed"
98
+ }`}
99
+ aria-label="Send"
100
+ >
101
+ <SendHorizontal className="h-4 w-4" />
102
+ </button>
103
+ )}
104
+ </div>
105
+ </div>
106
+ );
107
+ }
src/app/components/chat/ChatLayout.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ReactNode } from "react";
2
+
3
+ interface ChatLayoutProps {
4
+ sidebar: ReactNode;
5
+ children: ReactNode;
6
+ }
7
+
8
+ export default function ChatLayout({ sidebar, children }: ChatLayoutProps) {
9
+ return (
10
+ <div className="flex h-screen bg-neutral-50 overflow-hidden">
11
+ {sidebar}
12
+ <div className="flex-1 flex flex-col min-w-0 relative">
13
+ {children}
14
+ </div>
15
+ </div>
16
+ );
17
+ }
src/app/components/chat/ChatWindow.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+ import { motion } from "motion/react";
3
+ import { Bot } from "lucide-react";
4
+ import type { Message } from "./types";
5
+ import MessageBubble from "./MessageBubble";
6
+
7
+ const WELCOME_FEATURES = [
8
+ { emoji: "📊", title: "Data Analysis", desc: "Get actionable insights and root cause" },
9
+ { emoji: "🧠", title: "Knowledge Based", desc: "Handbook, SOP, Documents, or Databases" },
10
+ { emoji: "🎤", title: "Voice Mode", desc: "Ask questions using your voice" },
11
+ { emoji: "💬", title: "Natural language", desc: "Natural interaction" },
12
+ ];
13
+
14
+ interface ChatWindowProps {
15
+ messages: Message[];
16
+ isLoading: boolean;
17
+ streamingMsgId: string | null;
18
+ userName?: string;
19
+ }
20
+
21
+ export default function ChatWindow({ messages, isLoading, streamingMsgId, userName }: ChatWindowProps) {
22
+ const firstName = userName?.split(" ")[0] ?? "";
23
+ const bottomRef = useRef<HTMLDivElement>(null);
24
+
25
+ useEffect(() => {
26
+ bottomRef.current?.scrollIntoView({ behavior: "smooth" });
27
+ }, [messages, isLoading]);
28
+
29
+ if (messages.length === 0 && !isLoading) {
30
+ return (
31
+ <div className="flex-1 overflow-y-auto flex flex-col items-center justify-center gap-6 px-4 sm:px-6 xl:px-8 pb-4">
32
+ <motion.div
33
+ className="flex flex-col items-center gap-4 xl:gap-6 w-full max-w-sm sm:max-w-md xl:max-w-2xl"
34
+ initial={{ opacity: 0, scale: 0.9 }}
35
+ animate={{ opacity: 1, scale: 1 }}
36
+ transition={{ duration: 0.5, ease: "easeOut" }}
37
+ >
38
+ <div className="w-20 h-20 xl:w-28 xl:h-28 rounded-3xl bg-gradient-to-br from-brand-green-light to-brand-green shadow-xl shadow-brand-green/25 flex items-center justify-center">
39
+ <Bot className="h-10 w-10 xl:h-14 xl:w-14 text-white" />
40
+ </div>
41
+
42
+ <div className="text-center">
43
+ <h2 className="text-2xl xl:text-4xl font-bold text-neutral-900">
44
+ {firstName ? `Hi, ${firstName}! 👋` : "Hello! 👋"}
45
+ </h2>
46
+ <p className="mt-2 text-neutral-500 text-sm xl:text-base leading-relaxed">
47
+ Leverage AI to analyze data, learn from patterns, and identify root causes automatically. Maintiva Agent acts as a virtual assistant, helping teams diagnose issues faster and make smarter decisions.
48
+ </p>
49
+ </div>
50
+
51
+ <div className="grid grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-4 w-full mt-2">
52
+ {WELCOME_FEATURES.map((f) => (
53
+ <div
54
+ key={f.title}
55
+ className="flex flex-col gap-1 xl:gap-2 p-3 xl:p-4 rounded-xl bg-white border border-neutral-100 shadow-sm text-left"
56
+ >
57
+ <span className="text-xl xl:text-3xl">{f.emoji}</span>
58
+ <span className="text-sm xl:text-base font-semibold text-neutral-800">{f.title}</span>
59
+ <span className="text-xs xl:text-sm text-neutral-400">{f.desc}</span>
60
+ </div>
61
+ ))}
62
+ </div>
63
+ </motion.div>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <div className="flex-1 overflow-y-auto py-4 space-y-1">
70
+ {/* Constrain message width on large/seminar screens */}
71
+ <div className="w-full max-w-4xl xl:max-w-5xl mx-auto">
72
+ {messages.map((message) => (
73
+ <MessageBubble
74
+ key={message.id}
75
+ message={message}
76
+ isStreamingPlaceholder={streamingMsgId === message.id}
77
+ />
78
+ ))}
79
+ <div ref={bottomRef} />
80
+ </div>
81
+ </div>
82
+ );
83
+ }
src/app/components/chat/FeedbackWidget.tsx ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef } from "react";
2
+ import { ThumbsUp, ThumbsDown, Copy, Check, Loader2, Volume2, VolumeX } from "lucide-react";
3
+ import { replayAudio } from "../../../audio/AudioPlayer";
4
+ import { textToSpeech } from "../../../services/voiceApi";
5
+
6
+ interface FeedbackWidgetProps {
7
+ messageId: string;
8
+ content: string;
9
+ audioChunks?: ArrayBuffer[];
10
+ audioSampleRate?: number;
11
+ }
12
+
13
+ export default function FeedbackWidget({
14
+ content,
15
+ audioChunks,
16
+ audioSampleRate,
17
+ }: FeedbackWidgetProps) {
18
+ const [thumbsUp, setThumbsUp] = useState(false);
19
+ const [thumbsDown, setThumbsDown] = useState(false);
20
+ const [copyState, setCopyState] = useState<"idle" | "loading" | "success">("idle");
21
+ const [speakerState, setSpeakerState] = useState<"idle" | "loading" | "playing">("idle");
22
+ const cancelPlayRef = useRef<(() => void) | null>(null);
23
+
24
+ const handleThumbsUp = () => {
25
+ setThumbsUp((v) => !v);
26
+ if (thumbsDown) setThumbsDown(false);
27
+ };
28
+
29
+ const handleThumbsDown = () => {
30
+ setThumbsDown((v) => !v);
31
+ if (thumbsUp) setThumbsUp(false);
32
+ };
33
+
34
+ const handleCopy = async () => {
35
+ if (copyState !== "idle") return;
36
+ setCopyState("loading");
37
+ try {
38
+ await navigator.clipboard.writeText(content);
39
+ setCopyState("success");
40
+ setTimeout(() => setCopyState("idle"), 2000);
41
+ } catch {
42
+ setCopyState("idle");
43
+ }
44
+ };
45
+
46
+ const handleSpeaker = async () => {
47
+ if (speakerState === "playing") {
48
+ cancelPlayRef.current?.();
49
+ cancelPlayRef.current = null;
50
+ setSpeakerState("idle");
51
+ return;
52
+ }
53
+
54
+ if (speakerState === "loading") return;
55
+
56
+ if (audioChunks && audioChunks.length > 0) {
57
+ // Voice mode: replay pre-stored audio immediately
58
+ setSpeakerState("playing");
59
+ const cancel = replayAudio(audioChunks, audioSampleRate ?? 24000);
60
+ cancelPlayRef.current = cancel;
61
+ // Calculate approx duration to reset state
62
+ const totalSamples = audioChunks.reduce((acc, c) => acc + c.byteLength / 2, 0);
63
+ const duration = (totalSamples / (audioSampleRate ?? 24000)) * 1000;
64
+ setTimeout(() => {
65
+ cancelPlayRef.current = null;
66
+ setSpeakerState("idle");
67
+ }, duration + 200);
68
+ } else {
69
+ // Text mode: request TTS now
70
+ setSpeakerState("loading");
71
+ try {
72
+ const { pcm, sampleRate } = await textToSpeech(content);
73
+ setSpeakerState("playing");
74
+ const cancel = replayAudio([pcm], sampleRate);
75
+ cancelPlayRef.current = cancel;
76
+ const duration = (pcm.byteLength / 2 / sampleRate) * 1000;
77
+ setTimeout(() => {
78
+ cancelPlayRef.current = null;
79
+ setSpeakerState("idle");
80
+ }, duration + 200);
81
+ } catch {
82
+ setSpeakerState("idle");
83
+ }
84
+ }
85
+ };
86
+
87
+ return (
88
+ <div className="flex items-center gap-1 ml-10 mt-1 px-4">
89
+ <button
90
+ onClick={handleThumbsUp}
91
+ className={`p-1 rounded transition-colors ${
92
+ thumbsUp ? "text-brand-green" : "text-neutral-300 hover:text-neutral-500"
93
+ }`}
94
+ aria-label="Thumbs up"
95
+ >
96
+ <ThumbsUp className="h-3.5 w-3.5" />
97
+ </button>
98
+
99
+ <button
100
+ onClick={handleThumbsDown}
101
+ className={`p-1 rounded transition-colors ${
102
+ thumbsDown ? "text-red-400" : "text-neutral-300 hover:text-neutral-500"
103
+ }`}
104
+ aria-label="Thumbs down"
105
+ >
106
+ <ThumbsDown className="h-3.5 w-3.5" />
107
+ </button>
108
+
109
+ <button
110
+ onClick={handleCopy}
111
+ className={`p-1 rounded transition-colors ${
112
+ copyState === "success"
113
+ ? "text-brand-green"
114
+ : "text-neutral-300 hover:text-neutral-500"
115
+ }`}
116
+ aria-label="Copy message"
117
+ >
118
+ {copyState === "loading" ? (
119
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />
120
+ ) : copyState === "success" ? (
121
+ <Check className="h-3.5 w-3.5" />
122
+ ) : (
123
+ <Copy className="h-3.5 w-3.5" />
124
+ )}
125
+ </button>
126
+
127
+ <button
128
+ onClick={handleSpeaker}
129
+ className={`p-1 rounded transition-colors ${
130
+ speakerState === "playing"
131
+ ? "text-brand-green"
132
+ : "text-neutral-300 hover:text-neutral-500"
133
+ }`}
134
+ aria-label="Play audio"
135
+ >
136
+ {speakerState === "loading" ? (
137
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />
138
+ ) : speakerState === "playing" ? (
139
+ <VolumeX className="h-3.5 w-3.5" />
140
+ ) : (
141
+ <Volume2 className="h-3.5 w-3.5" />
142
+ )}
143
+ </button>
144
+ </div>
145
+ );
146
+ }
src/app/components/chat/MessageBubble.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from "motion/react";
2
+ import { Bot } from "lucide-react";
3
+ import type { Message } from "./types";
4
+ import TypingIndicator from "./TypingIndicator";
5
+ import FeedbackWidget from "./FeedbackWidget";
6
+ import MarkdownRenderer from "./renderers/MarkdownRenderer";
7
+
8
+ interface MessageBubbleProps {
9
+ message: Message;
10
+ isStreamingPlaceholder?: boolean;
11
+ }
12
+
13
+ export default function MessageBubble({ message, isStreamingPlaceholder }: MessageBubbleProps) {
14
+ if (message.role === "user") {
15
+ return (
16
+ <motion.div
17
+ className="flex justify-end px-3 sm:px-4 py-1"
18
+ initial={{ opacity: 0, y: 12 }}
19
+ animate={{ opacity: 1, y: 0 }}
20
+ transition={{ duration: 0.3, ease: "easeOut" }}
21
+ >
22
+ <div className="max-w-[88%] sm:max-w-[75%] xl:max-w-[65%] px-4 py-3 rounded-2xl rounded-br-sm bg-gradient-to-br from-brand-green-light to-brand-green text-white text-sm xl:text-base leading-relaxed shadow-md shadow-brand-green/20">
23
+ {message.content}
24
+ </div>
25
+ </motion.div>
26
+ );
27
+ }
28
+
29
+ // Show TypingIndicator when the assistant placeholder has no content yet
30
+ if (isStreamingPlaceholder && message.content === "") {
31
+ return <TypingIndicator />;
32
+ }
33
+
34
+ return (
35
+ <motion.div
36
+ initial={{ opacity: 0, y: 12 }}
37
+ animate={{ opacity: 1, y: 0 }}
38
+ transition={{ duration: 0.3, ease: "easeOut" }}
39
+ >
40
+ <div className="flex gap-3 px-3 sm:px-4 py-2">
41
+ <div className="w-7 h-7 xl:w-8 xl:h-8 rounded-full bg-gradient-to-br from-brand-green-light to-brand-green flex-shrink-0 flex items-center justify-center shadow-sm">
42
+ <Bot className="h-4 w-4 text-white" />
43
+ </div>
44
+
45
+ <div className="max-w-[92%] sm:max-w-[88%] xl:max-w-[80%] bg-white border border-neutral-100 rounded-2xl rounded-tl-sm shadow-sm px-4 xl:px-5 py-3 xl:py-4">
46
+ <MarkdownRenderer content={message.content} skipPreprocess={isStreamingPlaceholder} />
47
+
48
+ {message.sources && message.sources.length > 0 && (
49
+ <div className="mt-3 pt-2 border-t border-neutral-100">
50
+ <p className="text-[10px] text-neutral-400 mb-1.5">Sources:</p>
51
+ <div className="flex flex-wrap gap-1">
52
+ {message.sources.map((src, i) => (
53
+ <span
54
+ key={i}
55
+ className="text-[10px] bg-neutral-100 text-neutral-600 px-2 py-0.5 rounded-full border border-neutral-200"
56
+ title={src.page_label ? `Page ${src.page_label}` : undefined}
57
+ >
58
+ 📄 {src.filename}
59
+ {src.page_label ? ` p.${src.page_label}` : ""}
60
+ </span>
61
+ ))}
62
+ </div>
63
+ </div>
64
+ )}
65
+ </div>
66
+ </div>
67
+
68
+ {!isStreamingPlaceholder && (
69
+ <FeedbackWidget
70
+ messageId={message.id}
71
+ content={message.content}
72
+ audioChunks={message.audioChunks}
73
+ audioSampleRate={message.audioSampleRate}
74
+ />
75
+ )}
76
+ </motion.div>
77
+ );
78
+ }
src/app/components/chat/Sidebar.tsx ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+ import {
4
+ Plus,
5
+ MessageSquare,
6
+ Trash2,
7
+ ChevronLeft,
8
+ ChevronRight,
9
+ Settings,
10
+ LogOut,
11
+ } from "lucide-react";
12
+ import maintivalogoUrl from "../../../assets/maintiva-logo.jpg";
13
+ import type { ChatSession, StoredUser } from "./types";
14
+
15
+ interface SidebarProps {
16
+ sessions: ChatSession[];
17
+ activeSessionId: string | null;
18
+ isLoadingSessions: boolean;
19
+ user: StoredUser | null;
20
+ onNewChat: () => void;
21
+ onSelectSession: (id: string) => void;
22
+ onDeleteSession: (id: string) => void;
23
+ onLogout: () => void;
24
+ mobileOpen: boolean;
25
+ onMobileClose: () => void;
26
+ }
27
+
28
+ function formatTime(dateStr: string | null | undefined): string {
29
+ if (!dateStr) return "";
30
+ const d = new Date(dateStr);
31
+ const now = new Date();
32
+ const diffMs = now.getTime() - d.getTime();
33
+ const diffDays = Math.floor(diffMs / 86400000);
34
+ if (diffDays === 0) {
35
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
36
+ }
37
+ if (diffDays === 1) return "Yesterday";
38
+ if (diffDays < 7) return `${diffDays}d ago`;
39
+ return d.toLocaleDateString([], { month: "short", day: "numeric" });
40
+ }
41
+
42
+ export default function Sidebar({
43
+ sessions,
44
+ activeSessionId,
45
+ isLoadingSessions,
46
+ user,
47
+ onNewChat,
48
+ onSelectSession,
49
+ onDeleteSession,
50
+ onLogout,
51
+ mobileOpen,
52
+ onMobileClose,
53
+ }: SidebarProps) {
54
+ const [collapsed, setCollapsed] = useState(false);
55
+ const [hoveredSession, setHoveredSession] = useState<string | null>(null);
56
+
57
+ return (
58
+ <>
59
+ {/* Mobile backdrop */}
60
+ <AnimatePresence>
61
+ {mobileOpen && (
62
+ <motion.div
63
+ className="fixed inset-0 bg-black/40 z-40 md:hidden"
64
+ initial={{ opacity: 0 }}
65
+ animate={{ opacity: 1 }}
66
+ exit={{ opacity: 0 }}
67
+ transition={{ duration: 0.2 }}
68
+ onClick={onMobileClose}
69
+ />
70
+ )}
71
+ </AnimatePresence>
72
+
73
+ <motion.div
74
+ className={[
75
+ "flex flex-col h-full flex-shrink-0 bg-white border-r border-neutral-100 overflow-hidden",
76
+ // Mobile: fixed overlay drawer; desktop: in-flow
77
+ "fixed inset-y-0 left-0 z-50 md:relative md:inset-auto md:z-auto",
78
+ // Mobile slide in/out; always visible on desktop
79
+ mobileOpen ? "translate-x-0" : "-translate-x-full",
80
+ "md:translate-x-0",
81
+ // CSS transition for mobile only; framer-motion handles desktop width
82
+ "transition-transform duration-300 ease-in-out md:transition-none",
83
+ // Never overflow phone screen
84
+ "max-w-[85vw] md:max-w-none",
85
+ ].join(" ")}
86
+ animate={{ width: collapsed ? 72 : 280 }}
87
+ transition={{ duration: 0.3, ease: "easeInOut" }}
88
+ >
89
+ {/* Collapse toggle — desktop only */}
90
+ <button
91
+ onClick={() => setCollapsed((v) => !v)}
92
+ className="hidden md:flex absolute -right-3 top-1/2 -translate-y-1/2 z-20 w-6 h-6 rounded-full bg-white border border-neutral-200 shadow-sm text-neutral-500 hover:text-brand-green hover:border-brand-green transition-all items-center justify-center"
93
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
94
+ >
95
+ {collapsed ? (
96
+ <ChevronRight className="h-3.5 w-3.5" />
97
+ ) : (
98
+ <ChevronLeft className="h-3.5 w-3.5" />
99
+ )}
100
+ </button>
101
+
102
+ {/* Header */}
103
+ <div className="flex items-center gap-3 px-4 py-4 border-b border-neutral-100">
104
+ <motion.div
105
+ className="w-9 h-9 rounded-xl overflow-hidden flex-shrink-0 shadow-md"
106
+ whileHover={{ scale: 1.05 }}
107
+ >
108
+ <img src={maintivalogoUrl} alt="Maintiva" className="w-full h-full object-cover" />
109
+ </motion.div>
110
+
111
+ <AnimatePresence>
112
+ {!collapsed && (
113
+ <motion.div
114
+ initial={{ opacity: 0, width: 0 }}
115
+ animate={{ opacity: 1, width: "auto" }}
116
+ exit={{ opacity: 0, width: 0 }}
117
+ transition={{ duration: 0.2 }}
118
+ className="overflow-hidden"
119
+ >
120
+ <p className="font-bold text-neutral-900 text-base whitespace-nowrap">Maintiva Agent</p>
121
+ <p className="text-xs text-neutral-400 whitespace-nowrap">AI Data Assistant</p>
122
+ </motion.div>
123
+ )}
124
+ </AnimatePresence>
125
+ </div>
126
+
127
+ {/* New Chat button */}
128
+ <div className="px-3 py-3">
129
+ <button
130
+ onClick={() => { onNewChat(); onMobileClose(); }}
131
+ className={`w-full flex items-center gap-3 rounded-xl px-3 py-2.5 bg-gradient-to-br from-brand-green-light to-brand-green text-white text-sm font-semibold shadow-md shadow-brand-green/20 hover:shadow-lg hover:shadow-brand-green/25 hover:brightness-105 transition-all ${
132
+ collapsed ? "justify-center px-0" : ""
133
+ }`}
134
+ >
135
+ <Plus className="h-4 w-4 flex-shrink-0" />
136
+ <AnimatePresence>
137
+ {!collapsed && (
138
+ <motion.span
139
+ initial={{ opacity: 0, width: 0 }}
140
+ animate={{ opacity: 1, width: "auto" }}
141
+ exit={{ opacity: 0, width: 0 }}
142
+ transition={{ duration: 0.2 }}
143
+ className="overflow-hidden whitespace-nowrap"
144
+ >
145
+ New Chat
146
+ </motion.span>
147
+ )}
148
+ </AnimatePresence>
149
+ </button>
150
+ </div>
151
+
152
+ {/* Sessions */}
153
+ <div className="flex-1 overflow-y-auto">
154
+ {!collapsed && sessions.length > 0 && (
155
+ <p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider px-4 pt-2 pb-1">
156
+ Recent
157
+ </p>
158
+ )}
159
+
160
+ {isLoadingSessions ? (
161
+ <div className="flex justify-center py-4">
162
+ <div className="w-4 h-4 rounded-full border-2 border-brand-green border-t-transparent animate-spin" />
163
+ </div>
164
+ ) : (
165
+ <div className="px-2 space-y-0.5">
166
+ {sessions.map((session) => {
167
+ const isActive = session.id === activeSessionId;
168
+ return (
169
+ <div
170
+ key={session.id}
171
+ className={`relative flex items-center gap-3 rounded-xl px-3 py-2.5 cursor-pointer transition-all duration-150 ${
172
+ isActive
173
+ ? "bg-brand-green-50 text-brand-green"
174
+ : "text-neutral-700 hover:bg-neutral-50"
175
+ }`}
176
+ onClick={() => { onSelectSession(session.id); onMobileClose(); }}
177
+ onMouseEnter={() => setHoveredSession(session.id)}
178
+ onMouseLeave={() => setHoveredSession(null)}
179
+ >
180
+ <MessageSquare
181
+ className={`h-4 w-4 flex-shrink-0 ${
182
+ isActive ? "text-brand-green" : "text-neutral-400"
183
+ }`}
184
+ />
185
+
186
+ <AnimatePresence>
187
+ {!collapsed && (
188
+ <motion.div
189
+ initial={{ opacity: 0, width: 0 }}
190
+ animate={{ opacity: 1, width: "auto" }}
191
+ exit={{ opacity: 0, width: 0 }}
192
+ transition={{ duration: 0.2 }}
193
+ className="flex-1 min-w-0 overflow-hidden"
194
+ >
195
+ <p className="text-sm font-medium truncate">{session.title}</p>
196
+ <p className="text-xs text-neutral-400 truncate">
197
+ {formatTime(session.updatedAt ?? session.createdAt)}
198
+ </p>
199
+ </motion.div>
200
+ )}
201
+ </AnimatePresence>
202
+
203
+ {!collapsed && hoveredSession === session.id && (
204
+ <button
205
+ onClick={(e) => {
206
+ e.stopPropagation();
207
+ onDeleteSession(session.id);
208
+ }}
209
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-all"
210
+ aria-label="Delete session"
211
+ >
212
+ <Trash2 className="h-3.5 w-3.5" />
213
+ </button>
214
+ )}
215
+ </div>
216
+ );
217
+ })}
218
+ </div>
219
+ )}
220
+ </div>
221
+
222
+ {/* Footer */}
223
+ <div className="border-t border-neutral-100 p-3">
224
+ <div className="flex items-center gap-3 rounded-xl px-2 py-2">
225
+ <div className="h-7 w-7 rounded-full bg-gradient-to-br from-brand-green-light to-brand-green flex items-center justify-center flex-shrink-0">
226
+ <span className="text-xs font-bold text-white">
227
+ {user?.name?.[0]?.toUpperCase() ?? "U"}
228
+ </span>
229
+ </div>
230
+
231
+ <AnimatePresence>
232
+ {!collapsed && (
233
+ <motion.div
234
+ initial={{ opacity: 0, width: 0 }}
235
+ animate={{ opacity: 1, width: "auto" }}
236
+ exit={{ opacity: 0, width: 0 }}
237
+ transition={{ duration: 0.2 }}
238
+ className="flex-1 min-w-0 overflow-hidden"
239
+ >
240
+ <p className="text-sm font-semibold text-neutral-900 truncate whitespace-nowrap">
241
+ {user?.name ?? "User"}
242
+ </p>
243
+ <p className="text-xs text-neutral-400 truncate whitespace-nowrap">
244
+ {user?.email ?? ""}
245
+ </p>
246
+ </motion.div>
247
+ )}
248
+ </AnimatePresence>
249
+
250
+ {!collapsed && (
251
+ <div className="flex items-center gap-0.5">
252
+ <button
253
+ className="p-1.5 rounded-lg text-neutral-400 hover:text-brand-green hover:bg-brand-green-50 transition-all"
254
+ aria-label="Settings"
255
+ >
256
+ <Settings className="h-4 w-4" />
257
+ </button>
258
+ <button
259
+ onClick={onLogout}
260
+ className="p-1.5 rounded-lg text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-all"
261
+ aria-label="Logout"
262
+ >
263
+ <LogOut className="h-4 w-4" />
264
+ </button>
265
+ </div>
266
+ )}
267
+
268
+ {collapsed && (
269
+ <button
270
+ onClick={onLogout}
271
+ className="p-1.5 rounded-lg text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-all"
272
+ aria-label="Logout"
273
+ >
274
+ <LogOut className="h-4 w-4" />
275
+ </button>
276
+ )}
277
+ </div>
278
+ </div>
279
+ </motion.div>
280
+ </>
281
+ );
282
+ }
src/app/components/chat/TypingIndicator.tsx ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import { motion } from "motion/react";
3
+ import { Bot } from "lucide-react";
4
+
5
+ function useLoadingMessages() {
6
+ const [messages, setMessages] = useState<string[]>([]);
7
+
8
+ useEffect(() => {
9
+ fetch("/loading-messages.yaml")
10
+ .then((r) => r.text())
11
+ .then((text) => {
12
+ const parsed = text
13
+ .split("\n")
14
+ .filter((l) => l.trimStart().startsWith("- "))
15
+ .map((l) => l.replace(/^\s*- /, "").trim())
16
+ .filter(Boolean);
17
+ if (parsed.length > 0) setMessages(parsed);
18
+ })
19
+ .catch(() => {});
20
+ }, []);
21
+
22
+ return messages;
23
+ }
24
+
25
+ export default function TypingIndicator() {
26
+ const loadingMessages = useLoadingMessages();
27
+ const [phraseIndex, setPhraseIndex] = useState(0);
28
+
29
+ useEffect(() => {
30
+ if (loadingMessages.length === 0) return;
31
+ setPhraseIndex(Math.floor(Math.random() * loadingMessages.length));
32
+ const id = setInterval(() => {
33
+ setPhraseIndex((prev) => {
34
+ let next: number;
35
+ do {
36
+ next = Math.floor(Math.random() * loadingMessages.length);
37
+ } while (loadingMessages.length > 1 && next === prev);
38
+ return next;
39
+ });
40
+ }, 300);
41
+ return () => clearInterval(id);
42
+ }, [loadingMessages]);
43
+
44
+ return (
45
+ <motion.div
46
+ className="flex gap-3 px-4 py-2"
47
+ initial={{ opacity: 0, y: 8 }}
48
+ animate={{ opacity: 1, y: 0 }}
49
+ transition={{ duration: 0.25, ease: "easeOut" }}
50
+ >
51
+ <div className="w-7 h-7 rounded-full bg-gradient-to-br from-brand-green-light to-brand-green flex-shrink-0 flex items-center justify-center shadow-sm">
52
+ <Bot className="h-4 w-4 text-white" />
53
+ </div>
54
+
55
+ <div className="bg-white border border-neutral-100 rounded-2xl rounded-tl-sm shadow-sm px-4 py-3 min-w-[6rem]">
56
+ {loadingMessages.length > 0 ? (
57
+ <span className="text-sm text-neutral-400 font-medium">
58
+ {loadingMessages[phraseIndex]}…
59
+ </span>
60
+ ) : (
61
+ <div className="flex items-center gap-1.5 animate-pulse">
62
+ <span className="w-2 h-2 rounded-full bg-neutral-300" />
63
+ <span className="w-2 h-2 rounded-full bg-neutral-300" style={{ animationDelay: "150ms" }} />
64
+ <span className="w-2 h-2 rounded-full bg-neutral-300" style={{ animationDelay: "300ms" }} />
65
+ </div>
66
+ )}
67
+ </div>
68
+ </motion.div>
69
+ );
70
+ }
src/app/components/chat/VoiceMicButton.tsx ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from "motion/react";
2
+ import { Loader2, Mic, MicOff, Volume2, WifiOff } from "lucide-react";
3
+ import type { VoiceState } from "../../../hooks/useVoiceSession";
4
+
5
+ interface VoiceMicButtonProps {
6
+ voiceState: VoiceState;
7
+ onToggle: () => void;
8
+ disabled?: boolean;
9
+ }
10
+
11
+ export default function VoiceMicButton({ voiceState, onToggle, disabled }: VoiceMicButtonProps) {
12
+ const isDisabled = disabled || voiceState === "RECONNECTING";
13
+
14
+ const stateConfig: Record<
15
+ VoiceState,
16
+ { icon: React.ReactNode; className: string; title: string; pulse: boolean; scalePulse: boolean }
17
+ > = {
18
+ IDLE: {
19
+ icon: <Mic className="h-4 w-4" />,
20
+ className: "bg-neutral-100 text-neutral-400 hover:bg-brand-green/10 hover:text-brand-green",
21
+ title: "Start voice session",
22
+ pulse: false,
23
+ scalePulse: false,
24
+ },
25
+ CONNECTING: {
26
+ icon: <Loader2 className="h-4 w-4 animate-spin" />,
27
+ className: "bg-brand-green/10 text-brand-green/60",
28
+ title: "Connecting...",
29
+ pulse: false,
30
+ scalePulse: false,
31
+ },
32
+ LISTENING: {
33
+ icon: <Mic className="h-4 w-4" />,
34
+ className: "bg-brand-green text-white shadow-md shadow-brand-green/25",
35
+ title: "Listening — click to stop",
36
+ pulse: true,
37
+ scalePulse: false,
38
+ },
39
+ PROCESSING: {
40
+ icon: <Loader2 className="h-4 w-4 animate-spin" />,
41
+ className: "bg-brand-amber text-white",
42
+ title: "Processing...",
43
+ pulse: false,
44
+ scalePulse: false,
45
+ },
46
+ SPEAKING: {
47
+ icon: <Volume2 className="h-4 w-4" />,
48
+ className: "bg-brand-cyan text-white",
49
+ title: "Agent is speaking — click to stop",
50
+ pulse: false,
51
+ scalePulse: true,
52
+ },
53
+ RECONNECTING: {
54
+ icon: <WifiOff className="h-4 w-4" />,
55
+ className: "bg-neutral-200 text-neutral-400 cursor-not-allowed",
56
+ title: "Reconnecting...",
57
+ pulse: false,
58
+ scalePulse: false,
59
+ },
60
+ ERROR: {
61
+ icon: <MicOff className="h-4 w-4" />,
62
+ className: "bg-red-100 text-red-400 hover:bg-red-200",
63
+ title: "Connection failed — click to retry",
64
+ pulse: false,
65
+ scalePulse: false,
66
+ },
67
+ };
68
+
69
+ const cfg = stateConfig[voiceState];
70
+
71
+ return (
72
+ <div className="relative flex-shrink-0">
73
+ {cfg.pulse && (
74
+ <motion.span
75
+ className="absolute inset-0 rounded-xl bg-brand-green/30"
76
+ animate={{ scale: [1, 1.6], opacity: [0.6, 0] }}
77
+ transition={{ duration: 1.2, repeat: Infinity, ease: "easeOut" }}
78
+ />
79
+ )}
80
+ <motion.button
81
+ onClick={onToggle}
82
+ disabled={isDisabled}
83
+ title={cfg.title}
84
+ animate={cfg.scalePulse ? { scale: [1, 1.05, 1] } : { scale: 1 }}
85
+ transition={cfg.scalePulse ? { duration: 1.4, repeat: Infinity, ease: "easeInOut" } : {}}
86
+ className={`relative w-9 h-9 rounded-xl flex items-center justify-center transition-all duration-200 ${cfg.className} ${isDisabled ? "pointer-events-none" : ""}`}
87
+ aria-label={cfg.title}
88
+ >
89
+ {cfg.icon}
90
+ </motion.button>
91
+ </div>
92
+ );
93
+ }
src/app/components/chat/VoiceStatusBar.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { X } from "lucide-react";
2
+ import { motion } from "motion/react";
3
+ import type { VoiceState } from "../../../hooks/useVoiceSession";
4
+
5
+ interface VoiceStatusBarProps {
6
+ voiceState: VoiceState;
7
+ onStop: () => void;
8
+ }
9
+
10
+ const STATE_LABELS: Record<VoiceState, string> = {
11
+ IDLE: "",
12
+ CONNECTING: "Connecting...",
13
+ LISTENING: "Listening...",
14
+ PROCESSING: "Processing...",
15
+ SPEAKING: "Agent is speaking",
16
+ RECONNECTING: "Reconnecting...",
17
+ ERROR: "Connection error",
18
+ };
19
+
20
+ const STATE_COLORS: Record<VoiceState, string> = {
21
+ IDLE: "",
22
+ CONNECTING: "bg-brand-green/10 text-brand-green border-brand-green/20",
23
+ LISTENING: "bg-brand-green/10 text-brand-green border-brand-green/20",
24
+ PROCESSING: "bg-brand-amber/10 text-brand-amber border-brand-amber/20",
25
+ SPEAKING: "bg-brand-cyan/10 text-brand-cyan border-brand-cyan/20",
26
+ RECONNECTING: "bg-neutral-100 text-neutral-500 border-neutral-200",
27
+ ERROR: "bg-red-50 text-red-500 border-red-200",
28
+ };
29
+
30
+ export default function VoiceStatusBar({ voiceState, onStop }: VoiceStatusBarProps) {
31
+ return (
32
+ <motion.div
33
+ initial={{ opacity: 0, y: 6 }}
34
+ animate={{ opacity: 1, y: 0 }}
35
+ exit={{ opacity: 0, y: 6 }}
36
+ transition={{ duration: 0.2 }}
37
+ className={`flex items-center justify-between gap-2 px-3 py-1.5 rounded-xl border text-xs font-medium ${STATE_COLORS[voiceState]}`}
38
+ >
39
+ <div className="flex items-center gap-2">
40
+ <span className="relative flex h-2 w-2">
41
+ <span
42
+ className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 ${
43
+ voiceState === "LISTENING"
44
+ ? "bg-brand-green"
45
+ : voiceState === "SPEAKING"
46
+ ? "bg-brand-cyan"
47
+ : voiceState === "PROCESSING"
48
+ ? "bg-brand-amber"
49
+ : "bg-neutral-400"
50
+ }`}
51
+ />
52
+ <span
53
+ className={`relative inline-flex rounded-full h-2 w-2 ${
54
+ voiceState === "LISTENING"
55
+ ? "bg-brand-green"
56
+ : voiceState === "SPEAKING"
57
+ ? "bg-brand-cyan"
58
+ : voiceState === "PROCESSING"
59
+ ? "bg-brand-amber"
60
+ : "bg-neutral-400"
61
+ }`}
62
+ />
63
+ </span>
64
+ <span>{STATE_LABELS[voiceState]}</span>
65
+ </div>
66
+ <button
67
+ onClick={onStop}
68
+ className="p-0.5 rounded hover:bg-black/10 transition-colors"
69
+ aria-label="End voice session"
70
+ >
71
+ <X className="h-3.5 w-3.5" />
72
+ </button>
73
+ </motion.div>
74
+ );
75
+ }
src/app/components/chat/renderers/MarkdownRenderer.tsx ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ReactMarkdown from "react-markdown";
2
+ import remarkGfm from "remark-gfm";
3
+ import remarkMath from "remark-math";
4
+ import rehypeKatex from "rehype-katex";
5
+ import type { Components } from "react-markdown";
6
+
7
+ function preprocessMarkdown(content: string): string {
8
+ let result = content.replace(/\|\|/g, "|\n|");
9
+ const lines = result.split("\n");
10
+ const processed = lines.map((line) => {
11
+ const tableStart = line.indexOf("|");
12
+ if (tableStart > 0) {
13
+ const tableContent = line.slice(tableStart);
14
+ if ((tableContent.match(/\|/g) ?? []).length >= 2) {
15
+ return line.slice(0, tableStart).trimEnd() + "\n\n" + tableContent;
16
+ }
17
+ }
18
+ return line;
19
+ });
20
+ result = processed.join("\n");
21
+ result = result.replace(/([^|\n])\n(\|)/g, "$1\n\n$2");
22
+ return result;
23
+ }
24
+
25
+ const components: Components = {
26
+ p: ({ children }) => (
27
+ <p className="text-sm text-neutral-800 leading-relaxed mb-3 last:mb-0">{children}</p>
28
+ ),
29
+ h1: ({ children }) => (
30
+ <h1 className="text-xl font-bold text-neutral-900 mt-4 mb-2 first:mt-0">{children}</h1>
31
+ ),
32
+ h2: ({ children }) => (
33
+ <h2 className="text-lg font-semibold text-neutral-800 mt-4 mb-2 first:mt-0">{children}</h2>
34
+ ),
35
+ h3: ({ children }) => (
36
+ <h3 className="text-base font-semibold text-neutral-700 mt-3 mb-1.5 first:mt-0">{children}</h3>
37
+ ),
38
+ ul: ({ children }) => (
39
+ <ul className="list-disc list-outside pl-5 mb-3 space-y-1 text-sm text-neutral-800">{children}</ul>
40
+ ),
41
+ ol: ({ children }) => (
42
+ <ol className="list-decimal list-outside pl-5 mb-3 space-y-1 text-sm text-neutral-800">{children}</ol>
43
+ ),
44
+ li: ({ children }) => <li className="leading-relaxed">{children}</li>,
45
+ code: ({ children, className }) => {
46
+ const isBlock = className?.startsWith("language-");
47
+ const language = className?.replace("language-", "") ?? "";
48
+ if (isBlock) {
49
+ return (
50
+ <div className="my-3 rounded-xl overflow-hidden border border-neutral-200">
51
+ {language && (
52
+ <div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2">
53
+ <span className="text-xs font-mono text-neutral-500">{language}</span>
54
+ </div>
55
+ )}
56
+ <div className="p-4 bg-neutral-50 overflow-x-auto">
57
+ <code className="text-xs font-mono leading-relaxed text-neutral-800 whitespace-pre">
58
+ {children}
59
+ </code>
60
+ </div>
61
+ </div>
62
+ );
63
+ }
64
+ return (
65
+ <code className="px-1.5 py-0.5 rounded-md bg-neutral-100 text-brand-green font-mono text-xs">
66
+ {children}
67
+ </code>
68
+ );
69
+ },
70
+ pre: ({ children }) => <>{children}</>,
71
+ blockquote: ({ children }) => (
72
+ <blockquote className="border-l-4 border-brand-green pl-4 py-1 my-3 text-sm text-neutral-600 italic bg-brand-green-50 rounded-r-xl">
73
+ {children}
74
+ </blockquote>
75
+ ),
76
+ a: ({ children, href }) => (
77
+ <a
78
+ href={href}
79
+ target="_blank"
80
+ rel="noopener noreferrer"
81
+ className="text-brand-green underline underline-offset-2 hover:text-brand-green/80 transition-colors"
82
+ >
83
+ {children}
84
+ </a>
85
+ ),
86
+ strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
87
+ hr: () => <hr className="border-neutral-200 my-3" />,
88
+ table: ({ children }) => (
89
+ <div className="my-3 overflow-x-auto rounded-xl border border-neutral-200 shadow-sm">
90
+ <table className="w-full text-sm border-collapse">{children}</table>
91
+ </div>
92
+ ),
93
+ thead: ({ children }) => (
94
+ <thead className="bg-neutral-50 border-b border-neutral-200">{children}</thead>
95
+ ),
96
+ th: ({ children }) => (
97
+ <th className="px-4 py-3 text-left text-xs font-semibold text-neutral-600 uppercase tracking-wider">
98
+ {children}
99
+ </th>
100
+ ),
101
+ td: ({ children }) => (
102
+ <td className="px-4 py-3 text-sm text-neutral-800 border-b border-neutral-100">{children}</td>
103
+ ),
104
+ tr: ({ children }) => (
105
+ <tr className="hover:bg-neutral-50 transition-colors">{children}</tr>
106
+ ),
107
+ };
108
+
109
+ interface MarkdownRendererProps {
110
+ content: string;
111
+ skipPreprocess?: boolean;
112
+ }
113
+
114
+ export default function MarkdownRenderer({ content, skipPreprocess }: MarkdownRendererProps) {
115
+ const processed = skipPreprocess ? content : preprocessMarkdown(content);
116
+
117
+ return (
118
+ <ReactMarkdown
119
+ remarkPlugins={[remarkGfm, remarkMath]}
120
+ rehypePlugins={[rehypeKatex]}
121
+ components={components}
122
+ >
123
+ {processed}
124
+ </ReactMarkdown>
125
+ );
126
+ }
src/app/components/chat/types.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ChatSource } from "../../../services/api";
2
+
3
+ export interface Message {
4
+ id: string;
5
+ role: "user" | "assistant";
6
+ content: string;
7
+ timestamp: number;
8
+ sources?: ChatSource[];
9
+ /** PCM audio chunks from TTS — only populated when sent via voice mode */
10
+ audioChunks?: ArrayBuffer[];
11
+ audioSampleRate?: number;
12
+ }
13
+
14
+ export interface ChatSession {
15
+ id: string;
16
+ title: string;
17
+ createdAt: string;
18
+ updatedAt: string | null;
19
+ }
20
+
21
+ export interface StoredUser {
22
+ user_id: string;
23
+ email: string;
24
+ name: string;
25
+ loginTime: string;
26
+ }
src/assets/maintiva-logo.jpg ADDED
src/audio/AudioPlayer.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const DEFAULT_SAMPLE_RATE = 16000;
2
+ const BUFFER_THRESHOLD_BYTES = 6400; // 200ms = 2 chunks before starting playback
3
+
4
+ export class AudioPlayer {
5
+ private context: AudioContext | null = null;
6
+ private nextPlayTime = 0;
7
+ private started = false;
8
+ private pendingBytes = 0;
9
+
10
+ init(sampleRate = DEFAULT_SAMPLE_RATE): void {
11
+ if (this.context) return;
12
+ this.context = new AudioContext({ sampleRate });
13
+ this.nextPlayTime = 0;
14
+ this.started = false;
15
+ this.pendingBytes = 0;
16
+ }
17
+
18
+ enqueue(rawPcm: ArrayBuffer): void {
19
+ if (!this.context) return;
20
+
21
+ const int16 = new Int16Array(rawPcm);
22
+ const float32 = new Float32Array(int16.length);
23
+ for (let i = 0; i < int16.length; i++) {
24
+ float32[i] = int16[i] / 32768;
25
+ }
26
+
27
+ const audioBuffer = this.context.createBuffer(1, float32.length, PLAYBACK_SAMPLE_RATE);
28
+ audioBuffer.copyToChannel(float32, 0);
29
+
30
+ this.pendingBytes += rawPcm.byteLength;
31
+
32
+ if (!this.started && this.pendingBytes >= BUFFER_THRESHOLD_BYTES) {
33
+ this.started = true;
34
+ this.nextPlayTime = this.context.currentTime;
35
+ }
36
+
37
+ if (this.started) {
38
+ const source = this.context.createBufferSource();
39
+ source.buffer = audioBuffer;
40
+ source.connect(this.context.destination);
41
+ source.start(this.nextPlayTime);
42
+ this.nextPlayTime += audioBuffer.duration;
43
+ }
44
+ }
45
+
46
+ drain(): void {
47
+ // Let queued buffers play out — nothing to do, the AudioContext schedule handles it
48
+ }
49
+
50
+ stopImmediately(): void {
51
+ if (!this.context) return;
52
+ this.context.close();
53
+ this.context = null;
54
+ this.nextPlayTime = 0;
55
+ this.started = false;
56
+ this.pendingBytes = 0;
57
+ }
58
+ }
59
+
60
+ export function replayAudio(
61
+ chunks: ArrayBuffer[],
62
+ sampleRate: number
63
+ ): () => void {
64
+ const ctx = new AudioContext({ sampleRate });
65
+ let nextTime = ctx.currentTime + 0.05;
66
+
67
+ for (const chunk of chunks) {
68
+ const int16 = new Int16Array(chunk);
69
+ const float32 = new Float32Array(int16.length);
70
+ for (let i = 0; i < int16.length; i++) float32[i] = int16[i] / 32768;
71
+ const buf = ctx.createBuffer(1, float32.length, sampleRate);
72
+ buf.copyToChannel(float32, 0);
73
+ const src = ctx.createBufferSource();
74
+ src.buffer = buf;
75
+ src.connect(ctx.destination);
76
+ src.start(nextTime);
77
+ nextTime += buf.duration;
78
+ }
79
+
80
+ return () => ctx.close();
81
+ }
src/audio/AudioRecorder.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const TARGET_SAMPLE_RATE = 16000;
2
+ const CHUNK_SAMPLES = 3200; // 100ms at 16kHz
3
+
4
+ export class AudioRecorder {
5
+ private context: AudioContext | null = null;
6
+ private stream: MediaStream | null = null;
7
+ private processor: ScriptProcessorNode | null = null;
8
+ private accumulator: Float32Array = new Float32Array(0);
9
+ micLevel = 0;
10
+
11
+ async start(onChunk: (pcm: ArrayBuffer) => void): Promise<void> {
12
+ this.stream = await navigator.mediaDevices.getUserMedia({
13
+ audio: {
14
+ sampleRate: TARGET_SAMPLE_RATE,
15
+ channelCount: 1,
16
+ echoCancellation: true,
17
+ noiseSuppression: true,
18
+ },
19
+ });
20
+
21
+ this.context = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
22
+ const source = this.context.createMediaStreamSource(this.stream);
23
+
24
+ // bufferSize 512 → fires every ~32ms at 16kHz; we accumulate to 100ms
25
+ this.processor = this.context.createScriptProcessor(512, 1, 1);
26
+
27
+ this.processor.onaudioprocess = (e) => {
28
+ const input = e.inputBuffer.getChannelData(0);
29
+ const combined = new Float32Array(this.accumulator.length + input.length);
30
+ combined.set(this.accumulator);
31
+ combined.set(input, this.accumulator.length);
32
+ this.accumulator = combined;
33
+
34
+ while (this.accumulator.length >= CHUNK_SAMPLES) {
35
+ const chunk = this.accumulator.slice(0, CHUNK_SAMPLES);
36
+ this.accumulator = this.accumulator.slice(CHUNK_SAMPLES);
37
+
38
+ // RMS for barge-in detection
39
+ let sumSq = 0;
40
+ for (let i = 0; i < chunk.length; i++) sumSq += chunk[i] * chunk[i];
41
+ this.micLevel = Math.sqrt(sumSq / chunk.length) * 32767;
42
+
43
+ // Convert float32 → int16 PCM
44
+ const int16 = new Int16Array(chunk.length);
45
+ for (let i = 0; i < chunk.length; i++) {
46
+ const s = Math.max(-1, Math.min(1, chunk[i]));
47
+ int16[i] = s < 0 ? s * 32768 : s * 32767;
48
+ }
49
+
50
+ onChunk(int16.buffer);
51
+ }
52
+ };
53
+
54
+ source.connect(this.processor);
55
+ this.processor.connect(this.context.destination);
56
+ }
57
+
58
+ stop(): void {
59
+ this.processor?.disconnect();
60
+ this.processor = null;
61
+ this.stream?.getTracks().forEach((t) => t.stop());
62
+ this.stream = null;
63
+ this.context?.close();
64
+ this.context = null;
65
+ this.accumulator = new Float32Array(0);
66
+ this.micLevel = 0;
67
+ }
68
+ }
src/hooks/useVoiceSession.ts ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect, useCallback } from "react";
2
+ import { AudioRecorder } from "../audio/AudioRecorder";
3
+ import { AudioPlayer } from "../audio/AudioPlayer";
4
+
5
+ export type VoiceState =
6
+ | "IDLE"
7
+ | "CONNECTING"
8
+ | "LISTENING"
9
+ | "PROCESSING"
10
+ | "SPEAKING"
11
+ | "RECONNECTING"
12
+ | "ERROR";
13
+
14
+ interface UseVoiceSessionOptions {
15
+ onTranscript: (text: string) => void;
16
+ onReply: (text: string) => void;
17
+ onError?: (code: string, message: string) => void;
18
+ /** When true, binary TTS audio frames from the WebSocket are ignored. */
19
+ bypassWsTts?: boolean;
20
+ }
21
+
22
+ export interface UseVoiceSessionReturn {
23
+ voiceState: VoiceState;
24
+ start: () => Promise<void>;
25
+ stop: () => void;
26
+ isActive: boolean;
27
+ }
28
+
29
+ const BARGE_IN_THRESHOLD = 500;
30
+ const MAX_RECONNECT_ATTEMPTS = 10;
31
+ const HEARTBEAT_INTERVAL_MS = 20_000;
32
+ const PONG_TIMEOUT_MS = 5_000;
33
+
34
+ function getWsUrl(): string {
35
+ return (import.meta as unknown as { env: Record<string, string> }).env
36
+ .VITE_API_BASE_VOICE_WS_URL ?? "wss://localhost:7861/ws/voice";
37
+ }
38
+
39
+ export function useVoiceSession(opts: UseVoiceSessionOptions): UseVoiceSessionReturn {
40
+ const [voiceState, setVoiceState] = useState<VoiceState>("IDLE");
41
+ const stateRef = useRef<VoiceState>("IDLE");
42
+
43
+ const wsRef = useRef<WebSocket | null>(null);
44
+ const recorderRef = useRef<AudioRecorder | null>(null);
45
+ const playerRef = useRef<AudioPlayer | null>(null);
46
+
47
+ const heartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
48
+ const pongTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
49
+ const rafRef = useRef<number | null>(null);
50
+ const reconnectAttemptRef = useRef(0);
51
+
52
+ // Keep opts in a ref so callbacks never go stale
53
+ const optsRef = useRef(opts);
54
+ useEffect(() => { optsRef.current = opts; });
55
+
56
+ const setState = useCallback((s: VoiceState) => {
57
+ stateRef.current = s;
58
+ setVoiceState(s);
59
+ }, []);
60
+
61
+ const clearHeartbeat = useCallback(() => {
62
+ if (heartbeatTimerRef.current) clearInterval(heartbeatTimerRef.current);
63
+ if (pongTimeoutRef.current) clearTimeout(pongTimeoutRef.current);
64
+ heartbeatTimerRef.current = null;
65
+ pongTimeoutRef.current = null;
66
+ }, []);
67
+
68
+ const stopBargeInLoop = useCallback(() => {
69
+ if (rafRef.current !== null) {
70
+ cancelAnimationFrame(rafRef.current);
71
+ rafRef.current = null;
72
+ }
73
+ }, []);
74
+
75
+ const closeWs = useCallback(() => {
76
+ if (wsRef.current) {
77
+ wsRef.current.onopen = null;
78
+ wsRef.current.onmessage = null;
79
+ wsRef.current.onerror = null;
80
+ wsRef.current.onclose = null;
81
+ wsRef.current.close();
82
+ wsRef.current = null;
83
+ }
84
+ }, []);
85
+
86
+ const stopSession = useCallback(() => {
87
+ stopBargeInLoop();
88
+ clearHeartbeat();
89
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
90
+ wsRef.current.send(JSON.stringify({ action: "stop" }));
91
+ }
92
+ closeWs();
93
+ recorderRef.current?.stop();
94
+ playerRef.current?.stopImmediately();
95
+ reconnectAttemptRef.current = 0;
96
+ setState("IDLE");
97
+ }, [clearHeartbeat, closeWs, setState, stopBargeInLoop]);
98
+
99
+ const startBargeInLoop = useCallback(() => {
100
+ const check = () => {
101
+ if (stateRef.current !== "SPEAKING") {
102
+ rafRef.current = null;
103
+ return;
104
+ }
105
+ const level = recorderRef.current?.micLevel ?? 0;
106
+ if (level > BARGE_IN_THRESHOLD && wsRef.current?.readyState === WebSocket.OPEN) {
107
+ wsRef.current.send(JSON.stringify({ action: "interrupt" }));
108
+ playerRef.current?.stopImmediately();
109
+ playerRef.current?.init();
110
+ }
111
+ rafRef.current = requestAnimationFrame(check);
112
+ };
113
+ rafRef.current = requestAnimationFrame(check);
114
+ }, []);
115
+
116
+ const startHeartbeat = useCallback(() => {
117
+ clearHeartbeat();
118
+ heartbeatTimerRef.current = setInterval(() => {
119
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
120
+ wsRef.current.send(JSON.stringify({ action: "ping" }));
121
+ pongTimeoutRef.current = setTimeout(() => {
122
+ // No pong received — reconnect
123
+ wsRef.current?.close();
124
+ }, PONG_TIMEOUT_MS);
125
+ }
126
+ }, HEARTBEAT_INTERVAL_MS);
127
+ }, [clearHeartbeat]);
128
+
129
+ const openWebSocket = useCallback(() => {
130
+ closeWs();
131
+ const ws = new WebSocket(getWsUrl());
132
+ ws.binaryType = "arraybuffer";
133
+ wsRef.current = ws;
134
+
135
+ ws.onopen = () => {
136
+ reconnectAttemptRef.current = 0;
137
+ setState("LISTENING");
138
+ startHeartbeat();
139
+ };
140
+
141
+ ws.onmessage = (event) => {
142
+ if (event.data instanceof ArrayBuffer) {
143
+ // Binary frame = TTS audio chunk from voice backend
144
+ if (!optsRef.current.bypassWsTts) {
145
+ if (stateRef.current === "SPEAKING" || stateRef.current === "PROCESSING") {
146
+ if (stateRef.current === "PROCESSING") {
147
+ setState("SPEAKING");
148
+ startBargeInLoop();
149
+ }
150
+ playerRef.current?.enqueue(event.data);
151
+ }
152
+ }
153
+ return;
154
+ }
155
+
156
+ try {
157
+ const msg = JSON.parse(event.data as string);
158
+
159
+ switch (msg.event) {
160
+ case "transcript":
161
+ if (!msg.is_partial) {
162
+ setState("PROCESSING");
163
+ stopBargeInLoop();
164
+ optsRef.current.onTranscript(msg.text as string);
165
+ }
166
+ break;
167
+
168
+ case "reply":
169
+ optsRef.current.onReply(msg.text as string);
170
+ playerRef.current?.init();
171
+ break;
172
+
173
+ case "tts_end":
174
+ playerRef.current?.drain();
175
+ setState("LISTENING");
176
+ stopBargeInLoop();
177
+ break;
178
+
179
+ case "interrupted":
180
+ playerRef.current?.stopImmediately();
181
+ setState("LISTENING");
182
+ stopBargeInLoop();
183
+ break;
184
+
185
+ case "pong":
186
+ if (pongTimeoutRef.current) clearTimeout(pongTimeoutRef.current);
187
+ break;
188
+
189
+ case "error":
190
+ optsRef.current.onError?.(msg.code as string, msg.message as string);
191
+ break;
192
+ }
193
+ } catch {
194
+ // non-JSON frame — ignore
195
+ }
196
+ };
197
+
198
+ ws.onerror = () => {
199
+ // onclose will fire next and handle reconnect
200
+ };
201
+
202
+ ws.onclose = () => {
203
+ clearHeartbeat();
204
+ stopBargeInLoop();
205
+ recorderRef.current?.stop();
206
+
207
+ if (stateRef.current === "IDLE") return; // intentional stop
208
+
209
+ // Reconnect with exponential backoff
210
+ if (reconnectAttemptRef.current >= MAX_RECONNECT_ATTEMPTS) {
211
+ setState("ERROR");
212
+ return;
213
+ }
214
+ setState("RECONNECTING");
215
+ const delay = Math.min(Math.pow(2, reconnectAttemptRef.current), 8) * 1000;
216
+ reconnectAttemptRef.current++;
217
+
218
+ setTimeout(async () => {
219
+ if (stateRef.current !== "RECONNECTING") return;
220
+ try {
221
+ if (!recorderRef.current) recorderRef.current = new AudioRecorder();
222
+ await recorderRef.current.start((chunk) => {
223
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
224
+ wsRef.current.send(chunk);
225
+ }
226
+ });
227
+ setState("CONNECTING");
228
+ openWebSocket();
229
+ } catch {
230
+ setState("ERROR");
231
+ }
232
+ }, delay);
233
+ };
234
+ }, [clearHeartbeat, closeWs, setState, startBargeInLoop, startHeartbeat, stopBargeInLoop]);
235
+
236
+ const start = useCallback(async () => {
237
+ if (stateRef.current !== "IDLE" && stateRef.current !== "ERROR") return;
238
+ setState("CONNECTING");
239
+
240
+ try {
241
+ if (!recorderRef.current) recorderRef.current = new AudioRecorder();
242
+ if (!playerRef.current) playerRef.current = new AudioPlayer();
243
+
244
+ await recorderRef.current.start((chunk) => {
245
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
246
+ wsRef.current.send(chunk);
247
+ }
248
+ });
249
+
250
+ // Init player inside user-gesture chain to satisfy autoplay policy
251
+ playerRef.current.init();
252
+
253
+ openWebSocket();
254
+ } catch {
255
+ recorderRef.current?.stop();
256
+ setState("ERROR");
257
+ }
258
+ }, [openWebSocket, setState]);
259
+
260
+ // Cleanup on unmount
261
+ useEffect(() => {
262
+ return () => {
263
+ stopSession();
264
+ };
265
+ // eslint-disable-next-line react-hooks/exhaustive-deps
266
+ }, []);
267
+
268
+ return {
269
+ voiceState,
270
+ start,
271
+ stop: stopSession,
272
+ isActive: voiceState !== "IDLE" && voiceState !== "ERROR",
273
+ };
274
+ }
src/services/api.ts CHANGED
@@ -48,7 +48,7 @@ export interface RoomDetail extends Room {
48
  messages: RoomMessage[];
49
  }
50
 
51
- export type DocumentStatus = "pending" | "processing" | "completed" | "failed";
52
 
53
  export interface ApiDocument {
54
  id: string;
@@ -231,6 +231,14 @@ export const ingestDatabaseClient = (clientId: string, userId: string): Promise<
231
  { method: "POST" }
232
  );
233
 
 
 
 
 
 
 
 
 
234
  // ─── Chat ─────────────────────────────────────────────────────────────────────
235
 
236
  export const streamChat = (
 
48
  messages: RoomMessage[];
49
  }
50
 
51
+ export type DocumentStatus = "uploaded" | "processing" | "completed" | "failed";
52
 
53
  export interface ApiDocument {
54
  id: string;
 
231
  { method: "POST" }
232
  );
233
 
234
+ // ─── Knowledge (Admin) ────────────────────────────────────────────────────────
235
+
236
+ export const rebuildKnowledge = (userId: string) =>
237
+ request<{ status: string; message: string }>(
238
+ `/api/v1/knowledge/rebuild?user_id=${userId}`,
239
+ { method: "POST" }
240
+ );
241
+
242
  // ─── Chat ─────────────────────────────────────────────────────────────────────
243
 
244
  export const streamChat = (
src/services/voiceApi.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const VOICE_BASE_URL =
2
+ (import.meta as unknown as { env: Record<string, string> }).env
3
+ .VITE_API_BASE_VOICE_URL ?? "http://localhost:7860";
4
+
5
+ export async function textToSpeech(
6
+ text: string,
7
+ provider = "gemini"
8
+ ): Promise<{ pcm: ArrayBuffer; sampleRate: number }> {
9
+ const response = await fetch(`${VOICE_BASE_URL}/tts`, {
10
+ method: "POST",
11
+ headers: { "Content-Type": "application/json" },
12
+ body: JSON.stringify({ text, provider }),
13
+ });
14
+ if (!response.ok) throw new Error(`TTS error: ${response.status}`);
15
+ const sampleRate = parseInt(
16
+ response.headers.get("X-Sample-Rate") ?? "24000",
17
+ 10
18
+ );
19
+ const pcm = await response.arrayBuffer();
20
+ return { pcm, sampleRate };
21
+ }
src/styles/theme.css CHANGED
@@ -2,6 +2,8 @@
2
 
3
  :root {
4
  --font-size: 16px;
 
 
5
  --background: #ffffff;
6
  --foreground: oklch(0.145 0 0);
7
  --card: #ffffff;
@@ -117,6 +119,12 @@
117
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
118
  --color-sidebar-border: var(--sidebar-border);
119
  --color-sidebar-ring: var(--sidebar-ring);
 
 
 
 
 
 
120
  }
121
 
122
  @layer base {
@@ -126,8 +134,16 @@
126
 
127
  body {
128
  @apply bg-background text-foreground;
 
 
 
129
  }
130
 
 
 
 
 
 
131
  /**
132
  * Default typography styles for HTML elements (h1-h4, p, label, button, input).
133
  * These are in @layer base, so Tailwind utility classes (like text-sm, text-lg) automatically override them.
@@ -135,6 +151,7 @@
135
 
136
  html {
137
  font-size: var(--font-size);
 
138
  }
139
 
140
  h1 {
 
2
 
3
  :root {
4
  --font-size: 16px;
5
+ --brand-green: #18AF4A;
6
+ --brand-green-light: #1EC75A;
7
  --background: #ffffff;
8
  --foreground: oklch(0.145 0 0);
9
  --card: #ffffff;
 
119
  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
120
  --color-sidebar-border: var(--sidebar-border);
121
  --color-sidebar-ring: var(--sidebar-ring);
122
+ --color-brand-green: var(--brand-green);
123
+ --color-brand-green-light: var(--brand-green-light);
124
+ --color-brand-green-50: #F0FDF5;
125
+ --color-brand-green-100: #BBF7D0;
126
+ --color-brand-amber: #F5A623;
127
+ --color-brand-cyan: #48C8E8;
128
  }
129
 
130
  @layer base {
 
134
 
135
  body {
136
  @apply bg-background text-foreground;
137
+ font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
138
+ -webkit-font-smoothing: antialiased;
139
+ -moz-osx-font-smoothing: grayscale;
140
  }
141
 
142
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
143
+ ::-webkit-scrollbar-track { background: transparent; }
144
+ ::-webkit-scrollbar-thumb { background: #D1D5DB; border-radius: 3px; }
145
+ ::-webkit-scrollbar-thumb:hover { background: #9CA3AF; }
146
+
147
  /**
148
  * Default typography styles for HTML elements (h1-h4, p, label, button, input).
149
  * These are in @layer base, so Tailwind utility classes (like text-sm, text-lg) automatically override them.
 
151
 
152
  html {
153
  font-size: var(--font-size);
154
+ scroll-behavior: smooth;
155
  }
156
 
157
  h1 {
src/vite-env.d.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module "*.jpg" {
4
+ const src: string;
5
+ export default src;
6
+ }
7
+
8
+ declare module "*.jpeg" {
9
+ const src: string;
10
+ export default src;
11
+ }
12
+
13
+ declare module "*.png" {
14
+ const src: string;
15
+ export default src;
16
+ }
17
+
18
+ declare module "*.svg" {
19
+ const src: string;
20
+ export default src;
21
+ }
tsconfig.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": false,
16
+ "noUnusedParameters": false,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "baseUrl": ".",
19
+ "paths": {
20
+ "@/*": ["./src/*"]
21
+ }
22
+ },
23
+ "include": ["src"]
24
+ }