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 = ` +
+ ${icon} + ${escHtml(event.tool)} +
+
${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 + + + +
+ + + + + +
+
+ Select a profile and start a new chat +
+ +
+
+
💬
+

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",