Commit 18d64a46 authored by Mahmoud Aglan's avatar Mahmoud Aglan

ko

parent 44c66e72
......@@ -3,5 +3,5 @@
<component name="Black">
<option name="sdkName" value="Python 3.14" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (son-of-anton)" project-jdk-type="Python SDK" />
</project>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.14" jdkType="Python SDK" />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.11 (son-of-anton)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
\ No newline at end of file
This diff is collapsed.
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -21,10 +21,30 @@ from backend.routes.files_routes import router as files_router
from backend.services.bedrock_service import close_http_client
def _run_migrations():
"""Add new columns to existing tables if they're missing (lightweight migration)."""
from sqlalchemy import inspect, text
try:
inspector = inspect(engine)
if "chats" in inspector.get_table_names():
columns = {c["name"] for c in inspector.get_columns("chats")}
with engine.connect() as conn:
if "max_tokens" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN max_tokens INTEGER DEFAULT 4096"))
print(" ✅ Added chats.max_tokens column")
if "reasoning_budget" not in columns:
conn.execute(text("ALTER TABLE chats ADD COLUMN reasoning_budget INTEGER DEFAULT 0"))
print(" ✅ Added chats.reasoning_budget column")
conn.commit()
except Exception as e:
print(f" ⚠️ Migration note: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- Startup ---
Base.metadata.create_all(bind=engine)
_run_migrations()
seed_superadmin()
print("🔥 Son of Anton is online.")
yield
......
......@@ -49,6 +49,8 @@ class Chat(Base):
title = Column(String(200), default="New Chat")
model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1")
knowledge_base_id = Column(String(36), nullable=True)
max_tokens = Column(Integer, default=4096)
reasoning_budget = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
......
......@@ -24,10 +24,16 @@ class CreateChatBody(BaseModel):
title: str = "New Chat"
model: str = "eu.anthropic.claude-opus-4-6-v1"
knowledge_base_id: Optional[str] = None
max_tokens: int = 4096
reasoning_budget: int = 0
class RenameChatBody(BaseModel):
title: str
class UpdateChatBody(BaseModel):
title: Optional[str] = None
model: Optional[str] = None
max_tokens: Optional[int] = None
reasoning_budget: Optional[int] = None
knowledge_base_id: Optional[str] = None
class SendMessageBody(BaseModel):
......@@ -57,7 +63,9 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db
user_id=user.id,
title=body.title,
model=body.model,
knowledge_base_id=body.knowledge_base_id,
knowledge_base_id=body.knowledge_base_id or None,
max_tokens=body.max_tokens,
reasoning_budget=body.reasoning_budget,
)
db.add(chat)
db.commit()
......@@ -74,11 +82,21 @@ def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session =
@router.put("/{chat_id}")
def rename_chat(chat_id: str, body: RenameChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404)
chat.title = body.title
if body.title is not None:
chat.title = body.title
if body.model is not None:
chat.model = body.model
if body.max_tokens is not None:
chat.max_tokens = body.max_tokens
if body.reasoning_budget is not None:
chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not None:
# Empty string means "clear the KB"
chat.knowledge_base_id = body.knowledge_base_id or None
db.commit()
return _chat_dict(chat)
......@@ -219,6 +237,12 @@ async def send_message(
db.add(assistant_msg)
db_user.tokens_used_this_month += input_tokens + output_tokens
# Persist the generation settings used for this message onto the chat
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()
......@@ -264,6 +288,8 @@ def _chat_dict(c: Chat) -> dict:
"title": c.title,
"model": c.model,
"knowledge_base_id": c.knowledge_base_id,
"max_tokens": c.max_tokens or 4096,
"reasoning_budget": c.reasoning_budget or 0,
"created_at": str(c.created_at),
"updated_at": str(c.updated_at),
}
......
"""
Knowledge base management and document upload.
Knowledge base management and document upload (supports multiple files at once).
"""
from pydantic import BaseModel
......@@ -73,50 +73,80 @@ def delete_kb(kb_id: str, user: User = Depends(get_current_user), db: Session =
@router.post("/{kb_id}/upload")
async def upload_document(
async def upload_documents(
kb_id: str,
file: UploadFile = File(...),
files: list[UploadFile] = File(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Upload one or more documents to a knowledge base."""
kb = _get_kb(kb_id, user, db)
content_bytes = await file.read()
if len(content_bytes) > MAX_UPLOAD_BYTES:
raise HTTPException(413, f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024}MB")
results = []
total_new_docs = 0
total_new_chunks = 0
total_new_chars = 0
filename = file.filename or "document.txt"
text = _extract_text(filename, content_bytes)
if not text.strip():
raise HTTPException(400, "Could not extract text from file")
chunks = _chunk_text(text, chunk_size=3000, overlap=300)
rag_service.add_documents(
collection_id=kb_id,
documents=chunks,
metadatas=[{"filename": filename, "chunk_index": i} for i in range(len(chunks))],
)
doc = KnowledgeDocument(
knowledge_base_id=kb_id,
filename=filename,
file_size=len(content_bytes),
chunk_count=len(chunks),
)
db.add(doc)
for file in files:
filename = file.filename or "document.txt"
try:
content_bytes = await file.read()
if len(content_bytes) > MAX_UPLOAD_BYTES:
results.append({
"filename": filename,
"error": f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024}MB",
})
continue
text = _extract_text(filename, content_bytes)
if not text.strip():
results.append({"filename": filename, "error": "Could not extract text from file"})
continue
chunks = _chunk_text(text, chunk_size=3000, overlap=300)
rag_service.add_documents(
collection_id=kb_id,
documents=chunks,
metadatas=[{"filename": filename, "chunk_index": i} for i in range(len(chunks))],
)
doc = KnowledgeDocument(
knowledge_base_id=kb_id,
filename=filename,
file_size=len(content_bytes),
chunk_count=len(chunks),
)
db.add(doc)
total_new_docs += 1
total_new_chunks += len(chunks)
total_new_chars += len(text)
results.append({
"filename": filename,
"chunks_added": len(chunks),
"characters": len(text),
"estimated_tokens": len(text) // 4,
})
except HTTPException as e:
results.append({"filename": filename, "error": str(e.detail)})
except Exception as e:
results.append({"filename": filename, "error": str(e)})
kb.document_count = (kb.document_count or 0) + 1
kb.chunk_count = (kb.chunk_count or 0) + len(chunks)
kb.total_characters = (kb.total_characters or 0) + len(text)
# Update KB aggregate stats
kb.document_count = (kb.document_count or 0) + total_new_docs
kb.chunk_count = (kb.chunk_count or 0) + total_new_chunks
kb.total_characters = (kb.total_characters or 0) + total_new_chars
db.commit()
return {
"filename": filename,
"chunks_added": len(chunks),
"characters": len(text),
"estimated_tokens": len(text) // 4,
"files": results,
"total_files": len(results),
"total_chunks_added": total_new_chunks,
"total_characters": total_new_chars,
}
......
......@@ -3,31 +3,40 @@ Build the `messages` list for the Bedrock/Anthropic API from chat history.
Keeps the most recent messages that fit within a character budget
(rough proxy for tokens — 1 token ≈ 4 chars).
Limits the total number of DB rows loaded to prevent hangs on very long chats.
"""
from sqlalchemy.orm import Session
from backend.models import Chat, Message
# ~180 000 tokens budget → ~720 000 characters
MAX_CONTEXT_CHARS = 720_000
# ~100 000 tokens budget → ~400 000 characters
MAX_CONTEXT_CHARS = 400_000
# Hard cap: never load more than this many messages from the DB
MAX_MESSAGES = 80
def build_messages(chat: Chat, db: Session) -> list[dict]:
"""
Return a list of {"role": ..., "content": ...} ready for the API.
Messages are oldest-first (chronological).
Only the most recent MAX_MESSAGES are considered, then trimmed by char budget.
"""
# Fetch the most recent N messages (descending) then reverse to chronological
rows: list[Message] = (
db.query(Message)
.filter(Message.chat_id == chat.id)
.order_by(Message.created_at.asc())
.order_by(Message.created_at.desc())
.limit(MAX_MESSAGES)
.all()
)
rows.reverse()
if not rows:
return []
# --- trim from the oldest to fit budget ---
# --- trim from the oldest to fit character budget ---
total_chars = sum(len(m.content or "") for m in rows)
idx = 0
while total_chars > MAX_CONTEXT_CHARS and idx < len(rows) - 2:
......
......@@ -37,8 +37,11 @@ export const listChats = (token) =>
export const createChat = (token, data = {}) =>
request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) =>
request("PUT", `/chats/${chatId}`, token, data);
export const renameChat = (token, chatId, title) =>
request("PUT", `/chats/${chatId}`, token, { title });
updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) =>
request("DELETE", `/chats/${chatId}`, token);
......@@ -106,9 +109,11 @@ export const getKnowledgeBase = (token, kbId) =>
export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export async function uploadDocument(token, kbId, file) {
export async function uploadDocuments(token, kbId, files) {
const form = new FormData();
form.append("file", file);
for (const file of files) {
form.append("files", file);
}
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
......@@ -121,6 +126,10 @@ export async function uploadDocument(token, kbId, file) {
return res.json();
}
// Backward-compat wrapper for single file
export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
/* ── Admin ─────────────────────────────────── */
export const adminStats = (token) =>
request("GET", "/admin/stats", token);
......
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store";
import {
getMessages, streamMessage, downloadZip, listKnowledgeBases,
getMessages, streamMessage, downloadZip, listKnowledgeBases, updateChat,
} from "../api";
import MessageBubble from "./MessageBubble";
import {
......@@ -15,27 +15,47 @@ const MODELS = [
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
// ── Load persisted settings from the chat object ──
const currentChat = state.chats.find((c) => c.id === chatId);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [model, setModel] = useState(MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(4096);
const [reasoningBudget, setReasoningBudget] = useState(0);
const [selectedKbId, setSelectedKbId] = useState(null);
const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [kbs, setKbs] = useState([]);
const [streamText, setStreamText] = useState("");
const [streamThinking, setStreamThinking] = useState("");
const [isThinking, setIsThinking] = useState(false);
const bottomRef = useRef(null);
const scrollContainerRef = useRef(null);
const inputRef = useRef(null);
const abortRef = useRef(null);
const shouldAutoScrollRef = useRef(true);
const rafRef = useRef(null);
// ── Scroll helpers ──
function handleContainerScroll() {
const el = scrollContainerRef.current;
if (!el) return;
const { scrollHeight, scrollTop, clientHeight } = el;
shouldAutoScrollRef.current = scrollHeight - scrollTop - clientHeight < 200;
}
const scroll = useCallback(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
const scrollToBottom = useCallback(() => {
if (!shouldAutoScrollRef.current) return;
if (rafRef.current) return; // already scheduled
rafRef.current = requestAnimationFrame(() => {
const el = scrollContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
rafRef.current = null;
});
}, []);
// ── Load messages & KBs on mount ──
useEffect(() => {
(async () => {
try {
......@@ -47,12 +67,39 @@ export default function ChatView({ chatId }) {
})();
}, [chatId, state.token]);
useEffect(scroll, [messages, streamText, streamThinking, scroll]);
// Scroll when messages change or stream updates
useEffect(scrollToBottom, [messages, streamText, streamThinking, scrollToBottom]);
useEffect(() => {
inputRef.current?.focus();
}, [chatId]);
// ── Save settings to backend ──
async function saveSettings() {
const data = {
model,
max_tokens: maxTokens,
reasoning_budget: reasoningBudget,
knowledge_base_id: selectedKbId || "",
};
try {
await updateChat(state.token, chatId, data);
dispatch({
type: "UPDATE_CHAT",
chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId },
});
} catch { /* ignore */ }
}
function toggleSettings() {
const closing = showSettings;
setShowSettings(!showSettings);
if (closing) {
saveSettings();
}
}
// ── Send message ──
async function handleSend() {
const content = input.trim();
if (!content || streaming) return;
......@@ -64,6 +111,7 @@ export default function ChatView({ chatId }) {
setStreamText("");
setStreamThinking("");
setIsThinking(false);
shouldAutoScrollRef.current = true; // Force scroll for user's own message
const ac = new AbortController();
abortRef.current = ac;
......@@ -126,6 +174,12 @@ export default function ChatView({ chatId }) {
created_at: new Date().toISOString(),
};
setMessages((p) => [...p, assistantMsg]);
// Sync settings to store (backend already saved them from the message)
dispatch({
type: "UPDATE_CHAT",
chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId },
});
} catch (err) {
setMessages((p) => [
...p,
......@@ -163,7 +217,11 @@ export default function ChatView({ chatId }) {
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
<div
ref={scrollContainerRef}
onScroll={handleContainerScroll}
className="flex-1 overflow-y-auto px-4 py-4 space-y-4"
>
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
))}
......@@ -192,8 +250,6 @@ export default function ChatView({ chatId }) {
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input area */}
......@@ -205,7 +261,7 @@ export default function ChatView({ chatId }) {
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5">
<Settings2 size={14} className="text-anton-accent" /> Generation Settings
</h3>
<button onClick={() => setShowSettings(false)} className="text-anton-muted hover:text-white"><X size={14} /></button>
<button onClick={toggleSettings} className="text-anton-muted hover:text-white"><X size={14} /></button>
</div>
{/* Model */}
......@@ -270,7 +326,7 @@ export default function ChatView({ chatId }) {
)}
<div className="flex items-end gap-2">
<button onClick={() => setShowSettings(!showSettings)}
<button onClick={toggleSettings}
className={`p-2.5 rounded-xl transition shrink-0 ${
showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"
}`}
......
......@@ -4,7 +4,7 @@ import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock";
import { User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check } from "lucide-react";
export default function MessageBubble({ message, isStreaming, isThinking }) {
const MessageBubble = React.memo(function MessageBubble({ message, isStreaming, isThinking }) {
const { role, content, thinking_content, input_tokens, output_tokens } = message;
const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false);
......@@ -133,4 +133,6 @@ export default function MessageBubble({ message, isStreaming, isThinking }) {
)}
</div>
);
}
\ No newline at end of file
});
export default MessageBubble;
\ No newline at end of file
......@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import {
createChat, deleteChat, renameChat,
listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocument,
listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocuments,
} from "../api";
import {
Plus, Trash2, Flame, LogOut, Shield, PanelLeftClose, PanelLeftOpen,
......@@ -20,6 +20,7 @@ export default function Sidebar({ onRefresh }) {
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("");
......@@ -75,15 +76,25 @@ export default function Sidebar({ onRefresh }) {
} catch { /* */ }
}
async function handleUpload(kbId, file) {
async function handleUpload(kbId, files) {
setUploading(true);
setUploadCount(files.length);
try {
await uploadDocument(state.token, kbId, file);
const result = await uploadDocuments(state.token, kbId, files);
// Check for per-file errors
const errors = (result.files || []).filter((f) => f.error);
if (errors.length > 0) {
alert(
`Uploaded ${result.files.length - errors.length} of ${result.files.length} files.\n\nErrors:\n` +
errors.map((e) => `• ${e.filename}: ${e.error}`).join("\n")
);
}
loadKbs();
} catch (e) {
alert(e.message);
} finally {
setUploading(false);
setUploadCount(0);
}
}
......@@ -221,9 +232,17 @@ export default function Sidebar({ onRefresh }) {
</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…" : "Upload file (.txt, .pdf, .md, .json, .csv …)"}
<input type="file" className="hidden" accept=".txt,.md,.pdf,.json,.csv,.py,.js,.ts,.cs,.html,.css,.xml,.yaml,.yml,.toml"
onChange={(e) => e.target.files[0] && handleUpload(kb.id, e.target.files[0])}
{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)}
......
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