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