Commit 99edb79c authored by Mahmoud Aglan's avatar Mahmoud Aglan

cool on mobile

parent c0e521e8
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -3,10 +3,11 @@ Son of Anton — Main FastAPI Application ...@@ -3,10 +3,11 @@ Son of Anton — Main FastAPI Application
""" """
import os import os
import time
from pathlib import Path from pathlib import Path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
...@@ -21,6 +22,9 @@ from backend.routes.files_routes import router as files_router ...@@ -21,6 +22,9 @@ from backend.routes.files_routes import router as files_router
from backend.routes.attachment_routes import router as attachment_router from backend.routes.attachment_routes import router as attachment_router
from backend.services.bedrock_service import close_http_client from backend.services.bedrock_service import close_http_client
APP_VERSION = "2.1.0"
APP_BUILD_TIME = str(int(time.time()))
def _run_migrations(): def _run_migrations():
"""Add new columns/tables to existing DB if they're missing.""" """Add new columns/tables to existing DB if they're missing."""
...@@ -54,7 +58,7 @@ async def lifespan(app: FastAPI): ...@@ -54,7 +58,7 @@ async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_run_migrations() _run_migrations()
seed_superadmin() seed_superadmin()
print("Son of Anton is online.") print(f"Son of Anton v{APP_VERSION} (build {APP_BUILD_TIME}) is online.")
yield yield
await close_http_client() await close_http_client()
print("Son of Anton shutting down.") print("Son of Anton shutting down.")
...@@ -63,7 +67,7 @@ async def lifespan(app: FastAPI): ...@@ -63,7 +67,7 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="Son of Anton", title="Son of Anton",
description="Avatar of All Elements of Code", description="Avatar of All Elements of Code",
version="2.0.0", version=APP_VERSION,
lifespan=lifespan, lifespan=lifespan,
) )
...@@ -75,6 +79,38 @@ app.add_middleware( ...@@ -75,6 +79,38 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
@app.middleware("http")
async def add_cache_headers(request: Request, call_next):
response: Response = await call_next(request)
path = request.url.path
# API responses: never cache
if path.startswith("/api"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# Hashed assets (contain hash in filename): cache aggressively
elif path.startswith("/assets/") and any(c in path for c in [".js", ".css"]):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
# HTML and everything else: never cache
elif path.endswith(".html") or not path.startswith("/assets"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# Always add version header for debugging
response.headers["X-App-Version"] = APP_VERSION
response.headers["X-Build-Time"] = APP_BUILD_TIME
return response
# Version endpoint for frontend to check
@app.get("/api/version")
def get_version():
return {"version": APP_VERSION, "build": APP_BUILD_TIME}
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(chat_router, prefix="/api/chats", tags=["Chats"]) app.include_router(chat_router, prefix="/api/chats", tags=["Chats"])
app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"])
...@@ -98,8 +134,15 @@ async def serve_frontend(full_path: str): ...@@ -98,8 +134,15 @@ async def serve_frontend(full_path: str):
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")
file_path = FRONTEND_DIR / full_path file_path = FRONTEND_DIR / full_path
if full_path and file_path.is_file(): if full_path and file_path.is_file():
return FileResponse(str(file_path)) resp = FileResponse(str(file_path))
# Don't cache non-hashed static files
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
return resp
index = FRONTEND_DIR / "index.html" index = FRONTEND_DIR / "index.html"
if index.is_file(): if index.is_file():
return FileResponse(str(index)) resp = FileResponse(str(index))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
return {"message": "Son of Anton API is running. Frontend not built."} return {"message": "Son of Anton API is running. Frontend not built."}
\ No newline at end of file
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="dark"> <html lang="en" class="dark">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Son of Anton</title> <title>Son of Anton</title>
<!-- KILL BROWSER CACHE FOR THIS HTML -->
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- PWA / Mobile -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#09090f" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet" rel="stylesheet" />
/> <link rel="icon"
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>" /> href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>" />
</head> </head>
<body class="bg-anton-bg text-anton-text font-sans">
<body class="bg-anton-bg text-anton-text font-sans overscroll-none">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>
\ No newline at end of file
...@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; ...@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { useApp } from "./store"; import { useApp } from "./store";
import { getMe } from "./api"; import { getMe } from "./api";
import * as streamManager from "./streamManager";
import LoginPage from "./pages/LoginPage"; import LoginPage from "./pages/LoginPage";
import ChatPage from "./pages/ChatPage"; import ChatPage from "./pages/ChatPage";
import AdminPage from "./pages/AdminPage"; import AdminPage from "./pages/AdminPage";
...@@ -11,6 +12,11 @@ export default function App() { ...@@ -11,6 +12,11 @@ export default function App() {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const [authChecked, setAuthChecked] = useState(!state.token); const [authChecked, setAuthChecked] = useState(!state.token);
// Connect streamManager to store dispatch
useEffect(() => {
streamManager.setDispatch(dispatch);
}, [dispatch]);
useEffect(() => { useEffect(() => {
if (!state.token) { if (!state.token) {
setAuthChecked(true); setAuthChecked(true);
...@@ -34,7 +40,7 @@ export default function App() { ...@@ -34,7 +40,7 @@ export default function App() {
if (!authChecked) { if (!authChecked) {
return ( return (
<div className="h-full flex items-center justify-center bg-anton-bg"> <div className="h-dvh flex items-center justify-center bg-anton-bg">
<div className="flex flex-col items-center gap-4 animate-fade-in"> <div className="flex flex-col items-center gap-4 animate-fade-in">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20"> <div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
<Flame size={32} className="text-white animate-pulse" /> <Flame size={32} className="text-white animate-pulse" />
......
...@@ -15,13 +15,6 @@ const FILE_TYPE_ICONS = { ...@@ -15,13 +15,6 @@ const FILE_TYPE_ICONS = {
text: FileCode, text: FileCode,
}; };
const FILE_TYPE_BADGE_COLORS = {
image: "bg-blue-500/20 text-blue-400",
video: "bg-purple-500/20 text-purple-400",
document: "bg-amber-500/20 text-amber-400",
text: "bg-green-500/20 text-green-400",
};
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) { const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking, token }) {
const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message; const { role, content, thinking_content, input_tokens, output_tokens, attachments } = message;
const isUser = role === "user"; const isUser = role === "user";
...@@ -38,16 +31,16 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -38,16 +31,16 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
const hasAttachments = attachments && attachments.length > 0; const hasAttachments = attachments && attachments.length > 0;
return ( return (
<div className={`flex gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}> <div className={`flex gap-2 sm:gap-3 animate-fade-in ${isUser ? "justify-end" : ""}`}>
{!isUser && ( {!isUser && (
<div className="shrink-0 mt-1"> <div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10"> <div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/10">
<Flame size={16} className="text-white" /> <Flame size={14} className="text-white sm:w-4 sm:h-4" />
</div> </div>
</div> </div>
)} )}
<div className={`max-w-[80%] ${isUser ? "order-first" : ""}`}> <div className={`max-w-[90%] sm:max-w-[80%] ${isUser ? "order-first" : ""}`}>
{/* Thinking block */} {/* Thinking block */}
{thinking_content && ( {thinking_content && (
<div className="mb-2"> <div className="mb-2">
...@@ -60,7 +53,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -60,7 +53,7 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>} {isThinking ? <span className="thinking-pulse">Reasoning…</span> : <span>View reasoning</span>}
</button> </button>
{(showThinking || isThinking) && ( {(showThinking || isThinking) && (
<div className="bg-purple-500/5 border border-purple-500/20 rounded-lg p-3 text-xs text-purple-300/80 font-mono whitespace-pre-wrap max-h-60 overflow-y-auto"> <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">
{thinking_content} {thinking_content}
{isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />} {isThinking && <span className="inline-block w-1.5 h-4 bg-purple-400 ml-0.5 animate-pulse" />}
</div> </div>
...@@ -70,10 +63,9 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -70,10 +63,9 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{/* Attachments */} {/* Attachments */}
{hasAttachments && ( {hasAttachments && (
<div className="mb-2 flex flex-wrap gap-2"> <div className="mb-2 flex flex-wrap gap-1.5 sm:gap-2">
{attachments.map((att) => { {attachments.map((att) => {
const Icon = FILE_TYPE_ICONS[att.file_type] || File; const Icon = FILE_TYPE_ICONS[att.file_type] || File;
const badgeColor = FILE_TYPE_BADGE_COLORS[att.file_type] || "bg-anton-border text-anton-muted";
const url = getAttachmentUrl(att.id); const url = getAttachmentUrl(att.id);
if (att.file_type === "image") { if (att.file_type === "image") {
...@@ -82,101 +74,58 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -82,101 +74,58 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
<img <img
src={`${url}?token=${token}`} src={`${url}?token=${token}`}
alt={att.original_filename} alt={att.original_filename}
className="max-w-[280px] max-h-[220px] rounded-lg border border-anton-border object-cover cursor-pointer hover:opacity-90 transition shadow-lg" 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"
onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)} onClick={() => setExpandedImage(expandedImage === att.id ? null : att.id)}
onError={(e) => { onError={(e) => { e.target.style.display = "none"; }}
e.target.style.display = "none";
e.target.nextSibling && (e.target.nextSibling.style.display = "flex");
}}
/> />
{/* Fallback if image fails */}
<div className="hidden items-center gap-2 bg-anton-card border border-anton-border rounded-lg px-3 py-2">
<Image size={16} className="text-blue-400" />
<span className="text-xs text-white">{att.original_filename}</span>
</div>
{expandedImage === att.id && ( {expandedImage === att.id && (
<div <div
className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-8 cursor-pointer" className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4 sm:p-8 cursor-pointer"
onClick={() => setExpandedImage(null)} onClick={() => setExpandedImage(null)}
> >
<img <img
src={`${url}?token=${token}`} src={`${url}?token=${token}`}
alt={att.original_filename} alt={att.original_filename}
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl" className="max-w-full max-h-full object-contain rounded-lg"
/> />
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/70 text-white text-sm px-4 py-2 rounded-lg">
{att.original_filename} — Click anywhere to close
</div>
</div> </div>
)} )}
<div className="absolute bottom-1 left-1 bg-black/70 text-[9px] text-white px-1.5 py-0.5 rounded flex items-center gap-1"> <div className="absolute bottom-1 left-1 bg-black/60 text-[8px] sm:text-[9px] text-white px-1.5 py-0.5 rounded">
<Image size={8} />
{att.original_filename} {att.original_filename}
</div> </div>
</div> </div>
); );
} }
if (att.file_type === "video") {
return ( return (
<a <a
key={att.id} key={att.id}
href={`${url}?token=${token}`} href={`${url}?token=${token}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-3 bg-anton-card border border-purple-500/30 rounded-lg px-4 py-3 hover:border-purple-400 transition group shadow-sm" 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"
> >
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0"> <Icon size={14} className="shrink-0 text-blue-400 sm:w-4 sm:h-4" />
<Film size={20} className="text-purple-400" />
</div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs text-white truncate max-w-[180px] font-medium">{att.original_filename}</div> <div className="text-[11px] sm:text-xs text-white truncate max-w-[120px] sm:max-w-[160px]">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted flex items-center gap-1.5 mt-0.5"> <div className="text-[9px] sm:text-[10px] text-anton-muted">{(att.file_size / 1024).toFixed(0)}KB</div>
<span className={`px-1 py-px rounded text-[8px] font-bold uppercase ${badgeColor}`}>Video</span>
<span>{att.file_size ? (att.file_size / 1024 / 1024).toFixed(1) + " MB" : ""}</span>
</div>
</div>
<ExternalLink size={12} className="text-anton-muted group-hover:text-purple-400 shrink-0" />
</a>
);
}
return (
<a
key={att.id}
href={`${url}?token=${token}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 bg-anton-card border border-anton-border rounded-lg px-4 py-3 hover:border-anton-accent transition group shadow-sm"
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${att.file_type === "document" ? "bg-amber-500/20" : "bg-green-500/20"}`}>
<Icon size={20} className={att.file_type === "document" ? "text-amber-400" : "text-green-400"} />
</div>
<div className="min-w-0">
<div className="text-xs text-white truncate max-w-[180px] font-medium">{att.original_filename}</div>
<div className="text-[10px] text-anton-muted flex items-center gap-1.5 mt-0.5">
<span className={`px-1 py-px rounded text-[8px] font-bold uppercase ${badgeColor}`}>
{att.file_type}
</span>
<span>{att.file_size ? (att.file_size / 1024).toFixed(0) + " KB" : ""}</span>
</div>
</div> </div>
<ExternalLink size={12} className="text-anton-muted group-hover:text-anton-accent shrink-0" /> <ExternalLink size={10} className="text-anton-muted group-hover:text-anton-accent shrink-0 sm:w-3 sm:h-3" />
</a> </a>
); );
})} })}
</div> </div>
)} )}
{/* Message bubble */} {/* Message content */}
<div className={`rounded-2xl px-4 py-3 ${isUser <div className={`rounded-2xl px-3 py-2.5 sm:px-4 sm:py-3 ${isUser
? "bg-anton-accent text-white rounded-br-md" ? "bg-anton-accent text-white rounded-br-md"
: "bg-anton-card border border-anton-border rounded-bl-md" : "bg-anton-card border border-anton-border rounded-bl-md"
}`}> }`}>
{isUser ? ( {isUser ? (
<div className="text-sm whitespace-pre-wrap">{_stripPrefixes(content)}</div> <div className="text-sm whitespace-pre-wrap break-words">{_stripPrefixes(content)}</div>
) : ( ) : (
<div className="prose-anton text-sm"> <div className="prose-anton text-sm break-words">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
...@@ -204,19 +153,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -204,19 +153,19 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
)} )}
</div> </div>
{/* Meta info */} {/* Actions bar */}
{!isUser && !isStreaming && content && ( {!isUser && !isStreaming && content && (
<div className="flex items-center gap-3 mt-1.5 px-1"> <div className="flex items-center gap-3 mt-1 px-1 flex-wrap">
<button <button
onClick={handleCopy} onClick={handleCopy}
className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition" className="flex items-center gap-1 text-[11px] text-anton-muted hover:text-white transition p-1 -ml-1 rounded active:scale-95"
> >
{copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />} {copied ? <Check size={11} className="text-anton-success" /> : <Copy size={11} />}
{copied ? "Copied" : "Copy"} {copied ? "Copied" : "Copy"}
</button> </button>
{(input_tokens > 0 || output_tokens > 0) && ( {(input_tokens > 0 || output_tokens > 0) && (
<span className="text-[11px] text-anton-muted"> <span className="text-[10px] sm:text-[11px] text-anton-muted">
{input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()} tokens {input_tokens?.toLocaleString()}↓ / {output_tokens?.toLocaleString()}
</span> </span>
)} )}
</div> </div>
...@@ -225,8 +174,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, ...@@ -225,8 +174,8 @@ const MessageBubble = React.memo(function MessageBubble({ message, isStreaming,
{isUser && ( {isUser && (
<div className="shrink-0 mt-1"> <div className="shrink-0 mt-1">
<div className="w-8 h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center"> <div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg bg-anton-card border border-anton-border flex items-center justify-center">
<User size={16} className="text-anton-muted" /> <User size={14} className="text-anton-muted sm:w-4 sm:h-4" />
</div> </div>
</div> </div>
)} )}
......
import React, { useState } from "react"; import React, { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useApp } from "../store"; import { useApp } from "../store";
import { createChat, deleteChat, renameChat } from "../api";
import { import {
createChat, deleteChat, renameChat, Flame, Plus, MessageSquare, Trash2, Pencil, Check, X,
listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocuments, Settings, LogOut, BookOpen, Shield, ChevronLeft,
} from "../api";
import * as streamManager from "../streamManager";
import {
Plus, Trash2, Flame, LogOut, Shield, PanelLeftClose, PanelLeftOpen,
MessageSquare, BookOpen, Upload, X, ChevronDown, ChevronRight, Edit2, Check,
Radio,
} from "lucide-react"; } from "lucide-react";
export default function Sidebar({ onRefresh }) { export default function Sidebar() {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const navigate = useNavigate(); const navigate = useNavigate();
const [tab, setTab] = useState("chats"); const [editingId, setEditingId] = useState(null);
const [kbs, setKbs] = useState([]); const [editTitle, setEditTitle] = useState("");
const [kbLoaded, setKbLoaded] = useState(false); const [deletingId, setDeletingId] = useState(null);
const [newKbName, setNewKbName] = useState("");
const [showNewKb, setShowNewKb] = useState(false);
const [expandedKb, setExpandedKb] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadCount, setUploadCount] = useState(0);
const [renamingId, setRenamingId] = useState(null);
const [renameVal, setRenameVal] = useState("");
const open = state.sidebarOpen;
const streamingChatIds = Object.keys(state.activeStreams);
const streamingCount = streamingChatIds.length;
async function handleNewChat() { async function handleNewChat() {
try { try {
const chat = await createChat(state.token); const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat }); dispatch({ type: "ADD_CHAT", chat });
onRefresh(); } catch { }
} catch { /* */ }
}
async function handleDelete(id) {
try {
streamManager.abortStream(id);
await deleteChat(state.token, id);
dispatch({ type: "DELETE_CHAT", chatId: id });
} catch { /* */ }
}
async function handleRename(id) {
if (!renameVal.trim()) return;
try {
await renameChat(state.token, id, renameVal.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id, title: renameVal.trim() } });
setRenamingId(null);
} catch { /* */ }
}
async function loadKbs() {
try {
const data = await listKnowledgeBases(state.token);
setKbs(data);
setKbLoaded(true);
} catch { /* */ }
} }
async function handleCreateKb() { async function handleDelete(chatId) {
if (!newKbName.trim()) return;
try { try {
await createKnowledgeBase(state.token, newKbName.trim()); await deleteChat(state.token, chatId);
setNewKbName(""); dispatch({ type: "REMOVE_CHAT", chatId });
setShowNewKb(false); } catch { }
loadKbs(); setDeletingId(null);
} catch { /* */ }
} }
async function handleDeleteKb(id) { async function handleRename(chatId) {
if (!confirm("Delete this knowledge base?")) return; if (!editTitle.trim()) {
try { setEditingId(null);
await deleteKnowledgeBase(state.token, id); return;
loadKbs();
} catch { /* */ }
} }
async function handleUpload(kbId, files) {
setUploading(true);
setUploadCount(files.length);
try { try {
const result = await uploadDocuments(state.token, kbId, files); await renameChat(state.token, chatId, editTitle.trim());
const errors = (result.files || []).filter((f) => f.error); dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } });
if (errors.length > 0) { } catch { }
alert( setEditingId(null);
`Uploaded ${result.files.length - errors.length} of ${result.files.length} files.\n\nErrors:\n` +
errors.map((e) => `• ${e.filename}: ${e.error}`).join("\n")
);
}
loadKbs();
} catch (e) {
alert(e.message);
} finally {
setUploading(false);
setUploadCount(0);
}
} }
function switchTab(t) { function startEdit(chat) {
setTab(t); setEditingId(chat.id);
if (t === "knowledge" && !kbLoaded) loadKbs(); setEditTitle(chat.title);
} }
if (!open) { const isSuperadmin = state.user?.role === "superadmin";
return (
<div className="w-12 bg-anton-surface border-r border-anton-border flex flex-col items-center py-3 gap-3 shrink-0">
<button onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })} className="p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition">
<PanelLeftOpen size={18} />
</button>
<button onClick={handleNewChat} className="p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition">
<Plus size={18} />
</button>
{streamingCount > 0 && (
<div className="w-7 h-7 rounded-full bg-anton-accent/20 flex items-center justify-center" title={`${streamingCount} chat${streamingCount !== 1 ? "s" : ""} streaming`}>
<Radio size={14} className="text-anton-accent animate-pulse" />
</div>
)}
</div>
);
}
return ( return (
<div className="w-72 bg-anton-surface border-r border-anton-border flex flex-col shrink-0"> <div className="h-full flex flex-col bg-anton-surface border-r border-anton-border w-full">
{/* Header */} {/* Header */}
<div className="p-3 border-b border-anton-border flex items-center justify-between"> <div className="p-4 border-b border-anton-border">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between mb-4">
<Flame size={20} className="text-anton-accent" /> <div className="flex items-center gap-2.5">
<span className="font-bold text-white text-sm">Son of Anton</span> <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>
<div className="flex items-center gap-1"> <div>
{streamingCount > 0 && ( <h1 className="text-sm font-bold text-white leading-tight">Son of Anton</h1>
<span className="text-[10px] bg-anton-accent/20 text-anton-accent px-1.5 py-0.5 rounded-full font-medium animate-pulse flex items-center gap-1"> <span className="text-[10px] text-anton-muted">v2.1.0</span>
<Radio size={10} /> {streamingCount}
</span>
)}
<button onClick={() => dispatch({ type: "TOGGLE_SIDEBAR" })} className="p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition">
<PanelLeftClose size={16} />
</button>
</div> </div>
</div> </div>
{/* Close button — mobile only */}
{/* Tab bar */}
<div className="flex border-b border-anton-border">
{[
{ key: "chats", label: "Chats", icon: MessageSquare },
{ key: "knowledge", label: "Knowledge", icon: BookOpen },
].map((t) => (
<button <button
key={t.key} onClick={() => dispatch({ type: "CLOSE_SIDEBAR" })}
onClick={() => switchTab(t.key)} className="lg:hidden p-2 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition active:scale-95"
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition ${tab === t.key ? "text-anton-accent border-b-2 border-anton-accent" : "text-anton-muted hover:text-white"
}`}
> >
<t.icon size={13} /> <ChevronLeft size={18} />
{t.label}
</button> </button>
))}
</div> </div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{tab === "chats" && (
<>
<button <button
onClick={handleNewChat} onClick={handleNewChat}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm" className="w-full flex items-center 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]"
> >
<Plus size={15} /> New Chat <Plus size={16} />
New Chat
</button> </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;
{state.chats.map((c) => {
const chatStreaming = !!state.activeStreams[c.id];
const isActive = state.activeChatId === c.id;
return ( return (
<div <div
key={c.id} key={chat.id}
className={`group flex items-center rounded-lg cursor-pointer transition ${isActive className={`group flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer transition-all ${isActive
? "bg-anton-accent/10 text-anton-accent" ? "bg-anton-accent/15 border border-anton-accent/30 text-white"
: chatStreaming : "hover:bg-anton-card text-anton-muted hover:text-white border border-transparent"
? "bg-purple-500/5 text-anton-text hover:bg-purple-500/10"
: "text-anton-text hover:bg-anton-card"
}`} }`}
onClick={() => {
if (!isEditing && !isDeleting) {
dispatch({ type: "SET_ACTIVE_CHAT", chatId: chat.id });
}
}}
> >
{renamingId === c.id ? ( <MessageSquare size={15} className={`shrink-0 ${isActive ? "text-anton-accent" : ""}`} />
<div className="flex items-center gap-1 flex-1 p-1">
{isEditing ? (
<div className="flex-1 flex items-center gap-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<input <input
value={renameVal} value={editTitle}
onChange={(e) => setRenameVal(e.target.value)} onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)} onKeyDown={(e) => {
if (e.key === "Enter") handleRename(chat.id);
if (e.key === "Escape") setEditingId(null);
}}
autoFocus autoFocus
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent" className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-anton-accent min-w-0"
/> />
<button onClick={() => handleRename(c.id)} className="p-1 text-anton-success"><Check size={12} /></button> <button onClick={() => handleRename(chat.id)} className="p-1 text-anton-success hover:bg-anton-success/10 rounded">
<button onClick={() => setRenamingId(null)} className="p-1 text-anton-muted"><X size={12} /></button> <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>
</div> </div>
) : ( ) : (
<> <>
<button <span className="flex-1 text-xs truncate">{chat.title}</span>
onClick={() => dispatch({ type: "SET_ACTIVE_CHAT", chatId: c.id })} <div className="hidden group-hover:flex items-center gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
className="flex-1 flex items-center gap-2 text-left px-3 py-2 text-sm truncate min-w-0" <button onClick={() => startEdit(chat)} className="p-1 text-anton-muted hover:text-white hover:bg-anton-card rounded transition">
> <Pencil size={11} />
{chatStreaming && (
<span className="w-2 h-2 bg-anton-accent rounded-full animate-pulse shrink-0" title="Streaming" />
)}
<span className="truncate">{c.title}</span>
</button>
<div className="hidden group-hover:flex items-center pr-1 gap-0.5 shrink-0">
<button
onClick={() => { setRenamingId(c.id); setRenameVal(c.title); }}
className="p-1 rounded hover:bg-anton-border text-anton-muted"
>
<Edit2 size={11} />
</button> </button>
<button onClick={() => handleDelete(c.id)} className="p-1 rounded hover:bg-red-500/20 text-anton-danger"> <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} /> <Trash2 size={11} />
</button> </button>
</div> </div>
...@@ -226,94 +151,41 @@ export default function Sidebar({ onRefresh }) { ...@@ -226,94 +151,41 @@ export default function Sidebar({ onRefresh }) {
</div> </div>
); );
})} })}
</> </div>
)}
{tab === "knowledge" && ( {/* Footer */}
<> <div className="p-3 border-t border-anton-border space-y-1">
<button <button
onClick={() => setShowNewKb(!showNewKb)} onClick={() => { navigate("/"); dispatch({ type: "CLOSE_SIDEBAR" }); }}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm" className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs text-anton-muted hover:text-white hover:bg-anton-card transition"
> >
<Plus size={15} /> New Knowledge Base <BookOpen size={14} />
Knowledge Bases
</button> </button>
{isSuperadmin && (
{showNewKb && (
<div className="flex gap-1 p-1">
<input
value={newKbName}
onChange={(e) => setNewKbName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateKb()}
placeholder="Name…"
autoFocus
className="flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
/>
<button onClick={handleCreateKb} className="px-2 py-1 bg-anton-accent rounded text-white text-xs">Add</button>
</div>
)}
{kbs.map((kb) => (
<div key={kb.id} className="rounded-lg border border-anton-border/50 overflow-hidden">
<button <button
onClick={() => setExpandedKb(expandedKb === kb.id ? null : kb.id)} onClick={() => { navigate("/admin"); dispatch({ type: "CLOSE_SIDEBAR" }); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-anton-card transition" className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs text-anton-muted hover:text-white hover:bg-anton-card transition"
> >
{expandedKb === kb.id ? <ChevronDown size={13} /> : <ChevronRight size={13} />} <Shield size={14} />
<BookOpen size={13} className="text-anton-accent shrink-0" /> Admin Panel
<span className="flex-1 truncate">{kb.name}</span>
<span className="text-xs text-anton-muted">{kb.document_count} docs</span>
</button>
{expandedKb === kb.id && (
<div className="px-3 pb-3 space-y-2 bg-anton-card/50">
<div className="text-xs text-anton-muted space-y-0.5">
<div>Chunks: {kb.chunk_count} &middot; ~{(kb.estimated_tokens / 1000).toFixed(0)}K tokens</div>
</div>
<label className={`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${uploading ? "opacity-50 pointer-events-none" : ""}`}>
<Upload size={12} />
{uploading ? `Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…` : "Upload files (.txt, .pdf, .md, .json, .csv …)"}
<input
type="file"
className="hidden"
multiple
accept=".txt,.md,.pdf,.json,.csv,.py,.js,.ts,.cs,.html,.css,.xml,.yaml,.yml,.toml"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) handleUpload(kb.id, Array.from(e.target.files));
e.target.value = "";
}}
/>
</label>
<button onClick={() => handleDeleteKb(kb.id)} className="flex items-center gap-1 text-xs text-anton-danger hover:underline">
<Trash2 size={11} /> Delete KB
</button> </button>
</div>
)} )}
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 rounded-md bg-anton-card flex items-center justify-center shrink-0">
<span className="text-[10px] font-bold text-anton-accent uppercase">
{(state.user?.username || "?")[0]}
</span>
</div> </div>
))} <span className="text-xs text-anton-muted truncate">{state.user?.username}</span>
</>
)}
</div> </div>
{/* Footer */}
<div className="p-3 border-t border-anton-border space-y-2">
{state.user?.role === "superadmin" && (
<button <button
onClick={() => navigate("/admin")} onClick={() => dispatch({ type: "LOGOUT" })}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:text-anton-accent hover:bg-anton-card transition" className="p-1.5 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-anton-danger/10 transition"
title="Logout"
> >
<Shield size={15} /> Admin Panel <LogOut size={14} />
</button>
)}
<div className="flex items-center justify-between px-2">
<div>
<div className="text-sm font-medium text-white">{state.user?.username}</div>
<div className="text-xs text-anton-muted">
{((state.user?.tokens_used_this_month || 0) / 1000).toFixed(0)}K /{" "}
{((state.user?.quota_tokens_monthly || 0) / 1000).toFixed(0)}K tokens
</div>
</div>
<button onClick={() => dispatch({ type: "LOGOUT" })} className="p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition">
<LogOut size={16} />
</button> </button>
</div> </div>
</div> </div>
......
...@@ -2,137 +2,287 @@ ...@@ -2,137 +2,287 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* ── Globals ───────────────────────────────── */ /* ═══════════════════════════════════════════ */
* { /* Use dvh for full-height on mobile */
scrollbar-width: thin; /* ═══════════════════════════════════════════ */
scrollbar-color: #2a2a3a #0a0a0f; :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);
} }
*::-webkit-scrollbar {
width: 6px; *,
*::before,
*::after {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
} }
*::-webkit-scrollbar-track {
background: #0a0a0f; html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
} }
*::-webkit-scrollbar-thumb {
background: #2a2a3a; body {
border-radius: 3px; 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;
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;
}
} }
html,
body,
#root { #root {
height: 100%; height: 100dvh;
margin: 0; width: 100vw;
overflow: hidden; overflow: hidden;
} }
/* ── Markdown prose adjustments ────────────── */ /* ═══════════════════════════════════════════ */
.prose-anton h1, /* Scrollbar styling */
.prose-anton h2, /* ═══════════════════════════════════════════ */
.prose-anton h3 { ::-webkit-scrollbar {
color: #f97316; width: 5px;
margin-top: 1em; height: 5px;
margin-bottom: 0.5em;
font-weight: 600;
} }
.prose-anton h1 { font-size: 1.5rem; }
.prose-anton h2 { font-size: 1.25rem; }
.prose-anton h3 { font-size: 1.1rem; }
.prose-anton p { ::-webkit-scrollbar-track {
margin-bottom: 0.75em; background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-anton-border);
border-radius: 10px;
}
::-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;
}
/* ═══════════════════════════════════════════ */
/* Prose (Markdown) styling */
/* ═══════════════════════════════════════════ */
.prose-anton {
line-height: 1.7; line-height: 1.7;
word-break: break-word;
overflow-wrap: break-word;
}
.prose-anton p {
margin: 0.5em 0;
}
.prose-anton p:first-child {
margin-top: 0;
}
.prose-anton p:last-child {
margin-bottom: 0;
} }
.prose-anton ul, .prose-anton ul,
.prose-anton ol { .prose-anton ol {
margin-left: 1.5em; padding-left: 1.25em;
margin-bottom: 0.75em; margin: 0.5em 0;
} }
.prose-anton li { .prose-anton li {
margin-bottom: 0.25em; margin: 0.15em 0;
} }
.prose-anton ul { list-style-type: disc; }
.prose-anton ol { list-style-type: decimal; }
.prose-anton a { .prose-anton code:not(pre code) {
color: #f97316; background: var(--color-anton-border);
text-decoration: underline; border-radius: 4px;
padding: 0.15em 0.35em;
font-family: "JetBrains Mono", monospace;
font-size: 0.85em;
color: #f0a0a0;
word-break: break-all;
} }
.prose-anton blockquote { .prose-anton blockquote {
border-left: 3px solid #f97316; border-left: 3px solid var(--color-anton-accent);
padding-left: 1em; padding-left: 0.75em;
color: #8888a0; margin: 0.5em 0;
margin: 0.75em 0; color: var(--color-anton-muted);
}
.prose-anton a {
color: var(--color-anton-accent);
text-decoration: underline;
text-underline-offset: 2px;
} }
.prose-anton table { .prose-anton table {
border-collapse: collapse; border-collapse: collapse;
margin: 0.75em 0; margin: 0.75em 0;
font-size: 0.85em;
width: 100%; width: 100%;
display: block;
overflow-x: auto;
} }
.prose-anton th, .prose-anton th,
.prose-anton td { .prose-anton td {
border: 1px solid #2a2a3a; border: 1px solid var(--color-anton-border);
padding: 0.4em 0.75em; padding: 0.4em 0.6em;
text-align: left; text-align: left;
white-space: nowrap;
} }
.prose-anton th { .prose-anton th {
background: #1a1a28; background: var(--color-anton-card);
font-weight: 600; font-weight: 600;
} }
.prose-anton code:not(pre code) { .prose-anton h1,
background: #1a1a28; .prose-anton h2,
padding: 0.15em 0.4em; .prose-anton h3,
border-radius: 4px; .prose-anton h4 {
font-size: 0.9em; font-weight: 600;
font-family: "JetBrains Mono", monospace; color: white;
color: #f97316; margin: 0.75em 0 0.35em;
} }
/* ── Thinking block animation ──────────────── */ .prose-anton h1 {
@keyframes thinkPulse { font-size: 1.35em;
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
} }
.thinking-pulse {
animation: thinkPulse 2s ease-in-out infinite; .prose-anton h2 {
font-size: 1.2em;
} }
/* ── Slider custom styling ─────────────────── */ .prose-anton h3 {
font-size: 1.05em;
}
.prose-anton hr {
border: none;
border-top: 1px solid var(--color-anton-border);
margin: 1em 0;
}
/* ═══════════════════════════════════════════ */
/* Range input styling */
/* ═══════════════════════════════════════════ */
input[type="range"] { input[type="range"] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none;
background: transparent;
width: 100%; width: 100%;
}
input[type="range"]::-webkit-slider-track {
height: 4px; height: 4px;
border-radius: 2px; border-radius: 999px;
background: #2a2a3a; background: var(--color-anton-border);
outline: none;
} }
input[type="range"]::-webkit-slider-thumb { input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; width: 18px;
height: 16px; height: 18px;
width: 16px;
border-radius: 50%; border-radius: 50%;
background: #f97316; background: var(--color-anton-accent);
margin-top: -6px;
cursor: pointer; cursor: pointer;
border: 2px solid var(--color-anton-bg);
} }
input[type="range"]::-moz-range-track {
height: 4px;
border-radius: 2px;
background: #2a2a3a;
}
input[type="range"]::-moz-range-thumb { input[type="range"]::-moz-range-thumb {
height: 16px; width: 18px;
width: 16px; height: 18px;
border-radius: 50%; border-radius: 50%;
background: #f97316; background: var(--color-anton-accent);
border: none;
cursor: pointer; cursor: pointer;
border: 2px solid var(--color-anton-bg);
}
/* ═══════════════════════════════════════════ */
/* Mobile-specific utilities */
/* ═══════════════════════════════════════════ */
@supports (height: 100dvh) {
.h-dvh {
height: 100dvh;
}
}
@supports not (height: 100dvh) {
.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 from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { AppProvider } from "./store"; import { AppProvider } from "./store";
import App from "./App";
import "./index.css"; import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
......
import React, { useEffect, useCallback } from "react"; import React, { useEffect } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { listChats } from "../api"; import { listChats } from "../api";
import Sidebar from "../components/Sidebar"; import Sidebar from "../components/Sidebar";
import ChatView from "../components/ChatView"; import ChatView from "../components/ChatView";
import { Flame, Paperclip, Layers, Zap } from "lucide-react"; import { Flame, MessageSquarePlus, Menu } from "lucide-react";
export default function ChatPage() { export default function ChatPage() {
const { state, dispatch } = useApp(); const { state, dispatch } = useApp();
const loadChats = useCallback(async () => { useEffect(() => {
(async () => {
try { try {
const chats = await listChats(state.token); const chats = await listChats(state.token);
dispatch({ type: "SET_CHATS", chats }); dispatch({ type: "SET_CHATS", chats });
} catch { /* ignore */ } } catch { }
})();
}, [state.token, dispatch]); }, [state.token, dispatch]);
useEffect(() => {
loadChats();
}, [loadChats]);
return ( return (
<div className="h-full flex"> <div className="h-dvh flex overflow-hidden relative">
<Sidebar onRefresh={loadChats} /> {/* Mobile overlay backdrop */}
<main className="flex-1 flex flex-col min-w-0"> {state.sidebarOpen && (
{state.activeChatId ? ( <div
<ChatView key={state.activeChatId} chatId={state.activeChatId} /> className="fixed inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden"
) : ( onClick={() => dispatch({ type: "CLOSE_SIDEBAR" })}
<EmptyState /> />
)} )}
</main>
</div>
);
}
function EmptyState() { {/* Sidebar — slides in on mobile, always visible on desktop */}
return ( <div
<div className="flex-1 flex items-center justify-center p-8"> className={`
<div className="text-center animate-fade-in max-w-lg"> fixed inset-y-0 left-0 z-40 w-72
<div className="inline-flex items-center justify-center w-24 h-24 rounded-3xl bg-gradient-to-br from-anton-accent/20 to-transparent border border-anton-accent/20 mb-6"> transform transition-transform duration-300 ease-in-out
<Flame size={44} className="text-anton-accent" /> lg:relative lg:translate-x-0 lg:z-auto
${state.sidebarOpen ? "translate-x-0" : "-translate-x-full"}
`}
>
<Sidebar />
</div> </div>
<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. Create a new chat to begin — but bring
real questions, not that first-result-of-Google garbage.
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-left"> {/* Main content area */}
<div className="bg-anton-surface border border-anton-border rounded-xl p-4"> <div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center gap-2 mb-2"> {/* Mobile top bar */}
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center"> <div className="flex items-center gap-3 px-4 py-3 border-b border-anton-border bg-anton-surface lg:hidden">
<Paperclip size={16} className="text-blue-400" /> <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"
>
<Menu size={22} />
</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> </div>
<span className="text-sm font-medium text-white">File Upload</span>
</div>
<p className="text-xs text-anton-muted leading-relaxed">
Drop images, videos, PDFs, or code files directly into chat. AI describes and analyzes them.
</p>
</div>
<div className="bg-anton-surface border border-anton-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Layers size={16} className="text-purple-400" />
</div>
<span className="text-sm font-medium text-white">Parallel Chats</span>
</div>
<p className="text-xs text-anton-muted leading-relaxed">
Run multiple conversations simultaneously. Switch between them while they stream.
</p>
</div> </div>
<div className="bg-anton-surface border border-anton-border rounded-xl p-4"> {/* Chat view or empty state */}
<div className="flex items-center gap-2 mb-2"> {state.activeChatId ? (
<div className="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center"> <ChatView chatId={state.activeChatId} />
<Zap size={16} className="text-green-400" /> ) : (
</div> <div className="flex-1 flex items-center justify-center p-6">
<span className="text-sm font-medium text-white">Full Code</span> <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">
<Flame size={40} className="text-white" />
</div> </div>
<p className="text-xs text-anton-muted leading-relaxed"> <h1 className="text-2xl font-bold text-white mb-2">Son of Anton</h1>
Production-ready code with syntax highlighting, download buttons, and ZIP export. <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> </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"
>
<MessageSquarePlus size={16} />
Open Chats
</button>
</div> </div>
</div> </div>
)}
</div> </div>
</div> </div>
); );
......
import React, { createContext, useContext, useReducer, useEffect } from "react"; import React, { createContext, useContext, useReducer, useEffect } from "react";
import { setDispatch } from "./streamManager";
const AppContext = createContext(); const AppContext = createContext(null);
const initialState = { const initialState = {
token: localStorage.getItem("token") || null, token: localStorage.getItem("token") || null,
user: JSON.parse(localStorage.getItem("user") || "null"), user: null,
chats: [], chats: [],
activeChatId: null, activeChatId: null,
sidebarOpen: true,
chatMessages: {}, chatMessages: {},
activeStreams: {}, activeStreams: {},
sidebarOpen: false, // mobile sidebar toggle
}; };
function reducer(state, action) { function reducer(state, action) {
switch (action.type) { switch (action.type) {
case "LOGIN": {
localStorage.setItem("token", action.token);
localStorage.setItem("user", JSON.stringify(action.user));
return { ...state, token: action.token, user: action.user };
}
case "SET_TOKEN": case "SET_TOKEN":
if (action.token) localStorage.setItem("token", action.token); localStorage.setItem("token", action.token);
else localStorage.removeItem("token");
return { ...state, token: action.token }; return { ...state, token: action.token };
case "SET_USER": case "SET_USER":
localStorage.setItem("user", JSON.stringify(action.user));
return { ...state, user: action.user }; return { ...state, user: action.user };
case "LOGOUT": case "LOGOUT":
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("user"); return { ...initialState, token: null };
return { ...initialState, token: null, user: null };
case "SET_CHATS": case "SET_CHATS":
return { ...state, chats: action.chats }; return { ...state, chats: action.chats };
case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId, sidebarOpen: false };
case "ADD_CHAT": case "ADD_CHAT":
return { return {
...state, ...state,
chats: [action.chat, ...state.chats], chats: [action.chat, ...state.chats],
activeChatId: action.chat.id, activeChatId: action.chat.id,
sidebarOpen: false,
}; };
case "UPDATE_CHAT": { case "UPDATE_CHAT": {
...@@ -52,65 +46,53 @@ function reducer(state, action) { ...@@ -52,65 +46,53 @@ function reducer(state, action) {
return { ...state, chats: updated }; return { ...state, chats: updated };
} }
case "REMOVE_CHAT": case "REMOVE_CHAT": {
case "DELETE_CHAT": { const filtered = state.chats.filter((c) => c.id !== action.chatId);
const chatId = action.chatId;
const filtered = state.chats.filter((c) => c.id !== chatId);
const newMessages = { ...state.chatMessages }; const newMessages = { ...state.chatMessages };
delete newMessages[chatId]; delete newMessages[action.chatId];
const newStreams = { ...state.activeStreams };
delete newStreams[chatId];
return { return {
...state, ...state,
chats: filtered, chats: filtered,
chatMessages: newMessages, chatMessages: newMessages,
activeStreams: newStreams, activeChatId: state.activeChatId === action.chatId ? null : state.activeChatId,
activeChatId:
state.activeChatId === chatId
? filtered[0]?.id || null
: state.activeChatId,
}; };
} }
case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId };
case "TOGGLE_SIDEBAR":
return { ...state, sidebarOpen: !state.sidebarOpen };
case "SET_MESSAGES": case "SET_MESSAGES":
return { return {
...state, ...state,
chatMessages: { chatMessages: { ...state.chatMessages, [action.chatId]: action.messages },
...state.chatMessages,
[action.chatId]: action.messages,
},
}; };
case "ADD_MESSAGE": case "ADD_MESSAGE": {
const prev = state.chatMessages[action.chatId] || [];
return { return {
...state, ...state,
chatMessages: { chatMessages: {
...state.chatMessages, ...state.chatMessages,
[action.chatId]: [ [action.chatId]: [...prev, action.message],
...(state.chatMessages[action.chatId] || []),
action.message,
],
}, },
}; };
}
case "SET_STREAMING": { case "SET_STREAMING":
if (action.streaming) { if (action.streaming) {
return { return { ...state, activeStreams: { ...state.activeStreams, [action.chatId]: true } };
...state, } else {
activeStreams: { ...state.activeStreams, [action.chatId]: true }, const s = { ...state.activeStreams };
}; delete s[action.chatId];
} return { ...state, activeStreams: s };
const next = { ...state.activeStreams };
delete next[action.chatId];
return { ...state, activeStreams: next };
} }
case "TOGGLE_SIDEBAR":
return { ...state, sidebarOpen: !state.sidebarOpen };
case "CLOSE_SIDEBAR":
return { ...state, sidebarOpen: false };
case "OPEN_SIDEBAR":
return { ...state, sidebarOpen: true };
default: default:
return state; return state;
} }
...@@ -118,11 +100,6 @@ function reducer(state, action) { ...@@ -118,11 +100,6 @@ function reducer(state, action) {
export function AppProvider({ children }) { export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
setDispatch(dispatch);
}, [dispatch]);
return ( return (
<AppContext.Provider value={{ state, dispatch }}> <AppContext.Provider value={{ state, dispatch }}>
{children} {children}
......
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ["./index.html", "./src/**/*.{js,jsx}"], content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
darkMode: "class",
theme: { theme: {
extend: { extend: {
colors: { colors: {
anton: { "anton-bg": "#09090f",
bg: "#0a0a0f", "anton-surface": "#0f0f18",
surface: "#12121a", "anton-card": "#161622",
card: "#1a1a28", "anton-border": "#1e1e30",
border: "#2a2a3a", "anton-text": "#e2e2f0",
accent: "#f97316", "anton-muted": "#6b6b8a",
accentDim: "#c2410c", "anton-accent": "#e63946",
text: "#e4e4ef", "anton-success": "#2ecc71",
muted: "#8888a0", "anton-danger": "#e74c3c",
user: "#1e293b",
assistant: "#15151f",
danger: "#ef4444",
success: "#22c55e",
},
}, },
fontFamily: { fontFamily: {
sans: ['"Inter"', "system-ui", "sans-serif"], sans: ['"Inter"', "system-ui", "sans-serif"],
mono: ['"JetBrains Mono"', '"Fira Code"', "monospace"], mono: ['"JetBrains Mono"', "monospace"],
}, },
animation: { height: {
"pulse-slow": "pulse 3s cubic-bezier(0.4,0,0.6,1) infinite", dvh: "100dvh",
"fade-in": "fadeIn 0.3s ease-out",
}, },
keyframes: { minHeight: {
fadeIn: { dvh: "100dvh",
"0%": { opacity: 0, transform: "translateY(8px)" },
"100%": { opacity: 1, transform: "translateY(0)" },
}, },
screens: {
xs: "480px",
}, },
}, },
}, },
......
...@@ -5,11 +5,20 @@ export default defineConfig({ ...@@ -5,11 +5,20 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
"/api": "http://localhost:8000", "/api": "http://localhost:80",
}, },
}, },
build: { build: {
outDir: "dist", // Content-hash all chunks so browsers fetch new versions
rollupOptions: {
output: {
entryFileNames: "assets/[name]-[hash].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash].[ext]",
},
},
// Generate a manifest for cache-busting verification
manifest: true,
sourcemap: false, sourcemap: false,
}, },
}); });
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment