Commit c0e521e8 authored by Mahmoud Aglan's avatar Mahmoud Aglan

new era

parent 06918c59
...@@ -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: 12129 Total Lines: 12159
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] 363 14KB frontend/src/components/Sidebar.jsx [043] 351 13KB 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] 128 3KB frontend/src/store.jsx [049] 134 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: 363 | SIZE: 14582 bytes │ LANGUAGE: jsx | LINES: 351 | SIZE: 14182 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 setKbs(data); 60 setKbLoaded(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 setNewKbName(""); 69 setShowNewKb(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 setUploading(true); 84 setUploadCount(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 setUploading(false); 99 setUploadCount(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} &middot; ~{(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} &middot; ~{(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: 128 | SIZE: 3459 bytes │ LANGUAGE: jsx | LINES: 134 | 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_CHAT": 33 case "LOGOUT":
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: 12129 # # Total Lines: 12159 #
# 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. #
......
...@@ -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 isStreamingGlobal = !!state.activeStreams[chatId]; const isChatStreaming = !!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) || isStreamingGlobal) return; if ((!content && !pendingFiles.length) || isChatStreaming) 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>
......
...@@ -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>
......
...@@ -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} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K tokens</div>
Chunks: {kb.chunk_count} &middot; ~{(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>
......
...@@ -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 max-w-md"> <p className="text-anton-muted mb-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>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment