[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 +2 -1
- README.md +1 -1
- index.html +2 -2
- public/maintiva-logo.jpg +0 -0
- src/app/components/KnowledgeManagement.tsx +1 -1
- src/app/components/Login.tsx +38 -38
- src/app/components/Main.tsx +223 -581
- src/app/components/chat/ChatInput.tsx +107 -0
- src/app/components/chat/ChatLayout.tsx +17 -0
- src/app/components/chat/ChatWindow.tsx +83 -0
- src/app/components/chat/FeedbackWidget.tsx +146 -0
- src/app/components/chat/MessageBubble.tsx +78 -0
- src/app/components/chat/Sidebar.tsx +282 -0
- src/app/components/chat/TypingIndicator.tsx +70 -0
- src/app/components/chat/VoiceMicButton.tsx +93 -0
- src/app/components/chat/VoiceStatusBar.tsx +75 -0
- src/app/components/chat/renderers/MarkdownRenderer.tsx +126 -0
- src/app/components/chat/types.ts +26 -0
- src/assets/maintiva-logo.jpg +0 -0
- src/audio/AudioPlayer.ts +81 -0
- src/audio/AudioRecorder.ts +68 -0
- src/hooks/useVoiceSession.ts +274 -0
- src/services/api.ts +9 -1
- src/services/voiceApi.ts +21 -0
- src/styles/theme.css +17 -0
- src/vite-env.d.ts +21 -0
- tsconfig.json +24 -0
.gitignore
CHANGED
|
@@ -36,8 +36,9 @@ Thumbs.db
|
|
| 36 |
*.local
|
| 37 |
vite.config.ts.timestamp-*
|
| 38 |
|
| 39 |
-
|
| 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:
|
| 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/
|
| 8 |
-
<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:
|
| 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.
|
| 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-[#
|
| 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-
|
| 180 |
-
<img src={logoUrl} alt="
|
| 181 |
<div className="text-center">
|
| 182 |
-
<h1 className="text-xl font-semibold text-slate-900">
|
| 183 |
-
<p className="text-slate-400 text-sm mt-0.5">
|
| 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 |
-
<
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 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 |
-
|
| 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 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 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
|
| 267 |
-
|
| 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
|
| 304 |
-
setCurrentChatId(null);
|
| 305 |
-
};
|
| 306 |
|
| 307 |
-
const
|
| 308 |
if (!user) return;
|
| 309 |
try {
|
| 310 |
await deleteRoom(chatId, user.user_id);
|
| 311 |
} catch {
|
| 312 |
return;
|
| 313 |
}
|
| 314 |
-
const
|
| 315 |
-
setChats(
|
| 316 |
if (currentChatId === chatId) {
|
| 317 |
-
setCurrentChatId(
|
| 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
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
if (!
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 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:
|
| 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 |
-
|
| 408 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 482 |
-
//
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 513 |
-
|
| 514 |
-
e.preventDefault();
|
| 515 |
-
handleSend();
|
| 516 |
-
}
|
| 517 |
-
};
|
| 518 |
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 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 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 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 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
</div>
|
| 608 |
|
| 609 |
-
{/*
|
| 610 |
-
<div className="
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
{
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
<
|
| 629 |
-
|
| 630 |
-
|
| 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 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 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 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 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 |
-
</
|
| 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 = "
|
| 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 |
+
}
|