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
This diff is collapsed.
......@@ -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
This diff is collapsed.
......@@ -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>
)}
......
This diff is collapsed.
......@@ -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