Commit 5f9caa31 authored by Mahmoud Aglan's avatar Mahmoud Aglan

COMMITTTTTTTT

parent bbef1395
This source diff could not be displayed because it is too large. You can view the blob instead.
""" """
Chat CRUD and message streaming with multimodal attachment support. Chat CRUD and message streaming with multimodal attachment support.
Generation runs in background and survives client disconnection.
""" """
import json import json
...@@ -11,11 +12,11 @@ from fastapi import APIRouter, Depends, HTTPException ...@@ -11,11 +12,11 @@ from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db, SessionLocal from backend.database import get_db
from backend.models import User, Chat, Message, ChatAttachment from backend.models import User, Chat, Message, ChatAttachment
from backend.auth import get_current_user from backend.auth import get_current_user
from backend.system_prompt import build_full_prompt from backend.services import attachment_service
from backend.services import bedrock_service, memory_service, rag_service, attachment_service from backend.services.generation_manager import manager as gen_manager
router = APIRouter() router = APIRouter()
...@@ -116,164 +117,52 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi ...@@ -116,164 +117,52 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi
return msgs return msgs
@router.post("/{chat_id}/messages") @router.get("/{chat_id}/generating")
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)): def check_generating(chat_id: str, user: User = Depends(get_current_user)):
user_id = user.id """Check if a background generation is active for this chat."""
return {"active": gen_manager.is_active(chat_id)}
async def generate():
db = SessionLocal()
try:
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user_id).first()
if not chat:
yield _sse({"type": "error", "message": "Chat not found"})
return
db_user = db.query(User).filter(User.id == user_id).first()
now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0
if now.month == 12:
db_user.quota_reset_date = datetime(now.year + 1, 1, 1)
else:
db_user.quota_reset_date = datetime(now.year, now.month + 1, 1)
db.commit()
if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly: @router.get("/{chat_id}/stream")
yield _sse({"type": "error", "message": "Monthly token quota exceeded."}) async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)):
return """Reconnect to an ongoing background generation's SSE stream."""
if not gen_manager.is_active(chat_id):
async def empty():
yield _sse({"type": "done", "message_id": ""})
return StreamingResponse(empty(), media_type="text/event-stream")
attachments = [] async def generate():
if body.attachment_ids: async for event in gen_manager.stream_events(chat_id):
attachments = ( yield _sse(event)
db.query(ChatAttachment)
.filter(ChatAttachment.id.in_(body.attachment_ids), ChatAttachment.chat_id == chat_id)
.all()
)
stored_content = body.content return StreamingResponse(generate(), media_type="text/event-stream")
if attachments:
labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"}
notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments]
stored_content = "\n".join(notes) + "\n" + body.content
user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
db.add(user_msg)
db.commit()
db.refresh(user_msg)
for att in attachments: @router.post("/{chat_id}/messages")
att.message_id = user_msg.id async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
if attachments: """Send a message. Generation runs in background and survives disconnection."""
db.commit() user_id = user.id
kb_id = body.knowledge_base_id or chat.knowledge_base_id # Start background generation
rag_context = None gen_manager.start(
if kb_id: chat_id=chat_id,
try: user_id=user_id,
rag_context = rag_service.query(kb_id, body.content, n_results=8) content=body.content,
except Exception: model=body.model or "eu.anthropic.claude-opus-4-6-v1",
pass max_tokens=body.max_tokens,
reasoning_budget=body.reasoning_budget,
system_prompt = build_full_prompt(rag_context) knowledge_base_id=body.knowledge_base_id,
messages = memory_service.build_messages(chat, db) attachment_ids=body.attachment_ids,
if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": body.content})
messages[-1]["content"] = content_blocks
model_id = body.model or chat.model
max_tokens = body.max_tokens
thinking_config = None
if body.reasoning_budget > 0:
thinking_config = {"enabled": True, "budget_tokens": body.reasoning_budget}
max_tokens = max_tokens + body.reasoning_budget
full_text = ""
full_thinking = ""
input_tokens = 0
output_tokens = 0
current_block_type = "text"
async for event in bedrock_service.stream_response(
messages=messages, system_prompt=system_prompt,
model_id=model_id, max_tokens=min(max_tokens, 65536),
thinking_config=thinking_config,
):
evt_type = event.get("type", "")
if evt_type == "message_start":
usage = event.get("message", {}).get("usage", {})
input_tokens = usage.get("input_tokens", 0)
elif evt_type == "content_block_start":
blk = event.get("content_block", {})
current_block_type = blk.get("type", "text")
if current_block_type == "thinking":
yield _sse({"type": "thinking_start"})
elif evt_type == "content_block_delta":
delta = event.get("delta", {})
dt = delta.get("type", "")
if dt == "thinking_delta":
t = delta.get("thinking", "")
full_thinking += t
yield _sse({"type": "thinking_delta", "content": t})
elif dt == "text_delta":
t = delta.get("text", "")
full_text += t
yield _sse({"type": "text_delta", "content": t})
elif evt_type == "content_block_stop":
if current_block_type == "thinking":
yield _sse({"type": "thinking_end"})
elif evt_type == "message_delta":
usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0)
assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None,
input_tokens=input_tokens, output_tokens=output_tokens,
) )
db.add(assistant_msg)
db_user.tokens_used_this_month += input_tokens + output_tokens
chat.model = model_id
chat.max_tokens = body.max_tokens
chat.reasoning_budget = body.reasoning_budget
chat.knowledge_base_id = body.knowledge_base_id or None
chat.updated_at = datetime.utcnow()
db.commit()
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
title = await _generate_title(body.content, full_text[:300])
chat.title = title[:120]
db.commit()
yield _sse({"type": "title_update", "title": chat.title})
except Exception:
pass
yield _sse({"type": "usage", "input_tokens": input_tokens, "output_tokens": output_tokens})
yield _sse({"type": "done", "message_id": assistant_msg.id})
except Exception as exc: # Stream events from background task
yield _sse({"type": "error", "message": str(exc)}) async def generate():
finally: async for event in gen_manager.stream_events(chat_id):
db.close() yield _sse(event)
return StreamingResponse(generate(), media_type="text/event-stream") return StreamingResponse(generate(), media_type="text/event-stream")
async def _generate_title(user_msg, ai_msg):
from backend.config import FAST_MODEL
result = await bedrock_service.invoke_model_simple(
model_id=FAST_MODEL,
prompt=f"Generate a concise title (max 6 words) for this conversation:\nUser: {user_msg[:200]}\nAssistant: {ai_msg[:200]}\nRespond ONLY with the title.",
max_tokens=30,
)
return result.strip().strip('"').strip("'")
def _sse(data): def _sse(data):
return f"data: {json.dumps(data)}\n\n" return f"data: {json.dumps(data)}\n\n"
......
"""
Background generation manager.
Decouples AI generation from the SSE HTTP connection so generation
continues even if the client disconnects.
"""
import asyncio
import json
from datetime import datetime
from typing import Optional
from dataclasses import dataclass, field
from backend.database import SessionLocal
from backend.models import User, Chat, Message, ChatAttachment
from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service
@dataclass
class GenerationState:
events: list = field(default_factory=list)
done: asyncio.Event = field(default_factory=asyncio.Event)
message_id: str = ""
error: str = ""
class GenerationManager:
def __init__(self):
self._active: dict[str, GenerationState] = {}
def is_active(self, chat_id: str) -> bool:
state = self._active.get(chat_id)
return state is not None and not state.done.is_set()
def get_state(self, chat_id: str) -> Optional[GenerationState]:
return self._active.get(chat_id)
def start(
self,
chat_id: str,
user_id: str,
content: str,
model: str,
max_tokens: int,
reasoning_budget: int,
knowledge_base_id: Optional[str],
attachment_ids: list[str],
) -> GenerationState:
# Abort any existing generation for this chat
old = self._active.get(chat_id)
if old and not old.done.is_set():
old.done.set()
state = GenerationState()
self._active[chat_id] = state
asyncio.create_task(
self._run(
state, chat_id, user_id, content, model,
max_tokens, reasoning_budget, knowledge_base_id, attachment_ids,
)
)
return state
async def stream_events(self, chat_id: str):
"""Async generator that yields events from an active generation."""
state = self._active.get(chat_id)
if not state:
return
idx = 0
while True:
while idx < len(state.events):
yield state.events[idx]
idx += 1
if state.done.is_set():
# Yield any remaining events
while idx < len(state.events):
yield state.events[idx]
idx += 1
break
await asyncio.sleep(0.02)
async def _run(
self,
state: GenerationState,
chat_id: str,
user_id: str,
content: str,
model_id: str,
max_tokens: int,
reasoning_budget: int,
knowledge_base_id: Optional[str],
attachment_ids: list[str],
):
db = SessionLocal()
try:
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user_id).first()
if not chat:
state.events.append({"type": "error", "message": "Chat not found"})
return
db_user = db.query(User).filter(User.id == user_id).first()
# Quota reset
now = datetime.utcnow()
if db_user.quota_reset_date and now >= db_user.quota_reset_date:
db_user.tokens_used_this_month = 0
if now.month == 12:
db_user.quota_reset_date = datetime(now.year + 1, 1, 1)
else:
db_user.quota_reset_date = datetime(now.year, now.month + 1, 1)
db.commit()
if db_user.tokens_used_this_month >= db_user.quota_tokens_monthly:
state.events.append({"type": "error", "message": "Monthly token quota exceeded."})
return
# Fetch attachments
attachments = []
if attachment_ids:
attachments = (
db.query(ChatAttachment)
.filter(ChatAttachment.id.in_(attachment_ids), ChatAttachment.chat_id == chat_id)
.all()
)
# Build stored content with attachment labels
stored_content = content
if attachments:
labels = {"image": "Image", "video": "Video", "document": "Document", "text": "File"}
notes = [f"[{labels.get(a.file_type, 'File')}: {a.original_filename}]" for a in attachments]
stored_content = "\n".join(notes) + "\n" + content
# Save user message
user_msg = Message(chat_id=chat_id, role="user", content=stored_content)
db.add(user_msg)
db.commit()
db.refresh(user_msg)
# Link attachments to message
for att in attachments:
att.message_id = user_msg.id
if attachments:
db.commit()
# RAG context
kb_id = knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
try:
rag_context = rag_service.query(kb_id, content, n_results=8)
except Exception:
pass
system_prompt = build_full_prompt(rag_context)
messages = memory_service.build_messages(chat, db)
# Inject multimodal content blocks
if attachments and messages and messages[-1]["role"] == "user":
content_blocks = attachment_service.build_claude_content_blocks(attachments)
content_blocks.append({"type": "text", "text": content})
messages[-1]["content"] = content_blocks
# Thinking config
effective_max = max_tokens
thinking_config = None
if reasoning_budget > 0:
thinking_config = {"enabled": True, "budget_tokens": reasoning_budget}
effective_max = max_tokens + reasoning_budget
full_text = ""
full_thinking = ""
input_tokens = 0
output_tokens = 0
current_block_type = "text"
async for event in bedrock_service.stream_response(
messages=messages,
system_prompt=system_prompt,
model_id=model_id,
max_tokens=min(effective_max, 65536),
thinking_config=thinking_config,
):
if state.done.is_set():
break # Aborted
evt_type = event.get("type", "")
if evt_type == "message_start":
usage = event.get("message", {}).get("usage", {})
input_tokens = usage.get("input_tokens", 0)
elif evt_type == "content_block_start":
blk = event.get("content_block", {})
current_block_type = blk.get("type", "text")
if current_block_type == "thinking":
state.events.append({"type": "thinking_start"})
elif evt_type == "content_block_delta":
delta = event.get("delta", {})
dt = delta.get("type", "")
if dt == "thinking_delta":
t = delta.get("thinking", "")
full_thinking += t
state.events.append({"type": "thinking_delta", "content": t})
elif dt == "text_delta":
t = delta.get("text", "")
full_text += t
state.events.append({"type": "text_delta", "content": t})
elif evt_type == "content_block_stop":
if current_block_type == "thinking":
state.events.append({"type": "thinking_end"})
elif evt_type == "message_delta":
usage = event.get("usage", {})
output_tokens = usage.get("output_tokens", 0)
# Save assistant message to DB
assistant_msg = Message(
chat_id=chat_id, role="assistant", content=full_text,
thinking_content=full_thinking or None,
input_tokens=input_tokens, output_tokens=output_tokens,
)
db.add(assistant_msg)
db_user.tokens_used_this_month += input_tokens + output_tokens
chat.model = model_id
chat.max_tokens = max_tokens
chat.reasoning_budget = reasoning_budget
chat.knowledge_base_id = knowledge_base_id or None
chat.updated_at = datetime.utcnow()
db.commit()
state.message_id = assistant_msg.id
# Auto-generate title
msg_count = db.query(Message).filter(Message.chat_id == chat_id).count()
if msg_count <= 2 and chat.title == "New Chat":
try:
title = await self._generate_title(content, full_text[:300])
chat.title = title[:120]
db.commit()
state.events.append({"type": "title_update", "title": chat.title})
except Exception:
pass
state.events.append({"type": "usage", "input_tokens": input_tokens, "output_tokens": output_tokens})
state.events.append({"type": "done", "message_id": assistant_msg.id})
except Exception as exc:
state.events.append({"type": "error", "message": str(exc)})
state.error = str(exc)
finally:
state.done.set()
db.close()
# Cleanup after 2 minutes
await asyncio.sleep(120)
self._active.pop(chat_id, None)
async def _generate_title(self, user_msg: str, ai_msg: str) -> str:
from backend.config import FAST_MODEL
result = await bedrock_service.invoke_model_simple(
model_id=FAST_MODEL,
prompt=f"Generate a concise title (max 6 words) for this conversation:\nUser: {user_msg[:200]}\nAssistant: {ai_msg[:200]}\nRespond ONLY with the title.",
max_tokens=30,
)
return result.strip().strip('"').strip("'")
# Singleton
manager = GenerationManager()
\ No newline at end of file
...@@ -45,6 +45,9 @@ export const deleteChat = (token, chatId) => ...@@ -45,6 +45,9 @@ 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);
export const checkGenerating = (token, chatId) =>
request("GET", `/chats/${chatId}/generating`, token);
export async function* streamMessage(token, chatId, body, signal) { 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", headers: headers(token), method: "POST", headers: headers(token),
...@@ -66,12 +69,12 @@ export async function* streamMessage(token, chatId, body, signal) { ...@@ -66,12 +69,12 @@ export async function* streamMessage(token, chatId, body, signal) {
for (const part of parts) { for (const part of parts) {
const line = part.trim(); const line = part.trim();
if (line.startsWith("data: ")) { if (line.startsWith("data: ")) {
try { yield JSON.parse(line.slice(6)); } catch { } try { yield JSON.parse(line.slice(6)); } catch { /* skip */ }
} }
} }
} }
if (buffer.trim().startsWith("data: ")) { if (buffer.trim().startsWith("data: ")) {
try { yield JSON.parse(buffer.trim().slice(6)); } catch { } try { yield JSON.parse(buffer.trim().slice(6)); } catch { /* skip */ }
} }
} }
...@@ -123,18 +126,10 @@ export const uploadDocument = (token, kbId, file) => ...@@ -123,18 +126,10 @@ export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]); uploadDocuments(token, kbId, [file]);
export const adminStats = (token) => request("GET", "/admin/stats", token); export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) => request("GET", "/admin/users", token); export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) => request("POST", "/admin/users", token, data);
export const adminCreateUser = (token, data) => export const adminUpdateUser = (token, userId, data) => request("PUT", `/admin/users/${userId}`, token, data);
request("POST", "/admin/users", token, data); export const adminDeleteUser = (token, userId) => request("DELETE", `/admin/users/${userId}`, token);
export const adminUpdateUser = (token, userId, data) =>
request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) => request("GET", "/admin/chats", token); export const adminListChats = (token) => request("GET", "/admin/chats", token);
export async function downloadZip(token, markdown) { export async function downloadZip(token, markdown) {
......
...@@ -9,35 +9,34 @@ import { ...@@ -9,35 +9,34 @@ import {
} from "lucide-react"; } 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" },
{ 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" },
]; ];
const FILE_TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode }; const 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 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" }; const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
function classifyFile(file) { function classifyFile(f) {
const ext = (file.name || "").split(".").pop().toLowerCase(); const ext = (f.name || "").split(".").pop().toLowerCase();
const mime = file.type || ""; const mime = f.type || "";
if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff", "svg"].includes(ext)) return "image"; if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm", "flv", "wmv", "m4v"].includes(ext)) return "video"; if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].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) { function fmtSize(b) {
if (!bytes) return "0 B"; if (!b) return "0B";
if (bytes < 1024) return `${bytes} B`; if (b < 1024) return b + "B";
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (b < 1048576) return (b / 1024).toFixed(0) + "KB";
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return (b / 1048576).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 isChatStreaming = !!state.activeStreams[chatId];
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
...@@ -62,12 +61,6 @@ export default function ChatView({ chatId }) { ...@@ -62,12 +61,6 @@ export default function ChatView({ chatId }) {
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId))); return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]); }, [chatId]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
const scrollBottom = useCallback(() => { const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return; if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => { rafRef.current = requestAnimationFrame(() => {
...@@ -92,17 +85,26 @@ export default function ChatView({ chatId }) { ...@@ -92,17 +85,26 @@ export default function ChatView({ chatId }) {
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]); useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]); useEffect(() => { inputRef.current?.focus(); }, [chatId]);
// Sync settings when chat changes
useEffect(() => {
if (currentChat) {
setModel(currentChat.model || MODELS[0].id);
setMaxTokens(currentChat.max_tokens || 4096);
setReasoningBudget(currentChat.reasoning_budget ?? 0);
setSelectedKbId(currentChat.knowledge_base_id || null);
}
}, [chatId]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
async function saveSettings() { async function saveSettings() {
try { try {
await updateChat(state.token, chatId, { await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "" });
model, max_tokens: maxTokens, dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId } });
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 */ } } catch { /* ignore */ }
} }
...@@ -112,368 +114,170 @@ export default function ChatView({ chatId }) { ...@@ -112,368 +114,170 @@ export default function ChatView({ chatId }) {
} }
function addFiles(files) { function addFiles(files) {
const newEntries = files.map((f) => { setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
const type = classifyFile(f);
return {
file: f,
type,
preview: type === "image" ? URL.createObjectURL(f) : null,
};
});
setPendingFiles((prev) => [...prev, ...newEntries]);
}
function handleFileSelect(e) {
const files = Array.from(e.target.files || []);
if (files.length) addFiles(files);
e.target.value = "";
} }
function removePending(i) { function removePending(i) {
setPendingFiles((prev) => { setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); });
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) || isChatStreaming) return; if ((!content && !pendingFiles.length) || streamData.streaming) return;
const text = content || "Please analyze the attached file(s)."; const text = content || "Please analyze the attached file(s).";
let attIds = []; let attIds = [], uploaded = [];
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) { } catch { setUploading(false); return; }
console.error("Upload failed:", err);
setUploading(false);
return;
}
setUploading(false); setUploading(false);
} }
dispatch({ dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
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 },
});
streamManager.startStream({ streamManager.startStream({
token: state.token, token: state.token, chatId,
chatId, body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds },
body: {
content: text, model, max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
attachment_ids: attIds,
},
}); });
} }
function handleKeyDown(e) { function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function handlePaste(e) { function handlePaste(e) {
const items = Array.from(e.clipboardData?.items || []); const items = Array.from(e.clipboardData?.items || []).filter((i) => i.kind === "file");
const fileItems = items.filter((i) => i.kind === "file"); if (!items.length) return;
if (!fileItems.length) return;
e.preventDefault(); e.preventDefault();
const files = fileItems.map((i) => i.getAsFile()).filter(Boolean); addFiles(items.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.stopPropagation();
if (e.currentTarget.contains(e.relatedTarget)) return;
setDragOver(false);
} }
function handleDrop(e) { function handleDrop(e) {
e.preventDefault(); e.preventDefault(); setDragOver(false);
e.stopPropagation();
setDragOver(false);
const files = Array.from(e.dataTransfer?.files || []); const files = Array.from(e.dataTransfer?.files || []);
if (files.length) addFiles(files); 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 <div className="flex-1 flex flex-col min-h-0 relative" onDrop={handleDrop} onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}>
className="flex-1 flex flex-col min-h-0 relative"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag-and-drop overlay */}
{dragOver && ( {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="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">
<div className="text-center"> <div className="text-center">
<Upload size={48} className="text-anton-accent mx-auto mb-3 animate-bounce" /> <Upload size={40} className="text-anton-accent mx-auto mb-2 animate-bounce" />
<p className="text-white text-lg font-semibold">Drop files here</p> <p className="text-white font-semibold">Drop files here</p>
<p className="text-anton-muted text-sm mt-1">Images, videos, PDFs, code files…</p>
</div> </div>
</div> </div>
)} )}
{/* Parallel streams banner */} {/* Messages */}
{otherStreams > 0 && ( <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-3 sm:px-4 py-4 space-y-3 sm:space-y-4">
<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"> {messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)}
<span className="w-2 h-2 bg-anton-accent rounded-full animate-pulse" />
{otherStreams} other chat{otherStreams !== 1 ? "s" : ""} streaming in background
</div>
)}
{/* Messages area */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} token={state.token} />
))}
{streaming && (streamData.thinking || streamData.text) && ( {streaming && (streamData.thinking || streamData.text) && (
<MessageBubble <MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
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">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> {[0, 150, 300].map((d) => <span key={d} className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />)}
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div> </div>
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span> <span className="text-anton-muted text-sm">Thinking…</span>
</div> </div>
)} )}
</div> </div>
{/* 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-3 sm:p-4 safe-bottom">
{/* 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-3 sm:p-4 space-y-3 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" /> Settings</h3>
<Settings2 size={14} className="text-anton-accent" /> Settings <button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
</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 <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">
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"> <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>
<span className="text-anton-muted">Max Tokens</span> <input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} className="w-full" />
<span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span>
</div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
</div> </div>
<div> <div>
<div className="flex justify-between text-xs mb-1"> <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>
<span className="text-anton-muted flex items-center gap-1"> <input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} className="w-full" />
<Brain size={12} className="text-purple-400" /> Reasoning
</span>
<span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span>
</div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
</div> </div>
<div> <div>
<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</label>
<BookOpen size={12} /> Knowledge Base <select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
</label>
<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>
{kbs.length === 0 && <p className="text-[10px] text-anton-muted mt-1">Create knowledge bases in the sidebar → Knowledge tab</p>}
</div> </div>
</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-1.5 animate-fade-in">
{pendingFiles.map((pf, i) => { {pendingFiles.map((pf, i) => {
const Icon = FILE_TYPE_ICONS[pf.type] || FileText; const Icon = 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 ( return (
<div key={i} className={`relative group rounded-lg overflow-hidden border ${colorClass}`}> <div key={i} className={`relative group rounded-lg overflow-hidden border ${TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card"}`}>
{pf.type === "image" && pf.preview ? ( {pf.type === "image" && pf.preview ? (
<img src={pf.preview} alt="" className="w-20 h-20 object-cover" /> <img src={pf.preview} alt="" className="w-16 h-16 sm:w-20 sm:h-20 object-cover" />
) : ( ) : (
<div className="w-20 h-20 flex flex-col items-center justify-center px-1"> <div className="w-16 h-16 sm:w-20 sm:h-20 flex flex-col items-center justify-center px-1">
<Icon size={22} className={`${iconColor} mb-1`} /> <Icon size={18} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} />
<span className="text-[9px] text-anton-muted text-center truncate w-full leading-tight"> <span className="text-[8px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
{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 <button onClick={() => removePending(i)} className="absolute -top-1 -right-1 w-4 h-4 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity shadow"><X size={8} /></button>
onClick={() => removePending(i)} <div className="absolute bottom-0 left-0 right-0 bg-black/70 text-[7px] text-white text-center py-px">{fmtSize(pf.file.size)}</div>
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-1.5 sm:gap-2">
<div className="flex items-end gap-2"> <button onClick={toggleSettings} className={`p-2 sm: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 sm: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,.gd,.dart,.vue,.svelte,.log" onChange={(e) => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} />
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 <textarea
ref={inputRef} ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
value={input} placeholder={pendingFiles.length ? "Add a message or send…" : "Ask anything…"}
onChange={(e) => setInput(e.target.value)} rows={1} style={{ maxHeight: "160px" }}
onKeyDown={handleKeyDown} className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 sm:px-4 sm:py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onPaste={handlePaste} onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px"; }}
placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything… (paste/drop files too)"}
rows={1}
style={{ maxHeight: "200px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onInput={(e) => {
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px";
}}
/> />
</div> </div>
{streaming ? ( {streaming ? (
<button <button onClick={() => streamManager.abortStream(chatId)} className="p-2 sm:p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></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 <button onClick={handleSend} disabled={(!input.trim() && !pendingFiles.length) || uploading} className="p-2 sm:p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30">
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>
{/* Status bar */} <div className="flex items-center gap-2 mt-1.5 text-[10px] text-anton-muted flex-wrap">
<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></span><span>{maxTokens.toLocaleString()} tok</span>
<span>{maxTokens.toLocaleString()} tokens</span> {reasoningBudget > 0 && <><span></span><span className="text-purple-400">🧠 {reasoningBudget.toLocaleString()}</span></>}
{reasoningBudget > 0 && ( {selectedKbId && <><span></span><span className="text-green-400">📚 RAG</span></>}
<> {pendingFiles.length > 0 && <><span></span><span className="text-blue-400">📎 {pendingFiles.length}</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 <button onClick={async () => { const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n"); if (all) try { await downloadZip(state.token, all); } catch { } }} className="ml-auto hover:text-anton-accent transition">⬇ 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>
......
...@@ -8,12 +8,7 @@ import { ...@@ -8,12 +8,7 @@ import {
Image, Film, FileText, ExternalLink, FileCode, File, 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: FileCode };
image: Image,
video: Film,
document: FileText,
text: FileCode,
};
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) { const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message; const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
...@@ -35,12 +30,12 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -35,12 +30,12 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{!isUser && ( {!isUser && (
<div className="shrink-0 mt-1"> <div className="shrink-0 mt-1">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10"> <div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
<Flame size={14} className="text-white sm:w-4 sm:h-4" /> <Flame size={14} className="text-white" />
</div> </div>
</div> </div>
)} )}
<div className={`max-w-[90%] sm:max-w-[80%] ${isUser ? "order-first" : ""}`}> <div className={`max-w-[92%] sm:max-w-[80%] min-w-0 ${isUser ? "order-first" : ""}`}>
{/* Thinking block */} {/* Thinking block */}
{thinking_content && ( {thinking_content && (
<div className="mb-2"> <div className="mb-2">
...@@ -53,7 +48,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -53,7 +48,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>} {isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button> </button>
{(showThinking || isThinking) && ( {(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-2.5 sm:p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-48 sm:max-h-60 overflow-y-auto"> <div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-2.5 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-48 sm:max-h-60 overflow-y-auto">
{thinking_content} {thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />} {isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div> </div>
...@@ -63,7 +58,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -63,7 +58,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{/* Attachments */} {/* Attachments */}
{hasAttachments && ( {hasAttachments && (
<div className="mb-2 flex flex-wrap gap-1.5 sm:gap-2"> <div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((att) => { {attachments.map((att) => {
const Icon = FILE_TYPE_ICONS[att.file_type] || File; const Icon = FILE_TYPE_ICONS[att.file_type] || File;
const url = getAttachmentUrl(att.id); const url = getAttachmentUrl(att.id);
...@@ -74,13 +69,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -74,13 +69,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<img <img
src={`${url}?token=${token}`} src={`${url}?token=${token}`}
alt={att.original_filename} alt={att.original_filename}
className="max-w-[200px] sm:max-w-[280px] max-h-[160px] sm:max-h-[220px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition shadow-lg" className="max-w-[180px] sm:max-w-[260px] max-h-[140px] sm:max-h-[200px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition shadow-md"
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"; }}
/> />
{expandedImage === att.id && ( {expandedImage === att.id && (
<div <div
className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4 sm:p-8 cursor-pointer" className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4 cursor-pointer"
onClick={() => setExpandedImage(null)} onClick={() => setExpandedImage(null)}
> >
<img <img
...@@ -90,7 +85,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -90,7 +85,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
/> />
</div> </div>
)} )}
<div className="absolute bottom-1 left-1 bg-black/60 text-[8px] sm:text-[9px] text-white px-1.5 py-0.5 rounded"> <div className="absolute bottom-1 left-1 bg-black/60 text-[8px] text-white px-1.5 py-0.5 rounded max-w-[90%] truncate">
{att.original_filename} {att.original_filename}
</div> </div>
</div> </div>
...@@ -103,22 +98,22 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -103,22 +98,22 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
href={`${url}?token=${token}`} href={`${url}?token=${token}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-1.5 sm:px-3 sm:py-2 hover:border-anton-accent transition group" className="flex items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-2.5 py-2 hover:border-anton-accent transition group max-w-[200px]"
> >
<Icon size={14} className="shrink-0 text-blue-400 sm:w-4 sm:h-4" /> <Icon size={15} className="shrink-0 text-blue-400" />
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="text-[11px] sm:text-xs text-white truncate max-w-[120px] sm:max-w-[160px]">{att.original_filename}</div> <div className="text-[11px] text-white truncate">{att.original_filename}</div>
<div className="text-[9px] sm:text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div> <div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
</div> </div>
<ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0 sm:w-3 sm:h-3" /> <ExternalLink size={11} className="text-anton-muted group-hover:text-anton-accent shrink-0" />
</a> </a>
); );
})} })}
</div> </div>
)} )}
{/* Message content */} {/* Message bubble */}
<div className={`rounded-2xl px-3 py-2.5 sm:px-4 sm:py-3 ${isUser <div className={`rounded-2xl px-3.5 py-2.5 sm:px-4 sm:py-3 ${isUser
? "bg-anton-accent text-white rounded-br-md" ? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md" : "bg-anton-card border border-anton-border rounded-bl-md"
}`}> }`}>
...@@ -153,13 +148,10 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -153,13 +148,10 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)} )}
</div> </div>
{/* Actions bar */} {/* Meta info */}
{!isUser && !isStreaming && content && ( {!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1 px-1 flex-wrap"> <div className="flex items-center gap-3 mt-1.5 px-1 flex-wrap">
<button <button onClick={handleCopy} className="flex items-center gap-1 text-[10px] sm:text-[11px] text-anton-muted hover:text-white transition">
onClick={handleCopy}
className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition p-1 -ml-1 rounded active:scale-95"
>
{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>
...@@ -175,7 +167,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -175,7 +167,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isUser && ( {isUser && (
<div className="shrink-0 mt-1"> <div className="shrink-0 mt-1">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"> <div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={14} className="text-anton-muted sm:w-4 sm:h-4" /> <User size={14} className="text-anton-muted" />
</div> </div>
</div> </div>
)} )}
......
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store"; import { useApp } from "../store";
import { createChat, deleteChat, renameChat } from "../api";
import { import {
Flame, Plus, MessageSquare, Trash2, Pencil, Check, X, listChats, deleteChat, renameChat,
Settings, LogOut, BookOpen, Shield, ChevronLeft, listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocuments,
} from "../api";
import {
Plus, Trash2, Edit3, Check, X, Flame, LogOut, Shield, MessageSquare,
BookOpen, Upload, FolderPlus, ChevronRight, FileText, Loader2,
} from "lucide-react"; } from "lucide-react";
export default function Sidebar() { export default function Sidebar({ activeChatId, onSelectChat, onNewChat }) {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const navigate = useNavigate(); const [editId, setEditId] = useState(null);
const [editingId, setEditingId] = useState(null);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
const [deletingId, setDeletingId] = useState(null); const [kbs, setKbs] = useState([]);
const [kbName, setKbName] = useState("");
const [expandedKb, setExpandedKb] = useState(null);
const [kbDetail, setKbDetail] = useState(null);
const [kbUploading, setKbUploading] = useState(false);
const [kbCreating, setKbCreating] = useState(false);
const tab = state.sidebarTab || "chats";
useEffect(() => {
if (tab === "knowledge") loadKbs();
}, [tab, state.token]);
async function handleNewChat() { async function loadKbs() {
try { try {
const chat = await createChat(state.token); const data = await listKnowledgeBases(state.token);
dispatch({ type: "ADD_CHAT", chat }); setKbs(data);
} catch { } } catch { /* ignore */ }
} }
async function handleDelete(chatId) { async function handleDeleteChat(e, id) {
e.stopPropagation();
if (!confirm("Delete this chat?")) return;
try { try {
await deleteChat(state.token, chatId); await deleteChat(state.token, id);
dispatch({ type: "REMOVE_CHAT", chatId }); dispatch({ type: "REMOVE_CHAT", chatId: id });
} catch { } } catch { /* ignore */ }
setDeletingId(null);
} }
async function handleRename(chatId) { function startRename(e, chat) {
if (!editTitle.trim()) { e.stopPropagation();
setEditingId(null); setEditId(chat.id);
return; setEditTitle(chat.title);
} }
async function saveRename(id) {
if (editTitle.trim()) {
try { try {
await renameChat(state.token, chatId, editTitle.trim()); await renameChat(state.token, id, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } }); dispatch({ type: "UPDATE_CHAT", chat: { id, title: editTitle.trim() } });
} catch { } } catch { /* ignore */ }
setEditingId(null); }
setEditId(null);
} }
function startEdit(chat) { async function handleCreateKb() {
setEditingId(chat.id); const name = kbName.trim();
setEditTitle(chat.title); if (!name) return;
setKbCreating(true);
try {
await createKnowledgeBase(state.token, name);
setKbName("");
await loadKbs();
} catch { /* ignore */ }
setKbCreating(false);
}
async function handleDeleteKb(e, kbId) {
e.stopPropagation();
if (!confirm("Delete this knowledge base and all its documents?")) return;
try {
await deleteKnowledgeBase(state.token, kbId);
await loadKbs();
if (expandedKb === kbId) setExpandedKb(null);
} catch { /* ignore */ }
} }
const isSuperadmin = state.user?.role === "superadmin"; async function handleUploadDocs(kbId, files) {
if (!files.length) return;
setKbUploading(true);
try {
await uploadDocuments(state.token, kbId, files);
await loadKbs();
} catch (err) {
alert("Upload failed: " + err.message);
}
setKbUploading(false);
}
return ( return (
<div className="h-full flex flex-col bg-anton-surface border-r border-anton-border w-full"> <div className="h-full flex flex-col bg-anton-surface border-r border-anton-border">
{/* Header */} {/* Logo */}
<div className="p-4 border-b border-anton-border"> <div className="p-4 pb-3">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20"> <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
<Flame size={18} className="text-white" /> <Flame size={18} className="text-white" />
</div> </div>
<div> <div>
<h1 className="text-sm font-bold text-white leading-tight">Son of Anton</h1> <h1 className="text-sm font-bold text-white leading-tight">Son of Anton</h1>
<span className="text-[10px] text-anton-muted">v2.1.0</span> <p className="text-[10px] text-anton-muted">Avatar of All Elements of Code</p>
</div> </div>
</div> </div>
{/* Close button — mobile only */} </div>
{/* Tab switcher */}
<div className="px-3 pb-2 flex gap-1">
<button <button
onClick={() => dispatch({ type: "CLOSE_SIDEBAR" })} onClick={() => dispatch({ type: "SET_SIDEBAR_TAB", tab: "chats" })}
className="lg:hidden p-2 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95" className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition ${tab === "chats" ? "bg-anton-accent/15 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"
}`}
> >
<ChevronLeft size={18} /> <MessageSquare size={13} /> Chats
</button> </button>
</div>
<button <button
onClick={handleNewChat} onClick={() => dispatch({ type: "SET_SIDEBAR_TAB", tab: "knowledge" })}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-anton-accent text-white rounded-xl hover:opacity-90 transition text-sm font-medium active:scale-[0.98]" className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition ${tab === "knowledge" ? "bg-green-500/15 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"
}`}
> >
<Plus size={16} /> <BookOpen size={13} /> Knowledge
New Chat
</button> </button>
</div> </div>
{/* Chat list */} {/* Content based on tab */}
<div className="flex-1 overflow-y-auto p-2 space-y-0.5"> <div className="flex-1 overflow-y-auto px-3 pb-2">
{state.chats.length === 0 && ( {tab === "chats" ? (
<div className="text-center py-10 text-anton-muted text-xs"> <>
No chats yet. Start a new one! <button
</div> onClick={onNewChat}
)} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-dashed border-anton-border text-anton-muted hover:text-white hover:border-anton-accent hover:bg-anton-accent/5 transition text-sm mb-2"
{state.chats.map((chat) => { >
const isActive = state.activeChatId === chat.id; <Plus size={16} /> New Chat
const isEditing = editingId === chat.id; </button>
const isDeleting = deletingId === chat.id;
return ( <div className="space-y-0.5">
{state.chats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition-all ${isActive onClick={() => onSelectChat(chat.id)}
? "bg-anton-accent/15 border border-anton-accent/30 text-white" className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition text-sm ${chat.id === activeChatId
: "hover:bg-anton-card text-anton-muted hover:text-white border border-transparent" ? "bg-anton-accent/15 text-white"
: "text-anton-muted hover:bg-anton-card hover:text-white"
}`} }`}
onClick={() => {
if (!isEditing && !isDeleting) {
dispatch({ type: "SET_ACTIVE_CHAT", chatId: chat.id });
}
}}
> >
<MessageSquare size={15} className={`shrink-0 ${isActive ? "text-anton-accent" : ""}`} /> <MessageSquare size={14} className="shrink-0 opacity-50" />
{editId === chat.id ? (
{isEditing ? ( <div className="flex-1 flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<div className="flex-1 flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<input <input
value={editTitle} value={editTitle}
onChange={(e) => setEditTitle(e.target.value)} onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => e.key === "Enter" && saveRename(chat.id)}
if (e.key === "Enter") handleRename(chat.id); className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-anton-accent"
if (e.key === "Escape") setEditingId(null);
}}
autoFocus autoFocus
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-anton-accent min-w-0"
/> />
<button onClick={() => handleRename(chat.id)} className="p-1 text-anton-success hover:bg-anton-success/10 rounded"> <button onClick={() => saveRename(chat.id)} className="text-anton-success"><Check size={12} /></button>
<Check size={12} /> <button onClick={() => setEditId(null)} className="text-anton-muted"><X size={12} /></button>
</button>
<button onClick={() => setEditingId(null)} className="p-1 text-anton-muted hover:bg-anton-card rounded">
<X size={12} />
</button>
</div>
) : isDeleting ? (
<div className="flex-1 flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-anton-danger truncate flex-1">Delete?</span>
<button onClick={() => handleDelete(chat.id)} className="p-1 text-anton-danger hover:bg-anton-danger/10 rounded">
<Check size={12} />
</button>
<button onClick={() => setDeletingId(null)} className="p-1 text-anton-muted hover:bg-anton-card rounded">
<X size={12} />
</button>
</div> </div>
) : ( ) : (
<> <>
<span className="flex-1 text-xs truncate">{chat.title}</span> <span className="flex-1 truncate text-xs">{chat.title}</span>
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}> <div className="hidden group-hover:flex items-center gap-0.5 shrink-0">
<button onClick={() => startEdit(chat)} className="p-1 text-anton-muted hover:text-white hover:bg-anton-card rounded transition"> <button onClick={(e) => startRename(e, chat)} className="p-1 rounded hover:bg-anton-bg transition"><Edit3 size={11} /></button>
<Pencil size={11} /> <button onClick={(e) => handleDeleteChat(e, chat.id)} className="p-1 rounded hover:bg-anton-bg text-anton-danger transition"><Trash2 size={11} /></button>
</button>
<button onClick={() => setDeletingId(chat.id)} className="p-1 text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 rounded transition">
<Trash2 size={11} />
</button>
</div> </div>
</> </>
)} )}
</div> </div>
); ))}
})}
</div> </div>
</>
{/* Footer */} ) : (
<div className="p-3 border-t border-anton-border space-y-1"> /* Knowledge Base tab */
<>
{/* Create new KB */}
<div className="flex gap-1.5 mb-3">
<input
value={kbName}
onChange={(e) => setKbName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateKb()}
placeholder="New knowledge base name…"
className="flex-1 bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:border-green-500 placeholder:text-anton-muted/50"
/>
<button <button
onClick={() => { navigate("/"); dispatch({ type: "CLOSE_SIDEBAR" }); }} onClick={handleCreateKb}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs text-anton-muted hover:text-white hover:bg-anton-card transition" disabled={!kbName.trim() || kbCreating}
className="px-3 py-2 rounded-lg bg-green-600 text-white text-xs hover:bg-green-500 transition disabled:opacity-40"
> >
<BookOpen size={14} /> {kbCreating ? <Loader2 size={14} className="animate-spin" /> : <FolderPlus size={14} />}
Knowledge Bases
</button> </button>
{isSuperadmin && ( </div>
{kbs.length === 0 ? (
<div className="text-center py-8 text-anton-muted text-xs">
<BookOpen size={24} className="mx-auto mb-2 opacity-30" />
<p>No knowledge bases yet.</p>
<p className="mt-1 text-[10px]">Create one above, then upload documents.</p>
</div>
) : (
<div className="space-y-1.5">
{kbs.map((kb) => (
<div key={kb.id} className="bg-anton-card border border-anton-border rounded-xl overflow-hidden">
<div
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-anton-bg/50 transition"
onClick={() => setExpandedKb(expandedKb === kb.id ? null : kb.id)}
>
<ChevronRight size={12} className={`text-anton-muted transition-transform ${expandedKb === kb.id ? "rotate-90" : ""}`} />
<BookOpen size={13} className="text-green-400 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-white truncate">{kb.name}</div>
<div className="text-[10px] text-anton-muted">{kb.document_count} docs · {kb.chunk_count} chunks</div>
</div>
<button <button
onClick={() => { navigate("/admin"); dispatch({ type: "CLOSE_SIDEBAR" }); }} onClick={(e) => handleDeleteKb(e, kb.id)}
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs text-anton-muted hover:text-white hover:bg-anton-card transition" className="p-1 rounded hover:bg-anton-bg text-anton-muted hover:text-anton-danger transition opacity-0 group-hover:opacity-100"
> >
<Shield size={14} /> <Trash2 size={11} />
Admin Panel
</button> </button>
</div>
{expandedKb === kb.id && (
<div className="px-3 pb-3 pt-1 border-t border-anton-border/50 space-y-2 animate-fade-in">
<div className="text-[10px] text-anton-muted space-y-0.5">
<p>~{kb.estimated_tokens?.toLocaleString() || 0} tokens</p>
{kb.description && <p>{kb.description}</p>}
</div>
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-green-500/30 text-green-400 text-xs cursor-pointer hover:bg-green-500/5 transition">
{kbUploading ? <Loader2 size={13} className="animate-spin" /> : <Upload size={13} />}
{kbUploading ? "Uploading…" : "Upload documents"}
<input
type="file" multiple className="hidden"
accept=".txt,.md,.pdf,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql"
onChange={(e) => {
const files = Array.from(e.target.files || []);
if (files.length) handleUploadDocs(kb.id, files);
e.target.value = "";
}}
disabled={kbUploading}
/>
</label>
</div>
)}
</div>
))}
</div>
)}
</>
)} )}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-md bg-anton-card flex items-center justify-center shrink-0">
<span className="text-[10px] font-bold text-anton-accent uppercase">
{(state.user?.username || "?")[0]}
</span>
</div> </div>
<span className="text-xs text-anton-muted truncate">{state.user?.username}</span>
{/* Footer */}
<div className="p-3 border-t border-anton-border">
<div className="flex items-center gap-2 text-xs text-anton-muted">
<div className="flex-1 min-w-0">
<div className="text-white font-medium truncate">{state.user?.username}</div>
<div className="text-[10px] truncate">
{((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>
{state.user?.role === "superadmin" && (
<a href="/admin" className="p-1.5 rounded-lg hover:bg-anton-card transition" title="Admin">
<Shield size={14} />
</a>
)}
<button <button
onClick={() => dispatch({ type: "LOGOUT" })} onClick={() => dispatch({ type: "LOGOUT" })}
className="p-1.5 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition" className="p-1.5 rounded-lg hover:bg-anton-card transition"
title="Logout" title="Logout"
> >
<LogOut size={14} /> <LogOut size={14} />
......
...@@ -2,79 +2,30 @@ ...@@ -2,79 +2,30 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ═══════════════════════════════════════════ */ /* ─── Base ─────────────────────────────────────── */
/* Use dvh for full-height on mobile */ * {
/* ═══════════════════════════════════════════ */
:root {
--color-anton-bg: #09090f;
--color-anton-surface: #0f0f18;
--color-anton-card: #161622;
--color-anton-border: #1e1e30;
--color-anton-text: #e2e2f0;
--color-anton-muted: #6b6b8a;
--color-anton-accent: #e63946;
--color-anton-success: #2ecc71;
--color-anton-danger: #e74c3c;
/* safe area insets for notched phones */
--sat: env(safe-area-inset-top, 0px);
--sab: env(safe-area-inset-bottom, 0px);
--sal: env(safe-area-inset-left, 0px);
--sar: env(safe-area-inset-right, 0px);
}
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
html { html {
-webkit-text-size-adjust: 100%; overflow: hidden;
text-size-adjust: 100%; height: 100%;
} }
body { body {
margin: 0;
padding: 0;
background: var(--color-anton-bg);
color: var(--color-anton-text);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
overflow: hidden; overflow: hidden;
height: 100%;
overscroll-behavior: none; overscroll-behavior: none;
/* Prevent pull-to-refresh on mobile */
-webkit-overflow-scrolling: touch;
}
/* Fix iOS textarea zoom — font MUST be >= 16px */
textarea,
input,
select {
font-size: 16px !important;
}
@media (min-width: 640px) {
textarea,
input,
select {
font-size: 14px !important;
}
} }
#root { #root {
height: 100dvh; height: 100%;
width: 100vw;
overflow: hidden;
} }
/* ═══════════════════════════════════════════ */ /* ─── Scrollbar ────────────────────────────────── */
/* Scrollbar styling */
/* ═══════════════════════════════════════════ */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 5px; width: 6px;
height: 5px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
...@@ -82,55 +33,24 @@ select { ...@@ -82,55 +33,24 @@ select {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--color-anton-border); background: #2a2a3a;
border-radius: 10px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--color-anton-muted); background: #3a3a4a;
}
/* Hide scrollbar on mobile for cleaner look */
@media (max-width: 640px) {
::-webkit-scrollbar {
width: 2px;
}
}
/* ═══════════════════════════════════════════ */
/* Animations */
/* ═══════════════════════════════════════════ */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.25s ease-out;
} }
.thinking-pulse { /* ─── Prose for AI messages ────────────────────── */
animation: pulse 1.5s ease-in-out infinite;
}
/* ═══════════════════════════════════════════ */
/* Prose (Markdown) styling */
/* ═══════════════════════════════════════════ */
.prose-anton { .prose-anton {
line-height: 1.7; color: #e0e0e0;
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.prose-anton p { .prose-anton p {
margin: 0.5em 0; margin: 0.5em 0;
line-height: 1.7;
} }
.prose-anton p:first-child { .prose-anton p:first-child {
...@@ -141,122 +61,184 @@ select { ...@@ -141,122 +61,184 @@ select {
margin-bottom: 0; margin-bottom: 0;
} }
.prose-anton strong {
color: #fff;
font-weight: 600;
}
.prose-anton em {
color: #c0c0d0;
}
.prose-anton a {
color: #ff4444;
text-decoration: underline;
text-underline-offset: 2px;
}
.prose-anton a:hover {
color: #ff6666;
}
.prose-anton ul, .prose-anton ul,
.prose-anton ol { .prose-anton ol {
padding-left: 1.25em;
margin: 0.5em 0; margin: 0.5em 0;
padding-left: 1.5em;
} }
.prose-anton li { .prose-anton li {
margin: 0.15em 0; margin: 0.25em 0;
line-height: 1.6;
}
.prose-anton li::marker {
color: #ff4444;
} }
.prose-anton code:not(pre code) { .prose-anton code:not(pre code) {
background: var(--color-anton-border); background: #1a1a2e;
color: #ff6b6b;
padding: 0.15em 0.4em;
border-radius: 4px; border-radius: 4px;
padding: 0.15em 0.35em; font-size: 0.88em;
font-family: "JetBrains Mono", monospace; font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
color: #f0a0a0;
word-break: break-all;
} }
.prose-anton blockquote { .prose-anton blockquote {
border-left: 3px solid var(--color-anton-accent); border-left: 3px solid #ff4444;
padding-left: 0.75em; padding-left: 1em;
margin: 0.5em 0; margin: 0.75em 0;
color: var(--color-anton-muted); color: #a0a0b0;
font-style: italic;
} }
.prose-anton a { .prose-anton h1,
color: var(--color-anton-accent); .prose-anton h2,
text-decoration: underline; .prose-anton h3,
text-underline-offset: 2px; .prose-anton h4 {
color: #fff;
font-weight: 700;
margin: 1em 0 0.5em;
}
.prose-anton h1 {
font-size: 1.4em;
}
.prose-anton h2 {
font-size: 1.2em;
}
.prose-anton h3 {
font-size: 1.1em;
}
.prose-anton hr {
border-color: #2a2a3a;
margin: 1.5em 0;
} }
.prose-anton table { .prose-anton table {
border-collapse: collapse; border-collapse: collapse;
margin: 0.75em 0; margin: 0.75em 0;
font-size: 0.85em;
width: 100%; width: 100%;
display: block; font-size: 0.9em;
overflow-x: auto;
} }
.prose-anton th, .prose-anton th,
.prose-anton td { .prose-anton td {
border: 1px solid var(--color-anton-border); border: 1px solid #2a2a3a;
padding: 0.4em 0.6em; padding: 0.4em 0.75em;
text-align: left; text-align: left;
white-space: nowrap;
} }
.prose-anton th { .prose-anton th {
background: var(--color-anton-card); background: #12121c;
color: #fff;
font-weight: 600; font-weight: 600;
} }
.prose-anton h1, .prose-anton img {
.prose-anton h2, max-width: 100%;
.prose-anton h3, border-radius: 8px;
.prose-anton h4 {
font-weight: 600;
color: white;
margin: 0.75em 0 0.35em;
} }
.prose-anton h1 { /* ─── Animations ───────────────────────────────── */
font-size: 1.35em; @keyframes fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
} }
.prose-anton h2 { .animate-fade-in {
font-size: 1.2em; animation: fade-in 0.2s ease-out;
} }
.prose-anton h3 { @keyframes thinking-pulse {
font-size: 1.05em;
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
} }
.prose-anton hr { .thinking-pulse {
border: none; animation: thinking-pulse 1.5s ease-in-out infinite;
border-top: 1px solid var(--color-anton-border);
margin: 1em 0;
} }
/* ═══════════════════════════════════════════ */ /* ─── Range slider ─────────────────────────────── */
/* Range input styling */
/* ═══════════════════════════════════════════ */
input[type="range"] { input[type="range"] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none;
width: 100%; width: 100%;
height: 4px; height: 6px;
border-radius: 999px; background: #1a1a2e;
background: var(--color-anton-border); border-radius: 3px;
outline: none; outline: none;
} }
input[type="range"]::-webkit-slider-thumb { input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
width: 18px; appearance: none;
height: 18px; width: 16px;
height: 16px;
background: #ff4444;
border-radius: 50%; border-radius: 50%;
background: var(--color-anton-accent);
cursor: pointer; cursor: pointer;
border: 2px solid var(--color-anton-bg); border: 2px solid #0d0d14;
} }
input[type="range"]::-moz-range-thumb { input[type="range"]::-moz-range-thumb {
width: 18px; width: 16px;
height: 18px; height: 16px;
background: #ff4444;
border-radius: 50%; border-radius: 50%;
background: var(--color-anton-accent);
cursor: pointer; cursor: pointer;
border: 2px solid var(--color-anton-bg); border: 2px solid #0d0d14;
}
/* ─── Safe area for mobile ─────────────────────── */
.safe-bottom {
padding-bottom: max(env(safe-area-inset-bottom, 0px), 0.75rem);
}
/* ─── Selection ────────────────────────────────── */
::selection {
background: rgba(255, 68, 68, 0.3);
color: #fff;
} }
/* ═══════════════════════════════════════════ */ /* ─── Mobile keyboard fix ──────────────────────── */
/* Mobile-specific utilities */
/* ═══════════════════════════════════════════ */
@supports (height: 100dvh) { @supports (height: 100dvh) {
.h-dvh { .h-dvh {
height: 100dvh; height: 100dvh;
...@@ -268,21 +250,3 @@ input[type="range"]::-moz-range-thumb { ...@@ -268,21 +250,3 @@ input[type="range"]::-moz-range-thumb {
height: 100vh; height: 100vh;
} }
} }
\ No newline at end of file
/* Ensure touch targets are at least 44px */
@media (max-width: 640px) {
button,
a,
[role="button"] {
min-height: 44px;
min-width: 44px;
}
/* Exception for inline/tiny buttons */
.prose-anton button,
.text-\[11px\] button {
min-height: auto;
min-width: auto;
}
}
\ No newline at end of file
import React, { useEffect } from "react"; import React, { useEffect, useState } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { listChats } from "../api"; import { listChats, createChat, checkGenerating } from "../api";
import * as streamManager from "../streamManager";
import Sidebar from "../components/Sidebar"; import Sidebar from "../components/Sidebar";
import ChatView from "../components/ChatView"; import ChatView from "../components/ChatView";
import { Flame, MessageSquarePlus, Menu } from "lucide-react"; import { Flame, Menu, Plus, MessageSquare } from "lucide-react";
export default function ChatPage() { export default function ChatPage() {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const [activeChatId, setActiveChatId] = useState(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
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 { } if (chats.length && !activeChatId) {
const firstId = chats[0].id;
setActiveChatId(firstId);
// Check if background generation is active
try {
const { active } = await checkGenerating(state.token, firstId);
if (active) streamManager.reconnectStream({ token: state.token, chatId: firstId });
} catch { /* ignore */ }
}
} catch { /* ignore */ }
})(); })();
}, [state.token, dispatch]); }, [state.token, dispatch]);
async function handleNewChat() {
try {
const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat });
setActiveChatId(chat.id);
dispatch({ type: "SET_SIDEBAR_OPEN", open: false });
} catch { /* ignore */ }
}
async function handleSelectChat(id) {
setActiveChatId(id);
dispatch({ type: "SET_SIDEBAR_OPEN", open: false });
// Check for active background generation on this chat
try {
const { active } = await checkGenerating(state.token, id);
if (active && !streamManager.isStreaming(id)) {
streamManager.reconnectStream({ token: state.token, chatId: id });
}
} catch { /* ignore */ }
}
return ( return (
<div className="h-dvh flex overflow-hidden relative"> <div className="h-dvh flex overflow-hidden bg-anton-bg">
{/* Mobile overlay backdrop */} {/* Mobile overlay */}
{state.sidebarOpen && ( {state.sidebarOpen && (
<div <div
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden" className="fixed inset-0 bg-black/60 z-30 lg:hidden"
onClick={() => dispatch({ type: "CLOSE_SIDEBAR" })} onClick={() => dispatch({ type: "SET_SIDEBAR_OPEN", open: false })}
/> />
)} )}
{/* Sidebar — slides in on mobile, always visible on desktop */} {/* Sidebar */}
<div <div className={`
className={` fixed inset-y-0 left-0 z-40 w-72 transform transition-transform duration-300 ease-in-out
fixed inset-y-0 left-0 z-40 w-72 lg:relative lg:translate-x-0 lg:w-72 lg:shrink-0
transform transition-transform duration-300 ease-in-out
lg:relative lg:translate-x-0 lg:z-auto
${state.sidebarOpen ? "translate-x-0" : "-translate-x-full"} ${state.sidebarOpen ? "translate-x-0" : "-translate-x-full"}
`} `}>
> <Sidebar
<Sidebar /> activeChatId={activeChatId}
onSelectChat={handleSelectChat}
onNewChat={handleNewChat}
/>
</div> </div>
{/* Main content area */} {/* Main content */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
{/* Mobile top bar */} {/* Mobile header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-anton-border bg-anton-surface lg:hidden"> <div className="flex items-center gap-3 px-4 py-3 border-b border-anton-border bg-anton-surface lg:hidden">
<button <button
onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })} onClick={() => dispatch({ type: "SET_SIDEBAR_OPEN", open: true })}
className="p-2 -ml-2 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95" className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
> >
<Menu size={22} /> <Menu size={20} />
</button> </button>
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Flame size={18} className="text-anton-accent shrink-0" /> <h1 className="text-sm font-semibold text-white truncate">
<span className="text-sm font-semibold text-white truncate"> {state.chats.find((c) => c.id === activeChatId)?.title || "Son of Anton"}
{state.activeChatId </h1>
? state.chats.find((c) => c.id === state.activeChatId)?.title || "Chat"
: "Son of Anton"}
</span>
</div> </div>
<button
onClick={handleNewChat}
className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
>
<Plus size={20} />
</button>
</div> </div>
{/* Chat view or empty state */} {/* Chat or empty state */}
{state.activeChatId ? ( {activeChatId ? (
<ChatView chatId={state.activeChatId} /> <ChatView chatId={activeChatId} />
) : ( ) : (
<div className="flex-1 flex items-center justify-center p-6"> <div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md"> <div className="text-center max-w-md">
<div className="w-20 h-20 mx-auto rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20 mb-6"> <div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center mx-auto mb-6 shadow-lg shadow-anton-accent/20">
<Flame size={40} className="text-white" /> <Flame size={40} className="text-white" />
</div> </div>
<h1 className="text-2xl font-bold text-white mb-2">Son of Anton</h1> <h2 className="text-2xl font-bold text-white mb-2">Son of Anton</h2>
<p className="text-anton-muted text-sm mb-6 leading-relaxed"> <p className="text-anton-muted mb-6">Avatar of All Elements of Code</p>
Avatar of All Elements of Code. Select an existing chat from the sidebar
or create a new one to begin.
</p>
<button <button
onClick={() => dispatch({ type: "OPEN_SIDEBAR" })} onClick={handleNewChat}
className="lg:hidden inline-flex items-center gap-2 px-5 py-2.5 bg-anton-accent text-white rounded-xl hover:opacity-90 transition active:scale-95 text-sm font-medium" className="inline-flex items-center gap-2 px-6 py-3 bg-anton-accent text-white rounded-xl hover:opacity-90 transition font-medium"
> >
<MessageSquarePlus size={16} /> <MessageSquare size={18} /> Start a conversation
Open Chats
</button> </button>
</div> </div>
</div> </div>
......
...@@ -6,97 +6,74 @@ const initialState = { ...@@ -6,97 +6,74 @@ const initialState = {
token: localStorage.getItem("token") || null, token: localStorage.getItem("token") || null,
user: null, user: null,
chats: [], chats: [],
activeChatId: null,
chatMessages: {}, chatMessages: {},
activeStreams: {}, activeStreams: {},
sidebarOpen: false, // mobile sidebar toggle sidebarOpen: false,
sidebarTab: "chats", // "chats" | "knowledge"
}; };
function reducer(state, action) { function reducer(state, action) {
switch (action.type) { switch (action.type) {
case "LOGIN":
localStorage.setItem("token", action.token);
return { ...state, token: action.token, user: action.user };
case "SET_TOKEN": case "SET_TOKEN":
localStorage.setItem("token", action.token); localStorage.setItem("token", action.token);
return { ...state, token: action.token }; return { ...state, token: action.token };
case "SET_USER": case "SET_USER":
return { ...state, user: action.user }; return { ...state, user: action.user };
case "LOGOUT": case "LOGOUT":
localStorage.removeItem("token"); localStorage.removeItem("token");
return { ...initialState, token: null }; return { ...initialState, token: null };
case "SET_CHATS": case "SET_CHATS":
return { ...state, chats: action.chats }; return { ...state, chats: action.chats };
case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId, sidebarOpen: false };
case "ADD_CHAT": case "ADD_CHAT":
return { ...state, chats: [action.chat, ...state.chats] };
case "UPDATE_CHAT":
return { return {
...state, ...state,
chats: [action.chat, ...state.chats], chats: state.chats.map((c) =>
activeChatId: action.chat.id,
sidebarOpen: false,
};
case "UPDATE_CHAT": {
const updated = state.chats.map((c) =>
c.id === action.chat.id ? { ...c, ...action.chat } : c c.id === action.chat.id ? { ...c, ...action.chat } : c
); ),
return { ...state, chats: updated }; };
} case "REMOVE_CHAT":
case "REMOVE_CHAT": {
const filtered = state.chats.filter((c) => c.id !== action.chatId);
const newMessages = { ...state.chatMessages };
delete newMessages[action.chatId];
return { return {
...state, ...state,
chats: filtered, chats: state.chats.filter((c) => c.id !== action.chatId),
chatMessages: newMessages, chatMessages: (() => {
activeChatId: state.activeChatId === action.chatId ? null : state.activeChatId, const m = { ...state.chatMessages };
delete m[action.chatId];
return m;
})(),
}; };
}
case "SET_MESSAGES": case "SET_MESSAGES":
return { return {
...state, ...state,
chatMessages: { ...state.chatMessages, [action.chatId]: action.messages }, chatMessages: { ...state.chatMessages, [action.chatId]: action.messages },
}; };
case "ADD_MESSAGE":
case "ADD_MESSAGE": {
const prev = state.chatMessages[action.chatId] || [];
return { return {
...state, ...state,
chatMessages: { chatMessages: {
...state.chatMessages, ...state.chatMessages,
[action.chatId]: [...prev, action.message], [action.chatId]: [
...(state.chatMessages[action.chatId] || []),
action.message,
],
}, },
}; };
}
case "SET_STREAMING": case "SET_STREAMING":
if (action.streaming) { return {
return { ...state, activeStreams: { ...state.activeStreams, [action.chatId]: true } }; ...state,
} else { activeStreams: action.streaming
? { ...state.activeStreams, [action.chatId]: true }
: (() => {
const s = { ...state.activeStreams }; const s = { ...state.activeStreams };
delete s[action.chatId]; delete s[action.chatId];
return { ...state, activeStreams: s }; return s;
} })(),
};
case "TOGGLE_SIDEBAR": case "SET_SIDEBAR_OPEN":
return { ...state, sidebarOpen: !state.sidebarOpen }; return { ...state, sidebarOpen: action.open };
case "SET_SIDEBAR_TAB":
case "CLOSE_SIDEBAR": return { ...state, sidebarTab: action.tab };
return { ...state, sidebarOpen: false };
case "OPEN_SIDEBAR":
return { ...state, sidebarOpen: true };
default: default:
return state; return state;
} }
...@@ -112,5 +89,7 @@ export function AppProvider({ children }) { ...@@ -112,5 +89,7 @@ export function AppProvider({ children }) {
} }
export function useApp() { export function useApp() {
return useContext(AppContext); const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be used within AppProvider");
return ctx;
} }
\ No newline at end of file
...@@ -4,7 +4,9 @@ const _streams = new Map(); ...@@ -4,7 +4,9 @@ const _streams = new Map();
const _listeners = new Map(); const _listeners = new Map();
let _dispatch = null; let _dispatch = null;
export function setDispatch(dispatch) { _dispatch = dispatch; } export function setDispatch(dispatch) {
_dispatch = dispatch;
}
export function getStreamData(chatId) { export function getStreamData(chatId) {
const s = _streams.get(chatId); const s = _streams.get(chatId);
...@@ -12,19 +14,32 @@ export function getStreamData(chatId) { ...@@ -12,19 +14,32 @@ export function getStreamData(chatId) {
return { streaming: true, text: s.text, thinking: s.thinking, isThinking: s.isThinking }; return { streaming: true, text: s.text, thinking: s.thinking, isThinking: s.isThinking };
} }
export function isStreaming(chatId) { return _streams.has(chatId); } export function isStreaming(chatId) {
return _streams.has(chatId);
}
export function subscribe(chatId, cb) { export function subscribe(chatId, cb) {
if (!_listeners.has(chatId)) _listeners.set(chatId, new Set()); if (!_listeners.has(chatId)) _listeners.set(chatId, new Set());
_listeners.get(chatId).add(cb); _listeners.get(chatId).add(cb);
return () => { const s = _listeners.get(chatId); if (s) { s.delete(cb); if (!s.size) _listeners.delete(chatId); } }; return () => {
const s = _listeners.get(chatId);
if (s) { s.delete(cb); if (!s.size) _listeners.delete(chatId); }
};
} }
function _notify(id) { const s = _listeners.get(id); if (s) s.forEach((cb) => cb()); } function _notify(id) {
const s = _listeners.get(id);
if (s) s.forEach((cb) => cb());
}
export function abortStream(chatId) { export function abortStream(chatId) {
const s = _streams.get(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 }); } if (s) {
s.abortController.abort();
_streams.delete(chatId);
_notify(chatId);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
}
} }
export function startStream({ token, chatId, body }) { export function startStream({ token, chatId, body }) {
...@@ -37,38 +52,108 @@ export function startStream({ token, chatId, body }) { ...@@ -37,38 +52,108 @@ export function startStream({ token, chatId, body }) {
(async () => { (async () => {
const s = _streams.get(chatId); const s = _streams.get(chatId);
if (!s) return; if (!s) return;
let usage = {}, msgId = ""; let usage = {};
let msgId = "";
try { try {
for await (const evt of streamMessage(token, chatId, body, ac.signal)) { for await (const evt of streamMessage(token, chatId, body, ac.signal)) {
if (ac.signal.aborted || !_streams.has(chatId)) break; if (ac.signal.aborted || !_streams.has(chatId)) break;
switch (evt.type) { _handleEvent(chatId, s, evt, (u) => { usage = u; }, (id) => { msgId = id; });
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;
}
} }
if (!ac.signal.aborted && _dispatch) { if (!ac.signal.aborted && _dispatch) {
_dispatch({ type: "ADD_MESSAGE", chatId, message: { _dispatch({
type: "ADD_MESSAGE", chatId, message: {
id: msgId || `gen-${Date.now()}`, role: "assistant", content: s.text, id: msgId || `gen-${Date.now()}`, role: "assistant", content: s.text,
thinking_content: s.thinking || null, input_tokens: usage.input_tokens || 0, thinking_content: s.thinking || null, input_tokens: usage.input_tokens || 0,
output_tokens: usage.output_tokens || 0, created_at: new Date().toISOString(), attachments: [], output_tokens: usage.output_tokens || 0, created_at: new Date().toISOString(), attachments: [],
}}); }
});
} }
} catch (err) { } catch (err) {
if (!ac.signal.aborted && _dispatch) { if (!ac.signal.aborted && _dispatch) {
_dispatch({ type: "ADD_MESSAGE", chatId, message: { _dispatch({
type: "ADD_MESSAGE", chatId, message: {
id: `err-${Date.now()}`, role: "assistant", content: `**Error:** ${err.message}`, id: `err-${Date.now()}`, role: "assistant", content: `**Error:** ${err.message}`,
created_at: new Date().toISOString(), attachments: [], created_at: new Date().toISOString(), attachments: [],
}}); }
});
} }
} finally { } finally {
_streams.delete(chatId); _notify(chatId); _streams.delete(chatId);
_notify(chatId);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
}
})();
}
/**
* Reconnect to an ongoing background generation via GET /stream endpoint.
*/
export function reconnectStream({ token, chatId }) {
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);
(async () => {
const s = _streams.get(chatId);
if (!s) return;
let usage = {};
let msgId = "";
try {
const res = await fetch(`/api/chats/${chatId}/stream`, {
headers: { Authorization: `Bearer ${token}` },
signal: ac.signal,
});
if (!res.ok) throw new Error("Reconnect failed");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
const line = part.trim();
if (line.startsWith("data: ")) {
try {
const evt = JSON.parse(line.slice(6));
if (ac.signal.aborted || !_streams.has(chatId)) break;
_handleEvent(chatId, s, evt, (u) => { usage = u; }, (id) => { msgId = id; });
} catch { /* skip */ }
}
}
}
if (!ac.signal.aborted && s.text && _dispatch) {
_dispatch({
type: "ADD_MESSAGE", chatId, message: {
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(), attachments: [],
}
});
}
} catch { /* reconnect failed, generation may be done */ }
finally {
_streams.delete(chatId);
_notify(chatId);
if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false }); if (_dispatch) _dispatch({ type: "SET_STREAMING", chatId, streaming: false });
} }
})(); })();
} }
function _handleEvent(chatId, s, evt, setUsage, setMsgId) {
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": setUsage({ 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": setMsgId(evt.message_id); break;
case "error": s.text += `\n\n**Error:** ${evt.message}`; _notify(chatId); break;
}
}
\ No newline at end of file
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"], content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class",
theme: { theme: {
extend: { extend: {
colors: { colors: {
"anton-bg": "#09090f", "anton-bg": "#09090f",
"anton-surface": "#0f0f18", "anton-surface": "#0d0d14",
"anton-card": "#161622", "anton-card": "#12121c",
"anton-border": "#1e1e30", "anton-border": "#1e1e2e",
"anton-text": "#e2e2f0", "anton-text": "#e0e0e0",
"anton-muted": "#6b6b8a", "anton-muted": "#6b6b80",
"anton-accent": "#e63946", "anton-accent": "#ff4444",
"anton-success": "#2ecc71", "anton-success": "#22c55e",
"anton-danger": "#e74c3c", "anton-danger": "#ef4444",
}, },
fontFamily: { fontFamily: {
sans: ['"Inter"', "system-ui", "sans-serif"], sans: ["Inter", "system-ui", "sans-serif"],
mono: ['"JetBrains Mono"', "monospace"], mono: ["JetBrains Mono", "Consolas", "monospace"],
},
height: {
dvh: "100dvh",
},
minHeight: {
dvh: "100dvh",
},
screens: {
xs: "480px",
}, },
}, },
}, },
......
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