Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class ChatMessage(BaseModel):
content: str
timestamp: Optional[datetime] = None
sources: List[str] = []
benchmarks: Optional[dict] = None


class ChatRequest(BaseModel):
Expand Down
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ python-dotenv==1.0.1
httpx==0.27.0
pytest==8.3.0
pytest-asyncio==0.24.0
psutil
34 changes: 31 additions & 3 deletions backend/routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@
from models.schemas import ChatRequest, ChatResponse
from services import ollama_service, db_service

router = APIRouter()
import time
import psutil

def _get_memory_usage():
mem = psutil.virtual_memory()
return round(mem.used / (1024 ** 3), 1), round(mem.total / (1024 ** 3), 1)

router = APIRouter()

def _retrieve_context(*args, **kwargs):
from services import rag_service as rag_service_module
Expand Down Expand Up @@ -57,6 +63,9 @@ async def chat_stream(req: ChatRequest):
"""Streaming chat — returns Server-Sent Events."""
if not await ollama_service.is_ollama_running():
raise HTTPException(503, "Ollama not running. Run: `ollama serve`")

first_token_time = None
start_time = time.perf_counter()

db_service.create_session(req.session_id, model=req.model)
history = db_service.get_history(req.session_id)
Expand All @@ -70,6 +79,8 @@ async def chat_stream(req: ChatRequest):
full_reply = []

async def event_stream():
nonlocal first_token_time
token_count = 0
async for token in ollama_service.chat_stream(
message=req.message,
model=req.model,
Expand All @@ -78,12 +89,29 @@ async def event_stream():
language=req.language,
temperature=req.temperature,
):
if first_token_time is None:
first_token_time = time.perf_counter()
full_reply.append(token)
token_count += 1
yield f"data: {json.dumps({'token': token})}\n\n"

end_time = time.perf_counter()

complete = "".join(full_reply)
db_service.save_message(req.session_id, "assistant", complete, sources)
yield f"data: {json.dumps({'done': True, 'sources': sources})}\n\n"
ttft_ms = round((first_token_time - start_time) * 1000) if first_token_time else 0
total_duration_ms = round((end_time - start_time) * 1000)
memory_used_gb, memory_total_gb = _get_memory_usage()

benchmarks = {
"ttft_ms": ttft_ms,
"total_duration_ms": total_duration_ms,
"token_count": token_count,
"memory_used_gb": memory_used_gb,
"memory_total_gb": memory_total_gb,
}

db_service.save_message(req.session_id, "assistant", complete, sources, benchmarks)
yield f"data: {json.dumps({'done': True, 'sources': sources, 'benchmarks': benchmarks})}\n\n"


return StreamingResponse(event_stream(), media_type="text/event-stream")
14 changes: 9 additions & 5 deletions backend/services/db_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def init_db():
content TEXT NOT NULL,
sources TEXT DEFAULT '[]',
created_at TEXT DEFAULT (datetime('now')),
benchmarks TEXT DEFAULT '{}',
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);

Expand Down Expand Up @@ -128,7 +129,9 @@ def init_db():
except sqlite3.OperationalError:
pass # column already exists


cols = [row[1] for row in conn.execute("PRAGMA table_info(messages)").fetchall()]
if "benchmarks" not in cols:
conn.execute("ALTER TABLE messages ADD COLUMN benchmarks TEXT DEFAULT '{}'")
# ─── Sessions ────────────────────────────────────────────────
def create_session(session_id: str, title: str = "New Chat", model: str = "llama3") -> dict:
with get_db() as conn:
Expand Down Expand Up @@ -173,12 +176,12 @@ def get_all_sessions() -> list[dict]:


# ─── Messages ────────────────────────────────────────────────
def save_message(session_id: str, role: str, content: str, sources: list = None):
def save_message(session_id: str, role: str, content: str, sources: list = None, benchmarks: dict = None):
sources = sources or []
with get_db() as conn:
conn.execute(
"INSERT INTO messages (session_id, role, content, sources) VALUES (?,?,?,?)",
(session_id, role, content, json.dumps(sources)),
"INSERT INTO messages (session_id, role, content, sources, benchmarks) VALUES (?,?,?,?,?)",
(session_id, role, content, json.dumps(sources), json.dumps(benchmarks)),
)
conn.execute(
"UPDATE sessions SET updated_at=datetime('now'), message_count=message_count+1 WHERE id=?",
Expand Down Expand Up @@ -206,7 +209,7 @@ def get_history(session_id: str, limit: int = 20) -> list[dict]:
def get_messages_full(session_id: str) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT role, content, sources, created_at FROM messages WHERE session_id=? ORDER BY created_at ASC",
"SELECT role, content, sources, created_at, benchmarks FROM messages WHERE session_id=? ORDER BY created_at ASC",
(session_id,),
).fetchall()
return [
Expand All @@ -215,6 +218,7 @@ def get_messages_full(session_id: str) -> list[dict]:
"content": r["content"],
"sources": json.loads(r["sources"] or "[]"),
"created_at": r["created_at"],
"benchmarks": json.loads(r["benchmarks"] or {})
}
for r in rows
]
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ export default function App() {
await api.streamMessage(
{ message: text, session_id: activeSid, model, use_documents: documents.length > 0, language },
(token) => setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, content: m.content + token } : m)),
(sources) => {
setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, sources, streaming: false } : m));
(sources, benchmarks) => {
setMessages(prev => prev.map(m => m.id === aiMsg.id ? { ...m, sources, benchmarks, streaming: false } : m));
refreshSessions();
}
);
Expand Down
215 changes: 199 additions & 16 deletions frontend/src/components/ChatWindow.jsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,84 @@
import { useState, useRef, useEffect } from "react";
import { exportSession } from "../utils/api";
import { AppLogoIcon, FileIcon, LockIcon } from "./Icons";
import { AppLogoIcon, ChartIcon, CloseIcon, CopyIcon, FileIcon, LockIcon, PlusCircleIcon, TemplateIcon } from "./Icons";
import CodeBlockWithCopy from "./CodeBlockWithCopy";
import PromptTemplateDialog from "./PromptTemplateDialog";

export default function ChatWindow({ messages, loading, onSend, sessionId }) {
const [input, setInput] = useState("");
const [showPlusMenu, setShowPlusMenu] = useState(false);
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState(null);
const bottomRef = useRef(null);
const textareaRef = useRef(null);
const plusMenuRef = useRef(null);

// NEW: state for selected messages and export format
const [selectedMessages, setSelectedMessages] = useState([]);
const [exportFormat, setExportFormat] = useState("markdown");
const [copiedMsgId, setCopiedMsgId] = useState(null);
const [hoveredStatsId, setHoveredStatsId] = useState(null);

useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]);

// Close plus menu on outside click
useEffect(() => {
function handleClickOutside(e) {
if (plusMenuRef.current && !plusMenuRef.current.contains(e.target)) {
setShowPlusMenu(false);
}
}
if (showPlusMenu) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showPlusMenu]);

function copyToClipboard(msgId, content) {
navigator.clipboard.writeText(content);
setCopiedMsgId(msgId);
setTimeout(() => setCopiedMsgId(null), 2000);
}

function handleSelectTemplate(template) {
setSelectedTemplate(template);
setShowTemplateDialog(false);
setShowPlusMenu(false);
setTimeout(() => textareaRef.current?.focus(), 0);
}

// Parse code blocks for copy button
function parseMessageWithCodeBlocks(content) {
if (!content) return [{ type: "text", content: "" }];
const parts = [];
const regex = /```(\w*)\n([\s\S]*?)```/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(content)) !== null) {
if (match.index > lastIndex) {
parts.push({ type: "text", content: content.slice(lastIndex, match.index) });
}
parts.push({
type: "code",
language: match[1] || "text",
code: match[2].trim()
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < content.length) {
parts.push({ type: "text", content: content.slice(lastIndex) });
}
if (parts.length === 0) {
parts.push({ type: "text", content });
}
return parts;
}
function send() {
if (!input.trim() || loading) return;
onSend(input.trim());
if ((!input.trim() && !selectedTemplate) || loading) return;
const message = selectedTemplate
? `${selectedTemplate.prompt}\n\n${input.trim()}`.trim()
: input.trim();
onSend(message);
setInput("");
setSelectedTemplate(null);
if (textareaRef.current) { textareaRef.current.style.height = "auto"; }
}

Expand Down Expand Up @@ -100,6 +166,74 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
<span className="text-xs text-gray-600">You</span>
</div>
)}
{msg.role === "assistant" && !msg.streaming && (
<div className="flex justify-end mt-1.5 mr-1 items-center gap-1">
{/* Copy button */}
<button
onClick={() => copyToClipboard(msg.id, msg.content)}
className="p-1 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition"
title="Copy response"
>
{copiedMsgId === msg.id ? (
<svg className="w-4 h-4 text-green-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
) : (
<CopyIcon className="w-4 h-4" />
)}
</button>

{/* Stats hover button */}
<div
className="relative"
onMouseEnter={() => setHoveredStatsId(msg.id)}
onMouseLeave={() => setHoveredStatsId(null)}
>
<button
className="p-1 rounded hover:bg-gray-800 text-gray-500 hover:text-gray-300 transition"
title="Performance stats"
>
<ChartIcon className="w-4 h-4" />
</button>

{hoveredStatsId === msg.id && msg.benchmarks && Object.keys(msg.benchmarks).length > 0 && (
<div className="absolute right-0 bottom-0 translate-x-full pl-2 z-50">
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 shadow-xl min-w-[220px]">
<p className="text-xs font-semibold text-gray-300 mb-2">Performance</p>
<div className="space-y-1.5 text-xs text-gray-400">
<div className="flex justify-between">
<span>Time to first token</span>
<span className="text-gray-300">{(msg.benchmarks.ttft_ms / 1000).toFixed(2)}s</span>
</div>
<div className="flex justify-between">
<span>Total duration</span>
<span className="text-gray-300">{(msg.benchmarks.total_duration_ms / 1000).toFixed(2)}s</span>
</div>
<div className="flex justify-between">
<span>Tokens generated</span>
<span className="text-gray-300">{msg.benchmarks.token_count}</span>
</div>
{msg.benchmarks.memory_used_gb && (
<div>
<div className="flex justify-between items-center">
<span>RAM usage</span>
<span className="inline-flex items-center gap-1 text-gray-300">
{msg.benchmarks.memory_used_gb} / {msg.benchmarks.memory_total_gb} GB
<span className="group relative">
<span className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-gray-600 text-gray-500 text-[9px] font-bold cursor-help leading-none">i</span>
<span className="hidden group-hover:block absolute right-0 top-full mt-1 bg-gray-800 border border-gray-600 rounded-md px-2 py-1.5 text-[10px] text-gray-400 w-[180px] leading-tight z-50 shadow-lg">
Total system memory in use across all processes, not just the LLM.
</span>
</span>
</span>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
))}
Expand All @@ -123,21 +257,70 @@ export default function ChatWindow({ messages, loading, onSend, sessionId }) {
<div ref={bottomRef} />
</div>

{/* Input */}
{/* Prompt Template Dialog */}
{showTemplateDialog && (
<PromptTemplateDialog
onSelect={handleSelectTemplate}
onClose={() => { setShowTemplateDialog(false); setShowPlusMenu(false); }}
/>
)}

{/* Input Form Footer */}
<div className="px-4 pb-4 pt-2 shrink-0">
<div className="flex items-end gap-2 bg-gray-900 border border-gray-700 rounded-2xl px-4 py-3 focus-within:border-purple-500 transition-colors">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(e); }}
onKeyDown={handleKey}
placeholder="Ask anything... (Enter to send, Shift+Enter for new line)"
rows={1}
className="flex-1 bg-transparent text-sm text-gray-100 placeholder-gray-500 resize-none outline-none"
style={{ minHeight: "24px", maxHeight: "160px" }}
/>
<button onClick={send} disabled={!input.trim() || loading}
className="shrink-0 text-sm bg-purple-600 hover:bg-purple-500 disabled:opacity-40 disabled:cursor-not-allowed text-white px-4 py-2 rounded-xl transition font-medium">
{/* Plus button for prompt templates */}
<div className="relative shrink-0" ref={plusMenuRef}>
<button
onClick={() => setShowPlusMenu(p => !p)}
className="p-1 text-gray-500 hover:text-purple-400 transition"
title="Insert prompt template"
>
<PlusCircleIcon className="w-5 h-5" />
</button>
{showPlusMenu && (
<div className="absolute bottom-full mb-2 left-0 bg-gray-800 border border-gray-700 rounded-lg shadow-xl py-1 min-w-[180px] z-50">
<button
onClick={() => { setShowTemplateDialog(true); }}
className="w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-gray-700 hover:text-purple-300 transition flex items-center gap-2"
>
<TemplateIcon className="w-4 h-4" />
Use Prompt Template
</button>
</div>
)}
</div>

{/* Selected template chip */}
<div className="flex-1 flex flex-col gap-1">
{selectedTemplate && (
<div className="flex items-center gap-1.5 bg-gray-800 rounded-lg px-2.5 py-1 w-fit">
<TemplateIcon className="w-3.5 h-3.5 text-purple-400" />
<span className="text-xs text-gray-300">{selectedTemplate.prompt_title}</span>
<button
onClick={() => setSelectedTemplate(null)}
className="text-gray-500 hover:text-gray-300 transition"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
)}
<textarea
ref={textareaRef}
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(e); }}
onKeyDown={handleKey}
placeholder="Ask anything... (Enter to send, Shift+Enter for new line)"
rows={1}
className="bg-transparent text-sm text-gray-100 placeholder-gray-500 resize-none outline-none w-full"
style={{ minHeight: "24px", maxHeight: "160px" }}
/>
</div>

<button
onClick={send}
disabled={(!input.trim() && !selectedTemplate) || loading}
className="shrink-0 text-sm bg-purple-600 hover:bg-purple-500 disabled:opacity-40 disabled:cursor-not-allowed text-white px-4 py-2 rounded-xl transition font-medium"
>
Send →
</button>
</div>
Expand Down
Loading
Loading