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.
Generation runs in background and survives client disconnection.
"""
import json
......@@ -11,11 +12,11 @@ from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
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.auth import get_current_user
from backend.system_prompt import build_full_prompt
from backend.services import bedrock_service, memory_service, rag_service, attachment_service
from backend.services import attachment_service
from backend.services.generation_manager import manager as gen_manager
router = APIRouter()
......@@ -116,162 +117,50 @@ def get_messages(chat_id: str, user: User = Depends(get_current_user), db: Sessi
return msgs
@router.post("/{chat_id}/messages")
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
user_id = user.id
@router.get("/{chat_id}/generating")
def check_generating(chat_id: str, user: User = Depends(get_current_user)):
"""Check if a background generation is active for this chat."""
return {"active": gen_manager.is_active(chat_id)}
@router.get("/{chat_id}/stream")
async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user)):
"""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")
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:
yield _sse({"type": "error", "message": "Monthly token quota exceeded."})
return
attachments = []
if body.attachment_ids:
attachments = (
db.query(ChatAttachment)
.filter(ChatAttachment.id.in_(body.attachment_ids), ChatAttachment.chat_id == chat_id)
.all()
)
stored_content = body.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" + 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:
att.message_id = user_msg.id
if attachments:
db.commit()
kb_id = body.knowledge_base_id or chat.knowledge_base_id
rag_context = None
if kb_id:
try:
rag_context = rag_service.query(kb_id, body.content, n_results=8)
except Exception:
pass
system_prompt = build_full_prompt(rag_context)
messages = memory_service.build_messages(chat, db)
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:
yield _sse({"type": "error", "message": str(exc)})
finally:
db.close()
async for event in gen_manager.stream_events(chat_id):
yield _sse(event)
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,
@router.post("/{chat_id}/messages")
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
"""Send a message. Generation runs in background and survives disconnection."""
user_id = user.id
# Start background generation
gen_manager.start(
chat_id=chat_id,
user_id=user_id,
content=body.content,
model=body.model or "eu.anthropic.claude-opus-4-6-v1",
max_tokens=body.max_tokens,
reasoning_budget=body.reasoning_budget,
knowledge_base_id=body.knowledge_base_id,
attachment_ids=body.attachment_ids,
)
return result.strip().strip('"').strip("'")
# Stream events from background task
async def generate():
async for event in gen_manager.stream_events(chat_id):
yield _sse(event)
return StreamingResponse(generate(), media_type="text/event-stream")
def _sse(data):
......@@ -302,4 +191,4 @@ def _att_brief(a):
"id": a.id, "original_filename": a.original_filename,
"mime_type": a.mime_type, "file_type": a.file_type,
"file_size": a.file_size,
}
}
\ No newline at end of file
"""
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) =>
export const getMessages = (token, chatId) =>
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) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", headers: headers(token),
......@@ -66,12 +69,12 @@ export async function* streamMessage(token, chatId, body, signal) {
for (const part of parts) {
const line = part.trim();
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: ")) {
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) =>
uploadDocuments(token, kbId, [file]);
export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) =>
request("POST", "/admin/users", token, data);
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 adminCreateUser = (token, data) => request("POST", "/admin/users", token, data);
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 async function downloadZip(token, markdown) {
......@@ -156,4 +151,4 @@ export async function downloadZip(token, markdown) {
const data = await res.json();
if (data.error) throw new Error(data.error);
}
}
}
\ No newline at end of file
......@@ -9,35 +9,34 @@ import {
} from "lucide-react";
const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6 (Primary)" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Claude Haiku 4.5 (Fast)" },
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Claude Opus 4.6" },
{ 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 FILE_TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
const FILE_TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: FileCode };
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 TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
function classifyFile(file) {
const ext = (file.name || "").split(".").pop().toLowerCase();
const mime = file.type || "";
if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff", "svg"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm", "flv", "wmv", "m4v"].includes(ext)) return "video";
function classifyFile(f) {
const ext = (f.name || "").split(".").pop().toLowerCase();
const mime = f.type || "";
if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(ext)) return "image";
if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video";
if (mime === "application/pdf" || ext === "pdf") return "document";
return "text";
}
function formatFileSize(bytes) {
if (!bytes) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
function fmtSize(b) {
if (!b) return "0B";
if (b < 1024) return b + "B";
if (b < 1048576) return (b / 1024).toFixed(0) + "KB";
return (b / 1048576).toFixed(1) + "MB";
}
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
const currentChat = state.chats.find((c) => c.id === chatId);
const messages = state.chatMessages[chatId] || [];
const isChatStreaming = !!state.activeStreams[chatId];
const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false);
......@@ -62,12 +61,6 @@ export default function ChatView({ chatId }) {
return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId)));
}, [chatId]);
function onScroll() {
const el = scrollRef.current;
if (!el) return;
autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
}
const scrollBottom = useCallback(() => {
if (!autoScroll.current || rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
......@@ -92,17 +85,26 @@ export default function ChatView({ chatId }) {
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
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() {
try {
await updateChat(state.token, chatId, {
model, max_tokens: maxTokens,
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 },
});
await updateChat(state.token, chatId, { model, max_tokens: maxTokens, 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 */ }
}
......@@ -112,368 +114,170 @@ export default function ChatView({ chatId }) {
}
function addFiles(files) {
const newEntries = files.map((f) => {
const type = classifyFile(f);
return {
file: f,
type,
preview: type === "image" ? URL.createObjectURL(f) : null,
};
});
setPendingFiles((prev) => [...prev, ...newEntries]);
}
function handleFileSelect(e) {
const files = Array.from(e.target.files || []);
if (files.length) addFiles(files);
e.target.value = "";
setPendingFiles((prev) => [...prev, ...files.map((f) => ({ file: f, type: classifyFile(f), preview: classifyFile(f) === "image" ? URL.createObjectURL(f) : null }))]);
}
function removePending(i) {
setPendingFiles((prev) => {
if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview);
return prev.filter((_, j) => j !== i);
});
setPendingFiles((prev) => { if (prev[i]?.preview) URL.revokeObjectURL(prev[i].preview); return prev.filter((_, j) => j !== i); });
}
async function handleSend() {
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).";
let attIds = [];
let uploaded = [];
let attIds = [], uploaded = [];
if (pendingFiles.length) {
setUploading(true);
try {
const res = await uploadAttachments(state.token, chatId, pendingFiles.map((p) => p.file));
uploaded = (res.attachments || []).filter((a) => !a.error);
attIds = uploaded.map((a) => a.id);
} catch (err) {
console.error("Upload failed:", err);
setUploading(false);
return;
}
} catch { setUploading(false); return; }
setUploading(false);
}
dispatch({
type: "ADD_MESSAGE",
chatId,
message: {
id: `tmp-${Date.now()}`,
role: "user",
content: text,
created_at: new Date().toISOString(),
attachments: uploaded,
},
});
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
setInput("");
pendingFiles.forEach((p) => { if (p.preview) URL.revokeObjectURL(p.preview); });
setPendingFiles([]);
autoScroll.current = true;
dispatch({
type: "UPDATE_CHAT",
chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId },
});
streamManager.startStream({
token: state.token,
chatId,
body: {
content: text, model, max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId,
attachment_ids: attIds,
},
token: state.token, chatId,
body: { content: text, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, attachment_ids: attIds },
});
}
function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
function handleKeyDown(e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }
function handlePaste(e) {
const items = Array.from(e.clipboardData?.items || []);
const fileItems = items.filter((i) => i.kind === "file");
if (!fileItems.length) return;
e.preventDefault();
const files = fileItems.map((i) => i.getAsFile()).filter(Boolean);
if (files.length) addFiles(files);
}
function handleDragOver(e) {
const items = Array.from(e.clipboardData?.items || []).filter((i) => i.kind === "file");
if (!items.length) return;
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}
function handleDragLeave(e) {
e.preventDefault();
e.stopPropagation();
if (e.currentTarget.contains(e.relatedTarget)) return;
setDragOver(false);
addFiles(items.map((i) => i.getAsFile()).filter(Boolean));
}
function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
e.preventDefault(); setDragOver(false);
const files = Array.from(e.dataTransfer?.files || []);
if (files.length) addFiles(files);
}
const streaming = streamData.streaming;
const otherStreams = Object.keys(state.activeStreams).filter((id) => id !== chatId).length;
return (
<div
className="flex-1 flex flex-col min-h-0 relative"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag-and-drop overlay */}
<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); }}>
{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">
<Upload size={48} className="text-anton-accent mx-auto mb-3 animate-bounce" />
<p className="text-white text-lg font-semibold">Drop files here</p>
<p className="text-anton-muted text-sm mt-1">Images, videos, PDFs, code files…</p>
<Upload size={40} className="text-anton-accent mx-auto mb-2 animate-bounce" />
<p className="text-white font-semibold">Drop files here</p>
</div>
</div>
)}
{/* Parallel streams banner */}
{otherStreams > 0 && (
<div className="bg-anton-accent/10 border-b border-anton-accent/20 px-4 py-1.5 text-xs text-anton-accent flex items-center gap-2">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-pulse" />
{otherStreams} other chat{otherStreams !== 1 ? "s" : ""} streaming in background
</div>
)}
{/* Messages area */}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((m) => (
<MessageBubble key={m.id} message={m} token={state.token} />
))}
{/* Messages */}
<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">
{messages.map((m) => <MessageBubble key={m.id} message={m} token={state.token} />)}
{streaming && (streamData.thinking || streamData.text) && (
<MessageBubble
message={{
id: "streaming", role: "assistant",
content: streamData.text,
thinking_content: streamData.thinking || null,
attachments: [],
}}
isStreaming
isThinking={streamData.isThinking}
token={state.token}
/>
<MessageBubble message={{ id: "streaming", role: "assistant", content: streamData.text, thinking_content: streamData.thinking || null, attachments: [] }} isStreaming isThinking={streamData.isThinking} token={state.token} />
)}
{streaming && !streamData.text && !streamData.thinking && (
<div className="flex items-center gap-2 px-4 py-3 animate-fade-in">
<div className="flex gap-1">
<span className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<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" }} />
{[0, 150, 300].map((d) => <span key={d} className="w-2 h-2 bg-anton-accent rounded-full animate-bounce" style={{ animationDelay: d + "ms" }} />)}
</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>
{/* Input area */}
<div className="border-t border-anton-border bg-anton-surface p-4">
{/* Settings panel */}
<div className="border-t border-anton-border bg-anton-surface p-3 sm:p-4 safe-bottom">
{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">
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5">
<Settings2 size={14} className="text-anton-accent" /> Settings
</h3>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white">
<X size={14} />
</button>
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
</div>
<div>
<label className="text-xs text-anton-muted mb-1 block">Model</label>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<select value={model} onChange={(e) => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
{MODELS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-anton-muted">Max Tokens</span>
<span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span>
</div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} />
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div>
<input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} className="w-full" />
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-anton-muted flex items-center gap-1">
<Brain size={12} className="text-purple-400" /> Reasoning
</span>
<span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span>
</div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} />
<div className="flex justify-between text-xs mb-1"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div>
<input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={(e) => setReasoningBudget(Number(e.target.value))} className="w-full" />
</div>
<div>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1">
<BookOpen size={12} /> Knowledge Base
</label>
<select
value={selectedKbId || ""}
onChange={(e) => setSelectedKbId(e.target.value || null)}
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label>
<select value={selectedKbId || ""} onChange={(e) => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<option value="">None</option>
{kbs.map((kb) => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}
</select>
{kbs.length === 0 && <p className="text-[10px] text-anton-muted mt-1">Create knowledge bases in the sidebar → Knowledge tab</p>}
</div>
</div>
)}
{/* Pending files preview */}
{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) => {
const Icon = FILE_TYPE_ICONS[pf.type] || FileText;
const colorClass = FILE_TYPE_COLORS[pf.type] || "border-anton-border bg-anton-card";
const iconColor = FILE_TYPE_ICON_COLORS[pf.type] || "text-anton-muted";
const Icon = TYPE_ICONS[pf.type] || FileText;
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 ? (
<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">
<Icon size={22} className={`${iconColor} mb-1`} />
<span className="text-[9px] text-anton-muted text-center truncate w-full leading-tight">
{pf.file.name.length > 12 ? pf.file.name.slice(0, 10) + "…" : pf.file.name}
</span>
<span className="text-[8px] text-anton-muted/60 uppercase mt-0.5">{pf.type}</span>
<div className="w-16 h-16 sm:w-20 sm:h-20 flex flex-col items-center justify-center px-1">
<Icon size={18} className={`${TYPE_ICON_COLORS[pf.type] || "text-anton-muted"} mb-0.5`} />
<span className="text-[8px] text-anton-muted text-center truncate w-full">{pf.file.name.slice(0, 10)}</span>
</div>
)}
<button
onClick={() => removePending(i)}
className="absolute -top-1 -right-1 w-5 h-5 bg-anton-danger rounded-full flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity 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>
<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>
<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>
</div>
);
})}
</div>
)}
{/* Input row */}
<div className="flex items-end gap-2">
<button
onClick={toggleSettings}
className={`p-2.5 rounded-xl transition shrink-0 ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}
>
<Settings2 size={18} />
</button>
<button
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 items-end gap-1.5 sm: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 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>
<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 = ""; }} />
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message or send to analyze files…" : "Ask Son of Anton anything… (paste/drop files too)"}
rows={1}
style={{ maxHeight: "200px" }}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white text-sm resize-none focus:outline-none focus:border-anton-accent transition"
onInput={(e) => {
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 200) + "px";
}}
ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste}
placeholder={pendingFiles.length ? "Add a message or send…" : "Ask anything…"}
rows={1} style={{ maxHeight: "160px" }}
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"
onInput={(e) => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px"; }}
/>
</div>
{streaming ? (
<button
onClick={() => streamManager.abortStream(chatId)}
className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"
>
<Square size={18} />
</button>
<button onClick={() => streamManager.abortStream(chatId)} className="p-2 sm:p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"><Square size={18} /></button>
) : (
<button
onClick={handleSend}
disabled={(!input.trim() && !pendingFiles.length) || 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"
>
<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">
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
)}
</div>
{/* Status bar */}
<div className="flex items-center gap-3 mt-2 text-[11px] text-anton-muted flex-wrap">
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-anton-muted flex-wrap">
<span>{MODELS.find((m) => m.id === model)?.label}</span>
<span></span>
<span>{maxTokens.toLocaleString()} tokens</span>
{reasoningBudget > 0 && (
<>
<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>
</>
)}
<span></span><span>{maxTokens.toLocaleString()} tok</span>
{reasoningBudget > 0 && <><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}</span></>}
{messages.some((m) => m.role === "assistant") && (
<button
onClick={async () => {
const all = messages.filter((m) => m.role === "assistant").map((m) => m.content).join("\n\n---\n\n");
if (all) try { await downloadZip(state.token, all); } catch { /* ignore */ }
}}
className="ml-auto hover:text-anton-accent transition"
>
⬇ Download code
</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>
)}
</div>
</div>
......
......@@ -8,12 +8,7 @@ import {
Image, Film, FileText, ExternalLink, FileCode, File,
} from "lucide-react";
const FILE_TYPE_ICONS = {
image: Image,
video: Film,
document: FileText,
text: FileCode,
};
const FILE_TYPE_ICONS = { image: Image, video: Film, document: FileText, text: FileCode };
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
......@@ -35,12 +30,12 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{!isUser && (
<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">
<Flame size={14} className="text-white sm:w-4 sm:h-4" />
<Flame size={14} className="text-white" />
</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_content && (
<div className="mb-2">
......@@ -53,7 +48,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button>
{(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}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div>
......@@ -63,7 +58,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{/* Attachments */}
{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) => {
const Icon = FILE_TYPE_ICONS[att.file_type] || File;
const url = getAttachmentUrl(att.id);
......@@ -74,13 +69,13 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<img
src={`${url}?token=${token}`}
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)}
onError={(e) => { e.target.style.display = "none"; }}
/>
{expandedImage === att.id && (
<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)}
>
<img
......@@ -90,7 +85,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
/>
</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}
</div>
</div>
......@@ -103,22 +98,22 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
href={`${url}?token=${token}`}
target="_blank"
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" />
<div className="min-w-0">
<div className="text-[11px] sm:text-xs text-white truncate max-w-[120px] sm:max-w-[160px]">{att.original_filename}</div>
<div className="text-[9px] sm:text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
<Icon size={15} className="shrink-0 text-blue-400" />
<div className="min-w-0 flex-1">
<div className="text-[11px] text-white truncate">{att.original_filename}</div>
<div className="text-[9px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</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>
);
})}
</div>
)}
{/* Message content */}
<div className={`rounded-2xl px-3 py-2.5 sm:px-4 sm:py-3 ${isUser
{/* Message bubble */}
<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-card border border-anton-border rounded-bl-md"
}`}>
......@@ -153,13 +148,10 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)}
</div>
{/* Actions bar */}
{/* Meta info */}
{!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1 px-1 flex-wrap">
<button
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"
>
<div className="flex items-center gap-3 mt-1.5 px-1 flex-wrap">
<button onClick={handleCopy} className="flex items-center gap-1 text-[10px] sm:text-[11px] text-anton-muted hover:text-white transition">
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"}
</button>
......@@ -175,7 +167,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isUser && (
<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">
<User size={14} className="text-anton-muted sm:w-4 sm:h-4" />
<User size={14} className="text-anton-muted" />
</div>
</div>
)}
......
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import React, { useState, useEffect } from "react";
import { useApp } from "../store";
import { createChat, deleteChat, renameChat } from "../api";
import {
Flame, Plus, MessageSquare, Trash2, Pencil, Check, X,
Settings, LogOut, BookOpen, Shield, ChevronLeft,
listChats, deleteChat, renameChat,
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";
export default function Sidebar() {
export default function Sidebar({ activeChatId, onSelectChat, onNewChat }) {
const { state, dispatch } = useApp();
const navigate = useNavigate();
const [editingId, setEditingId] = useState(null);
const [editId, setEditId] = useState(null);
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 {
const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat });
} catch { }
const data = await listKnowledgeBases(state.token);
setKbs(data);
} catch { /* ignore */ }
}
async function handleDelete(chatId) {
async function handleDeleteChat(e, id) {
e.stopPropagation();
if (!confirm("Delete this chat?")) return;
try {
await deleteChat(state.token, chatId);
dispatch({ type: "REMOVE_CHAT", chatId });
} catch { }
setDeletingId(null);
await deleteChat(state.token, id);
dispatch({ type: "REMOVE_CHAT", chatId: id });
} catch { /* ignore */ }
}
function startRename(e, chat) {
e.stopPropagation();
setEditId(chat.id);
setEditTitle(chat.title);
}
async function handleRename(chatId) {
if (!editTitle.trim()) {
setEditingId(null);
return;
async function saveRename(id) {
if (editTitle.trim()) {
try {
await renameChat(state.token, id, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id, title: editTitle.trim() } });
} catch { /* ignore */ }
}
setEditId(null);
}
async function handleCreateKb() {
const name = kbName.trim();
if (!name) return;
setKbCreating(true);
try {
await renameChat(state.token, chatId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } });
} catch { }
setEditingId(null);
await createKnowledgeBase(state.token, name);
setKbName("");
await loadKbs();
} catch { /* ignore */ }
setKbCreating(false);
}
function startEdit(chat) {
setEditingId(chat.id);
setEditTitle(chat.title);
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 (
<div className="h-full flex flex-col bg-anton-surface border-r border-anton-border w-full">
{/* Header */}
<div className="p-4 border-b border-anton-border">
<div className="flex items-center justify-between mb-4">
<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">
<Flame size={18} className="text-white" />
</div>
<div>
<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>
</div>
<div className="h-full flex flex-col bg-anton-surface border-r border-anton-border">
{/* Logo */}
<div className="p-4 pb-3">
<div className="flex items-center gap-3">
<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" />
</div>
<div>
<h1 className="text-sm font-bold text-white leading-tight">Son of Anton</h1>
<p className="text-[10px] text-anton-muted">Avatar of All Elements of Code</p>
</div>
{/* Close button — mobile only */}
<button
onClick={() => dispatch({ type: "CLOSE_SIDEBAR" })}
className="lg:hidden p-2 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
>
<ChevronLeft size={18} />
</button>
</div>
</div>
{/* Tab switcher */}
<div className="px-3 pb-2 flex gap-1">
<button
onClick={handleNewChat}
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]"
onClick={() => dispatch({ type: "SET_SIDEBAR_TAB", tab: "chats" })}
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"
}`}
>
<Plus size={16} />
New Chat
<MessageSquare size={13} /> Chats
</button>
<button
onClick={() => dispatch({ type: "SET_SIDEBAR_TAB", tab: "knowledge" })}
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"
}`}
>
<BookOpen size={13} /> Knowledge
</button>
</div>
{/* Chat list */}
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
{state.chats.length === 0 && (
<div className="text-center py-10 text-anton-muted text-xs">
No chats yet. Start a new one!
</div>
)}
{state.chats.map((chat) => {
const isActive = state.activeChatId === chat.id;
const isEditing = editingId === chat.id;
const isDeleting = deletingId === chat.id;
return (
<div
key={chat.id}
className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition-all ${isActive
? "bg-anton-accent/15 border border-anton-accent/30 text-white"
: "hover:bg-anton-card text-anton-muted hover:text-white border border-transparent"
}`}
onClick={() => {
if (!isEditing && !isDeleting) {
dispatch({ type: "SET_ACTIVE_CHAT", chatId: chat.id });
}
}}
{/* Content based on tab */}
<div className="flex-1 overflow-y-auto px-3 pb-2">
{tab === "chats" ? (
<>
<button
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"
>
<MessageSquare size={15} className={`shrink-0 ${isActive ? "text-anton-accent" : ""}`} />
{isEditing ? (
<div className="flex-1 flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(chat.id);
if (e.key === "Escape") setEditingId(null);
}}
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">
<Check size={12} />
</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>
<Plus size={16} /> New Chat
</button>
<div className="space-y-0.5">
{state.chats.map((chat) => (
<div
key={chat.id}
onClick={() => onSelectChat(chat.id)}
className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition text-sm ${chat.id === activeChatId
? "bg-anton-accent/15 text-white"
: "text-anton-muted hover:bg-anton-card hover:text-white"
}`}
>
<MessageSquare size={14} className="shrink-0 opacity-50" />
{editId === chat.id ? (
<div className="flex-1 flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && saveRename(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"
autoFocus
/>
<button onClick={() => saveRename(chat.id)} className="text-anton-success"><Check size={12} /></button>
<button onClick={() => setEditId(null)} className="text-anton-muted"><X size={12} /></button>
</div>
) : (
<>
<span className="flex-1 truncate text-xs">{chat.title}</span>
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0">
<button onClick={(e) => startRename(e, chat)} className="p-1 rounded hover:bg-anton-bg transition"><Edit3 size={11} /></button>
<button onClick={(e) => handleDeleteChat(e, chat.id)} className="p-1 rounded hover:bg-anton-bg text-anton-danger transition"><Trash2 size={11} /></button>
</div>
</>
)}
</div>
) : (
<>
<span className="flex-1 text-xs truncate">{chat.title}</span>
<div className="hidden group-hover:flex items-center gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
<button onClick={() => startEdit(chat)} className="p-1 text-anton-muted hover:text-white hover:bg-anton-card rounded transition">
<Pencil size={11} />
</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>
);
})}
</>
) : (
/* 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
onClick={handleCreateKb}
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"
>
{kbCreating ? <Loader2 size={14} className="animate-spin" /> : <FolderPlus size={14} />}
</button>
</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
onClick={(e) => handleDeleteKb(e, kb.id)}
className="p-1 rounded hover:bg-anton-bg text-anton-muted hover:text-anton-danger transition opacity-0 group-hover:opacity-100"
>
<Trash2 size={11} />
</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>
{/* Footer */}
<div className="p-3 border-t border-anton-border space-y-1">
<button
onClick={() => { navigate("/"); dispatch({ type: "CLOSE_SIDEBAR" }); }}
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"
>
<BookOpen size={14} />
Knowledge Bases
</button>
{isSuperadmin && (
<button
onClick={() => { navigate("/admin"); dispatch({ type: "CLOSE_SIDEBAR" }); }}
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"
>
<Shield size={14} />
Admin Panel
</button>
)}
<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 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>
<span className="text-xs text-anton-muted truncate">{state.user?.username}</span>
</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
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"
>
<LogOut size={14} />
......
......@@ -2,79 +2,30 @@
@tailwind components;
@tailwind utilities;
/* ═══════════════════════════════════════════ */
/* 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;
/* ─── Base ─────────────────────────────────────── */
* {
-webkit-tap-highlight-color: transparent;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
overflow: hidden;
height: 100%;
}
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;
height: 100%;
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 {
height: 100dvh;
width: 100vw;
overflow: hidden;
height: 100%;
}
/* ═══════════════════════════════════════════ */
/* Scrollbar styling */
/* ═══════════════════════════════════════════ */
/* ─── Scrollbar ────────────────────────────────── */
::-webkit-scrollbar {
width: 5px;
height: 5px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
......@@ -82,55 +33,24 @@ select {
}
::-webkit-scrollbar-thumb {
background: var(--color-anton-border);
border-radius: 10px;
background: #2a2a3a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-anton-muted);
}
/* 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 {
animation: pulse 1.5s ease-in-out infinite;
background: #3a3a4a;
}
/* ═══════════════════════════════════════════ */
/* Prose (Markdown) styling */
/* ═══════════════════════════════════════════ */
/* ─── Prose for AI messages ────────────────────── */
.prose-anton {
line-height: 1.7;
color: #e0e0e0;
word-break: break-word;
overflow-wrap: break-word;
}
.prose-anton p {
margin: 0.5em 0;
line-height: 1.7;
}
.prose-anton p:first-child {
......@@ -141,122 +61,184 @@ select {
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 ol {
padding-left: 1.25em;
margin: 0.5em 0;
padding-left: 1.5em;
}
.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) {
background: var(--color-anton-border);
background: #1a1a2e;
color: #ff6b6b;
padding: 0.15em 0.4em;
border-radius: 4px;
padding: 0.15em 0.35em;
font-family: "JetBrains Mono", monospace;
font-size: 0.85em;
color: #f0a0a0;
word-break: break-all;
font-size: 0.88em;
font-family: 'JetBrains Mono', monospace;
}
.prose-anton blockquote {
border-left: 3px solid var(--color-anton-accent);
padding-left: 0.75em;
margin: 0.5em 0;
color: var(--color-anton-muted);
border-left: 3px solid #ff4444;
padding-left: 1em;
margin: 0.75em 0;
color: #a0a0b0;
font-style: italic;
}
.prose-anton a {
color: var(--color-anton-accent);
text-decoration: underline;
text-underline-offset: 2px;
.prose-anton h1,
.prose-anton h2,
.prose-anton h3,
.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 {
border-collapse: collapse;
margin: 0.75em 0;
font-size: 0.85em;
width: 100%;
display: block;
overflow-x: auto;
font-size: 0.9em;
}
.prose-anton th,
.prose-anton td {
border: 1px solid var(--color-anton-border);
padding: 0.4em 0.6em;
border: 1px solid #2a2a3a;
padding: 0.4em 0.75em;
text-align: left;
white-space: nowrap;
}
.prose-anton th {
background: var(--color-anton-card);
background: #12121c;
color: #fff;
font-weight: 600;
}
.prose-anton h1,
.prose-anton h2,
.prose-anton h3,
.prose-anton h4 {
font-weight: 600;
color: white;
margin: 0.75em 0 0.35em;
.prose-anton img {
max-width: 100%;
border-radius: 8px;
}
.prose-anton h1 {
font-size: 1.35em;
/* ─── Animations ───────────────────────────────── */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
.prose-anton h2 {
font-size: 1.2em;
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.prose-anton h3 {
font-size: 1.05em;
@keyframes thinking-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.prose-anton hr {
border: none;
border-top: 1px solid var(--color-anton-border);
margin: 1em 0;
.thinking-pulse {
animation: thinking-pulse 1.5s ease-in-out infinite;
}
/* ═══════════════════════════════════════════ */
/* Range input styling */
/* ═══════════════════════════════════════════ */
/* ─── Range slider ─────────────────────────────── */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 999px;
background: var(--color-anton-border);
height: 6px;
background: #1a1a2e;
border-radius: 3px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
appearance: none;
width: 16px;
height: 16px;
background: #ff4444;
border-radius: 50%;
background: var(--color-anton-accent);
cursor: pointer;
border: 2px solid var(--color-anton-bg);
border: 2px solid #0d0d14;
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
width: 16px;
height: 16px;
background: #ff4444;
border-radius: 50%;
background: var(--color-anton-accent);
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-specific utilities */
/* ═══════════════════════════════════════════ */
/* ─── Mobile keyboard fix ──────────────────────── */
@supports (height: 100dvh) {
.h-dvh {
height: 100dvh;
......@@ -267,22 +249,4 @@ input[type="range"]::-moz-range-thumb {
.h-dvh {
height: 100vh;
}
}
/* 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 { listChats } from "../api";
import { listChats, createChat, checkGenerating } from "../api";
import * as streamManager from "../streamManager";
import Sidebar from "../components/Sidebar";
import ChatView from "../components/ChatView";
import { Flame, MessageSquarePlus, Menu } from "lucide-react";
import { Flame, Menu, Plus, MessageSquare } from "lucide-react";
export default function ChatPage() {
const { state, dispatch } = useApp();
const [activeChatId, setActiveChatId] = useState(null);
useEffect(() => {
(async () => {
try {
const chats = await listChats(state.token);
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]);
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 (
<div className="h-dvh flex overflow-hidden relative">
{/* Mobile overlay backdrop */}
<div className="h-dvh flex overflow-hidden bg-anton-bg">
{/* Mobile overlay */}
{state.sidebarOpen && (
<div
className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => dispatch({ type: "CLOSE_SIDEBAR" })}
className="fixed inset-0 bg-black/60 z-30 lg:hidden"
onClick={() => dispatch({ type: "SET_SIDEBAR_OPEN", open: false })}
/>
)}
{/* Sidebar — slides in on mobile, always visible on desktop */}
<div
className={`
fixed inset-y-0 left-0 z-40 w-72
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"}
`}
>
<Sidebar />
{/* Sidebar */}
<div className={`
fixed inset-y-0 left-0 z-40 w-72 transform transition-transform duration-300 ease-in-out
lg:relative lg:translate-x-0 lg:w-72 lg:shrink-0
${state.sidebarOpen ? "translate-x-0" : "-translate-x-full"}
`}>
<Sidebar
activeChatId={activeChatId}
onSelectChat={handleSelectChat}
onNewChat={handleNewChat}
/>
</div>
{/* Main content area */}
{/* Main content */}
<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">
<button
onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })}
className="p-2 -ml-2 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
onClick={() => dispatch({ type: "SET_SIDEBAR_OPEN", open: true })}
className="p-1.5 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"
>
<Menu size={22} />
<Menu size={20} />
</button>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Flame size={18} className="text-anton-accent shrink-0" />
<span className="text-sm font-semibold text-white truncate">
{state.activeChatId
? state.chats.find((c) => c.id === state.activeChatId)?.title || "Chat"
: "Son of Anton"}
</span>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-semibold text-white truncate">
{state.chats.find((c) => c.id === activeChatId)?.title || "Son of Anton"}
</h1>
</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>
{/* Chat view or empty state */}
{state.activeChatId ? (
<ChatView chatId={state.activeChatId} />
{/* Chat or empty 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="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" />
</div>
<h1 className="text-2xl font-bold text-white mb-2">Son of Anton</h1>
<p className="text-anton-muted text-sm mb-6 leading-relaxed">
Avatar of All Elements of Code. Select an existing chat from the sidebar
or create a new one to begin.
</p>
<h2 className="text-2xl font-bold text-white mb-2">Son of Anton</h2>
<p className="text-anton-muted mb-6">Avatar of All Elements of Code</p>
<button
onClick={() => dispatch({ type: "OPEN_SIDEBAR" })}
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"
onClick={handleNewChat}
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} />
Open Chats
<MessageSquare size={18} /> Start a conversation
</button>
</div>
</div>
......
......@@ -6,97 +6,74 @@ const initialState = {
token: localStorage.getItem("token") || null,
user: null,
chats: [],
activeChatId: null,
chatMessages: {},
activeStreams: {},
sidebarOpen: false, // mobile sidebar toggle
sidebarOpen: false,
sidebarTab: "chats", // "chats" | "knowledge"
};
function reducer(state, action) {
switch (action.type) {
case "LOGIN":
localStorage.setItem("token", action.token);
return { ...state, token: action.token, user: action.user };
case "SET_TOKEN":
localStorage.setItem("token", action.token);
return { ...state, token: action.token };
case "SET_USER":
return { ...state, user: action.user };
case "LOGOUT":
localStorage.removeItem("token");
return { ...initialState, token: null };
case "SET_CHATS":
return { ...state, chats: action.chats };
case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId, sidebarOpen: false };
case "ADD_CHAT":
return { ...state, chats: [action.chat, ...state.chats] };
case "UPDATE_CHAT":
return {
...state,
chats: [action.chat, ...state.chats],
activeChatId: action.chat.id,
sidebarOpen: false,
chats: state.chats.map((c) =>
c.id === action.chat.id ? { ...c, ...action.chat } : c
),
};
case "UPDATE_CHAT": {
const updated = state.chats.map((c) =>
c.id === action.chat.id ? { ...c, ...action.chat } : c
);
return { ...state, chats: updated };
}
case "REMOVE_CHAT": {
const filtered = state.chats.filter((c) => c.id !== action.chatId);
const newMessages = { ...state.chatMessages };
delete newMessages[action.chatId];
case "REMOVE_CHAT":
return {
...state,
chats: filtered,
chatMessages: newMessages,
activeChatId: state.activeChatId === action.chatId ? null : state.activeChatId,
chats: state.chats.filter((c) => c.id !== action.chatId),
chatMessages: (() => {
const m = { ...state.chatMessages };
delete m[action.chatId];
return m;
})(),
};
}
case "SET_MESSAGES":
return {
...state,
chatMessages: { ...state.chatMessages, [action.chatId]: action.messages },
};
case "ADD_MESSAGE": {
const prev = state.chatMessages[action.chatId] || [];
case "ADD_MESSAGE":
return {
...state,
chatMessages: {
...state.chatMessages,
[action.chatId]: [...prev, action.message],
[action.chatId]: [
...(state.chatMessages[action.chatId] || []),
action.message,
],
},
};
}
case "SET_STREAMING":
if (action.streaming) {
return { ...state, activeStreams: { ...state.activeStreams, [action.chatId]: true } };
} else {
const s = { ...state.activeStreams };
delete s[action.chatId];
return { ...state, activeStreams: s };
}
case "TOGGLE_SIDEBAR":
return { ...state, sidebarOpen: !state.sidebarOpen };
case "CLOSE_SIDEBAR":
return { ...state, sidebarOpen: false };
case "OPEN_SIDEBAR":
return { ...state, sidebarOpen: true };
return {
...state,
activeStreams: action.streaming
? { ...state.activeStreams, [action.chatId]: true }
: (() => {
const s = { ...state.activeStreams };
delete s[action.chatId];
return s;
})(),
};
case "SET_SIDEBAR_OPEN":
return { ...state, sidebarOpen: action.open };
case "SET_SIDEBAR_TAB":
return { ...state, sidebarTab: action.tab };
default:
return state;
}
......@@ -112,5 +89,7 @@ export function AppProvider({ children }) {
}
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();
const _listeners = new Map();
let _dispatch = null;
export function setDispatch(dispatch) { _dispatch = dispatch; }
export function setDispatch(dispatch) {
_dispatch = dispatch;
}
export function getStreamData(chatId) {
const s = _streams.get(chatId);
......@@ -12,19 +14,32 @@ export function getStreamData(chatId) {
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) {
if (!_listeners.has(chatId)) _listeners.set(chatId, new Set());
_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) {
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 }) {
......@@ -37,38 +52,108 @@ export function startStream({ token, chatId, body }) {
(async () => {
const s = _streams.get(chatId);
if (!s) return;
let usage = {}, msgId = "";
let usage = {};
let msgId = "";
try {
for await (const evt of streamMessage(token, chatId, body, ac.signal)) {
if (ac.signal.aborted || !_streams.has(chatId)) break;
switch (evt.type) {
case "thinking_start": s.isThinking = true; _notify(chatId); break;
case "thinking_delta": s.thinking += evt.content; _notify(chatId); break;
case "thinking_end": s.isThinking = false; _notify(chatId); break;
case "text_delta": s.text += evt.content; _notify(chatId); break;
case "usage": usage = { input_tokens: evt.input_tokens, output_tokens: evt.output_tokens }; break;
case "title_update": if (_dispatch) _dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: evt.title } }); break;
case "done": msgId = evt.message_id; break;
case "error": s.text += `\n\n**Error:** ${evt.message}`; _notify(chatId); break;
}
_handleEvent(chatId, s, evt, (u) => { usage = u; }, (id) => { msgId = id; });
}
if (!ac.signal.aborted && _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: [],
}});
_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 (err) {
if (!ac.signal.aborted && _dispatch) {
_dispatch({ type: "ADD_MESSAGE", chatId, message: {
id: `err-${Date.now()}`, role: "assistant", content: `**Error:** ${err.message}`,
created_at: new Date().toISOString(), attachments: [],
}});
_dispatch({
type: "ADD_MESSAGE", chatId, message: {
id: `err-${Date.now()}`, role: "assistant", content: `**Error:** ${err.message}`,
created_at: new Date().toISOString(), attachments: [],
}
});
}
} 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 });
}
})();
}
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} */
export default {
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
"anton-bg": "#09090f",
"anton-surface": "#0f0f18",
"anton-card": "#161622",
"anton-border": "#1e1e30",
"anton-text": "#e2e2f0",
"anton-muted": "#6b6b8a",
"anton-accent": "#e63946",
"anton-success": "#2ecc71",
"anton-danger": "#e74c3c",
"anton-surface": "#0d0d14",
"anton-card": "#12121c",
"anton-border": "#1e1e2e",
"anton-text": "#e0e0e0",
"anton-muted": "#6b6b80",
"anton-accent": "#ff4444",
"anton-success": "#22c55e",
"anton-danger": "#ef4444",
},
fontFamily: {
sans: ['"Inter"', "system-ui", "sans-serif"],
mono: ['"JetBrains Mono"', "monospace"],
},
height: {
dvh: "100dvh",
},
minHeight: {
dvh: "100dvh",
},
screens: {
xs: "480px",
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "Consolas", "monospace"],
},
},
},
......
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