diff --git a/.gitignore b/.gitignore index 46fb772..87cdfba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ ssh_hosts.json +navi.db .venv/ __pycache__/ *.pyc diff --git a/client/index.html b/client/index.html index 39fa03d..f753e61 100644 --- a/client/index.html +++ b/client/index.html @@ -48,6 +48,6 @@ - + diff --git a/client/js/api.js b/client/js/api.js new file mode 100644 index 0000000..0c660b2 --- /dev/null +++ b/client/js/api.js @@ -0,0 +1,23 @@ +/** REST API calls. All functions return parsed JSON or throw on error. */ + +async function request(method, path, body) { + const res = await fetch(path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok && res.status !== 204) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || res.statusText); + } + return res.status === 204 ? null : res.json(); +} + +export const api = { + getProfiles: () => request('GET', '/agents/profiles'), + getSessions: () => request('GET', '/sessions'), + getSession: (id) => request('GET', `/sessions/${id}`), + createSession: (profileId) => request('POST', '/sessions', { profile_id: profileId }), + deleteSession: (id) => request('DELETE', `/sessions/${id}`), + sendMessage: (id, text) => request('POST', `/sessions/${id}/messages`, { content: text }), +}; diff --git a/client/js/app.js b/client/js/app.js new file mode 100644 index 0000000..b8027f2 --- /dev/null +++ b/client/js/app.js @@ -0,0 +1,235 @@ +import { api } from './api.js'; +import { WsClient } from './ws.js'; +import { appendMessage, appendToolCall, appendTypingIndicator, + removeTypingIndicator, appendError, showEmptyState, + scrollToBottom } from './chat.js'; +import { renderProfiles, renderSessions, updateChatHeader } from './sidebar.js'; + +// ── DOM refs ───────────────────────────────────────────────────────────────── + +const profileSelect = document.getElementById('profile-select'); +const btnNew = document.getElementById('btn-new'); +const sessionListEl = document.getElementById('session-list'); +const chatHeaderEl = document.getElementById('chat-header'); +const messagesEl = document.getElementById('messages'); +const textarea = document.getElementById('input'); +const btnSend = document.getElementById('btn-send'); + +// ── State ───────────────────────────────────────────────────────────────────── + +const STORAGE_KEY = 'navi_current_session'; + +let profiles = []; +let sessions = []; +let currentId = localStorage.getItem(STORAGE_KEY) ?? null; +let streaming = false; +let currentBubble = null; + +const ws = new WsClient(); + +// ── Boot ────────────────────────────────────────────────────────────────────── + +async function init() { + textarea.addEventListener('keydown', onKey); + textarea.addEventListener('input', autoResize); + btnSend.addEventListener('click', sendMessage); + btnNew.addEventListener('click', newChat); + + [profiles, sessions] = await Promise.all([api.getProfiles(), api.getSessions()]); + + // Attach preview text from message history + sessions = await Promise.all(sessions.map(enrichSession)); + + renderProfiles(profileSelect, profiles); + rerenderSidebar(); + + // Restore last active session + if (currentId && sessions.some(s => s.session_id === currentId)) { + await openSession(currentId, false); + } else { + currentId = null; + showEmptyState(messagesEl); + setInputEnabled(false); + } +} + +// ── Sessions ────────────────────────────────────────────────────────────────── + +async function newChat() { + const profileId = profileSelect.value; + if (!profileId) return; + btnNew.disabled = true; + try { + const session = await api.createSession(profileId); + sessions.unshift({ ...session, preview: '', profile_name: profileName(profileId) }); + rerenderSidebar(); + await openSession(session.session_id, false); + } finally { + btnNew.disabled = false; + } +} + +async function openSession(sessionId, skipLoad = false) { + ws.disconnect(); + currentId = sessionId; + localStorage.setItem(STORAGE_KEY, sessionId); + rerenderSidebar(); + + const s = sessions.find(s => s.session_id === sessionId); + const pId = s?.profile_id ?? ''; + const pName = s?.profile_name ?? profileName(pId); + updateChatHeader(chatHeaderEl, pId, pName); + + if (!skipLoad) { + await loadHistory(sessionId); + } + + connectWs(sessionId); + setInputEnabled(true); +} + +async function loadHistory(sessionId) { + messagesEl.innerHTML = ''; + try { + const data = await api.getSession(sessionId); + for (const msg of data.messages) { + if (msg.role === 'system') continue; + if (msg.role === 'user' || (msg.role === 'assistant' && msg.content)) { + appendMessage(messagesEl, msg.role, msg.content); + } + } + scrollToBottom(messagesEl); + } catch (e) { + console.error('loadHistory', e); + } +} + +async function deleteSession(sessionId) { + await api.deleteSession(sessionId).catch(console.error); + sessions = sessions.filter(s => s.session_id !== sessionId); + if (currentId === sessionId) { + ws.disconnect(); + currentId = null; + localStorage.removeItem(STORAGE_KEY); + showEmptyState(messagesEl); + updateChatHeader(chatHeaderEl, null); + setInputEnabled(false); + } + rerenderSidebar(); +} + +// ── WebSocket ───────────────────────────────────────────────────────────────── + +function connectWs(sessionId) { + ws.connect(sessionId, { + onClose: () => { if (streaming) finishStream(); }, + onMessage: handleWsEvent, + }); +} + +function handleWsEvent(event) { + switch (event.type) { + case 'stream_start': + streaming = true; + removeTypingIndicator(messagesEl); + currentBubble = appendMessage(messagesEl, 'assistant', ''); + currentBubble.classList.add('cursor'); + break; + + case 'stream_delta': + if (currentBubble) { + currentBubble.textContent += event.delta; + scrollToBottom(messagesEl); + } + break; + + case 'tool_call': + appendToolCall(messagesEl, event); + scrollToBottom(messagesEl); + break; + + case 'stream_end': + finishStream(event.content); + setInputEnabled(true); + break; + + case 'error': + finishStream(); + appendError(messagesEl, event.message); + setInputEnabled(true); + break; + } +} + +function finishStream(finalContent) { + streaming = false; + removeTypingIndicator(messagesEl); + if (currentBubble) { + currentBubble.classList.remove('cursor'); + if (finalContent !== undefined) { + currentBubble.textContent = finalContent; + updatePreview(currentId, finalContent); + } + currentBubble = null; + } + scrollToBottom(messagesEl); +} + +// ── Sending ─────────────────────────────────────────────────────────────────── + +async function sendMessage() { + const text = textarea.value.trim(); + if (!text || !ws.ready || streaming) return; + + textarea.value = ''; + autoResize(); + setInputEnabled(false); + + appendMessage(messagesEl, 'user', text); + appendTypingIndicator(messagesEl); + scrollToBottom(messagesEl); + + ws.send(text); +} + +function onKey(e) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function rerenderSidebar() { + renderSessions(sessionListEl, sessions, currentId, { + onSelect: (id) => { if (id !== currentId) openSession(id); }, + onDelete: deleteSession, + }); +} + +function updatePreview(sessionId, text) { + const s = sessions.find(s => s.session_id === sessionId); + if (s) s.preview = text.slice(0, 60); + rerenderSidebar(); +} + +function profileName(profileId) { + return profiles.find(p => p.id === profileId)?.name ?? profileId; +} + +async function enrichSession(s) { + return { ...s, profile_name: profileName(s.profile_id), preview: s.preview || '' }; +} + +function setInputEnabled(on) { + textarea.disabled = !on; + btnSend.disabled = !on; + if (on) textarea.focus(); +} + +function autoResize() { + textarea.style.height = 'auto'; + textarea.style.height = Math.min(textarea.scrollHeight, 180) + 'px'; +} + +// ── Start ───────────────────────────────────────────────────────────────────── + +init(); diff --git a/client/js/chat.js b/client/js/chat.js new file mode 100644 index 0000000..903ac47 --- /dev/null +++ b/client/js/chat.js @@ -0,0 +1,89 @@ +/** Chat area DOM helpers. All functions receive the messages container element. */ + +const TOOL_ICONS = { + web_search: '🔍', + filesystem: '📁', + http_request: '🌐', + code_exec: '⚙️', + terminal: '💻', + ssh_exec: '🖧', +}; + +function esc(str) { + return String(str ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function timeLabel(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d)) return ''; + const diff = Date.now() - d; + if (diff < 60_000) return 'just now'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return d.toLocaleDateString(); +} + +export function appendMessage(el, role, content) { + const wrap = document.createElement('div'); + wrap.className = `msg ${role}`; + + const bubble = document.createElement('div'); + bubble.className = 'bubble'; + bubble.textContent = content; + + const time = document.createElement('div'); + time.className = 'msg-time'; + time.textContent = timeLabel(new Date().toISOString()); + + wrap.append(bubble, time); + el.appendChild(wrap); + return bubble; +} + +export function appendToolCall(el, event) { + const icon = TOOL_ICONS[event.tool] ?? '🔧'; + const card = document.createElement('div'); + card.className = `tool-card${event.success ? '' : ' error'}`; + card.innerHTML = ` +
+ ${icon} + ${esc(event.tool)} +
+
${esc(event.result)}
`; + el.appendChild(card); +} + +export function appendTypingIndicator(el) { + removeTypingIndicator(el); + const div = document.createElement('div'); + div.className = 'typing'; + div.id = 'typing-indicator'; + div.innerHTML = ''; + el.appendChild(div); +} + +export function removeTypingIndicator(el) { + el.querySelector('#typing-indicator')?.remove(); +} + +export function appendError(el, message) { + const div = document.createElement('div'); + div.className = 'msg-error'; + div.textContent = `Error: ${message}`; + el.appendChild(div); +} + +export function showEmptyState(el) { + el.innerHTML = ` +
+
💬
+

Start a new conversation

+
`; +} + +export function scrollToBottom(el) { + el.scrollTop = el.scrollHeight; +} diff --git a/client/js/sidebar.js b/client/js/sidebar.js new file mode 100644 index 0000000..23f2f9c --- /dev/null +++ b/client/js/sidebar.js @@ -0,0 +1,60 @@ +/** Sidebar DOM helpers. */ + +function esc(str) { + return String(str ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function timeLabel(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d)) return ''; + const diff = Date.now() - d; + if (diff < 60_000) return 'just now'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return d.toLocaleDateString(); +} + +export function renderProfiles(selectEl, profiles) { + selectEl.innerHTML = profiles + .map(p => ``) + .join(''); +} + +export function renderSessions(listEl, sessions, currentId, { onSelect, onDelete }) { + if (!sessions.length) { + listEl.innerHTML = '
No conversations yet
'; + return; + } + + listEl.innerHTML = sessions.map(s => { + const active = s.session_id === currentId ? ' active' : ''; + const preview = esc(s.preview || 'No messages yet'); + const name = esc(s.profile_name || s.profile_id); + const time = timeLabel(s.last_active); + return ` +
+
+
${name}
+
${preview}
+
${time}
+
+ +
`; + }).join(''); + + listEl.querySelectorAll('.session-item').forEach(el => + el.addEventListener('click', () => onSelect(el.dataset.id)) + ); + listEl.querySelectorAll('.btn-delete').forEach(btn => + btn.addEventListener('click', e => { e.stopPropagation(); onDelete(btn.dataset.id); }) + ); +} + +export function updateChatHeader(headerEl, profileId, profileName) { + headerEl.innerHTML = profileId + ? `${esc(profileId)} ${esc(profileName || profileId)}` + : 'Select a profile and start a new chat'; +} diff --git a/client/js/ws.js b/client/js/ws.js new file mode 100644 index 0000000..bac65c0 --- /dev/null +++ b/client/js/ws.js @@ -0,0 +1,39 @@ +/** WebSocket client wrapper. */ + +export class WsClient { + #ws = null; + #sessionId = null; + #handlers = {}; + + connect(sessionId, handlers) { + this.disconnect(); + this.#sessionId = sessionId; + this.#handlers = handlers; + + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + this.#ws = new WebSocket(`${proto}://${location.host}/ws/sessions/${sessionId}`); + + this.#ws.onopen = () => handlers.onOpen?.(); + this.#ws.onclose = (e) => handlers.onClose?.(e); + this.#ws.onerror = (e) => handlers.onError?.(e); + this.#ws.onmessage = (e) => handlers.onMessage?.(JSON.parse(e.data)); + } + + send(content) { + if (this.#ws?.readyState === WebSocket.OPEN) { + this.#ws.send(JSON.stringify({ type: 'message', content })); + return true; + } + return false; + } + + disconnect() { + this.#ws?.close(); + this.#ws = null; + this.#sessionId = null; + } + + get ready() { + return this.#ws?.readyState === WebSocket.OPEN; + } +} diff --git a/navi/api/deps.py b/navi/api/deps.py index 8c4ae30..9aed576 100644 --- a/navi/api/deps.py +++ b/navi/api/deps.py @@ -5,12 +5,13 @@ from fastapi import Depends +from navi.config import settings from navi.core import ( Agent, BackendRegistry, - InMemorySessionStore, ProfileRegistry, SessionStore, + SqliteSessionStore, ToolRegistry, build_default_registries, ) @@ -33,7 +34,7 @@ return _registries()[2] -_session_store = InMemorySessionStore() +_session_store = SqliteSessionStore(settings.db_path) def get_session_store() -> SessionStore: diff --git a/navi/config.py b/navi/config.py index d2ba20f..d6b761f 100644 --- a/navi/config.py +++ b/navi/config.py @@ -20,6 +20,9 @@ # SSH tool: path to JSON file with named connections ssh_hosts_file: str = "ssh_hosts.json" + # Database + db_path: str = "navi.db" + log_level: str = "INFO" @property diff --git a/navi/core/__init__.py b/navi/core/__init__.py index f5079ab..2601b6d 100644 --- a/navi/core/__init__.py +++ b/navi/core/__init__.py @@ -1,6 +1,7 @@ from .agent import Agent, AgentEvent, StreamEnd, TextDelta, ToolEvent from .registry import BackendRegistry, ProfileRegistry, ToolRegistry, build_default_registries from .session import InMemorySessionStore, Session, SessionStore +from .sqlite_session_store import SqliteSessionStore __all__ = [ "Agent", @@ -15,4 +16,5 @@ "Session", "SessionStore", "InMemorySessionStore", + "SqliteSessionStore", ] diff --git a/navi/core/sqlite_session_store.py b/navi/core/sqlite_session_store.py new file mode 100644 index 0000000..b6d4eb0 --- /dev/null +++ b/navi/core/sqlite_session_store.py @@ -0,0 +1,91 @@ +"""SQLite-backed session store — sessions survive server restarts.""" + +import json +import sqlite3 +from datetime import datetime + +import aiosqlite + +from navi.llm.base import Message + +from .session import Session, SessionStore + +_CREATE_TABLE = """ +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + messages TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + last_active TEXT NOT NULL +) +""" + + +class SqliteSessionStore(SessionStore): + def __init__(self, db_path: str = "navi.db") -> None: + self._db_path = db_path + # Create table synchronously so it's ready before any async call + with sqlite3.connect(db_path) as conn: + conn.execute(_CREATE_TABLE) + conn.commit() + + async def create(self, profile_id: str) -> Session: + session = Session(profile_id=profile_id) + async with aiosqlite.connect(self._db_path) as db: + await db.execute( + "INSERT INTO sessions (id, profile_id, messages, created_at, last_active) " + "VALUES (?, ?, '[]', ?, ?)", + (session.id, session.profile_id, + session.created_at.isoformat(), session.last_active.isoformat()), + ) + await db.commit() + return session + + async def get(self, session_id: str) -> Session | None: + async with aiosqlite.connect(self._db_path) as db: + async with db.execute( + "SELECT id, profile_id, messages, created_at, last_active " + "FROM sessions WHERE id = ?", + (session_id,), + ) as cur: + row = await cur.fetchone() + return self._row_to_session(row) if row else None + + async def save(self, session: Session) -> None: + session.last_active = datetime.utcnow() + messages_json = json.dumps( + [m.model_dump(exclude_none=True) for m in session.messages], + ensure_ascii=False, + ) + async with aiosqlite.connect(self._db_path) as db: + await db.execute( + "UPDATE sessions SET messages = ?, last_active = ? WHERE id = ?", + (messages_json, session.last_active.isoformat(), session.id), + ) + await db.commit() + + async def list_all(self) -> list[Session]: + async with aiosqlite.connect(self._db_path) as db: + async with db.execute( + "SELECT id, profile_id, messages, created_at, last_active " + "FROM sessions ORDER BY last_active DESC" + ) as cur: + rows = await cur.fetchall() + return [self._row_to_session(r) for r in rows] + + async def delete(self, session_id: str) -> bool: + async with aiosqlite.connect(self._db_path) as db: + cur = await db.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + await db.commit() + return cur.rowcount > 0 + + def _row_to_session(self, row: tuple) -> Session: + id_, profile_id, messages_json, created_at, last_active = row + messages = [Message.model_validate(m) for m in json.loads(messages_json)] + return Session( + id=id_, + profile_id=profile_id, + messages=messages, + created_at=datetime.fromisoformat(created_at), + last_active=datetime.fromisoformat(last_active), + ) diff --git a/pyproject.toml b/pyproject.toml index 530263f..4a780cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ "fastapi>=0.111", "uvicorn[standard]>=0.29", "aiofiles>=23.0", + "aiosqlite>=0.20", # LLM backends "ollama>=0.2",