import { sendVisitorMessage } from './websocket.js'; import { classify } from './edge-worker-client.js'; import { setMood, setSpeechBubble } from './agents.js'; import { getOrRefreshToken, getPubkey, disconnectNostrIdentity, showIdentityPrompt } from './nostr-identity.js'; const $fps = document.getElementById('fps'); const $activeJobs = document.getElementById('active-jobs'); const $connStatus = document.getElementById('connection-status'); const $log = document.getElementById('event-log'); const MAX_LOG = 6; const logEntries = []; let uiInitialized = false; // ── Session-mode send override ──────────────────────────────────────────────── let _sessionSendHandler = null; export function setSessionSendHandler(fn) { _sessionSendHandler = fn; } export function setInputBarSessionMode(active, placeholder) { const $input = document.getElementById('visitor-input'); if (!$input) return; if (active) { $input.classList.add('session-active'); $input.placeholder = placeholder || 'Ask Timmy (session active)…'; } else { $input.classList.remove('session-active'); $input.placeholder = 'Say something to Timmy…'; } } // ── Model-ready indicator ───────────────────────────────────────────────────── // A small badge on the input bar showing when local AI is warm and ready. // Hidden until the first `ready` event from the edge worker. let $readyBadge = null; export function setEdgeWorkerReady() { if (!$readyBadge) { $readyBadge = document.createElement('span'); $readyBadge.id = 'edge-ready-badge'; $readyBadge.title = 'Local AI active — trivial queries answered without Lightning payment'; $readyBadge.style.cssText = [ 'font-size:10px;color:#44cc88;border:1px solid #226644', 'border-radius:3px;padding:1px 5px;margin-left:6px', 'vertical-align:middle;cursor:default', ].join(';'); $readyBadge.textContent = '⚡ local AI'; const $input = document.getElementById('visitor-input'); $input?.insertAdjacentElement('afterend', $readyBadge); // Fallback: append to send button area if (!$readyBadge.isConnected) { document.getElementById('send-btn')?.insertAdjacentElement('afterend', $readyBadge); } } $readyBadge.style.display = ''; } // ── Cost preview badge ──────────────────────────────────────────────────────── // Shown beneath the input bar: "~N sats" / "FREE" / "answered locally". // Fetched from GET /api/estimate once the user stops typing (300 ms debounce). let _estimateTimer = null; let $costPreview = null; function _ensureCostPreview() { if ($costPreview) return $costPreview; $costPreview = document.getElementById('timmy-cost-preview'); if (!$costPreview) { $costPreview = document.createElement('div'); $costPreview.id = 'timmy-cost-preview'; $costPreview.style.cssText = 'font-size:11px;color:#88aacc;margin-top:3px;min-height:14px;transition:opacity .3s;opacity:0;'; const $input = document.getElementById('visitor-input'); $input?.parentElement?.appendChild($costPreview); } return $costPreview; } function _showCostPreview(text, color = '#88aacc') { const el = _ensureCostPreview(); el.textContent = text; el.style.color = color; el.style.opacity = '1'; } function _hideCostPreview() { const el = _ensureCostPreview(); el.style.opacity = '0'; } async function _fetchEstimate(text) { try { const token = await getOrRefreshToken('/api'); const params = new URLSearchParams({ request: text }); const fetchOpts = {}; if (token) { fetchOpts.headers = { 'X-Nostr-Token': token }; } const res = await fetch(`/api/estimate?${params}`, fetchOpts); if (!res.ok) return; const data = await res.json(); const ft = data.identity?.free_tier; if (ft?.serve === 'free') { _showCostPreview('FREE via generosity pool', '#44dd88'); } else if (ft?.serve === 'partial') { _showCostPreview(`~${ft.chargeSats} sats (${ft.absorbSats} absorbed)`, '#ffdd44'); } else { const sats = data.estimatedSats ?? '?'; _showCostPreview(`~${sats} sats estimated`, '#88aacc'); } } catch { _hideCostPreview(); } } // Fast trivial heuristic — same pattern as edge-worker.js _isGreeting(). // Prevents /api/estimate network calls for greeting messages on every keypress. const _TRIVIAL_RE = /^(hi|hey|hello|howdy|greetings|yo|sup|hiya|what'?s up)[!?.,]?\s*$/i; function _scheduleCostPreview(text) { clearTimeout(_estimateTimer); if (!text || text.length < 4) { _hideCostPreview(); return; } // Skip estimate entirely for trivially local messages — zero network calls if (_TRIVIAL_RE.test(text.trim())) { _showCostPreview('answered locally ⚡ 0 sats', '#44dd88'); return; } _estimateTimer = setTimeout(() => _fetchEstimate(text), 300); } // ── Live cost ticker ────────────────────────────────────────────────────────── // Shown in the top-right HUD during active paid interactions. // Updated via WebSocket `cost_update` messages from the backend. let $costTicker = null; let _tickerHideTimer = null; function _ensureCostTicker() { if ($costTicker) return $costTicker; $costTicker = document.getElementById('timmy-cost-ticker'); if (!$costTicker) { $costTicker = document.createElement('div'); $costTicker.id = 'timmy-cost-ticker'; $costTicker.style.cssText = [ 'position:fixed;top:36px;right:16px', 'font-size:11px;font-family:"Courier New",monospace', 'color:#ffcc44;text-shadow:0 0 6px #aa8822', 'letter-spacing:1px', 'pointer-events:none;z-index:10', 'transition:opacity .4s;opacity:0', ].join(';'); document.body.appendChild($costTicker); } return $costTicker; } export function showCostTicker(sats) { clearTimeout(_tickerHideTimer); const el = _ensureCostTicker(); el.textContent = `⚡ ~${sats} sats`; el.style.opacity = '1'; } export function updateCostTicker(sats, isFinal = false) { clearTimeout(_tickerHideTimer); const el = _ensureCostTicker(); el.textContent = isFinal ? `⚡ ${sats} sats charged` : `⚡ ~${sats} sats`; el.style.opacity = '1'; if (isFinal) { _tickerHideTimer = setTimeout(hideCostTicker, 5000); } } export function hideCostTicker() { if (!$costTicker) return; $costTicker.style.opacity = '0'; } // ── Nostr identity UI ───────────────────────────────────────────────────────── let _nostrStatusEl = null; let _connectNostrBtn = null; let _disconnectNostrBtn = null; let _nostrPubkeyDisplay = null; let _getAlbyBtn = null; export function initNostrIdentityUI() { _nostrStatusEl = document.getElementById('nostr-identity-status'); if (!_nostrStatusEl) return; _nostrStatusEl.innerHTML = ` `; _connectNostrBtn = document.getElementById('connect-nostr-btn'); _disconnectNostrBtn = document.getElementById('disconnect-nostr-btn'); _nostrPubkeyDisplay = document.getElementById('nostr-pubkey-display'); _getAlbyBtn = document.getElementById('get-alby-btn'); if (_connectNostrBtn) { _connectNostrBtn.addEventListener('click', () => { showIdentityPrompt('/api'); }); } if (_disconnectNostrBtn) { _disconnectNostrBtn.addEventListener('click', () => { disconnectNostrIdentity(); _updateNostrIdentityUI(null); }); } window.addEventListener('nostr:identity-ready', e => { _updateNostrIdentityUI(e.detail.pubkey); }); window.addEventListener('nostr:identity-disconnected', () => { _updateNostrIdentityUI(null); }); _updateNostrIdentityUI(getPubkey()); } function _updateNostrIdentityUI(pubkey) { const hasNip07 = typeof window !== 'undefined' && !!window.nostr; if (pubkey) { const formattedPubkey = pubkey.slice(0, 8) + '…' + pubkey.slice(-4); if (_nostrPubkeyDisplay) { _nostrPubkeyDisplay.textContent = `⚡ ${formattedPubkey}`; _nostrPubkeyDisplay.style.display = 'inline-block'; } if (_connectNostrBtn) _connectNostrBtn.style.display = 'none'; if (_disconnectNostrBtn) _disconnectNostrBtn.style.display = 'inline-block'; if (_getAlbyBtn) _getAlbyBtn.style.display = 'none'; } else { if (_nostrPubkeyDisplay) _nostrPubkeyDisplay.style.display = 'none'; if (_disconnectNostrBtn) _disconnectNostrBtn.style.display = 'none'; if (hasNip07) { if (_connectNostrBtn) { _connectNostrBtn.textContent = '⚡ Connect Nostr'; _connectNostrBtn.style.display = 'inline-block'; } if (_getAlbyBtn) _getAlbyBtn.style.display = 'none'; } else { if (_connectNostrBtn) _connectNostrBtn.style.display = 'none'; if (_getAlbyBtn) { _getAlbyBtn.textContent = 'Get Alby'; _getAlbyBtn.style.display = 'inline-block'; _getAlbyBtn.title = 'Install Alby or another NIP-07 extension to connect your Nostr identity'; _getAlbyBtn.onclick = () => window.open('https://getalby.com/', '_blank'); } } } } // ── Input bar ───────────────────────────────────────────────────────────────── export function initUI() { if (uiInitialized) return; uiInitialized = true; initInputBar(); initHeatmap(); initNostrIdentityUI(); } function initInputBar() { const $input = document.getElementById('visitor-input'); const $sendBtn = document.getElementById('send-btn'); if (!$input || !$sendBtn) return; $input.addEventListener('input', () => _scheduleCostPreview($input.value.trim())); async function send() { const text = $input.value.trim(); if (!text) return; $input.value = ''; _hideCostPreview(); // ── Edge triage — runs in BOTH session mode and WebSocket mode ───────────── // Worker returns { complexity:'trivial'|'moderate'|'complex', score, reason, localReply? } const cls = await classify(text); if (cls.complexity === 'trivial' && cls.localReply) { // Greeting / small-talk → answer locally, 0 sats, no network call in any mode appendSystemMessage(`you: ${text}`); setSpeechBubble(`${cls.localReply} ⚡ local`); _showCostPreview('answered locally ⚡ 0 sats', '#44dd88'); setTimeout(_hideCostPreview, 3000); return; } // Non-trivial: delegate to session handler (if active) or WebSocket if (_sessionSendHandler) { // moderate/complex — fire estimate async for cost preview, then hand off if (cls.complexity === 'moderate' || cls.complexity === 'complex') { _fetchEstimate(text); } _sessionSendHandler(text); return; } // moderate or complex — fetch cost estimate (driven by complexity outcome), // then route to server via WebSocket. if (cls.complexity === 'moderate' || cls.complexity === 'complex') { _fetchEstimate(text); } // Route to server via WebSocket sendVisitorMessage(text); appendSystemMessage(`you: ${text}`); } $sendBtn.addEventListener('click', send); $input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); } export function updateUI({ fps, jobCount, connectionState }) { if ($fps) $fps.textContent = `FPS: ${fps}`; if ($activeJobs) $activeJobs.textContent = `JOBS: ${jobCount}`; if ($connStatus) { if (connectionState === 'connected') { $connStatus.textContent = '● CONNECTED'; $connStatus.className = 'connected'; } else if (connectionState === 'connecting') { $connStatus.textContent = '◌ CONNECTING...'; $connStatus.className = ''; } else { $connStatus.textContent = '○ OFFLINE'; $connStatus.className = ''; } } } export function appendSystemMessage(text) { if (!$log) return; const el = document.createElement('div'); el.className = 'log-entry'; el.textContent = text; logEntries.push(el); if (logEntries.length > MAX_LOG) { const removed = logEntries.shift(); $log.removeChild(removed); } $log.appendChild(el); $log.scrollTop = $log.scrollHeight; } export function appendChatMessage(agentLabel, message, cssColor, agentId) { void agentLabel; void cssColor; void agentId; appendSystemMessage(message); } /** * Render a debate argument or verdict in the event log (#21). * Visually distinct from regular chat: colored by agent with a debate prefix. */ export function appendDebateMessage(agent, argument, isVerdict, accepted) { if (!$log) return; const el = document.createElement('div'); el.className = 'log-entry debate-entry'; if (isVerdict) { el.classList.add('debate-verdict'); el.classList.add(accepted ? 'debate-accepted' : 'debate-rejected'); el.textContent = `⚖ ${agent}: ${argument}`; } else { el.classList.add(agent === 'Beta-A' ? 'debate-a' : 'debate-b'); el.textContent = `⚖ ${agent}: ${(argument || '').slice(0, 120)}`; } logEntries.push(el); if (logEntries.length > MAX_LOG) { const removed = logEntries.shift(); $log.removeChild(removed); } $log.appendChild(el); $log.scrollTop = $log.scrollHeight; } export function loadChatHistory() { return []; } export function saveChatHistory() {} // ── Activity heatmap (#9) ───────────────────────────────────────────────────── // Fetches /api/stats/activity and renders a 24-segment heatmap. // Auto-refreshes every 5 minutes. On mobile, collapses to an icon that opens // a full-screen overlay. const HEATMAP_REFRESH_MS = 5 * 60 * 1000; // 5 minutes let _heatmapTimer = null; let _lastHours = null; // number[24] cached for overlay re-render /** Convert an hour index (0 = oldest, 23 = current) to a UTC hour label like "3pm" or "midnight". */ function _hourLabel(hourIndex) { const now = new Date(); const currentHour = now.getUTCHours(); // slot 23 = current UTC hour, slot 0 = 23 hours ago const h = ((currentHour - (23 - hourIndex)) % 24 + 24) % 24; if (h === 0) return 'midnight'; if (h === 12) return 'noon'; return h < 12 ? `${h}am` : `${h - 12}pm`; } /** Interpolate from dim blue (#111133) to bright blue-white (#88ccff) based on 0–1 intensity. */ function _segmentColor(intensity) { // dim: [17, 17, 51] bright: [136, 204, 255] const r = Math.round(17 + (136 - 17) * intensity); const g = Math.round(17 + (204 - 17) * intensity); const b = Math.round(51 + (255 - 51) * intensity); return `rgb(${r},${g},${b})`; } function _renderSegments(hours, container, isMobile) { container.innerHTML = ''; const max = Math.max(...hours, 1); // avoid div-by-zero const currentSlot = 23; hours.forEach((count, i) => { const seg = document.createElement('div'); seg.className = 'hm-seg' + (i === currentSlot ? ' hm-seg-current' : ''); const intensity = count / max; const color = _segmentColor(intensity); seg.style.background = color; if (i === currentSlot) seg.style.color = color; // used by pulse animation seg.dataset.index = String(i); seg.dataset.count = String(count); if (isMobile) { seg.style.width = '14px'; seg.style.height = '28px'; } container.appendChild(seg); }); } function _initHeatmapTooltip(barEl) { const $tip = document.getElementById('heatmap-tooltip'); if (!$tip) return; barEl.addEventListener('mousemove', e => { const seg = e.target.closest('.hm-seg'); if (!seg) { $tip.style.display = 'none'; return; } const i = Number(seg.dataset.index); const count = Number(seg.dataset.count); const label = _hourLabel(i); $tip.textContent = `${label}: ${count} job${count !== 1 ? 's' : ''} submitted`; $tip.style.display = 'block'; $tip.style.left = `${e.clientX + 10}px`; $tip.style.top = `${e.clientY - 24}px`; }); barEl.addEventListener('mouseleave', () => { $tip.style.display = 'none'; }); } async function _fetchAndRenderHeatmap() { try { const res = await fetch('/api/stats/activity'); if (!res.ok) return; const data = await res.json(); const hours = Array.isArray(data.hours) ? data.hours : []; if (hours.length !== 24) return; _lastHours = hours; const $bar = document.getElementById('heatmap-bar'); if ($bar) _renderSegments(hours, $bar, false); const $overlayBar = document.getElementById('heatmap-overlay-bar'); if ($overlayBar) _renderSegments(hours, $overlayBar, true); } catch { // silently ignore fetch errors } } export function initHeatmap() { const $bar = document.getElementById('heatmap-bar'); const $iconBtn = document.getElementById('heatmap-icon-btn'); const $overlay = document.getElementById('heatmap-overlay'); const $closeBtn = document.getElementById('heatmap-overlay-close'); if ($bar) _initHeatmapTooltip($bar); if ($iconBtn && $overlay) { $iconBtn.addEventListener('click', () => { $overlay.classList.add('open'); if (_lastHours) { const $overlayBar = document.getElementById('heatmap-overlay-bar'); if ($overlayBar) _renderSegments(_lastHours, $overlayBar, true); } }); } if ($closeBtn && $overlay) { $closeBtn.addEventListener('click', () => $overlay.classList.remove('open')); } // Initial fetch then schedule refresh void _fetchAndRenderHeatmap(); _heatmapTimer = setInterval(_fetchAndRenderHeatmap, HEATMAP_REFRESH_MS); }