Commit c0e521e8 authored by Mahmoud Aglan's avatar Mahmoud Aglan

new era

parent 06918c59
......@@ -15,10 +15,10 @@
PROJECT CODEBASE — FULL SOURCE DUMP
==============================================================================
Generated: 2026-03-19 10:39:13
Generated: 2026-03-19 10:53:17
Source Dir: /Users/mahmoudaglan/son-of-anton
Total Files: 55
Total Lines: 12129
Total Lines: 12159
Total Size: 433KB
THIS FILE CONTAINS THE COMPLETE CODEBASE INCLUDING:
......@@ -151,20 +151,20 @@ Collected file paths:
[033] 4542 158KB frontend/package-lock.json
[034] 27 646B frontend/package.json
[035] 5 80B frontend/postcss.config.js
[036] 21 543B frontend/src/App.jsx
[036] 57 1KB frontend/src/App.jsx
[037] 159 5KB frontend/src/api.js
[038] 158 4KB frontend/src/components/AttachmentPreview.jsx
[039] 246 14KB frontend/src/components/ChatView.jsx
[040] 109 3KB frontend/src/components/CodeBlock.jsx
[041] 81 1KB frontend/src/components/FileUploadButton.jsx
[042] 160 7KB frontend/src/components/MessageBubble.jsx
[043] 363 14KB frontend/src/components/Sidebar.jsx
[043] 351 13KB frontend/src/components/Sidebar.jsx
[044] 137 2KB frontend/src/index.css
[045] 15 409B frontend/src/main.jsx
[046] 260 12KB frontend/src/pages/AdminPage.jsx
[047] 59 2KB frontend/src/pages/ChatPage.jsx
[048] 150 5KB frontend/src/pages/LoginPage.jsx
[049] 128 3KB frontend/src/store.jsx
[049] 134 3KB frontend/src/store.jsx
[050] 74 3KB frontend/src/streamManager.js
[051] 38 1KB frontend/tailwind.config.js
[052] 14 269B frontend/vite.config.js
......@@ -10492,31 +10492,67 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [036/55]: frontend/src/App.jsx
│ LANGUAGE: jsx | LINES: 21 | SIZE: 543 bytes
│ LANGUAGE: jsx | LINES: 57 | SIZE: 1660 bytes
├──────────────────────────────────────────────────────────────────────────────
1 import React from "react";
2 import { Routes, Route, Navigate } from "react-router-dom";
1 import React, { useEffect, useState } from "react";
2 import { Routes, Route } from "react-router-dom";
3 import { useApp } from "./store";
4 import LoginPage from "./pages/LoginPage";
5 import ChatPage from "./pages/ChatPage";
6 import AdminPage from "./pages/AdminPage";
7
8 export default function App() {
9 const { state } = useApp();
10 const loggedIn = !!state.token;
11
12 if (!loggedIn) {
13 return <LoginPage />;
14 }
15
16 return (
17 <Routes>
18 <Route path="/admin" element={<AdminPage />} />
19 <Route path="/*" element={<ChatPage />} />
20 </Routes>
21 );
22 }│
4 import { getMe } from "./api";
5 import LoginPage from "./pages/LoginPage";
6 import ChatPage from "./pages/ChatPage";
7 import AdminPage from "./pages/AdminPage";
8 import { Flame } from "lucide-react";
9
10 export default function App() {
11 const { state, dispatch } = useApp();
12 const [authChecked, setAuthChecked] = useState(!state.token);
13
14 useEffect(() => {
15 if (!state.token) {
16 setAuthChecked(true);
17 return;
18 }
19 if (state.user) {
20 setAuthChecked(true);
21 return;
22 }
23 (async () => {
24 try {
25 const user = await getMe(state.token);
26 dispatch({ type: "SET_USER", user });
27 } catch {
28 dispatch({ type: "LOGOUT" });
29 } finally {
30 setAuthChecked(true);
31 }
32 })();
33 }, [state.token, state.user, dispatch]);
34
35 if (!authChecked) {
36 return (
37 <div className="h-full flex items-center justify-center bg-anton-bg">
38 <div className="flex flex-col items-center gap-4 animate-fade-in">
39 <div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
40 <Flame size={32} className="text-white animate-pulse" />
41 </div>
42 <p className="text-anton-muted text-sm">Loading...</p>
43 </div>
44 </div>
45 );
46 }
47
48 if (!state.token) {
49 return <LoginPage />;
50 }
51
52 return (
53 <Routes>
54 <Route path="/admin" element={<AdminPage />} />
55 <Route path="/*" element={<ChatPage />} />
56 </Routes>
57 );
58 }│
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [036]: frontend/src/App.jsx
......@@ -11496,7 +11532,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [043/55]: frontend/src/components/Sidebar.jsx
│ LANGUAGE: jsx | LINES: 363 | SIZE: 14582 bytes
│ LANGUAGE: jsx | LINES: 351 | SIZE: 14182 bytes
├──────────────────────────────────────────────────────────────────────────────
1 import React, { useState } from "react";
......@@ -11539,330 +11575,318 @@ Collected file paths:
38
39 async function handleDelete(id) {
40 try {
41 // Abort any active stream for this chat before deleting
42 streamManager.abortStream(id);
43 await deleteChat(state.token, id);
44 dispatch({ type: "REMOVE_CHAT", chatId: id });
45 } catch { /* */ }
46 }
47
48 async function handleRename(id) {
49 if (!renameVal.trim()) return;
50 try {
51 await renameChat(state.token, id, renameVal.trim());
52 dispatch({ type: "UPDATE_CHAT", chat: { id, title: renameVal.trim() } });
53 setRenamingId(null);
54 } catch { /* */ }
55 }
56
57 async function loadKbs() {
58 try {
59 const data = await listKnowledgeBases(state.token);
60 setKbs(data);
61 setKbLoaded(true);
62 } catch { /* */ }
63 }
64
65 async function handleCreateKb() {
66 if (!newKbName.trim()) return;
67 try {
68 await createKnowledgeBase(state.token, newKbName.trim());
69 setNewKbName("");
70 setShowNewKb(false);
71 loadKbs();
72 } catch { /* */ }
73 }
74
75 async function handleDeleteKb(id) {
76 if (!confirm("Delete this knowledge base?")) return;
77 try {
78 await deleteKnowledgeBase(state.token, id);
79 loadKbs();
80 } catch { /* */ }
81 }
82
83 async function handleUpload(kbId, files) {
84 setUploading(true);
85 setUploadCount(files.length);
86 try {
87 const result = await uploadDocuments(state.token, kbId, files);
88 const errors = (result.files || []).filter((f) => f.error);
89 if (errors.length > 0) {
90 alert(
91 `Uploaded ${result.files.length - errors.length} of ${result.files.length} files.\n\nErrors:\n` +
92 errors.map((e) => `• ${e.filename}: ${e.error}`).join("\n")
93 );
94 }
95 loadKbs();
96 } catch (e) {
97 alert(e.message);
98 } finally {
99 setUploading(false);
100 setUploadCount(0);
101 }
102 }
103
104 function switchTab(t) {
105 setTab(t);
106 if (t === "knowledge" && !kbLoaded) loadKbs();
107 }
108
109 // ── Collapsed sidebar ──
110 if (!open) {
111 return (
112 <div className="w-12 bg-anton-surface border-r border-anton-border flex flex-col items-center py-3 gap-3 shrink-0">
113 <button
114 onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
115 className="p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
116 >
117 <PanelLeftOpen size={18} />
118 </button>
119 <button
120 onClick={handleNewChat}
121 className="p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition"
122 >
123 <Plus size={18} />
124 </button>
125 {/* Streaming count badge when sidebar is collapsed */}
126 {streamingCount > 0 && (
127 <div className="w-6 h-6 rounded-full bg-anton-accent/20 flex items-center justify-center">
128 <span className="text-[10px] text-anton-accent font-bold animate-pulse">
129 {streamingCount}
130 </span>
131 </div>
132 )}
133 </div>
134 );
135 }
136
137 // ── Full sidebar ──
138 return (
139 <div className="w-72 bg-anton-surface border-r border-anton-border flex flex-col shrink-0">
140 {/* Header */}
141 <div className="p-3 border-b border-anton-border flex items-center justify-between">
142 <div className="flex items-center gap-2">
143 <Flame size={20} className="text-anton-accent" />
144 <span className="font-bold text-white text-sm">Son of Anton</span>
145 {streamingCount > 0 && (
146 <span className="text-[10px] bg-anton-accent/20 text-anton-accent px-1.5 py-0.5 rounded-full font-medium animate-pulse">
147 {streamingCount} streaming
148 </span>
149 )}
150 </div>
151 <button
152 onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
153 className="p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
154 >
155 <PanelLeftClose size={16} />
156 </button>
157 </div>
158
159 {/* Tabs */}
160 <div className="flex border-b border-anton-border">
161 {[
162 { key: "chats", label: "Chats", icon: MessageSquare },
163 { key: "knowledge", label: "Knowledge", icon: BookOpen },
164 ].map((t) => (
165 <button
166 key={t.key}
167 onClick={() => switchTab(t.key)}
168 className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition ${
169 tab === t.key
170 ? "text-anton-accent border-b-2 border-anton-accent"
171 : "text-anton-muted hover:text-white"
172 }`}
173 >
174 <t.icon size={13} />
175 {t.label}
176 </button>
177 ))}
178 </div>
179
180 {/* Content */}
181 <div className="flex-1 overflow-y-auto p-2 space-y-1">
182 {tab === "chats" && (
183 <>
184 <button
185 onClick={handleNewChat}
186 className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
187 >
188 <Plus size={15} /> New Chat
189 </button>
190
191 {state.chats.map((c) => {
192 const chatStreaming = !!state.activeStreams[c.id];
193 return (
194 <div
195 key={c.id}
196 className={`group flex items-center rounded-lg cursor-pointer transition ${
197 state.activeChatId === c.id
198 ? "bg-anton-accent/10 text-anton-accent"
199 : "text-anton-text hover:bg-anton-card"
200 }`}
201 >
202 {renamingId === c.id ? (
203 <div className="flex items-center gap-1 flex-1 p-1">
204 <input
205 value={renameVal}
206 onChange={(e) => setRenameVal(e.target.value)}
207 onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)}
208 autoFocus
209 className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
210 />
211 <button onClick={() => handleRename(c.id)} className="p-1 text-anton-success">
212 <Check size={12} />
213 </button>
214 <button onClick={() => setRenamingId(null)} className="p-1 text-anton-muted">
215 <X size={12} />
216 </button>
217 </div>
218 ) : (
219 <>
220 <button
221 onClick={() => dispatch({ type: "SET_ACTIVE_CHAT", chatId: c.id })}
222 className="flex-1 flex items-center gap-2 text-left px-3 py-2 text-sm truncate min-w-0"
223 >
224 {/* Streaming indicator dot */}
225 {chatStreaming && (
226 <span className="w-2 h-2 bg-anton-accent rounded-full animate-pulse shrink-0" />
227 )}
228 <span className="truncate">{c.title}</span>
229 </button>
230 <div className="hidden group-hover:flex items-center pr-1 gap-0.5 shrink-0">
231 <button
232 onClick={() => {
233 setRenamingId(c.id);
234 setRenameVal(c.title);
235 }}
236 className="p-1 rounded hover:bg-anton-border text-anton-muted"
237 >
238 <Edit2 size={11} />
239 </button>
240 <button
241 onClick={() => handleDelete(c.id)}
242 className="p-1 rounded hover:bg-red-500/20 text-anton-danger"
243 >
244 <Trash2 size={11} />
245 </button>
246 </div>
247 </>
248 )}
249 </div>
250 );
251 })}
252 </>
253 )}
254
255 {tab === "knowledge" && (
256 <>
257 <button
258 onClick={() => setShowNewKb(!showNewKb)}
259 className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
260 >
261 <Plus size={15} /> New Knowledge Base
262 </button>
263
264 {showNewKb && (
265 <div className="flex gap-1 p-1">
266 <input
267 value={newKbName}
268 onChange={(e) => setNewKbName(e.target.value)}
269 onKeyDown={(e) => e.key === "Enter" && handleCreateKb()}
270 placeholder="Name…"
271 autoFocus
272 className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
273 />
274 <button onClick={handleCreateKb} className="px-2 py-1 bg-anton-accent rounded text-white text-xs">
275 Add
276 </button>
277 </div>
278 )}
279
280 {kbs.map((kb) => (
281 <div key={kb.id} className="rounded-lg border border-anton-border/50 overflow-hidden">
282 <button
283 onClick={() => setExpandedKb(expandedKb === kb.id ? null : kb.id)}
284 className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-anton-card transition"
285 >
286 {expandedKb === kb.id ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
287 <BookOpen size={13} className="text-anton-accent shrink-0" />
288 <span className="flex-1 truncate">{kb.name}</span>
289 <span className="text-xs text-anton-muted">{kb.document_count} docs</span>
290 </button>
291
292 {expandedKb === kb.id && (
293 <div className="px-3 pb-3 space-y-2 bg-anton-card/50">
294 <div className="text-xs text-anton-muted space-y-0.5">
295 <div>
296 Chunks: {kb.chunk_count} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K
297 tokens
298 </div>
299 </div>
300 <label
301 className={`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${
302 uploading ? "opacity-50 pointer-events-none" : ""
303 }`}
304 >
305 <Upload size={12} />
306 {uploading
307 ? `Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…`
308 : "Upload files (.txt, .pdf, .md, .json, .csv …)"}
309 <input
310 type="file"
311 className="hidden"
312 multiple
313 accept=".txt,.md,.pdf,.json,.csv,.py,.js,.ts,.cs,.html,.css,.xml,.yaml,.yml,.toml"
314 onChange={(e) => {
315 if (e.target.files && e.target.files.length > 0) {
316 handleUpload(kb.id, Array.from(e.target.files));
317 }
318 e.target.value = "";
319 }}
320 />
321 </label>
322 <button
323 onClick={() => handleDeleteKb(kb.id)}
324 className="flex items-center gap-1 text-xs text-anton-danger hover:underline"
325 >
326 <Trash2 size={11} /> Delete KB
327 </button>
328 </div>
329 )}
330 </div>
331 ))}
332 </>
41 streamManager.abortStream(id);
42 await deleteChat(state.token, id);
43 dispatch({ type: "DELETE_CHAT", chatId: id });
44 } catch { /* */ }
45 }
46
47 async function handleRename(id) {
48 if (!renameVal.trim()) return;
49 try {
50 await renameChat(state.token, id, renameVal.trim());
51 dispatch({ type: "UPDATE_CHAT", chat: { id, title: renameVal.trim() } });
52 setRenamingId(null);
53 } catch { /* */ }
54 }
55
56 async function loadKbs() {
57 try {
58 const data = await listKnowledgeBases(state.token);
59 setKbs(data);
60 setKbLoaded(true);
61 } catch { /* */ }
62 }
63
64 async function handleCreateKb() {
65 if (!newKbName.trim()) return;
66 try {
67 await createKnowledgeBase(state.token, newKbName.trim());
68 setNewKbName("");
69 setShowNewKb(false);
70 loadKbs();
71 } catch { /* */ }
72 }
73
74 async function handleDeleteKb(id) {
75 if (!confirm("Delete this knowledge base?")) return;
76 try {
77 await deleteKnowledgeBase(state.token, id);
78 loadKbs();
79 } catch { /* */ }
80 }
81
82 async function handleUpload(kbId, files) {
83 setUploading(true);
84 setUploadCount(files.length);
85 try {
86 const result = await uploadDocuments(state.token, kbId, files);
87 const errors = (result.files || []).filter((f) => f.error);
88 if (errors.length > 0) {
89 alert(
90 `Uploaded ${result.files.length - errors.length} of ${result.files.length} files.\n\nErrors:\n` +
91 errors.map((e) => `• ${e.filename}: ${e.error}`).join("\n")
92 );
93 }
94 loadKbs();
95 } catch (e) {
96 alert(e.message);
97 } finally {
98 setUploading(false);
99 setUploadCount(0);
100 }
101 }
102
103 function switchTab(t) {
104 setTab(t);
105 if (t === "knowledge" && !kbLoaded) loadKbs();
106 }
107
108 if (!open) {
109 return (
110 <div className="w-12 bg-anton-surface border-r border-anton-border flex flex-col items-center py-3 gap-3 shrink-0">
111 <button
112 onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
113 className="p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
114 >
115 <PanelLeftOpen size={18} />
116 </button>
117 <button
118 onClick={handleNewChat}
119 className="p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition"
120 >
121 <Plus size={18} />
122 </button>
123 {streamingCount > 0 && (
124 <div className="w-6 h-6 rounded-full bg-anton-accent/20 flex items-center justify-center">
125 <span className="text-[10px] text-anton-accent font-bold animate-pulse">
126 {streamingCount}
127 </span>
128 </div>
129 )}
130 </div>
131 );
132 }
133
134 return (
135 <div className="w-72 bg-anton-surface border-r border-anton-border flex flex-col shrink-0">
136 <div className="p-3 border-b border-anton-border flex items-center justify-between">
137 <div className="flex items-center gap-2">
138 <Flame size={20} className="text-anton-accent" />
139 <span className="font-bold text-white text-sm">Son of Anton</span>
140 {streamingCount > 0 && (
141 <span className="text-[10px] bg-anton-accent/20 text-anton-accent px-1.5 py-0.5 rounded-full font-medium animate-pulse">
142 {streamingCount} streaming
143 </span>
144 )}
145 </div>
146 <button
147 onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
148 className="p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
149 >
150 <PanelLeftClose size={16} />
151 </button>
152 </div>
153
154 <div className="flex border-b border-anton-border">
155 {[
156 { key: "chats", label: "Chats", icon: MessageSquare },
157 { key: "knowledge", label: "Knowledge", icon: BookOpen },
158 ].map((t) => (
159 <button
160 key={t.key}
161 onClick={() => switchTab(t.key)}
162 className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition ${tab === t.key
163 ? "text-anton-accent border-b-2 border-anton-accent"
164 : "text-anton-muted hover:text-white"
165 }`}
166 >
167 <t.icon size={13} />
168 {t.label}
169 </button>
170 ))}
171 </div>
172
173 <div className="flex-1 overflow-y-auto p-2 space-y-1">
174 {tab === "chats" && (
175 <>
176 <button
177 onClick={handleNewChat}
178 className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
179 >
180 <Plus size={15} /> New Chat
181 </button>
182
183 {state.chats.map((c) => {
184 const chatStreaming = !!state.activeStreams[c.id];
185 return (
186 <div
187 key={c.id}
188 className={`group flex items-center rounded-lg cursor-pointer transition ${state.activeChatId === c.id
189 ? "bg-anton-accent/10 text-anton-accent"
190 : "text-anton-text hover:bg-anton-card"
191 }`}
192 >
193 {renamingId === c.id ? (
194 <div className="flex items-center gap-1 flex-1 p-1">
195 <input
196 value={renameVal}
197 onChange={(e) => setRenameVal(e.target.value)}
198 onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)}
199 autoFocus
200 className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
201 />
202 <button onClick={() => handleRename(c.id)} className="p-1 text-anton-success">
203 <Check size={12} />
204 </button>
205 <button onClick={() => setRenamingId(null)} className="p-1 text-anton-muted">
206 <X size={12} />
207 </button>
208 </div>
209 ) : (
210 <>
211 <button
212 onClick={() => dispatch({ type: "SET_ACTIVE_CHAT", chatId: c.id })}
213 className="flex-1 flex items-center gap-2 text-left px-3 py-2 text-sm truncate min-w-0"
214 >
215 {chatStreaming && (
216 <span className="w-2 h-2 bg-anton-accent rounded-full animate-pulse shrink-0" />
217 )}
218 <span className="truncate">{c.title}</span>
219 </button>
220 <div className="hidden group-hover:flex items-center pr-1 gap-0.5 shrink-0">
221 <button
222 onClick={() => {
223 setRenamingId(c.id);
224 setRenameVal(c.title);
225 }}
226 className="p-1 rounded hover:bg-anton-border text-anton-muted"
227 >
228 <Edit2 size={11} />
229 </button>
230 <button
231 onClick={() => handleDelete(c.id)}
232 className="p-1 rounded hover:bg-red-500/20 text-anton-danger"
233 >
234 <Trash2 size={11} />
235 </button>
236 </div>
237 </>
238 )}
239 </div>
240 );
241 })}
242 </>
243 )}
244
245 {tab === "knowledge" && (
246 <>
247 <button
248 onClick={() => setShowNewKb(!showNewKb)}
249 className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
250 >
251 <Plus size={15} /> New Knowledge Base
252 </button>
253
254 {showNewKb && (
255 <div className="flex gap-1 p-1">
256 <input
257 value={newKbName}
258 onChange={(e) => setNewKbName(e.target.value)}
259 onKeyDown={(e) => e.key === "Enter" && handleCreateKb()}
260 placeholder="Name…"
261 autoFocus
262 className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
263 />
264 <button onClick={handleCreateKb} className="px-2 py-1 bg-anton-accent rounded text-white text-xs">
265 Add
266 </button>
267 </div>
268 )}
269
270 {kbs.map((kb) => (
271 <div key={kb.id} className="rounded-lg border border-anton-border/50 overflow-hidden">
272 <button
273 onClick={() => setExpandedKb(expandedKb === kb.id ? null : kb.id)}
274 className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-anton-card transition"
275 >
276 {expandedKb === kb.id ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
277 <BookOpen size={13} className="text-anton-accent shrink-0" />
278 <span className="flex-1 truncate">{kb.name}</span>
279 <span className="text-xs text-anton-muted">{kb.document_count} docs</span>
280 </button>
281
282 {expandedKb === kb.id && (
283 <div className="px-3 pb-3 space-y-2 bg-anton-card/50">
284 <div className="text-xs text-anton-muted space-y-0.5">
285 <div>
286 Chunks: {kb.chunk_count} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K
287 tokens
288 </div>
289 </div>
290 <label
291 className={`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${uploading ? "opacity-50 pointer-events-none" : ""
292 }`}
293 >
294 <Upload size={12} />
295 {uploading
296 ? `Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…`
297 : "Upload files (.txt, .pdf, .md, .json, .csv …)"}
298 <input
299 type="file"
300 className="hidden"
301 multiple
302 accept=".txt,.md,.pdf,.json,.csv,.py,.js,.ts,.cs,.html,.css,.xml,.yaml,.yml,.toml"
303 onChange={(e) => {
304 if (e.target.files && e.target.files.length > 0) {
305 handleUpload(kb.id, Array.from(e.target.files));
306 }
307 e.target.value = "";
308 }}
309 />
310 </label>
311 <button
312 onClick={() => handleDeleteKb(kb.id)}
313 className="flex items-center gap-1 text-xs text-anton-danger hover:underline"
314 >
315 <Trash2 size={11} /> Delete KB
316 </button>
317 </div>
318 )}
319 </div>
320 ))}
321 </>
322 )}
323 </div>
324
325 <div className="p-3 border-t border-anton-border space-y-2">
326 {state.user?.role === "superadmin" && (
327 <button
328 onClick={() => navigate("/admin")}
329 className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:text-anton-accent hover:bg-anton-card transition"
330 >
331 <Shield size={15} /> Admin Panel
332 </button>
333 )}
334 </div>
335
336 {/* Footer */}
337 <div className="p-3 border-t border-anton-border space-y-2">
338 {state.user?.role === "superadmin" && (
339 <button
340 onClick={() => navigate("/admin")}
341 className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:text-anton-accent hover:bg-anton-card transition"
342 >
343 <Shield size={15} /> Admin Panel
344 </button>
345 )}
346 <div className="flex items-center justify-between px-2">
347 <div>
348 <div className="text-sm font-medium text-white">{state.user?.username}</div>
349 <div className="text-xs text-anton-muted">
350 {((state.user?.tokens_used_this_month || 0) / 1000).toFixed(0)}K /{" "}
351 {((state.user?.quota_tokens_monthly || 0) / 1000).toFixed(0)}K tokens
352 </div>
353 </div>
354 <button
355 onClick={() => dispatch({ type: "LOGOUT" })}
356 className="p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition"
357 >
358 <LogOut size={16} />
359 </button>
360 </div>
361 </div>
362 </div>
363 );
364 }│
334 <div className="flex items-center justify-between px-2">
335 <div>
336 <div className="text-sm font-medium text-white">{state.user?.username}</div>
337 <div className="text-xs text-anton-muted">
338 {((state.user?.tokens_used_this_month || 0) / 1000).toFixed(0)}K /{" "}
339 {((state.user?.quota_tokens_monthly || 0) / 1000).toFixed(0)}K tokens
340 </div>
341 </div>
342 <button
343 onClick={() => dispatch({ type: "LOGOUT" })}
344 className="p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition"
345 >
346 <LogOut size={16} />
347 </button>
348 </div>
349 </div>
350 </div>
351 );
352 }│
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [043]: frontend/src/components/Sidebar.jsx
......@@ -12540,7 +12564,7 @@ Collected file paths:
┌──────────────────────────────────────────────────────────────────────────────
│ 📄 FILE [049/55]: frontend/src/store.jsx
│ LANGUAGE: jsx | LINES: 128 | SIZE: 3459 bytes
│ LANGUAGE: jsx | LINES: 134 | SIZE: 3493 bytes
├──────────────────────────────────────────────────────────────────────────────
1 import React, { createContext, useContext, useReducer, useEffect } from "react";
......@@ -12550,128 +12574,134 @@ Collected file paths:
5
6 const initialState = {
7 token: localStorage.getItem("token") || null,
8 user: null,
8 user: JSON.parse(localStorage.getItem("user") || "null"),
9 chats: [],
10 activeChatId: null,
11 sidebarOpen: true,
12 chatMessages: {}, // chatId -> [messages]
13 activeStreams: {}, // chatId -> true (which chats are currently streaming)
12 chatMessages: {},
13 activeStreams: {},
14 };
15
16 function reducer(state, action) {
17 switch (action.type) {
18 case "SET_TOKEN":
19 if (action.token) localStorage.setItem("token", action.token);
20 else localStorage.removeItem("token");
21 return { ...state, token: action.token };
22
23 case "SET_USER":
24 return { ...state, user: action.user };
25
26 case "LOGOUT":
27 localStorage.removeItem("token");
28 return { ...initialState, token: null };
29
30 case "SET_CHATS":
31 return { ...state, chats: action.chats };
18 case "LOGIN": {
19 localStorage.setItem("token", action.token);
20 localStorage.setItem("user", JSON.stringify(action.user));
21 return { ...state, token: action.token, user: action.user };
22 }
23
24 case "SET_TOKEN":
25 if (action.token) localStorage.setItem("token", action.token);
26 else localStorage.removeItem("token");
27 return { ...state, token: action.token };
28
29 case "SET_USER":
30 localStorage.setItem("user", JSON.stringify(action.user));
31 return { ...state, user: action.user };
32
33 case "ADD_CHAT":
34 return {
35 ...state,
36 chats: [action.chat, ...state.chats],
37 activeChatId: action.chat.id,
38 };
39
40 case "UPDATE_CHAT": {
41 const updated = state.chats.map((c) =>
42 c.id === action.chat.id ? { ...c, ...action.chat } : c
43 );
44 return { ...state, chats: updated };
45 }
46
47 case "DELETE_CHAT": {
48 const filtered = state.chats.filter((c) => c.id !== action.chatId);
49 const newMessages = { ...state.chatMessages };
50 delete newMessages[action.chatId];
51 const newStreams = { ...state.activeStreams };
52 delete newStreams[action.chatId];
53 return {
54 ...state,
55 chats: filtered,
56 chatMessages: newMessages,
57 activeStreams: newStreams,
58 activeChatId:
59 state.activeChatId === action.chatId
60 ? filtered[0]?.id || null
61 : state.activeChatId,
62 };
63 }
64
65 case "SET_ACTIVE_CHAT":
66 return { ...state, activeChatId: action.chatId };
67
68 case "TOGGLE_SIDEBAR":
69 return { ...state, sidebarOpen: !state.sidebarOpen };
70
71 // ── Per-chat message management ──────────────
72 case "SET_MESSAGES":
73 return {
74 ...state,
75 chatMessages: {
76 ...state.chatMessages,
77 [action.chatId]: action.messages,
78 },
79 };
33 case "LOGOUT":
34 localStorage.removeItem("token");
35 localStorage.removeItem("user");
36 return { ...initialState, token: null, user: null };
37
38 case "SET_CHATS":
39 return { ...state, chats: action.chats };
40
41 case "ADD_CHAT":
42 return {
43 ...state,
44 chats: [action.chat, ...state.chats],
45 activeChatId: action.chat.id,
46 };
47
48 case "UPDATE_CHAT": {
49 const updated = state.chats.map((c) =>
50 c.id === action.chat.id ? { ...c, ...action.chat } : c
51 );
52 return { ...state, chats: updated };
53 }
54
55 case "REMOVE_CHAT":
56 case "DELETE_CHAT": {
57 const chatId = action.chatId;
58 const filtered = state.chats.filter((c) => c.id !== chatId);
59 const newMessages = { ...state.chatMessages };
60 delete newMessages[chatId];
61 const newStreams = { ...state.activeStreams };
62 delete newStreams[chatId];
63 return {
64 ...state,
65 chats: filtered,
66 chatMessages: newMessages,
67 activeStreams: newStreams,
68 activeChatId:
69 state.activeChatId === chatId
70 ? filtered[0]?.id || null
71 : state.activeChatId,
72 };
73 }
74
75 case "SET_ACTIVE_CHAT":
76 return { ...state, activeChatId: action.chatId };
77
78 case "TOGGLE_SIDEBAR":
79 return { ...state, sidebarOpen: !state.sidebarOpen };
80
81 case "ADD_MESSAGE":
81 case "SET_MESSAGES":
82 return {
83 ...state,
84 chatMessages: {
85 ...state.chatMessages,
86 [action.chatId]: [
87 ...(state.chatMessages[action.chatId] || []),
88 action.message,
89 ],
90 },
91 };
92
93 // ── Background streaming flags ───────────────
94 // NOW PER-CHAT — no longer blocks other chats
95 case "SET_STREAMING": {
96 if (action.streaming) {
97 return {
98 ...state,
99 activeStreams: { ...state.activeStreams, [action.chatId]: true },
100 };
101 }
102 const next = { ...state.activeStreams };
103 delete next[action.chatId];
104 return { ...state, activeStreams: next };
105 }
106
107 default:
108 return state;
109 }
110 }
111
112 export function AppProvider({ children }) {
113 const [state, dispatch] = useReducer(reducer, initialState);
114
115 // Give the background stream manager access to dispatch
116 useEffect(() => {
117 setDispatch(dispatch);
118 }, [dispatch]);
119
120 return (
121 <AppContext.Provider value={{ state, dispatch }}>
122 {children}
123 </AppContext.Provider>
124 );
125 }
126
127 export function useApp() {
128 return useContext(AppContext);
129 }│
86 [action.chatId]: action.messages,
87 },
88 };
89
90 case "ADD_MESSAGE":
91 return {
92 ...state,
93 chatMessages: {
94 ...state.chatMessages,
95 [action.chatId]: [
96 ...(state.chatMessages[action.chatId] || []),
97 action.message,
98 ],
99 },
100 };
101
102 case "SET_STREAMING": {
103 if (action.streaming) {
104 return {
105 ...state,
106 activeStreams: { ...state.activeStreams, [action.chatId]: true },
107 };
108 }
109 const next = { ...state.activeStreams };
110 delete next[action.chatId];
111 return { ...state, activeStreams: next };
112 }
113
114 default:
115 return state;
116 }
117 }
118
119 export function AppProvider({ children }) {
120 const [state, dispatch] = useReducer(reducer, initialState);
121
122 useEffect(() => {
123 setDispatch(dispatch);
124 }, [dispatch]);
125
126 return (
127 <AppContext.Provider value={{ state, dispatch }}>
128 {children}
129 </AppContext.Provider>
130 );
131 }
132
133 export function useApp() {
134 return useContext(AppContext);
135 }│
└──────────────────────────────────────────────────────────────────────────────
✅ END OF [049]: frontend/src/store.jsx
......@@ -12901,9 +12931,9 @@ Collected file paths:
# ✅ END OF COMPLETE CODEBASE DUMP #
# #
# Total Files: 55 #
# Total Lines: 12129 #
# Total Lines: 12159 #
# Total Size: 433KB #
# Generated: 2026-03-19 10:39:13 #
# Generated: 2026-03-19 10:53:17 #
# #
# This file contains EVERYTHING: source code, configs, env vars, Docker, #
# CI/CD, build tools, package manifests, docs — the complete picture. #
......
......@@ -3,27 +3,41 @@ import { useApp } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments } from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, Image, FileText, Film, Loader2 } from "lucide-react";
import {
Send, Square, Settings2, X, Brain, BookOpen, Paperclip,
FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode,
} from "lucide-react";
const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" },
];
const FILE_TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode };
const FILE_TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
const FILE_TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
function classifyFile(file) {
const ext = (file.name || "").split(".").pop().toLowerCase();
const mime = file.type || "";
if (mime.startsWith("image/") || ["jpg","jpeg","png","gif","webp","bmp"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4","mov","avi","mkv","webm"].includes(ext)) return "video";
if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff", "svg"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm", "flv", "wmv", "m4v"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document";
return "text";
}
function formatFileSize(bytes) {
if (!bytes) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
const currentChat = state.chats.find((c) => c.id === chatId);
const messages = state.chatMessages[chatId] || [];
const isStreamingGlobal = !!state.activeStreams[chatId];
const isChatStreaming = !!state.activeStreams[chatId];
const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false);
......@@ -34,6 +48,7 @@ export default function ChatView({ chatId }) {
const [kbs, setKbs] = useState([]);
const [pendingFiles, setPendingFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollRef = useRef(null);
......@@ -64,10 +79,13 @@ export default function ChatView({ chatId }) {
useEffect(() => {
(async () => {
try {
const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), listKnowledgeBases(state.token)]);
const [msgs, kbData] = await Promise.all([
getMessages(state.token, chatId),
listKnowledgeBases(state.token),
]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData);
} catch {}
} catch { /* ignore */ }
})();
}, [chatId, state.token, dispatch]);
......@@ -76,9 +94,16 @@ export default function ChatView({ chatId }) {
async function saveSettings() {
try {
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "" });
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
} catch {}
await updateChat(state.token, chatId, {
model, max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId || "",
});
dispatch({
type: "UPDATE_CHAT",
chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId },
});
} catch { /* ignore */ }
}
function toggleSettings() {
......@@ -86,66 +111,172 @@ export default function ChatView({ chatId }) {
setShowSettings(!showSettings);
}
function addFiles(files) {
const newEntries = files.map((f) => {
const type = classifyFile(f);
return {
file: f,
type,
preview: type === "image" ? URL.createObjectURL(f) : null,
};
});
setPendingFiles((prev) => [...prev, ...newEntries]);
}
function handleFileSelect(e) {
const files = Array.from(e.target.files || []);
setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
if (files.length) addFiles(files);
e.target.value = "";
}
function removePending(i) {
setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); });
setPendingFiles((prev) => {
if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview);
return prev.filter((_, j) => j !== i);
});
}
async function handleSend() {
const content = input.trim();
if ((!content && !pendingFiles.length) || isStreamingGlobal) return;
if ((!content && !pendingFiles.length) || isChatStreaming) return;
const text = content || "Please analyze the attached file(s).";
let attIds = [], uploaded = [];
let attIds = [];
let uploaded = [];
if (pendingFiles.length) {
setUploading(true);
try {
const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
uploaded = (res.attachments || []).filter((a) => !a.error);
attIds = uploaded.map((a) => a.id);
} catch (err) { console.error(err); setUploading(false); return; }
} catch (err) {
console.error("Upload failed:", err);
setUploading(false);
return;
}
setUploading(false);
}
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
dispatch({
type: "ADD_MESSAGE",
chatId,
message: {
id: `tmp-${Date.now()}`,
role: "user",
content: text,
created_at: new Date().toISOString(),
attachments: uploaded,
},
});
setInput("");
pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
setPendingFiles([]);
autoScroll.current = true;
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
streamManager.startStream({ token: state.token, chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds } });
dispatch({
type: "UPDATE_CHAT",
chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId },
});
streamManager.startStream({
token: state.token,
chatId,
body: {
content: text, model, max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
attachment_ids: attIds,
},
});
}
function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function handlePaste(e) {
const imgs = Array.from(e.clipboardData?.items || []).filter((i) => i.type.startsWith("image/"));
if (!imgs.length) return;
const items = Array.from(e.clipboardData?.items || []);
const fileItems = items.filter((i) => i.kind === "file");
if (!fileItems.length) return;
e.preventDefault();
const files = fileItems.map((i) => i.getAsFile()).filter(Boolean);
if (files.length) addFiles(files);
}
function handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}
function handleDragLeave(e) {
e.preventDefault();
setPendingFiles((prev) => [...prev, ...imgs.map((i) => { const f = i.getAsFile(); return { file: f, type: "image", preview: URL.createObjectURL(f) }; })]);
e.stopPropagation();
if (e.currentTarget.contains(e.relatedTarget)) return;
setDragOver(false);
}
function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
const files = Array.from(e.dataTransfer?.files || []);
if (files.length) setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
if (files.length) addFiles(files);
}
const streaming = streamData.streaming;
const otherStreams = Object.keys(state.activeStreams).filter((id) => id !== chatId).length;
return (
<div className="flex-1 flex flex-col min-h-0" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
<div
className="flex-1 flex flex-col min-h-0 relative"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag-and-drop overlay */}
{dragOver && (
<div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none animate-fade-in">
<div className="text-center">
<Upload size={48} className="text-anton-accent mx-auto mb-3 animate-bounce" />
<p className="text-white text-lg font-semibold">Drop files here</p>
<p className="text-anton-muted text-sm mt-1">Images, videos, PDFs, code files…</p>
</div>
</div>
)}
{/* Parallel streams banner */}
{otherStreams > 0 && (
<div className="bg-anton-accent/10 border-b border-anton-accent/20 px-4 py-1.5 text-xs text-anton-accent flex items-center gap-2">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-pulse" />
{otherStreams} other chat{otherStreams !== 1 ? "s" : ""} streaming in background
</div>
)}
{/* Messages area */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)}
{messages.map((m) => (
<MessageBubble key={m.id} message={m} token={state.token} />
))}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
<MessageBubble
message={{
id: "streaming", role: "assistant",
content: streamData.text,
thinking_content: streamData.thinking || null,
attachments: [],
}}
isStreaming
isThinking={streamData.isThinking}
token={state.token}
/>
)}
{streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-4 py-3 animate-fade-in">
<div className="flex gap-1">
......@@ -158,30 +289,54 @@ export default function ChatView({ chatId }) {
)}
</div>
{/* Input area */}
<div className="border-t border-anton-border bg-anton-surface p-4">
{/* Settings panel */}
{showSettings && (
<div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5">
<Settings2 size={14} className="text-anton-accent" /> Settings
</h3>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white">
<X size={14} />
</button>
</div>
<div>
<label className="text-xs text-anton-muted mb-1 block">Model</label>
<select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div>
<div className="flex justify-between text-xs mb-1">
<span className="text-anton-muted">Max Tokens</span>
<span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span>
</div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
</div>
<div>
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div>
<div className="flex justify-between text-xs mb-1">
<span className="text-anton-muted flex items-center gap-1">
<Brain size={12} className="text-purple-400" /> Reasoning
</span>
<span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span>
</div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
</div>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1">
<BookOpen size={12} /> Knowledge Base
</label>
<select
value={selectedKbId || ""}
onChange={(e) => setSelectedKbId(e.target.value || null)}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<option value="">None</option>
{kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select>
......@@ -189,58 +344,139 @@ export default function ChatView({ chatId }) {
</div>
)}
{/* Pending files preview */}
{pendingFiles.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 animate-fade-in">
{pendingFiles.map((pf, i) => (
<div key={i} className="relative group bg-anton-card border border-anton-border rounded-lg overflow-hidden">
{pf.type === "image" && pf.preview ? (
<img src={pf.preview} alt="" className="w-16 h-16 object-cover" />
) : (
<div className="w-16 h-16 flex flex-col items-center justify-center px-1">
<FileText size={20} className="text-anton-muted mb-1" />
<span className="text-[9px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
{pendingFiles.map((pf, i) => {
const Icon = FILE_TYPE_ICONS[pf.type] || FileText;
const colorClass = FILE_TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card";
const iconColor = FILE_TYPE_ICON_COLORS[pf.type] || "text-anton-muted";
return (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${colorClass}`}>
{pf.type === "image" && pf.preview ? (
<img src={pf.preview} alt="" className="w-20 h-20 object-cover" />
) : (
<div className="w-20 h-20 flex flex-col items-center justify-center px-1">
<Icon size={22} className={`${iconColor} mb-1`} />
<span className="text-[9px] text-anton-muted text-center truncate w-full leading-tight">
{pf.file.name.length > 12 ? pf.file.name.slice(0, 10) + "…" : pf.file.name}
</span>
<span className="text-[8px] text-anton-muted/60 uppercase mt-0.5">{pf.type}</span>
</div>
)}
<button
onClick={() => removePending(i)}
className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity shadow-lg"
>
<X size={10} />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[8px] text-white text-center py-0.5">
{formatFileSize(pf.file.size)}
</div>
)}
<button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-[8px] text-white text-center py-0.5">{(pf.file.size / 1024).toFixed(0)}KB</div>
</div>
))}
</div>
);
})}
</div>
)}
{/* Input row */}
<div className="flex items-end gap-2">
<button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.dart,.vue,.svelte,.log" onChange={handleFileSelect} />
<button
onClick={toggleSettings}
className={`p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}
>
<Settings2 size={18} />
</button>
<button
onClick={() => fileRef.current?.click()}
className={`p-2.5 rounded-xl transition shrink-0 ${pendingFiles.length ? "bg-green-500/20 text-green-400 border border-green-500/30" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}
title="Attach files — images, videos, PDFs, code files"
>
<Paperclip size={18} />
</button>
<input
ref={fileRef}
type="file"
multiple
className="hidden"
accept="image/*,video/*,audio/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.hpp,.go,.rs,.rb,.php,.html,.css,.scss,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log,.env,.ini,.cfg,.conf,.r,.docx,.xlsx"
onChange={handleFileSelect}
/>
<div className="flex-1 relative">
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything…"}
rows={1} style={{ maxHeight: "200px" }}
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything… (paste/drop files too)"}
rows={1}
style={{ maxHeight: "200px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px"; }} />
onInput={(e) => {
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px";
}}
/>
</div>
{streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button>
<button
onClick={() => streamManager.abortStream(chatId)}
className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"
>
<Square size={18} />
</button>
) : (
<button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || isStreamingGlobal || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed">
<button
onClick={handleSend}
disabled={(!input.trim() && !pendingFiles.length) || isChatStreaming || uploading}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
)}
</div>
<div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted">
{/* Status bar */}
<div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted flex-wrap">
<span>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span><span>{maxTokens.toLocaleString()} tokens</span>
{reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
{pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""}</span></>}
<span></span>
<span>{maxTokens.toLocaleString()} tokens</span>
{reasoningBudget > 0 && (
<>
<span></span>
<span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span>
</>
)}
{selectedKbId && (
<>
<span></span>
<span className="text-green-400">📚 RAG</span>
</>
)}
{pendingFiles.length > 0 && (
<>
<span></span>
<span className="text-blue-400">📎 {pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""}</span>
</>
)}
{messages.some((m) => m.role === "assistant") && (
<button onClick={async () => { const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all); } catch {} }}
className="ml-auto hover:text-anton-accent transition">⬇ Download code</button>
<button
onClick={async () => {
const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
if (all) try { await downloadZip(state.token, all); } catch { /* ignore */ }
}}
className="ml-auto hover:text-anton-accent transition"
>
⬇ Download code
</button>
)}
</div>
</div>
</div>
);
}
}
\ No newline at end of file
......@@ -5,11 +5,21 @@ import CodeBlock from "./CodeBlock";
import { getAttachmentUrl } from "../api";
import {
User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check,
Image, Film, FileText, ExternalLink,
Image, Film, FileText, ExternalLink, FileCode, File,
} from "lucide-react";
const FILE_TYPE_ICONS = {
image: Image, video: Film, document: FileText, text: FileText,
image: Image,
video: Film,
document: FileText,
text: FileCode,
};
const FILE_TYPE_BADGE_COLORS = {
image: "bg-blue-500/20 text-blue-400",
video: "bg-purple-500/20 text-purple-400",
document: "bg-amber-500/20 text-amber-400",
text: "bg-green-500/20 text-green-400",
};
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) {
......@@ -38,10 +48,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)}
<div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}>
{/* Thinking block */}
{thinking_content && (
<div className="mb-2">
<button onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1">
<button
onClick={() => setShowThinking(!showThinking)}
className="flex items-center gap-1.5 text-xs text-purple-400 hover:text-purple-300 transition mb-1"
>
<Brain size={12} />
{showThinking ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
......@@ -55,38 +68,98 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</div>
)}
{/* Attachments */}
{hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((att) => {
const Icon = FILE_TYPE_ICONS[att.file_type] || FileText;
const Icon = FILE_TYPE_ICONS[att.file_type] || File;
const badgeColor = FILE_TYPE_BADGE_COLORS[att.file_type] || "bg-anton-border text-anton-muted";
const url = getAttachmentUrl(att.id);
if (att.file_type === "image") {
return (
<div key={att.id} className="relative group">
<img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-[240px] max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition"
<img
src={`${url}?token=${token}`}
alt={att.original_filename}
className="max-w-[280px] max-h-[220px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition shadow-lg"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => { e.target.style.display = "none"; }} />
onError={(e) => {
e.target.style.display = "none";
e.target.nextSibling && (e.target.nextSibling.style.display = "flex");
}}
/>
{/* Fallback if image fails */}
<div className="hidden items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-3 py-2">
<Image size={16} className="text-blue-400" />
<span className="text-xs text-white">{att.original_filename}</span>
</div>
{expandedImage === att.id && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-8 cursor-pointer"
onClick={() => setExpandedImage(null)}>
<img src={`${url}?token=${token}`} alt={att.original_filename}
className="max-w-full max-h-full object-contain rounded-lg" />
<div
className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-8 cursor-pointer"
onClick={() => setExpandedImage(null)}
>
<img
src={`${url}?token=${token}`}
alt={att.original_filename}
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
/>
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/70 text-white text-sm px-4 py-2 rounded-lg">
{att.original_filename} — Click anywhere to close
</div>
</div>
)}
<div className="absolute bottom-1 left-1 bg-black/60 text-[9px] text-white px-1.5 py-0.5 rounded">
<div className="absolute bottom-1 left-1 bg-black/70 text-[9px] text-white px-1.5 py-0.5 rounded flex items-center gap-1">
<Image size={8} />
{att.original_filename}
</div>
</div>
);
}
if (att.file_type === "video") {
return (
<a
key={att.id}
href={`${url}?token=${token}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 bg-anton-card border border-purple-500/30 rounded-lg px-4 py-3 hover:border-purple-400 transition group shadow-sm"
>
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Film size={20} className="text-purple-400" />
</div>
<div className="min-w-0">
<div className="text-xs text-white truncate max-w-[180px] font-medium">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted flex items-center gap-1.5 mt-0.5">
<span className={`px-1 py-px rounded text-[8px] font-bold uppercase ${badgeColor}`}>Video</span>
<span>{att.file_size ? (att.file_size / 1024 / 1024).toFixed(1) + " MB" : ""}</span>
</div>
</div>
<ExternalLink size={12} className="text-anton-muted group-hover:text-purple-400 shrink-0" />
</a>
);
}
return (
<a key={att.id} href={`${url}?token=${token}`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-3 py-2 hover:border-anton-accent transition group">
<Icon size={16} className="shrink-0 text-blue-400" />
<a
key={att.id}
href={`${url}?token=${token}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 bg-anton-card border border-anton-border rounded-lg px-4 py-3 hover:border-anton-accent transition group shadow-sm"
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${att.file_type === "document" ? "bg-amber-500/20" : "bg-green-500/20"}`}>
<Icon size={20} className={att.file_type === "document" ? "text-amber-400" : "text-green-400"} />
</div>
<div className="min-w-0">
<div className="text-xs text-white truncate max-w-[160px]">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
<div className="text-xs text-white truncate max-w-[180px] font-medium">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted flex items-center gap-1.5 mt-0.5">
<span className={`px-1 py-px rounded text-[8px] font-bold uppercase ${badgeColor}`}>
{att.file_type}
</span>
<span>{att.file_size ? (att.file_size / 1024).toFixed(0) + " KB" : ""}</span>
</div>
</div>
<ExternalLink size={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a>
......@@ -95,28 +168,33 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
</div>
)}
<div className={`rounded-2xl px-4 py-3 ${
isUser ? "bg-anton-accent text-white rounded-br-md" : "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
{/* Message bubble */}
<div className={`rounded-2xl px-4 py-3 ${isUser
? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md"
}`}>
{isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div>
) : (
<div className="prose-anton text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || "");
const rawLang = match?.[1] || "";
if (inline) return <code className={className} {...props}>{children}</code>;
let lang = rawLang, filename = null;
if (rawLang.includes(":")) {
const idx = rawLang.indexOf(":");
lang = rawLang.slice(0, idx);
filename = rawLang.slice(idx + 1);
}
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} />;
},
pre({ children }) { return <>{children}</>; },
}}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\S+)/.exec(className || "");
const rawLang = match?.[1] || "";
if (inline) return <code className={className} {...props}>{children}</code>;
let lang = rawLang, filename = null;
if (rawLang.includes(":")) {
const idx = rawLang.indexOf(":");
lang = rawLang.slice(0, idx);
filename = rawLang.slice(idx + 1);
}
return <CodeBlock language={lang} filename={filename} code={String(children).replace(/\n$/, "")} />;
},
pre({ children }) { return <>{children}</>; },
}}
>
{content || ""}
</ReactMarkdown>
{isStreaming && !isThinking && (
......@@ -126,9 +204,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)}
</div>
{/* Meta info */}
{!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1.5 px-1">
<button onClick={handleCopy} className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition">
<button
onClick={handleCopy}
className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition"
>
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
</button>
......@@ -157,4 +239,4 @@ function _stripPrefixes(text) {
return text.replace(/^\[(?:Image|Video|Document|File):\s[^\]]*\]\n?/gm, "").trim();
}
export default MessageBubble;
export default MessageBubble;
\ No newline at end of file
......@@ -9,6 +9,7 @@ import * as streamManager from "../streamManager";
import {
Plus, Trash2, Flame, LogOut, Shield, PanelLeftClose, PanelLeftOpen,
MessageSquare, BookOpen, Upload, X, ChevronDown, ChevronRight, Edit2, Check,
Radio,
} from "lucide-react";
export default function Sidebar({ onRefresh }) {
......@@ -26,7 +27,8 @@ export default function Sidebar({ onRefresh }) {
const [renameVal, setRenameVal] = useState("");
const open = state.sidebarOpen;
const streamingCount = Object.keys(state.activeStreams).length;
const streamingChatIds = Object.keys(state.activeStreams);
const streamingCount = streamingChatIds.length;
async function handleNewChat() {
try {
......@@ -108,23 +110,15 @@ export default function Sidebar({ onRefresh }) {
if (!open) {
return (
<div className="w-12 bg-anton-surface border-r border-anton-border flex flex-col items-center py-3 gap-3 shrink-0">
<button
onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
className="p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
>
<button onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })} className="p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition">
<PanelLeftOpen size={18} />
</button>
<button
onClick={handleNewChat}
className="p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition"
>
<button onClick={handleNewChat} className="p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition">
<Plus size={18} />
</button>
{streamingCount > 0 && (
<div className="w-6 h-6 rounded-full bg-anton-accent/20 flex items-center justify-center">
<span className="text-[10px] text-anton-accent font-bold animate-pulse">
{streamingCount}
</span>
<div className="w-7 h-7 rounded-full bg-anton-accent/20 flex items-center justify-center" title={`${streamingCount} chat${streamingCount !== 1 ? "s" : ""} streaming`}>
<Radio size={14} className="text-anton-accent animate-pulse" />
</div>
)}
</div>
......@@ -133,24 +127,25 @@ export default function Sidebar({ onRefresh }) {
return (
<div className="w-72 bg-anton-surface border-r border-anton-border flex flex-col shrink-0">
{/* Header */}
<div className="p-3 border-b border-anton-border flex items-center justify-between">
<div className="flex items-center gap-2">
<Flame size={20} className="text-anton-accent" />
<span className="font-bold text-white text-sm">Son of Anton</span>
</div>
<div className="flex items-center gap-1">
{streamingCount > 0 && (
<span className="text-[10px] bg-anton-accent/20 text-anton-accent px-1.5 py-0.5 rounded-full font-medium animate-pulse">
{streamingCount} streaming
<span className="text-[10px] bg-anton-accent/20 text-anton-accent px-1.5 py-0.5 rounded-full font-medium animate-pulse flex items-center gap-1">
<Radio size={10} /> {streamingCount}
</span>
)}
<button onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })} className="p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition">
<PanelLeftClose size={16} />
</button>
</div>
<button
onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
className="p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
>
<PanelLeftClose size={16} />
</button>
</div>
{/* Tab bar */}
<div className="flex border-b border-anton-border">
{[
{ key: "chats", label: "Chats", icon: MessageSquare },
......@@ -159,9 +154,7 @@ export default function Sidebar({ onRefresh }) {
<button
key={t.key}
onClick={() => switchTab(t.key)}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition ${tab === t.key
? "text-anton-accent border-b-2 border-anton-accent"
: "text-anton-muted hover:text-white"
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition ${tab === t.key ? "text-anton-accent border-b-2 border-anton-accent" : "text-anton-muted hover:text-white"
}`}
>
<t.icon size={13} />
......@@ -170,6 +163,7 @@ export default function Sidebar({ onRefresh }) {
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{tab === "chats" && (
<>
......@@ -182,12 +176,15 @@ export default function Sidebar({ onRefresh }) {
{state.chats.map((c) => {
const chatStreaming = !!state.activeStreams[c.id];
const isActive = state.activeChatId === c.id;
return (
<div
key={c.id}
className={`group flex items-center rounded-lg cursor-pointer transition ${state.activeChatId === c.id
className={`group flex items-center rounded-lg cursor-pointer transition ${isActive
? "bg-anton-accent/10 text-anton-accent"
: "text-anton-text hover:bg-anton-card"
: chatStreaming
? "bg-purple-500/5 text-anton-text hover:bg-purple-500/10"
: "text-anton-text hover:bg-anton-card"
}`}
>
{renamingId === c.id ? (
......@@ -199,12 +196,8 @@ export default function Sidebar({ onRefresh }) {
autoFocus
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
/>
<button onClick={() => handleRename(c.id)} className="p-1 text-anton-success">
<Check size={12} />
</button>
<button onClick={() => setRenamingId(null)} className="p-1 text-anton-muted">
<X size={12} />
</button>
<button onClick={() => handleRename(c.id)} className="p-1 text-anton-success"><Check size={12} /></button>
<button onClick={() => setRenamingId(null)} className="p-1 text-anton-muted"><X size={12} /></button>
</div>
) : (
<>
......@@ -213,24 +206,18 @@ export default function Sidebar({ onRefresh }) {
className="flex-1 flex items-center gap-2 text-left px-3 py-2 text-sm truncate min-w-0"
>
{chatStreaming && (
<span className="w-2 h-2 bg-anton-accent rounded-full animate-pulse shrink-0" />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-pulse shrink-0" title="Streaming" />
)}
<span className="truncate">{c.title}</span>
</button>
<div className="hidden group-hover:flex items-center pr-1 gap-0.5 shrink-0">
<button
onClick={() => {
setRenamingId(c.id);
setRenameVal(c.title);
}}
onClick={() => { setRenamingId(c.id); setRenameVal(c.title); }}
className="p-1 rounded hover:bg-anton-border text-anton-muted"
>
<Edit2 size={11} />
</button>
<button
onClick={() => handleDelete(c.id)}
className="p-1 rounded hover:bg-red-500/20 text-anton-danger"
>
<button onClick={() => handleDelete(c.id)} className="p-1 rounded hover:bg-red-500/20 text-anton-danger">
<Trash2 size={11} />
</button>
</div>
......@@ -261,9 +248,7 @@ export default function Sidebar({ onRefresh }) {
autoFocus
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
/>
<button onClick={handleCreateKb} className="px-2 py-1 bg-anton-accent rounded text-white text-xs">
Add
</button>
<button onClick={handleCreateKb} className="px-2 py-1 bg-anton-accent rounded text-white text-xs">Add</button>
</div>
)}
......@@ -282,36 +267,23 @@ export default function Sidebar({ onRefresh }) {
{expandedKb === kb.id && (
<div className="px-3 pb-3 space-y-2 bg-anton-card/50">
<div className="text-xs text-anton-muted space-y-0.5">
<div>
Chunks: {kb.chunk_count} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K
tokens
</div>
<div>Chunks: {kb.chunk_count} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K tokens</div>
</div>
<label
className={`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${uploading ? "opacity-50 pointer-events-none" : ""
}`}
>
<label className={`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${uploading ? "opacity-50 pointer-events-none" : ""}`}>
<Upload size={12} />
{uploading
? `Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…`
: "Upload files (.txt, .pdf, .md, .json, .csv …)"}
{uploading ? `Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…` : "Upload files (.txt, .pdf, .md, .json, .csv …)"}
<input
type="file"
className="hidden"
multiple
accept=".txt,.md,.pdf,.json,.csv,.py,.js,.ts,.cs,.html,.css,.xml,.yaml,.yml,.toml"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
handleUpload(kb.id, Array.from(e.target.files));
}
if (e.target.files && e.target.files.length > 0) handleUpload(kb.id, Array.from(e.target.files));
e.target.value = "";
}}
/>
</label>
<button
onClick={() => handleDeleteKb(kb.id)}
className="flex items-center gap-1 text-xs text-anton-danger hover:underline"
>
<button onClick={() => handleDeleteKb(kb.id)} className="flex items-center gap-1 text-xs text-anton-danger hover:underline">
<Trash2 size={11} /> Delete KB
</button>
</div>
......@@ -322,6 +294,7 @@ export default function Sidebar({ onRefresh }) {
)}
</div>
{/* Footer */}
<div className="p-3 border-t border-anton-border space-y-2">
{state.user?.role === "superadmin" && (
<button
......@@ -339,10 +312,7 @@ export default function Sidebar({ onRefresh }) {
{((state.user?.quota_tokens_monthly || 0) / 1000).toFixed(0)}K tokens
</div>
</div>
<button
onClick={() => dispatch({ type: "LOGOUT" })}
className="p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition"
>
<button onClick={() => dispatch({ type: "LOGOUT" })} className="p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition">
<LogOut size={16} />
</button>
</div>
......
......@@ -3,7 +3,7 @@ import { useApp } from "../store";
import { listChats } from "../api";
import Sidebar from "../components/Sidebar";
import ChatView from "../components/ChatView";
import { Flame, MessageSquarePlus } from "lucide-react";
import { Flame, Paperclip, Layers, Zap } from "lucide-react";
export default function ChatPage() {
const { state, dispatch } = useApp();
......@@ -12,9 +12,7 @@ export default function ChatPage() {
try {
const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats });
} catch {
/* ignore */
}
} catch { /* ignore */ }
}, [state.token, dispatch]);
useEffect(() => {
......@@ -24,7 +22,6 @@ export default function ChatPage() {
return (
<div className="h-full flex">
<Sidebar onRefresh={loadChats} />
<main className="flex-1 flex flex-col min-w-0">
{state.activeChatId ? (
<ChatView key={state.activeChatId} chatId={state.activeChatId} />
......@@ -39,20 +36,52 @@ export default function ChatPage() {
function EmptyState() {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center animate-fade-in">
<div className="text-center animate-fade-in max-w-lg">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-3xl bg-gradient-to-br from-anton-accent/20 to-transparent border border-anton-accent/20 mb-6">
<Flame size={44} className="text-anton-accent" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Son of Anton</h2>
<p className="text-anton-muted max-w-md">
<p className="text-anton-muted mb-6">
Avatar of All Elements of Code. Create a new chat to begin — but bring
real questions, not that first-result-of-Google garbage.
</p>
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-anton-muted">
<span>📎 Supports images, videos, PDFs, and documents</span>
</div>
<div className="mt-1 flex items-center justify-center gap-2 text-xs text-anton-muted">
<span>⚡ Multiple chats can stream in parallel</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-left">
<div className="bg-anton-surface border border-anton-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Paperclip size={16} className="text-blue-400" />
</div>
<span className="text-sm font-medium text-white">File Upload</span>
</div>
<p className="text-xs text-anton-muted leading-relaxed">
Drop images, videos, PDFs, or code files directly into chat. AI describes and analyzes them.
</p>
</div>
<div className="bg-anton-surface border border-anton-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Layers size={16} className="text-purple-400" />
</div>
<span className="text-sm font-medium text-white">Parallel Chats</span>
</div>
<p className="text-xs text-anton-muted leading-relaxed">
Run multiple conversations simultaneously. Switch between them while they stream.
</p>
</div>
<div className="bg-anton-surface border border-anton-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center">
<Zap size={16} className="text-green-400" />
</div>
<span className="text-sm font-medium text-white">Full Code</span>
</div>
<p className="text-xs text-anton-muted leading-relaxed">
Production-ready code with syntax highlighting, download buttons, and ZIP export.
</p>
</div>
</div>
</div>
</div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment