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

ko

parent 44c66e72
...@@ -3,5 +3,5 @@ ...@@ -3,5 +3,5 @@
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.14" /> <option name="sdkName" value="Python 3.14" />
</component> </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> </project>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4"> <module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$">
<orderEntry type="jdk" jdkName="Python 3.14" jdkType="Python SDK" /> <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" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </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 ...@@ -21,10 +21,30 @@ from backend.routes.files_routes import router as files_router
from backend.services.bedrock_service import close_http_client 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# --- Startup --- # --- Startup ---
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_run_migrations()
seed_superadmin() seed_superadmin()
print("🔥 Son of Anton is online.") print("🔥 Son of Anton is online.")
yield yield
......
...@@ -49,6 +49,8 @@ class Chat(Base): ...@@ -49,6 +49,8 @@ class Chat(Base):
title = Column(String(200), default="New Chat") title = Column(String(200), default="New Chat")
model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1") model = Column(String(100), default="eu.anthropic.claude-opus-4-6-v1")
knowledge_base_id = Column(String(36), nullable=True) 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) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
......
...@@ -24,10 +24,16 @@ class CreateChatBody(BaseModel): ...@@ -24,10 +24,16 @@ class CreateChatBody(BaseModel):
title: str = "New Chat" title: str = "New Chat"
model: str = "eu.anthropic.claude-opus-4-6-v1" model: str = "eu.anthropic.claude-opus-4-6-v1"
knowledge_base_id: Optional[str] = None knowledge_base_id: Optional[str] = None
max_tokens: int = 4096
reasoning_budget: int = 0
class RenameChatBody(BaseModel): class UpdateChatBody(BaseModel):
title: str 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): class SendMessageBody(BaseModel):
...@@ -57,7 +63,9 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db ...@@ -57,7 +63,9 @@ def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db
user_id=user.id, user_id=user.id,
title=body.title, title=body.title,
model=body.model, 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.add(chat)
db.commit() db.commit()
...@@ -74,11 +82,21 @@ def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session = ...@@ -74,11 +82,21 @@ def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session =
@router.put("/{chat_id}") @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() chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: if not chat:
raise HTTPException(404) 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() db.commit()
return _chat_dict(chat) return _chat_dict(chat)
...@@ -219,6 +237,12 @@ async def send_message( ...@@ -219,6 +237,12 @@ async def send_message(
db.add(assistant_msg) db.add(assistant_msg)
db_user.tokens_used_this_month += input_tokens + output_tokens 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() chat.updated_at = datetime.utcnow()
db.commit() db.commit()
...@@ -264,6 +288,8 @@ def _chat_dict(c: Chat) -> dict: ...@@ -264,6 +288,8 @@ def _chat_dict(c: Chat) -> dict:
"title": c.title, "title": c.title,
"model": c.model, "model": c.model,
"knowledge_base_id": c.knowledge_base_id, "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), "created_at": str(c.created_at),
"updated_at": str(c.updated_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 from pydantic import BaseModel
...@@ -73,50 +73,80 @@ def delete_kb(kb_id: str, user: User = Depends(get_current_user), db: Session = ...@@ -73,50 +73,80 @@ def delete_kb(kb_id: str, user: User = Depends(get_current_user), db: Session =
@router.post("/{kb_id}/upload") @router.post("/{kb_id}/upload")
async def upload_document( async def upload_documents(
kb_id: str, kb_id: str,
file: UploadFile = File(...), files: list[UploadFile] = File(...),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Upload one or more documents to a knowledge base."""
kb = _get_kb(kb_id, user, db) kb = _get_kb(kb_id, user, db)
content_bytes = await file.read()
if len(content_bytes) > MAX_UPLOAD_BYTES: results = []
raise HTTPException(413, f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024}MB") total_new_docs = 0
total_new_chunks = 0
total_new_chars = 0
filename = file.filename or "document.txt" for file in files:
text = _extract_text(filename, content_bytes) filename = file.filename or "document.txt"
try:
if not text.strip(): content_bytes = await file.read()
raise HTTPException(400, "Could not extract text from file")
if len(content_bytes) > MAX_UPLOAD_BYTES:
chunks = _chunk_text(text, chunk_size=3000, overlap=300) results.append({
"filename": filename,
rag_service.add_documents( "error": f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024}MB",
collection_id=kb_id, })
documents=chunks, continue
metadatas=[{"filename": filename, "chunk_index": i} for i in range(len(chunks))],
) text = _extract_text(filename, content_bytes)
doc = KnowledgeDocument( if not text.strip():
knowledge_base_id=kb_id, results.append({"filename": filename, "error": "Could not extract text from file"})
filename=filename, continue
file_size=len(content_bytes),
chunk_count=len(chunks), chunks = _chunk_text(text, chunk_size=3000, overlap=300)
)
db.add(doc) 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 # Update KB aggregate stats
kb.chunk_count = (kb.chunk_count or 0) + len(chunks) kb.document_count = (kb.document_count or 0) + total_new_docs
kb.total_characters = (kb.total_characters or 0) + len(text) kb.chunk_count = (kb.chunk_count or 0) + total_new_chunks
kb.total_characters = (kb.total_characters or 0) + total_new_chars
db.commit() db.commit()
return { return {
"filename": filename, "files": results,
"chunks_added": len(chunks), "total_files": len(results),
"characters": len(text), "total_chunks_added": total_new_chunks,
"estimated_tokens": len(text) // 4, "total_characters": total_new_chars,
} }
......
...@@ -3,31 +3,40 @@ Build the `messages` list for the Bedrock/Anthropic API from chat history. ...@@ -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 Keeps the most recent messages that fit within a character budget
(rough proxy for tokens — 1 token ≈ 4 chars). (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 sqlalchemy.orm import Session
from backend.models import Chat, Message from backend.models import Chat, Message
# ~180 000 tokens budget → ~720 000 characters # ~100 000 tokens budget → ~400 000 characters
MAX_CONTEXT_CHARS = 720_000 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]: def build_messages(chat: Chat, db: Session) -> list[dict]:
""" """
Return a list of {"role": ..., "content": ...} ready for the API. Return a list of {"role": ..., "content": ...} ready for the API.
Messages are oldest-first (chronological). 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] = ( rows: list[Message] = (
db.query(Message) db.query(Message)
.filter(Message.chat_id == chat.id) .filter(Message.chat_id == chat.id)
.order_by(Message.created_at.asc()) .order_by(Message.created_at.desc())
.limit(MAX_MESSAGES)
.all() .all()
) )
rows.reverse()
if not rows: if not rows:
return [] 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) total_chars = sum(len(m.content or "") for m in rows)
idx = 0 idx = 0
while total_chars > MAX_CONTEXT_CHARS and idx < len(rows) - 2: while total_chars > MAX_CONTEXT_CHARS and idx < len(rows) - 2:
......
...@@ -37,8 +37,11 @@ export const listChats = (token) => ...@@ -37,8 +37,11 @@ export const listChats = (token) =>
export const createChat = (token, data = {}) => export const createChat = (token, data = {}) =>
request("POST", "/chats", 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) => export const renameChat = (token, chatId, title) =>
request("PUT", `/chats/${chatId}`, token, { title }); updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) => export const deleteChat = (token, chatId) =>
request("DELETE", `/chats/${chatId}`, token); request("DELETE", `/chats/${chatId}`, token);
...@@ -106,9 +109,11 @@ export const getKnowledgeBase = (token, kbId) => ...@@ -106,9 +109,11 @@ export const getKnowledgeBase = (token, kbId) =>
export const deleteKnowledgeBase = (token, kbId) => export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token); request("DELETE", `/knowledge/${kbId}`, token);
export async function uploadDocument(token, kbId, file) { export async function uploadDocuments(token, kbId, files) {
const form = new FormData(); 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`, { const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
...@@ -121,6 +126,10 @@ export async function uploadDocument(token, kbId, file) { ...@@ -121,6 +126,10 @@ export async function uploadDocument(token, kbId, file) {
return res.json(); return res.json();
} }
// Backward-compat wrapper for single file
export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
/* ── Admin ─────────────────────────────────── */ /* ── Admin ─────────────────────────────────── */
export const adminStats = (token) => export const adminStats = (token) =>
request("GET", "/admin/stats", token); request("GET", "/admin/stats", token);
......
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { import {
getMessages, streamMessage, downloadZip, listKnowledgeBases, getMessages, streamMessage, downloadZip, listKnowledgeBases, updateChat,
} from "../api"; } from "../api";
import MessageBubble from "./MessageBubble"; import MessageBubble from "./MessageBubble";
import { import {
...@@ -15,27 +15,47 @@ const MODELS = [ ...@@ -15,27 +15,47 @@ const MODELS = [
export default function ChatView({ chatId }) { export default function ChatView({ chatId }) {
const { state, dispatch } = useApp(); 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 [messages, setMessages] = useState([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [model, setModel] = useState(MODELS[0].id); const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(4096); const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(0); const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [selectedKbId, setSelectedKbId] = useState(null); const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [kbs, setKbs] = useState([]); const [kbs, setKbs] = useState([]);
const [streamText, setStreamText] = useState(""); const [streamText, setStreamText] = useState("");
const [streamThinking, setStreamThinking] = useState(""); const [streamThinking, setStreamThinking] = useState("");
const [isThinking, setIsThinking] = useState(false); const [isThinking, setIsThinking] = useState(false);
const bottomRef = useRef(null); const scrollContainerRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const abortRef = 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(() => { const scrollToBottom = useCallback(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); 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(() => { useEffect(() => {
(async () => { (async () => {
try { try {
...@@ -47,12 +67,39 @@ export default function ChatView({ chatId }) { ...@@ -47,12 +67,39 @@ export default function ChatView({ chatId }) {
})(); })();
}, [chatId, state.token]); }, [chatId, state.token]);
useEffect(scroll, [messages, streamText, streamThinking, scroll]); // Scroll when messages change or stream updates
useEffect(scrollToBottom, [messages, streamText, streamThinking, scrollToBottom]);
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, [chatId]); }, [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() { async function handleSend() {
const content = input.trim(); const content = input.trim();
if (!content || streaming) return; if (!content || streaming) return;
...@@ -64,6 +111,7 @@ export default function ChatView({ chatId }) { ...@@ -64,6 +111,7 @@ export default function ChatView({ chatId }) {
setStreamText(""); setStreamText("");
setStreamThinking(""); setStreamThinking("");
setIsThinking(false); setIsThinking(false);
shouldAutoScrollRef.current = true; // Force scroll for user's own message
const ac = new AbortController(); const ac = new AbortController();
abortRef.current = ac; abortRef.current = ac;
...@@ -126,6 +174,12 @@ export default function ChatView({ chatId }) { ...@@ -126,6 +174,12 @@ export default function ChatView({ chatId }) {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}; };
setMessages((p) => [...p, assistantMsg]); 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) { } catch (err) {
setMessages((p) => [ setMessages((p) => [
...p, ...p,
...@@ -163,7 +217,11 @@ export default function ChatView({ chatId }) { ...@@ -163,7 +217,11 @@ export default function ChatView({ chatId }) {
return ( return (
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
{/* Messages */} {/* 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) => ( {messages.map((m) => (
<MessageBubble key={m.id} message={m} /> <MessageBubble key={m.id} message={m} />
))} ))}
...@@ -192,8 +250,6 @@ export default function ChatView({ chatId }) { ...@@ -192,8 +250,6 @@ export default function ChatView({ chatId }) {
<span className="text-anton-muted text-sm">Son of Anton is thinking…</span> <span className="text-anton-muted text-sm">Son of Anton is thinking…</span>
</div> </div>
)} )}
<div ref={bottomRef} />
</div> </div>
{/* Input area */} {/* Input area */}
...@@ -205,7 +261,7 @@ export default function ChatView({ chatId }) { ...@@ -205,7 +261,7 @@ export default function ChatView({ chatId }) {
<h3 className="text-sm font-semibold text-white flex items-center gap-1.5"> <h3 className="text-sm font-semibold text-white flex items-center gap-1.5">
<Settings2 size={14} className="text-anton-accent" /> Generation Settings <Settings2 size={14} className="text-anton-accent" /> Generation Settings
</h3> </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> </div>
{/* Model */} {/* Model */}
...@@ -270,7 +326,7 @@ export default function ChatView({ chatId }) { ...@@ -270,7 +326,7 @@ export default function ChatView({ chatId }) {
)} )}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<button onClick={() => setShowSettings(!showSettings)} <button onClick={toggleSettings}
className={`p-2.5 rounded-xl transition shrink-0 ${ 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" 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"; ...@@ -4,7 +4,7 @@ import remarkGfm from "remark-gfm";
import CodeBlock from "./CodeBlock"; import CodeBlock from "./CodeBlock";
import { User, Flame, ChevronDown, ChevronRight, Brain, Copy, Check } from "lucide-react"; 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 { role, content, thinking_content, input_tokens, output_tokens } = message;
const isUser = role === "user"; const isUser = role === "user";
const [showThinking, setShowThinking] = useState(false); const [showThinking, setShowThinking] = useState(false);
...@@ -133,4 +133,6 @@ export default function MessageBubble({ message, isStreaming, isThinking }) { ...@@ -133,4 +133,6 @@ export default function MessageBubble({ message, isStreaming, isThinking }) {
)} )}
</div> </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"; ...@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useApp } from "../store"; import { useApp } from "../store";
import { import {
createChat, deleteChat, renameChat, createChat, deleteChat, renameChat,
listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocument, listKnowledgeBases, createKnowledgeBase, deleteKnowledgeBase, uploadDocuments,
} from "../api"; } from "../api";
import { import {
Plus, Trash2, Flame, LogOut, Shield, PanelLeftClose, PanelLeftOpen, Plus, Trash2, Flame, LogOut, Shield, PanelLeftClose, PanelLeftOpen,
...@@ -20,6 +20,7 @@ export default function Sidebar({ onRefresh }) { ...@@ -20,6 +20,7 @@ export default function Sidebar({ onRefresh }) {
const [showNewKb, setShowNewKb] = useState(false); const [showNewKb, setShowNewKb] = useState(false);
const [expandedKb, setExpandedKb] = useState(null); const [expandedKb, setExpandedKb] = useState(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadCount, setUploadCount] = useState(0);
const [renamingId, setRenamingId] = useState(null); const [renamingId, setRenamingId] = useState(null);
const [renameVal, setRenameVal] = useState(""); const [renameVal, setRenameVal] = useState("");
...@@ -75,15 +76,25 @@ export default function Sidebar({ onRefresh }) { ...@@ -75,15 +76,25 @@ export default function Sidebar({ onRefresh }) {
} catch { /* */ } } catch { /* */ }
} }
async function handleUpload(kbId, file) { async function handleUpload(kbId, files) {
setUploading(true); setUploading(true);
setUploadCount(files.length);
try { 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(); loadKbs();
} catch (e) { } catch (e) {
alert(e.message); alert(e.message);
} finally { } finally {
setUploading(false); setUploading(false);
setUploadCount(0);
} }
} }
...@@ -221,9 +232,17 @@ export default function Sidebar({ onRefresh }) { ...@@ -221,9 +232,17 @@ export default function Sidebar({ onRefresh }) {
</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" : ""}`}> <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} /> <Upload size={12} />
{uploading ? "Uploading…" : "Upload file (.txt, .pdf, .md, .json, .csv …)"} {uploading
<input type="file" className="hidden" accept=".txt,.md,.pdf,.json,.csv,.py,.js,.ts,.cs,.html,.css,.xml,.yaml,.yml,.toml" ? `Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…`
onChange={(e) => e.target.files[0] && handleUpload(kb.id, e.target.files[0])} : "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> </label>
<button onClick={() => handleDeleteKb(kb.id)} <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