diff --git a/client/app.js b/client/app.js
new file mode 100644
index 0000000..cafd8f0
--- /dev/null
+++ b/client/app.js
@@ -0,0 +1,335 @@
+'use strict';
+
+const API = ''; // same origin
+
+// ── State ────────────────────────────────────────────────────────────────────
+
+let profiles = [];
+let sessions = []; // [{id, profile_id, last_active, preview}]
+let currentSession = null; // session id
+let ws = null;
+let streaming = false;
+let currentBubble = null; // DOM element being streamed into
+
+// ── DOM refs ─────────────────────────────────────────────────────────────────
+
+const profileSelect = document.getElementById('profile-select');
+const btnNew = document.getElementById('btn-new');
+const sessionList = document.getElementById('session-list');
+const chatHeader = document.getElementById('chat-header');
+const messagesEl = document.getElementById('messages');
+const textarea = document.getElementById('input');
+const btnSend = document.getElementById('btn-send');
+
+// ── Init ─────────────────────────────────────────────────────────────────────
+
+async function init() {
+ await loadProfiles();
+ await loadSessions();
+ textarea.addEventListener('keydown', onKey);
+ btnSend.addEventListener('click', sendMessage);
+ btnNew.addEventListener('click', createSession);
+ textarea.addEventListener('input', autoResize);
+}
+
+// ── Profiles ─────────────────────────────────────────────────────────────────
+
+async function loadProfiles() {
+ try {
+ const res = await fetch(`${API}/agents/profiles`);
+ profiles = await res.json();
+ profileSelect.innerHTML = profiles.map(p =>
+ ``
+ ).join('');
+ } catch (e) {
+ console.error('Failed to load profiles', e);
+ }
+}
+
+// ── Sessions ─────────────────────────────────────────────────────────────────
+
+async function loadSessions() {
+ try {
+ const res = await fetch(`${API}/sessions`);
+ const data = await res.json();
+ sessions = data.map(s => ({ ...s, preview: '' }));
+ renderSessionList();
+ } catch (e) {
+ console.error('Failed to load sessions', e);
+ }
+}
+
+async function createSession() {
+ const profileId = profileSelect.value;
+ if (!profileId) return;
+ btnNew.disabled = true;
+ try {
+ const res = await fetch(`${API}/sessions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ profile_id: profileId }),
+ });
+ const session = await res.json();
+ sessions.unshift({ ...session, preview: 'New conversation' });
+ renderSessionList();
+ await switchSession(session.session_id);
+ } catch (e) {
+ console.error('Failed to create session', e);
+ } finally {
+ btnNew.disabled = false;
+ }
+}
+
+async function switchSession(sessionId) {
+ if (currentSession === sessionId) return;
+ disconnectWS();
+ currentSession = sessionId;
+ renderSessionList();
+ await loadHistory(sessionId);
+ connectWS(sessionId);
+ setInputEnabled(true);
+}
+
+async function loadHistory(sessionId) {
+ messagesEl.innerHTML = '';
+ try {
+ const res = await fetch(`${API}/sessions/${sessionId}`);
+ const data = await res.json();
+ updateChatHeader(data.profile_id);
+
+ const profile = profiles.find(p => p.id === data.profile_id);
+
+ for (const msg of data.messages) {
+ if (msg.role === 'system') continue;
+ if (msg.role === 'user') {
+ appendMessage('user', msg.content);
+ } else if (msg.role === 'assistant' && msg.content) {
+ appendMessage('assistant', msg.content);
+ }
+ }
+ scrollToBottom();
+ } catch (e) {
+ console.error('Failed to load history', e);
+ }
+}
+
+function renderSessionList() {
+ if (sessions.length === 0) {
+ sessionList.innerHTML = '
No conversations yet
';
+ return;
+ }
+ sessionList.innerHTML = sessions.map(s => {
+ const profile = profiles.find(p => p.id === s.profile_id);
+ const name = profile ? profile.name : s.profile_id;
+ const time = formatTime(s.last_active);
+ const preview = s.preview || 'No messages yet';
+ const active = s.session_id === currentSession ? ' active' : '';
+ return `
+
+
${escHtml(name)}
+
${escHtml(preview)}
+
${time}
+
`;
+ }).join('');
+
+ sessionList.querySelectorAll('.session-item').forEach(el => {
+ el.addEventListener('click', () => switchSession(el.dataset.id));
+ });
+}
+
+function updateSessionPreview(sessionId, text) {
+ const s = sessions.find(s => s.session_id === sessionId);
+ if (s) s.preview = text.slice(0, 60);
+ renderSessionList();
+}
+
+// ── WebSocket ─────────────────────────────────────────────────────────────────
+
+function connectWS(sessionId) {
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
+ const url = `${proto}://${location.host}/ws/sessions/${sessionId}`;
+ ws = new WebSocket(url);
+
+ ws.onopen = () => console.log('WS connected', sessionId);
+ ws.onclose = (e) => {
+ console.log('WS closed', e.code);
+ if (streaming) finishStream();
+ };
+ ws.onerror = (e) => console.error('WS error', e);
+ ws.onmessage = (e) => handleWsEvent(JSON.parse(e.data));
+}
+
+function disconnectWS() {
+ if (ws) { ws.close(); ws = null; }
+}
+
+function handleWsEvent(event) {
+ switch (event.type) {
+ case 'stream_start':
+ streaming = true;
+ removeTypingIndicator();
+ currentBubble = appendMessage('assistant', '');
+ currentBubble.classList.add('cursor');
+ break;
+
+ case 'stream_delta':
+ if (currentBubble) {
+ currentBubble.textContent += event.delta;
+ scrollToBottom();
+ }
+ break;
+
+ case 'tool_call':
+ appendToolCall(event);
+ scrollToBottom();
+ break;
+
+ case 'stream_end':
+ finishStream(event.content);
+ setInputEnabled(true);
+ break;
+
+ case 'error':
+ finishStream();
+ appendError(event.message);
+ setInputEnabled(true);
+ break;
+ }
+}
+
+function finishStream(finalContent) {
+ streaming = false;
+ removeTypingIndicator();
+ if (currentBubble) {
+ currentBubble.classList.remove('cursor');
+ if (finalContent !== undefined) {
+ currentBubble.textContent = finalContent;
+ updateSessionPreview(currentSession, finalContent);
+ }
+ currentBubble = null;
+ }
+ scrollToBottom();
+}
+
+// ── Sending ───────────────────────────────────────────────────────────────────
+
+async function sendMessage() {
+ const text = textarea.value.trim();
+ if (!text || !ws || ws.readyState !== WebSocket.OPEN || streaming) return;
+
+ textarea.value = '';
+ autoResize();
+ setInputEnabled(false);
+
+ appendMessage('user', text);
+ appendTypingIndicator();
+ scrollToBottom();
+
+ ws.send(JSON.stringify({ type: 'message', content: text }));
+}
+
+function onKey(e) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ sendMessage();
+ }
+}
+
+// ── DOM helpers ───────────────────────────────────────────────────────────────
+
+function appendMessage(role, content) {
+ const msg = document.createElement('div');
+ msg.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 = formatTime(new Date().toISOString());
+
+ msg.appendChild(bubble);
+ msg.appendChild(time);
+ messagesEl.appendChild(msg);
+ return bubble;
+}
+
+function appendToolCall(event) {
+ const icons = { web_search: '🔍', filesystem: '📁', http_request: '🌐', code_exec: '⚙️', terminal: '💻' };
+ const icon = icons[event.tool] || '🔧';
+ const card = document.createElement('div');
+ card.className = `tool-card${event.success ? '' : ' error'}`;
+ card.innerHTML = `
+
+ ${escHtml(event.result)}
`;
+ messagesEl.appendChild(card);
+}
+
+function appendTypingIndicator() {
+ removeTypingIndicator();
+ const el = document.createElement('div');
+ el.className = 'typing';
+ el.id = 'typing';
+ el.innerHTML = '';
+ messagesEl.appendChild(el);
+}
+
+function removeTypingIndicator() {
+ document.getElementById('typing')?.remove();
+}
+
+function appendError(message) {
+ const el = document.createElement('div');
+ el.className = 'msg-error';
+ el.textContent = `Error: ${message}`;
+ messagesEl.appendChild(el);
+}
+
+function updateChatHeader(profileId) {
+ const profile = profiles.find(p => p.id === profileId);
+ const name = profile ? profile.name : profileId;
+ chatHeader.innerHTML = `${escHtml(profileId)} ${escHtml(name)}`;
+}
+
+function setInputEnabled(enabled) {
+ textarea.disabled = !enabled;
+ btnSend.disabled = !enabled;
+ if (enabled) textarea.focus();
+}
+
+function scrollToBottom() {
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+}
+
+function autoResize() {
+ textarea.style.height = 'auto';
+ textarea.style.height = Math.min(textarea.scrollHeight, 180) + 'px';
+}
+
+function escHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function formatTime(iso) {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (isNaN(d)) return '';
+ const now = new Date();
+ const diff = 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();
+}
+
+// ── Start ─────────────────────────────────────────────────────────────────────
+
+init();
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..39fa03d
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ Navi
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
💬
+
Start a new conversation
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/style.css b/client/style.css
new file mode 100644
index 0000000..d4951e3
--- /dev/null
+++ b/client/style.css
@@ -0,0 +1,292 @@
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+:root {
+ --sidebar-w: 260px;
+ --bg: #f0f2f5;
+ --sidebar-bg: #ffffff;
+ --border: #e0e0e0;
+ --text: #1a1a1a;
+ --text-muted: #777;
+ --accent: #0066ff;
+ --accent-hover: #0052cc;
+ --user-bubble: #0066ff;
+ --user-text: #ffffff;
+ --bot-bubble: #ffffff;
+ --bot-text: #1a1a1a;
+ --tool-bg: #fffbe6;
+ --tool-border: #ffe58f;
+ --tool-text: #7d6000;
+ --error-bg: #fff1f0;
+ --error-border: #ffccc7;
+ --error-text: #a8071a;
+ --input-bg: #ffffff;
+ --radius: 12px;
+ --shadow: 0 1px 3px rgba(0,0,0,0.08);
+}
+
+html, body { height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; color: var(--text); background: var(--bg); }
+
+/* ── Layout ─────────────────────────────────────────── */
+
+.app {
+ display: flex;
+ height: 100vh;
+ overflow: hidden;
+}
+
+/* ── Sidebar ─────────────────────────────────────────── */
+
+.sidebar {
+ width: var(--sidebar-w);
+ min-width: var(--sidebar-w);
+ background: var(--sidebar-bg);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.sidebar-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.sidebar-header h1 {
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: -0.3px;
+}
+
+.sidebar-header select {
+ width: 100%;
+ padding: 7px 10px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg);
+ font-size: 13px;
+ color: var(--text);
+ cursor: pointer;
+ outline: none;
+}
+.sidebar-header select:focus { border-color: var(--accent); }
+
+.btn-new {
+ width: 100%;
+ padding: 8px;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: 8px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+.btn-new:hover { background: var(--accent-hover); }
+.btn-new:disabled { opacity: 0.5; cursor: not-allowed; }
+
+.session-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+}
+
+.session-item {
+ padding: 10px 12px;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background 0.1s;
+ margin-bottom: 2px;
+}
+.session-item:hover { background: var(--bg); }
+.session-item.active { background: #e8f0ff; }
+.session-item .s-profile { font-size: 11px; color: var(--accent); font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 2px; }
+.session-item .s-preview { font-size: 13px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.session-item .s-time { font-size: 11px; color: var(--text-muted); margin-top: 3px; }
+
+.empty-sessions { padding: 20px 12px; color: var(--text-muted); font-size: 13px; text-align: center; }
+
+/* ── Main chat area ──────────────────────────────────── */
+
+.chat {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--bg);
+}
+
+.chat-header {
+ padding: 14px 20px;
+ background: var(--sidebar-bg);
+ border-bottom: 1px solid var(--border);
+ font-size: 14px;
+ color: var(--text-muted);
+ min-height: 49px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.chat-header .profile-badge {
+ background: var(--accent);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ padding: 2px 8px;
+ border-radius: 99px;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+}
+
+.messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+/* ── Message bubbles ─────────────────────────────────── */
+
+.msg {
+ display: flex;
+ flex-direction: column;
+ max-width: 72%;
+}
+.msg.user { align-self: flex-end; align-items: flex-end; }
+.msg.assistant { align-self: flex-start; align-items: flex-start; }
+
+.bubble {
+ padding: 10px 14px;
+ border-radius: var(--radius);
+ line-height: 1.55;
+ box-shadow: var(--shadow);
+ word-break: break-word;
+ white-space: pre-wrap;
+}
+.msg.user .bubble { background: var(--user-bubble); color: var(--user-text); border-bottom-right-radius: 3px; }
+.msg.assistant .bubble { background: var(--bot-bubble); color: var(--bot-text); border-bottom-left-radius: 3px; }
+
+.msg-time { font-size: 11px; color: var(--text-muted); margin-top: 4px; padding: 0 2px; }
+
+/* Tool call card */
+.tool-card {
+ align-self: flex-start;
+ max-width: 80%;
+ background: var(--tool-bg);
+ border: 1px solid var(--tool-border);
+ border-radius: var(--radius);
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--tool-text);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.tool-card .tool-header { display: flex; align-items: center; gap: 6px; font-weight: 600; }
+.tool-card .tool-icon { font-size: 14px; }
+.tool-card .tool-result { color: var(--text-muted); font-size: 11px; margin-top: 2px; max-height: 60px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.tool-card.error { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); }
+
+/* Typing indicator */
+.typing {
+ align-self: flex-start;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 10px 14px;
+ background: var(--bot-bubble);
+ border-radius: var(--radius);
+ border-bottom-left-radius: 3px;
+ box-shadow: var(--shadow);
+}
+.typing span { width: 7px; height: 7px; background: var(--text-muted); border-radius: 50%; animation: blink 1.2s infinite; }
+.typing span:nth-child(2) { animation-delay: 0.2s; }
+.typing span:nth-child(3) { animation-delay: 0.4s; }
+@keyframes blink { 0%,80%,100% { opacity: 0.2; } 40% { opacity: 1; } }
+
+/* Cursor while streaming */
+.cursor::after { content: "▋"; animation: cursor-blink 0.7s step-start infinite; font-size: 0.9em; margin-left: 1px; }
+@keyframes cursor-blink { 50% { opacity: 0; } }
+
+/* Error message */
+.msg-error {
+ align-self: center;
+ background: var(--error-bg);
+ border: 1px solid var(--error-border);
+ color: var(--error-text);
+ padding: 8px 16px;
+ border-radius: 8px;
+ font-size: 13px;
+}
+
+/* Empty state */
+.empty-chat {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+ gap: 8px;
+}
+.empty-chat .icon { font-size: 40px; }
+.empty-chat p { font-size: 15px; }
+
+/* ── Input bar ───────────────────────────────────────── */
+
+.input-bar {
+ padding: 16px 20px;
+ background: var(--sidebar-bg);
+ border-top: 1px solid var(--border);
+ display: flex;
+ gap: 10px;
+ align-items: flex-end;
+}
+
+.input-bar textarea {
+ flex: 1;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--input-bg);
+ font-family: inherit;
+ font-size: 14px;
+ color: var(--text);
+ resize: none;
+ min-height: 44px;
+ max-height: 180px;
+ overflow-y: auto;
+ outline: none;
+ transition: border-color 0.15s;
+ line-height: 1.5;
+}
+.input-bar textarea:focus { border-color: var(--accent); }
+.input-bar textarea:disabled { opacity: 0.5; }
+
+.btn-send {
+ width: 44px;
+ height: 44px;
+ flex-shrink: 0;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius);
+ font-size: 18px;
+ cursor: pointer;
+ transition: background 0.15s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.btn-send:hover { background: var(--accent-hover); }
+.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* ── Scrollbar ───────────────────────────────────────── */
+::-webkit-scrollbar { width: 5px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: #ccc; border-radius: 99px; }
diff --git a/navi/main.py b/navi/main.py
index 0b4dd59..789dde1 100644
--- a/navi/main.py
+++ b/navi/main.py
@@ -2,6 +2,8 @@
import structlog
from fastapi import FastAPI
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
from navi.api.routes import agents, health, messages, sessions
from navi.api.websocket import router as ws_router
@@ -24,3 +26,10 @@
app.include_router(sessions.router)
app.include_router(messages.router)
app.include_router(ws_router)
+
+app.mount("/static", StaticFiles(directory="client"), name="static")
+
+
+@app.get("/", include_in_schema=False)
+async def index() -> FileResponse:
+ return FileResponse("client/index.html")
diff --git a/pyproject.toml b/pyproject.toml
index 7a45402..451bf59 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,6 +12,7 @@
# API
"fastapi>=0.111",
"uvicorn[standard]>=0.29",
+ "aiofiles>=23.0",
# LLM backends
"ollama>=0.2",