Commit bde969b1 authored by Mahmoud Aglan's avatar Mahmoud Aglan

jj

parent 18d64a46
......@@ -49,12 +49,13 @@ export const deleteChat = (token, chatId) =>
export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token);
/* ── Streaming message ─────────────────────── */
export async function* streamMessage(token, chatId, body) {
/* ── Streaming message (accepts AbortSignal) ─ */
export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST",
headers: headers(token),
body: JSON.stringify(body),
signal,
});
if (!res.ok) {
......
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store";
import {
getMessages, streamMessage, downloadZip, listKnowledgeBases, updateChat,
getMessages, downloadZip, listKnowledgeBases, updateChat,
} from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import {
Send, Square, Settings2, X, Brain, BookOpen, ChevronDown,
......@@ -16,27 +17,37 @@ const MODELS = [
export default function ChatView({ chatId }) {
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 [messages, setMessages] = useState([]);
const messages = state.chatMessages[chatId] || [];
const isStreamingGlobal = !!state.activeStreams[chatId];
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [kbs, setKbs] = useState([]);
const [streamText, setStreamText] = useState("");
const [streamThinking, setStreamThinking] = useState("");
const [isThinking, setIsThinking] = useState(false);
// High-frequency stream data — lives outside the global store to avoid
// re-rendering every component on every token
const [streamData, setStreamData] = useState(streamManager.getStreamData(chatId));
const scrollContainerRef = useRef(null);
const inputRef = useRef(null);
const abortRef = useRef(null);
const shouldAutoScrollRef = useRef(true);
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 ──
function handleContainerScroll() {
const el = scrollContainerRef.current;
......@@ -47,7 +58,7 @@ export default function ChatView({ chatId }) {
const scrollToBottom = useCallback(() => {
if (!shouldAutoScrollRef.current) return;
if (rafRef.current) return; // already scheduled
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
const el = scrollContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
......@@ -55,26 +66,28 @@ export default function ChatView({ chatId }) {
});
}, []);
// ── Load messages & KBs on mount ──
// ── Load messages & KB list on mount ──
useEffect(() => {
(async () => {
try {
const msgs = await getMessages(state.token, chatId);
setMessages(msgs);
const kbData = await listKnowledgeBases(state.token);
const [msgs, kbData] = await Promise.all([
getMessages(state.token, chatId),
listKnowledgeBases(state.token),
]);
dispatch({ type: "SET_MESSAGES", chatId, messages: msgs });
setKbs(kbData);
} catch { /* */ }
})();
}, [chatId, state.token]);
}, [chatId, state.token, dispatch]);
// Scroll when messages change or stream updates
useEffect(scrollToBottom, [messages, streamText, streamThinking, scrollToBottom]);
// Scroll when content changes
useEffect(scrollToBottom, [messages, streamData.text, streamData.thinking, scrollToBottom]);
useEffect(() => {
inputRef.current?.focus();
}, [chatId]);
// ── Save settings to backend ──
// ── Settings persistence ──
async function saveSettings() {
const data = {
model,
......@@ -86,115 +99,67 @@ export default function ChatView({ chatId }) {
await updateChat(state.token, chatId, data);
dispatch({
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() {
const closing = showSettings;
setShowSettings(!showSettings);
if (closing) {
saveSettings();
}
if (closing) saveSettings();
}
// ── Send message ──
async function handleSend() {
function handleSend() {
const content = input.trim();
if (!content || streaming) return;
const userMsg = { id: `tmp-${Date.now()}`, role: "user", content, created_at: new Date().toISOString() };
setMessages((p) => [...p, userMsg]);
if (!content || isStreamingGlobal) return;
// Optimistic user message
const userMsg = {
id: `tmp-${Date.now()}`,
role: "user",
content,
created_at: new Date().toISOString(),
};
dispatch({ type: "ADD_MESSAGE", chatId, message: userMsg });
setInput("");
setStreaming(true);
setStreamText("");
setStreamThinking("");
setIsThinking(false);
shouldAutoScrollRef.current = true; // Force scroll for user's own message
shouldAutoScrollRef.current = true;
const ac = new AbortController();
abortRef.current = ac;
// Sync settings to store immediately
dispatch({
type: "UPDATE_CHAT",
chat: {
id: chatId,
model,
max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
},
});
try {
const body = {
// Kick off background stream — survives chat switching
streamManager.startStream({
token: state.token,
chatId,
body: {
content,
model,
max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
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() {
abortRef.current?.abort();
streamManager.abortStream(chatId);
}
function handleKeyDown(e) {
......@@ -210,10 +175,14 @@ export default function ChatView({ chatId }) {
.map((m) => m.content)
.join("\n\n---\n\n");
if (all) {
try { await downloadZip(state.token, all); } catch { /* */ }
try {
await downloadZip(state.token, all);
} catch { /* */ }
}
}
const streaming = streamData.streaming;
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Messages */}
......@@ -226,21 +195,22 @@ export default function ChatView({ chatId }) {
<MessageBubble key={m.id} message={m} />
))}
{/* Streaming in progress */}
{streaming && (streamThinking || streamText) && (
{/* Streaming overlay — in-progress response */}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble
message={{
id: "streaming",
role: "assistant",
content: streamText,
thinking_content: streamThinking || null,
content: streamData.text,
thinking_content: streamData.thinking || null,
}}
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 gap-1">
<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 }) {
{/* Input area */}
<div className="border-t border-anton-border bg-anton-surface p-4">
{/* Settings bar */}
{/* Settings panel */}
{showSettings && (
<div className="mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5">
<Settings2 size={14} className="text-anton-accent" /> Generation Settings
</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>
{/* Model */}
<div>
<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"
>
{MODELS.map((m) => (
<option key={m.id} value={m.id}>{m.label}</option>
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
</div>
......@@ -282,11 +258,17 @@ export default function ChatView({ chatId }) {
<span className="text-anton-muted">Max Output Tokens</span>
<span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span>
</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))}
/>
<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>
......@@ -300,11 +282,17 @@ export default function ChatView({ chatId }) {
{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}
</span>
</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))}
/>
<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>
......@@ -313,12 +301,16 @@ export default function ChatView({ chatId }) {
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1">
<BookOpen size={12} /> Knowledge Base (RAG)
</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"
>
<option value="">None</option>
{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>
</div>
......@@ -326,9 +318,12 @@ export default function ChatView({ chatId }) {
)}
<div className="flex items-end gap-2">
<button onClick={toggleSettings}
<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"
showSettings
? "bg-anton-accent/20 text-anton-accent"
: "text-anton-muted hover:text-white hover:bg-anton-card"
}`}
>
<Settings2 size={18} />
......@@ -352,13 +347,16 @@ export default function ChatView({ chatId }) {
</div>
{streaming ? (
<button onClick={handleStop}
<button
onClick={handleStop}
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()}
<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"
>
<Send size={18} />
......@@ -374,7 +372,9 @@ export default function ChatView({ chatId }) {
{reasoningBudget > 0 && (
<>
<span></span>
<span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()} reasoning</span>
<span className="text-purple-400">
🧠 {reasoningBudget.toLocaleString()} reasoning
</span>
</>
)}
{selectedKbId && (
......@@ -384,7 +384,10 @@ export default function ChatView({ chatId }) {
</>
)}
{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
</button>
)}
......
......@@ -5,6 +5,7 @@ import {
createChat, deleteChat, renameChat,
listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocuments,
} from "../api";
import * as streamManager from "../streamManager";
import {
Plus, Trash2, Flame, LogOut, Shield, PanelLeftClose, PanelLeftOpen,
MessageSquare, BookOpen, Upload, X, ChevronDown, ChevronRight, Edit2, Check,
......@@ -13,7 +14,7 @@ import {
export default function Sidebar({ onRefresh }) {
const { state, dispatch } = useApp();
const navigate = useNavigate();
const [tab, setTab] = useState("chats"); // "chats" | "knowledge"
const [tab, setTab] = useState("chats");
const [kbs, setKbs] = useState([]);
const [kbLoaded, setKbLoaded] = useState(false);
const [newKbName, setNewKbName] = useState("");
......@@ -25,6 +26,7 @@ export default function Sidebar({ onRefresh }) {
const [renameVal, setRenameVal] = useState("");
const open = state.sidebarOpen;
const streamingCount = Object.keys(state.activeStreams).length;
async function handleNewChat() {
try {
......@@ -36,6 +38,8 @@ export default function Sidebar({ onRefresh }) {
async function handleDelete(id) {
try {
// Abort any active stream for this chat before deleting
streamManager.abortStream(id);
await deleteChat(state.token, id);
dispatch({ type: "REMOVE_CHAT", chatId: id });
} catch { /* */ }
......@@ -81,12 +85,11 @@ export default function Sidebar({ onRefresh }) {
setUploadCount(files.length);
try {
const result = await uploadDocuments(state.token, kbId, files);
// Check for per-file errors
const errors = (result.files || []).filter((f) => f.error);
if (errors.length > 0) {
alert(
`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();
......@@ -103,19 +106,35 @@ export default function Sidebar({ onRefresh }) {
if (t === "knowledge" && !kbLoaded) loadKbs();
}
// ── Collapsed sidebar ──
if (!open) {
return (
<div className="w-12 bg-anton-surface border-r border-anton-border flex flex-col items-center py-3 gap-3 shrink-0">
<button onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })} className="p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition">
<button
onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
className="p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
>
<PanelLeftOpen size={18} />
</button>
<button onClick={handleNewChat} className="p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition">
<button
onClick={handleNewChat}
className="p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition"
>
<Plus size={18} />
</button>
{/* 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>
);
}
// ── Full sidebar ──
return (
<div className="w-72 bg-anton-surface border-r border-anton-border flex flex-col shrink-0">
{/* Header */}
......@@ -123,8 +142,16 @@ export default function Sidebar({ onRefresh }) {
<div className="flex items-center gap-2">
<Flame size={20} className="text-anton-accent" />
<span className="font-bold text-white text-sm">Son of Anton</span>
{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>
<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} />
</button>
</div>
......@@ -139,7 +166,9 @@ export default function Sidebar({ onRefresh }) {
key={t.key}
onClick={() => switchTab(t.key)}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition ${
tab === t.key ? "text-anton-accent border-b-2 border-anton-accent" : "text-anton-muted hover:text-white"
tab === t.key
? "text-anton-accent border-b-2 border-anton-accent"
: "text-anton-muted hover:text-white"
}`}
>
<t.icon size={13} />
......@@ -152,52 +181,81 @@ export default function Sidebar({ onRefresh }) {
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{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"
>
<Plus size={15} /> New Chat
</button>
{state.chats.map((c) => (
<div
key={c.id}
className={`group flex items-center rounded-lg cursor-pointer transition ${
state.activeChatId === c.id ? "bg-anton-accent/10 text-anton-accent" : "text-anton-text hover:bg-anton-card"
}`}
>
{renamingId === c.id ? (
<div className="flex items-center gap-1 flex-1 p-1">
<input value={renameVal} onChange={(e) => setRenameVal(e.target.value)}
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={() => handleRename(c.id)} className="p-1 text-anton-success"><Check size={12} /></button>
<button onClick={() => setRenamingId(null)} className="p-1 text-anton-muted"><X size={12} /></button>
</div>
) : (
<>
<button onClick={() => dispatch({ type: "SET_ACTIVE_CHAT", chatId: c.id })}
className="flex-1 text-left px-3 py-2 text-sm truncate"
>
{c.title}
</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>
{state.chats.map((c) => {
const chatStreaming = !!state.activeStreams[c.id];
return (
<div
key={c.id}
className={`group flex items-center rounded-lg cursor-pointer transition ${
state.activeChatId === c.id
? "bg-anton-accent/10 text-anton-accent"
: "text-anton-text hover:bg-anton-card"
}`}
>
{renamingId === c.id ? (
<div className="flex items-center gap-1 flex-1 p-1">
<input
value={renameVal}
onChange={(e) => setRenameVal(e.target.value)}
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={() => handleRename(c.id)} className="p-1 text-anton-success">
<Check size={12} />
</button>
<button onClick={() => setRenamingId(null)} className="p-1 text-anton-muted">
<X size={12} />
</button>
</div>
</>
)}
</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" && (
<>
<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"
>
<Plus size={15} /> New Knowledge Base
......@@ -205,18 +263,24 @@ export default function Sidebar({ onRefresh }) {
{showNewKb && (
<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()}
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"
/>
<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>
)}
{kbs.map((kb) => (
<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"
>
{expandedKb === kb.id ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
......@@ -228,14 +292,24 @@ export default function Sidebar({ onRefresh }) {
{expandedKb === kb.id && (
<div className="px-3 pb-3 space-y-2 bg-anton-card/50">
<div className="text-xs text-anton-muted space-y-0.5">
<div>Chunks: {kb.chunk_count} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K tokens</div>
<div>
Chunks: {kb.chunk_count} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K
tokens
</div>
</div>
<label className={`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${uploading ? "opacity-50 pointer-events-none" : ""}`}>
<label
className={`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${
uploading ? "opacity-50 pointer-events-none" : ""
}`}
>
<Upload size={12} />
{uploading
? `Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…`
: "Upload files (.txt, .pdf, .md, .json, .csv …)"}
<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"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
......@@ -245,8 +319,10 @@ export default function Sidebar({ onRefresh }) {
}}
/>
</label>
<button onClick={() => handleDeleteKb(kb.id)}
className="flex items-center gap-1 text-xs text-anton-danger hover:underline">
<button
onClick={() => handleDeleteKb(kb.id)}
className="flex items-center gap-1 text-xs text-anton-danger hover:underline"
>
<Trash2 size={11} /> Delete KB
</button>
</div>
......@@ -260,7 +336,8 @@ export default function Sidebar({ onRefresh }) {
{/* Footer */}
<div className="p-3 border-t border-anton-border space-y-2">
{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"
>
<Shield size={15} /> Admin Panel
......@@ -270,10 +347,12 @@ export default function Sidebar({ onRefresh }) {
<div>
<div className="text-sm font-medium text-white">{state.user?.username}</div>
<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>
<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"
>
<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 { setDispatch } from "./streamManager";
const AppContext = createContext();
......@@ -12,6 +16,8 @@ const initialState = {
chats: [],
activeChatId: null,
sidebarOpen: true,
chatMessages: {}, // { [chatId]: Message[] }
activeStreams: {}, // { [chatId]: true } — which chats are currently streaming
};
function reducer(state, action) {
......@@ -24,7 +30,13 @@ function reducer(state, action) {
case "LOGOUT":
localStorage.removeItem("soa_token");
localStorage.removeItem("soa_user");
return { ...initialState, token: null, user: null };
return {
...initialState,
token: null,
user: null,
chatMessages: {},
activeStreams: {},
};
case "SET_CHATS":
return { ...state, chats: action.chats };
......@@ -45,9 +57,15 @@ function reducer(state, action) {
case "REMOVE_CHAT": {
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 {
...state,
chats: filtered,
chatMessages: newMessages,
activeStreams: newStreams,
activeChatId:
state.activeChatId === action.chatId
? filtered[0]?.id || null
......@@ -61,6 +79,41 @@ function reducer(state, action) {
case "TOGGLE_SIDEBAR":
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:
return state;
}
......@@ -68,6 +121,12 @@ function reducer(state, action) {
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
// Give the background stream manager access to dispatch
useEffect(() => {
setDispatch(dispatch);
}, [dispatch]);
return (
<AppContext.Provider value={{ state, dispatch }}>
{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