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