Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
S
Son Of Anton
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Son Of Anton
Commits
c0e521e8
Commit
c0e521e8
authored
Mar 19, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
new era
parent
06918c59
Changes
5
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
989 additions
and
642 deletions
+989
-642
FULL_CODEBASE.txt
FULL_CODEBASE.txt
+493
-463
ChatView.jsx
frontend/src/components/ChatView.jsx
+299
-63
MessageBubble.jsx
frontend/src/components/MessageBubble.jsx
+120
-38
Sidebar.jsx
frontend/src/components/Sidebar.jsx
+36
-66
ChatPage.jsx
frontend/src/pages/ChatPage.jsx
+41
-12
No files found.
FULL_CODEBASE.txt
View file @
c0e521e8
...
...
@@ -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: 121
2
9
Total Lines: 121
5
9
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] 3
63 14
KB frontend/src/components/Sidebar.jsx
[043] 3
51 13
KB 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] 1
28
3KB frontend/src/store.jsx
[049] 1
34
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: 3
63 | SIZE: 145
82 bytes
│ LANGUAGE: jsx | LINES: 3
51 | SIZE: 141
82 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 setKb
s(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 set
NewKbName(""
);
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 setUpload
ing(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 setUpload
ing(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} · ~{(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 setKb
Loaded(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 set
ShowNewKb(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 setUpload
Count(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 setUpload
Count(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} · ~{(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: 1
28 | SIZE: 3459
bytes
│ LANGUAGE: jsx | LINES: 1
34 | 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_CHA
T":
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 "
LOGOU
T":
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: 121
2
9 #
# Total Lines: 121
5
9 #
# 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. #
...
...
frontend/src/components/ChatView.jsx
View file @
c0e521e8
...
...
@@ -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
is
StreamingGlobal
=
!!
state
.
activeStreams
[
chatId
];
const
is
ChatStreaming
=
!!
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
)
||
is
StreamingGlobal
)
return
;
if
((
!
content
&&
!
pendingFiles
.
length
)
||
is
ChatStreaming
)
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
frontend/src/components/MessageBubble.jsx
View file @
c0e521e8
...
...
@@ -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
frontend/src/components/Sidebar.jsx
View file @
c0e521e8
...
...
@@ -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
}
·
~
{
(
kb
.
estimated_tokens
/
1000
).
toFixed
(
0
)
}
K
tokens
</
div
>
<
div
>
Chunks:
{
kb
.
chunk_count
}
·
~
{
(
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
>
...
...
frontend/src/pages/ChatPage.jsx
View file @
c0e521e8
...
...
@@ -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 m
ax-w-md
"
>
<
p
className=
"text-anton-muted m
b-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
>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment