Commit bde969b1 authored by Mahmoud Aglan's avatar Mahmoud Aglan

jj

parent 18d64a46
...@@ -49,12 +49,13 @@ export const deleteChat = (token, chatId) => ...@@ -49,12 +49,13 @@ export const deleteChat = (token, chatId) =>
export const getMessages = (token, chatId) => export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token); request("GET", `/chats/${chatId}/messages`, token);
/* ── Streaming message ─────────────────────── */ /* ── Streaming message (accepts AbortSignal) ─ */
export async function* streamMessage(token, chatId, body) { export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", method: "POST",
headers: headers(token), headers: headers(token),
body: JSON.stringify(body), body: JSON.stringify(body),
signal,
}); });
if (!res.ok) { if (!res.ok) {
......
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { import {
getMessages, streamMessage, downloadZip, listKnowledgeBases, updateChat, getMessages, downloadZip, listKnowledgeBases, updateChat,
} from "../api"; } from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import { import {
Send, Square, Settings2, X, Brain, BookOpen, ChevronDown, Send, Square, Settings2, X, Brain, BookOpen, ChevronDown,
...@@ -16,27 +17,37 @@ const MODELS = [ ...@@ -16,27 +17,37 @@ const MODELS = [
export default function ChatView({ chatId }) { export default function ChatView({ chatId }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
// ── Load persisted settings from the chat object ── // ── Persisted settings from the chat object ──
const currentChat = state.chats.find((c) => c.id === chatId); const currentChat = state.chats.find((c) => c.id === chatId);
const [messages, setMessages] = useState([]); const messages = state.chatMessages[chatId] || [];
const isStreamingGlobal = !!state.activeStreams[chatId];
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [model, setModel] = useState(currentChat?.model || MODELS[0].id); const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096); const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0); const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null); const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [streamText, setStreamText] = useState("");
const [streamThinking, setStreamThinking] = useState(""); // High-frequency stream data — lives outside the global store to avoid
const [isThinking, setIsThinking] = useState(false); // re-rendering every component on every token
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollContainerRef = useRef(null); const scrollContainerRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const abortRef = useRef(null);
const shouldAutoScrollRef = useRef(true); const shouldAutoScrollRef = useRef(true);
const rafRef = useRef(null); const rafRef = useRef(null);
// ── Subscribe to background stream data for THIS chat ──
useEffect(() => {
setStreamData(streamManager.getStreamData(chatId));
const unsub = streamManager.subscribe(chatId, () => {
setStreamData(streamManager.getStreamData(chatId));
});
return unsub;
}, [chatId]);
// ── Scroll helpers ── // ── Scroll helpers ──
function handleContainerScroll() { function handleContainerScroll() {
const el = scrollContainerRef.current; const el = scrollContainerRef.current;
...@@ -47,7 +58,7 @@ export default function ChatView({ chatId }) { ...@@ -47,7 +58,7 @@ export default function ChatView({ chatId }) {
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
if (!shouldAutoScrollRef.current) return; if (!shouldAutoScrollRef.current) return;
if (rafRef.current) return; // already scheduled if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
const el = scrollContainerRef.current; const el = scrollContainerRef.current;
if (el) el.scrollTop = el.scrollHeight; if (el) el.scrollTop = el.scrollHeight;
...@@ -55,26 +66,28 @@ export default function ChatView({ chatId }) { ...@@ -55,26 +66,28 @@ export default function ChatView({ chatId }) {
}); });
}, []); }, []);
// ── Load messages & KBs on mount ── // ── Load messages & KB list on mount ──
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const msgs = await getMessages(state.token, chatId); const [msgs, kbData] = await Promise.all([
setMessages(msgs); getMessages(state.token, chatId),
const kbData = await listKnowledgeBases(state.token); listKnowledgeBases(state.token),
]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData); setKbs(kbData);
} catch { /* */ } } catch { /* */ }
})(); })();
}, [chatId, state.token]); }, [chatId, state.token, dispatch]);
// Scroll when messages change or stream updates // Scroll when content changes
useEffect(scrollToBottom, [messages, streamText, streamThinking, scrollToBottom]); useEffect(scrollToBottom, [messages, streamData.text, streamData.thinking, scrollToBottom]);
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, [chatId]); }, [chatId]);
// ── Save settings to backend ── // ── Settings persistence ──
async function saveSettings() { async function saveSettings() {
const data = { const data = {
model, model,
...@@ -86,115 +99,67 @@ export default function ChatView({ chatId }) { ...@@ -86,115 +99,67 @@ export default function ChatView({ chatId }) {
await updateChat(state.token, chatId, data); await updateChat(state.token, chatId, data);
dispatch({ dispatch({
type: "UPDATE_CHAT", type: "UPDATE_CHAT",
chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId }, chat: {
id: chatId,
model,
max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
},
}); });
} catch { /* ignore */ } } catch { /* */ }
} }
function toggleSettings() { function toggleSettings() {
const closing = showSettings; const closing = showSettings;
setShowSettings(!showSettings); setShowSettings(!showSettings);
if (closing) { if (closing) saveSettings();
saveSettings();
}
} }
// ── Send message ── // ── Send message ──
async function handleSend() { function handleSend() {
const content = input.trim(); const content = input.trim();
if (!content || streaming) return; if (!content || isStreamingGlobal) return;
const userMsg = { id: `tmp-${Date.now()}`, role: "user", content, created_at: new Date().toISOString() }; // Optimistic user message
setMessages((p) => [...p, userMsg]); const userMsg = {
id: `tmp-${Date.now()}`,
role: "user",
content,
created_at: new Date().toISOString(),
};
dispatch({ type: "ADD_MESSAGE", chatId, message: userMsg });
setInput(""); setInput("");
setStreaming(true); shouldAutoScrollRef.current = true;
setStreamText("");
setStreamThinking("");
setIsThinking(false);
shouldAutoScrollRef.current = true; // Force scroll for user's own message
const ac = new AbortController(); // Sync settings to store immediately
abortRef.current = ac; dispatch({
type: "UPDATE_CHAT",
chat: {
id: chatId,
model,
max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
},
});
try { // Kick off background stream — survives chat switching
const body = { streamManager.startStream({
token: state.token,
chatId,
body: {
content, content,
model, model,
max_tokens: maxTokens, max_tokens: maxTokens,
reasoning_budget: reasoningBudget, reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId, knowledge_base_id: selectedKbId,
}; },
});
let fullText = "";
let fullThinking = "";
let usage = {};
let msgId = "";
for await (const evt of streamMessage(state.token, chatId, body)) {
if (ac.signal.aborted) break;
switch (evt.type) {
case "thinking_start":
setIsThinking(true);
break;
case "thinking_delta":
fullThinking += evt.content;
setStreamThinking(fullThinking);
break;
case "thinking_end":
setIsThinking(false);
break;
case "text_delta":
fullText += evt.content;
setStreamText(fullText);
break;
case "usage":
usage = { input_tokens: evt.input_tokens, output_tokens: evt.output_tokens };
break;
case "title_update":
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: evt.title } });
break;
case "done":
msgId = evt.message_id;
break;
case "error":
fullText += `\n\n**Error:** ${evt.message}`;
setStreamText(fullText);
break;
}
}
const assistantMsg = {
id: msgId || `gen-${Date.now()}`,
role: "assistant",
content: fullText,
thinking_content: fullThinking || null,
input_tokens: usage.input_tokens || 0,
output_tokens: usage.output_tokens || 0,
created_at: new Date().toISOString(),
};
setMessages((p) => [...p, assistantMsg]);
// Sync settings to store (backend already saved them from the message)
dispatch({
type: "UPDATE_CHAT",
chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId },
});
} catch (err) {
setMessages((p) => [
...p,
{ id: `err-${Date.now()}`, role: "assistant", content: `**Error:** ${err.message}`, created_at: new Date().toISOString() },
]);
} finally {
setStreamText("");
setStreamThinking("");
setStreaming(false);
abortRef.current = null;
}
} }
function handleStop() { function handleStop() {
abortRef.current?.abort(); streamManager.abortStream(chatId);
} }
function handleKeyDown(e) { function handleKeyDown(e) {
...@@ -210,10 +175,14 @@ export default function ChatView({ chatId }) { ...@@ -210,10 +175,14 @@ export default function ChatView({ chatId }) {
.map((m) => m.content) .map((m) => m.content)
.join("\n\n---\n\n"); .join("\n\n---\n\n");
if (all) { if (all) {
try { await downloadZip(state.token, all); } catch { /* */ } try {
await downloadZip(state.token, all);
} catch { /* */ }
} }
} }
const streaming = streamData.streaming;
return ( return (
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
{/* Messages */} {/* Messages */}
...@@ -226,21 +195,22 @@ export default function ChatView({ chatId }) { ...@@ -226,21 +195,22 @@ export default function ChatView({ chatId }) {
<MessageBubble key={m.id} message={m} /> <MessageBubble key={m.id} message={m} />
))} ))}
{/* Streaming in progress */} {/* Streaming overlay — in-progress response */}
{streaming && (streamThinking || streamText) && ( {streaming && (streamData.thinking || streamData.text) && (
<MessageBubble <MessageBubble
message={{ message={{
id: "streaming", id: "streaming",
role: "assistant", role: "assistant",
content: streamText, content: streamData.text,
thinking_content: streamThinking || null, thinking_content: streamData.thinking || null,
}} }}
isStreaming isStreaming
isThinking={isThinking} isThinking={streamData.isThinking}
/> />
)} )}
{streaming && !streamText && !streamThinking && ( {/* Waiting indicator */}
{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">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> <span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
...@@ -254,24 +224,30 @@ export default function ChatView({ chatId }) { ...@@ -254,24 +224,30 @@ export default function ChatView({ chatId }) {
{/* Input area */} {/* 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 bar */} {/* 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"> <h3 className="text-sm font-semibold text-white flex items-center gap-1.5">
<Settings2 size={14} className="text-anton-accent" /> Generation Settings <Settings2 size={14} className="text-anton-accent" /> Generation Settings
</h3> </h3>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button> <button onClick={toggleSettings} className="text-anton-muted hover:text-white">
<X size={14} />
</button>
</div> </div>
{/* Model */} {/* Model */}
<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)} <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" 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) => ( {MODELS.map((m) => (
<option key={m.id} value={m.id}>{m.label}</option> <option key={m.id} value={m.id}>
{m.label}
</option>
))} ))}
</select> </select>
</div> </div>
...@@ -282,11 +258,17 @@ export default function ChatView({ chatId }) { ...@@ -282,11 +258,17 @@ export default function ChatView({ chatId }) {
<span className="text-anton-muted">Max Output Tokens</span> <span className="text-anton-muted">Max Output Tokens</span>
<span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span> <span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span>
</div> </div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} <input
type="range"
min={256}
max={65536}
step={256}
value={maxTokens}
onChange={(e) => setMaxTokens(Number(e.target.value))} onChange={(e) => setMaxTokens(Number(e.target.value))}
/> />
<div className="flex justify-between text-[10px] text-anton-muted mt-0.5"> <div className="flex justify-between text-[10px] text-anton-muted mt-0.5">
<span>256</span><span>64K</span> <span>256</span>
<span>64K</span>
</div> </div>
</div> </div>
...@@ -300,11 +282,17 @@ export default function ChatView({ chatId }) { ...@@ -300,11 +282,17 @@ export default function ChatView({ chatId }) {
{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()} {reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}
</span> </span>
</div> </div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} <input
type="range"
min={0}
max={32000}
step={500}
value={reasoningBudget}
onChange={(e) => setReasoningBudget(Number(e.target.value))} onChange={(e) => setReasoningBudget(Number(e.target.value))}
/> />
<div className="flex justify-between text-[10px] text-anton-muted mt-0.5"> <div className="flex justify-between text-[10px] text-anton-muted mt-0.5">
<span>Off</span><span>32K tokens</span> <span>Off</span>
<span>32K tokens</span>
</div> </div>
</div> </div>
...@@ -313,12 +301,16 @@ export default function ChatView({ chatId }) { ...@@ -313,12 +301,16 @@ export default function ChatView({ chatId }) {
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"> <label className="text-xs text-anton-muted mb-1 flex items-center gap-1">
<BookOpen size={12} /> Knowledge Base (RAG) <BookOpen size={12} /> Knowledge Base (RAG)
</label> </label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} <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" 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) => ( {kbs.map((kb) => (
<option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option> <option key={kb.id} value={kb.id}>
{kb.name} ({kb.document_count} docs)
</option>
))} ))}
</select> </select>
</div> </div>
...@@ -326,9 +318,12 @@ export default function ChatView({ chatId }) { ...@@ -326,9 +318,12 @@ export default function ChatView({ chatId }) {
)} )}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<button onClick={toggleSettings} <button
onClick={toggleSettings}
className={`p-2.5 rounded-xl transition shrink-0 ${ 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" showSettings
? "bg-anton-accent/20 text-anton-accent"
: "text-anton-muted hover:text-white hover:bg-anton-card"
}`} }`}
> >
<Settings2 size={18} /> <Settings2 size={18} />
...@@ -352,13 +347,16 @@ export default function ChatView({ chatId }) { ...@@ -352,13 +347,16 @@ export default function ChatView({ chatId }) {
</div> </div>
{streaming ? ( {streaming ? (
<button onClick={handleStop} <button
onClick={handleStop}
className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0" className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"
> >
<Square size={18} /> <Square size={18} />
</button> </button>
) : ( ) : (
<button onClick={handleSend} disabled={!input.trim()} <button
onClick={handleSend}
disabled={!input.trim() || isStreamingGlobal}
className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed" className="p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
> >
<Send size={18} /> <Send size={18} />
...@@ -374,7 +372,9 @@ export default function ChatView({ chatId }) { ...@@ -374,7 +372,9 @@ export default function ChatView({ chatId }) {
{reasoningBudget > 0 && ( {reasoningBudget > 0 && (
<> <>
<span></span> <span></span>
<span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()} reasoning</span> <span className="text-purple-400">
🧠 {reasoningBudget.toLocaleString()} reasoning
</span>
</> </>
)} )}
{selectedKbId && ( {selectedKbId && (
...@@ -384,7 +384,10 @@ export default function ChatView({ chatId }) { ...@@ -384,7 +384,10 @@ export default function ChatView({ chatId }) {
</> </>
)} )}
{messages.some((m) => m.role === "assistant") && ( {messages.some((m) => m.role === "assistant") && (
<button onClick={handleDownloadAll} className="ml-auto hover:text-anton-accent transition"> <button
onClick={handleDownloadAll}
className="ml-auto hover:text-anton-accent transition"
>
⬇ Download all code ⬇ Download all code
</button> </button>
)} )}
......
...@@ -5,6 +5,7 @@ import { ...@@ -5,6 +5,7 @@ import {
createChat, deleteChat, renameChat, createChat, deleteChat, renameChat,
listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocuments, listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocuments,
} from "../api"; } from "../api";
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,
...@@ -13,7 +14,7 @@ import { ...@@ -13,7 +14,7 @@ import {
export default function Sidebar({ onRefresh }) { export default function Sidebar({ onRefresh }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const navigate = useNavigate(); const navigate = useNavigate();
const [tab, setTab] = useState("chats"); // "chats" | "knowledge" const [tab, setTab] = useState("chats");
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [kbLoaded, setKbLoaded] = useState(false); const [kbLoaded, setKbLoaded] = useState(false);
const [newKbName, setNewKbName] = useState(""); const [newKbName, setNewKbName] = useState("");
...@@ -25,6 +26,7 @@ export default function Sidebar({ onRefresh }) { ...@@ -25,6 +26,7 @@ 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;
async function handleNewChat() { async function handleNewChat() {
try { try {
...@@ -36,6 +38,8 @@ export default function Sidebar({ onRefresh }) { ...@@ -36,6 +38,8 @@ export default function Sidebar({ onRefresh }) {
async function handleDelete(id) { async function handleDelete(id) {
try { try {
// Abort any active stream for this chat before deleting
streamManager.abortStream(id);
await deleteChat(state.token, id); await deleteChat(state.token, id);
dispatch({ type: "REMOVE_CHAT", chatId: id }); dispatch({ type: "REMOVE_CHAT", chatId: id });
} catch { /* */ } } catch { /* */ }
...@@ -81,12 +85,11 @@ export default function Sidebar({ onRefresh }) { ...@@ -81,12 +85,11 @@ export default function Sidebar({ onRefresh }) {
setUploadCount(files.length); setUploadCount(files.length);
try { try {
const result = await uploadDocuments(state.token, kbId, files); const result = await uploadDocuments(state.token, kbId, files);
// Check for per-file errors
const errors = (result.files || []).filter((f) => f.error); const errors = (result.files || []).filter((f) => f.error);
if (errors.length > 0) { if (errors.length > 0) {
alert( alert(
`Uploaded ${result.files.length - errors.length} of ${result.files.length} files.\n\nErrors:\n` + `Uploaded ${result.files.length - errors.length} of ${result.files.length} files.\n\nErrors:\n` +
errors.map((e) => `• ${e.filename}: ${e.error}`).join("\n") errors.map((e) => `• ${e.filename}: ${e.error}`).join("\n")
); );
} }
loadKbs(); loadKbs();
...@@ -103,19 +106,35 @@ export default function Sidebar({ onRefresh }) { ...@@ -103,19 +106,35 @@ export default function Sidebar({ onRefresh }) {
if (t === "knowledge" && !kbLoaded) loadKbs(); if (t === "knowledge" && !kbLoaded) loadKbs();
} }
// ── Collapsed sidebar ──
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 onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })} className="p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"> <button
onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
className="p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
>
<PanelLeftOpen size={18} /> <PanelLeftOpen size={18} />
</button> </button>
<button onClick={handleNewChat} className="p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition"> <button
onClick={handleNewChat}
className="p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition"
>
<Plus size={18} /> <Plus size={18} />
</button> </button>
{/* Streaming count badge when sidebar is collapsed */}
{streamingCount > 0 && (
<div className="w-6 h-6 rounded-full bg-anton-accent/20 flex items-center justify-center">
<span className="text-[10px] text-anton-accent font-bold animate-pulse">
{streamingCount}
</span>
</div>
)}
</div> </div>
); );
} }
// ── Full sidebar ──
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 */} {/* Header */}
...@@ -123,8 +142,16 @@ export default function Sidebar({ onRefresh }) { ...@@ -123,8 +142,16 @@ export default function Sidebar({ onRefresh }) {
<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>
{streamingCount > 0 && (
<span className="text-[10px] bg-anton-accent/20 text-anton-accent px-1.5 py-0.5 rounded-full font-medium animate-pulse">
{streamingCount} streaming
</span>
)}
</div> </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>
...@@ -139,7 +166,9 @@ export default function Sidebar({ onRefresh }) { ...@@ -139,7 +166,9 @@ export default function Sidebar({ onRefresh }) {
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 ${ 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" tab === t.key
? "text-anton-accent border-b-2 border-anton-accent"
: "text-anton-muted hover:text-white"
}`} }`}
> >
<t.icon size={13} /> <t.icon size={13} />
...@@ -152,52 +181,81 @@ export default function Sidebar({ onRefresh }) { ...@@ -152,52 +181,81 @@ export default function Sidebar({ onRefresh }) {
<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" && (
<> <>
<button onClick={handleNewChat} <button
onClick={handleNewChat}
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" 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"
> >
<Plus size={15} /> New Chat <Plus size={15} /> New Chat
</button> </button>
{state.chats.map((c) => ( {state.chats.map((c) => {
<div const chatStreaming = !!state.activeStreams[c.id];
key={c.id} return (
className={`group flex items-center rounded-lg cursor-pointer transition ${ <div
state.activeChatId === c.id ? "bg-anton-accent/10 text-anton-accent" : "text-anton-text hover:bg-anton-card" key={c.id}
}`} className={`group flex items-center rounded-lg cursor-pointer transition ${
> state.activeChatId === c.id
{renamingId === c.id ? ( ? "bg-anton-accent/10 text-anton-accent"
<div className="flex items-center gap-1 flex-1 p-1"> : "text-anton-text hover:bg-anton-card"
<input value={renameVal} onChange={(e) => setRenameVal(e.target.value)} }`}
onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)} >
autoFocus {renamingId === c.id ? (
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" <div className="flex items-center gap-1 flex-1 p-1">
/> <input
<button onClick={() => handleRename(c.id)} className="p-1 text-anton-success"><Check size={12} /></button> value={renameVal}
<button onClick={() => setRenamingId(null)} className="p-1 text-anton-muted"><X size={12} /></button> onChange={(e) => setRenameVal(e.target.value)}
</div> onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)}
) : ( autoFocus
<> className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
<button onClick={() => dispatch({ type: "SET_ACTIVE_CHAT", chatId: c.id })} />
className="flex-1 text-left px-3 py-2 text-sm truncate" <button onClick={() => handleRename(c.id)} className="p-1 text-anton-success">
> <Check size={12} />
{c.title} </button>
</button> <button onClick={() => setRenamingId(null)} className="p-1 text-anton-muted">
<div className="hidden group-hover:flex items-center pr-1 gap-0.5 shrink-0"> <X size={12} />
<button onClick={() => { setRenamingId(c.id); setRenameVal(c.title); }} </button>
className="p-1 rounded hover:bg-anton-border text-anton-muted"><Edit2 size={11} /></button>
<button onClick={() => handleDelete(c.id)}
className="p-1 rounded hover:bg-red-500/20 text-anton-danger"><Trash2 size={11} /></button>
</div> </div>
</> ) : (
)} <>
</div> <button
))} onClick={() => dispatch({ type: "SET_ACTIVE_CHAT", chatId: c.id })}
className="flex-1 flex items-center gap-2 text-left px-3 py-2 text-sm truncate min-w-0"
>
{/* Streaming indicator dot */}
{chatStreaming && (
<span className="w-2 h-2 bg-anton-accent rounded-full animate-pulse shrink-0" />
)}
<span className="truncate">{c.title}</span>
</button>
<div className="hidden group-hover:flex items-center pr-1 gap-0.5 shrink-0">
<button
onClick={() => {
setRenamingId(c.id);
setRenameVal(c.title);
}}
className="p-1 rounded hover:bg-anton-border text-anton-muted"
>
<Edit2 size={11} />
</button>
<button
onClick={() => handleDelete(c.id)}
className="p-1 rounded hover:bg-red-500/20 text-anton-danger"
>
<Trash2 size={11} />
</button>
</div>
</>
)}
</div>
);
})}
</> </>
)} )}
{tab === "knowledge" && ( {tab === "knowledge" && (
<> <>
<button onClick={() => setShowNewKb(!showNewKb)} <button
onClick={() => setShowNewKb(!showNewKb)}
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" 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"
> >
<Plus size={15} /> New Knowledge Base <Plus size={15} /> New Knowledge Base
...@@ -205,18 +263,24 @@ export default function Sidebar({ onRefresh }) { ...@@ -205,18 +263,24 @@ export default function Sidebar({ onRefresh }) {
{showNewKb && ( {showNewKb && (
<div className="flex gap-1 p-1"> <div className="flex gap-1 p-1">
<input value={newKbName} onChange={(e) => setNewKbName(e.target.value)} <input
value={newKbName}
onChange={(e) => setNewKbName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateKb()} onKeyDown={(e) => e.key === "Enter" && handleCreateKb()}
placeholder="Name…" autoFocus placeholder="Name…"
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">Add</button> <button onClick={handleCreateKb} className="px-2 py-1 bg-anton-accent rounded text-white text-xs">
Add
</button>
</div> </div>
)} )}
{kbs.map((kb) => ( {kbs.map((kb) => (
<div key={kb.id} className="rounded-lg border border-anton-border/50 overflow-hidden"> <div key={kb.id} className="rounded-lg border border-anton-border/50 overflow-hidden">
<button onClick={() => setExpandedKb(expandedKb === kb.id ? null : kb.id)} <button
onClick={() => setExpandedKb(expandedKb === kb.id ? null : kb.id)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-anton-card transition" className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-anton-card transition"
> >
{expandedKb === kb.id ? <ChevronDown size={13} /> : <ChevronRight size={13} />} {expandedKb === kb.id ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
...@@ -228,14 +292,24 @@ export default function Sidebar({ onRefresh }) { ...@@ -228,14 +292,24 @@ 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>Chunks: {kb.chunk_count} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K tokens</div> <div>
Chunks: {kb.chunk_count} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K
tokens
</div>
</div> </div>
<label className={`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${uploading ? "opacity-50 pointer-events-none" : ""}`}> <label
className={`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${
uploading ? "opacity-50 pointer-events-none" : ""
}`}
>
<Upload size={12} /> <Upload size={12} />
{uploading {uploading
? `Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…` ? `Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…`
: "Upload files (.txt, .pdf, .md, .json, .csv …)"} : "Upload files (.txt, .pdf, .md, .json, .csv …)"}
<input type="file" className="hidden" multiple <input
type="file"
className="hidden"
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) {
...@@ -245,8 +319,10 @@ export default function Sidebar({ onRefresh }) { ...@@ -245,8 +319,10 @@ export default function Sidebar({ onRefresh }) {
}} }}
/> />
</label> </label>
<button onClick={() => handleDeleteKb(kb.id)} <button
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>
...@@ -260,7 +336,8 @@ export default function Sidebar({ onRefresh }) { ...@@ -260,7 +336,8 @@ export default function Sidebar({ onRefresh }) {
{/* Footer */} {/* 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 onClick={() => navigate("/admin")} <button
onClick={() => navigate("/admin")}
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" 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"
> >
<Shield size={15} /> Admin Panel <Shield size={15} /> Admin Panel
...@@ -270,10 +347,12 @@ export default function Sidebar({ onRefresh }) { ...@@ -270,10 +347,12 @@ export default function Sidebar({ onRefresh }) {
<div> <div>
<div className="text-sm font-medium text-white">{state.user?.username}</div> <div className="text-sm font-medium text-white">{state.user?.username}</div>
<div className="text-xs text-anton-muted"> <div className="text-xs text-anton-muted">
{((state.user?.tokens_used_this_month || 0) / 1000).toFixed(0)}K / {((state.user?.quota_tokens_monthly || 0) / 1000).toFixed(0)}K tokens {((state.user?.tokens_used_this_month || 0) / 1000).toFixed(0)}K /{" "}
{((state.user?.quota_tokens_monthly || 0) / 1000).toFixed(0)}K tokens
</div> </div>
</div> </div>
<button onClick={() => dispatch({ type: "LOGOUT" })} <button
onClick={() => dispatch({ type: "LOGOUT" })}
className="p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition" className="p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition"
> >
<LogOut size={16} /> <LogOut size={16} />
......
/** /**
* Global state via React Context + useReducer * Global state via React Context + useReducer.
*
* Holds chat messages and streaming flags so they persist
* across chat switches (background streams keep running).
*/ */
import React, { createContext, useContext, useReducer, useEffect } from "react"; import React, { createContext, useContext, useReducer, useEffect } from "react";
import { setDispatch } from "./streamManager";
const AppContext = createContext(); const AppContext = createContext();
...@@ -12,6 +16,8 @@ const initialState = { ...@@ -12,6 +16,8 @@ const initialState = {
chats: [], chats: [],
activeChatId: null, activeChatId: null,
sidebarOpen: true, sidebarOpen: true,
chatMessages: {}, // { [chatId]: Message[] }
activeStreams: {}, // { [chatId]: true } — which chats are currently streaming
}; };
function reducer(state, action) { function reducer(state, action) {
...@@ -24,7 +30,13 @@ function reducer(state, action) { ...@@ -24,7 +30,13 @@ function reducer(state, action) {
case "LOGOUT": case "LOGOUT":
localStorage.removeItem("soa_token"); localStorage.removeItem("soa_token");
localStorage.removeItem("soa_user"); localStorage.removeItem("soa_user");
return { ...initialState, token: null, user: null }; return {
...initialState,
token: null,
user: null,
chatMessages: {},
activeStreams: {},
};
case "SET_CHATS": case "SET_CHATS":
return { ...state, chats: action.chats }; return { ...state, chats: action.chats };
...@@ -45,9 +57,15 @@ function reducer(state, action) { ...@@ -45,9 +57,15 @@ function reducer(state, action) {
case "REMOVE_CHAT": { case "REMOVE_CHAT": {
const filtered = state.chats.filter((c) => c.id !== action.chatId); const filtered = state.chats.filter((c) => c.id !== action.chatId);
const newMessages = { ...state.chatMessages };
delete newMessages[action.chatId];
const newStreams = { ...state.activeStreams };
delete newStreams[action.chatId];
return { return {
...state, ...state,
chats: filtered, chats: filtered,
chatMessages: newMessages,
activeStreams: newStreams,
activeChatId: activeChatId:
state.activeChatId === action.chatId state.activeChatId === action.chatId
? filtered[0]?.id || null ? filtered[0]?.id || null
...@@ -61,6 +79,41 @@ function reducer(state, action) { ...@@ -61,6 +79,41 @@ function reducer(state, action) {
case "TOGGLE_SIDEBAR": case "TOGGLE_SIDEBAR":
return { ...state, sidebarOpen: !state.sidebarOpen }; return { ...state, sidebarOpen: !state.sidebarOpen };
// ── Per-chat message management ──────────────
case "SET_MESSAGES":
return {
...state,
chatMessages: {
...state.chatMessages,
[action.chatId]: action.messages,
},
};
case "ADD_MESSAGE":
return {
...state,
chatMessages: {
...state.chatMessages,
[action.chatId]: [
...(state.chatMessages[action.chatId] || []),
action.message,
],
},
};
// ── Background streaming flags ───────────────
case "SET_STREAMING": {
if (action.streaming) {
return {
...state,
activeStreams: { ...state.activeStreams, [action.chatId]: true },
};
}
const next = { ...state.activeStreams };
delete next[action.chatId];
return { ...state, activeStreams: next };
}
default: default:
return state; return state;
} }
...@@ -68,6 +121,12 @@ function reducer(state, action) { ...@@ -68,6 +121,12 @@ function reducer(state, action) {
export function AppProvider({ children }) { export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
// Give the background stream manager access to dispatch
useEffect(() => {
setDispatch(dispatch);
}, [dispatch]);
return ( return (
<AppContext.Provider value={{ state, dispatch }}> <AppContext.Provider value={{ state, dispatch }}>
{children} {children}
......
/**
* Son of Anton — Background Stream Manager
*
* Runs AI streams outside React component lifecycle.
* Switching chats does NOT abort streams. Multiple chats
* can stream simultaneously. Components subscribe to
* per-chat updates via subscribe().
*/
import { streamMessage } from "./api";
// chatId -> { text, thinking, isThinking, abortController }
const _streams = new Map();
// chatId -> Set<() => void>
const _listeners = new Map();
// Store dispatch reference, set once from AppProvider
let _dispatch = null;
export function setDispatch(dispatch) {
_dispatch = dispatch;
}
/** Read current stream data for a chat (non-reactive, call from inside subscriber) */
export function getStreamData(chatId) {
const s = _streams.get(chatId);
if (!s) return { streaming: false, text: "", thinking: "", isThinking: false };
return {
streaming: true,
text: s.text,
thinking: s.thinking,
isThinking: s.isThinking,
};
}
/** Is this chat currently streaming? */
export function isStreaming(chatId) {
return _streams.has(chatId);
}
/** Subscribe to stream data changes for a specific chat. Returns unsubscribe fn. */
export function subscribe(chatId, callback) {
if (!_listeners.has(chatId)) _listeners.set(chatId, new Set());
_listeners.get(chatId).add(callback);
return () => {
const set = _listeners.get(chatId);
if (set) {
set.delete(callback);
if (set.size === 0) _listeners.delete(chatId);
}
};
}
function _notify(chatId) {
const set = _listeners.get(chatId);
if (set) set.forEach((cb) => cb());
}
/** Abort a running stream for a chat */
export function abortStream(chatId) {
const s = _streams.get(chatId);
if (s) {
s.abortController.abort();
_streams.delete(chatId);
_notify(chatId);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
}
}
/**
* Start a background stream for a chat.
* Does nothing if that chat is already streaming.
*
* @param {object} opts
* @param {string} opts.token - JWT
* @param {string} opts.chatId - chat UUID
* @param {object} opts.body - SendMessageBody
*/
export function startStream({ token, chatId, body }) {
if (_streams.has(chatId)) return;
const ac = new AbortController();
_streams.set(chatId, {
text: "",
thinking: "",
isThinking: false,
abortController: ac,
});
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: true });
_notify(chatId);
// Fire-and-forget async IIFE — runs entirely in the background
(async () => {
const s = _streams.get(chatId);
if (!s) return;
let usage = {};
let msgId = "";
try {
for await (const evt of streamMessage(token, chatId, body, ac.signal)) {
if (ac.signal.aborted) break;
if (!_streams.has(chatId)) break;
switch (evt.type) {
case "thinking_start":
s.isThinking = true;
_notify(chatId);
break;
case "thinking_delta":
s.thinking += evt.content;
_notify(chatId);
break;
case "thinking_end":
s.isThinking = false;
_notify(chatId);
break;
case "text_delta":
s.text += evt.content;
_notify(chatId);
break;
case "usage":
usage = {
input_tokens: evt.input_tokens,
output_tokens: evt.output_tokens,
};
break;
case "title_update":
if (_dispatch)
_dispatch({
type: "UPDATE_CHAT",
chat: { id: chatId, title: evt.title },
});
break;
case "done":
msgId = evt.message_id;
break;
case "error":
s.text += `\n\n**Error:** ${evt.message}`;
_notify(chatId);
break;
}
}
// Stream finished normally — persist the final assistant message
if (!ac.signal.aborted && _dispatch) {
const assistantMsg = {
id: msgId || `gen-${Date.now()}`,
role: "assistant",
content: s.text,
thinking_content: s.thinking || null,
input_tokens: usage.input_tokens || 0,
output_tokens: usage.output_tokens || 0,
created_at: new Date().toISOString(),
};
_dispatch({ type: "ADD_MESSAGE", chatId, message: assistantMsg });
}
} catch (err) {
// Only surface errors that aren't deliberate aborts
if (!ac.signal.aborted && _dispatch) {
const errMsg = {
id: `err-${Date.now()}`,
role: "assistant",
content: `**Error:** ${err.message}`,
created_at: new Date().toISOString(),
};
_dispatch({ type: "ADD_MESSAGE", chatId, message: errMsg });
}
} finally {
_streams.delete(chatId);
_notify(chatId);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
}
})();
}
\ No newline at end of file
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