Files
timmy-tower/the-matrix/js/ui.js
2026-03-23 20:01:26 +00:00

308 lines
11 KiB
JavaScript

import { sendVisitorMessage } from './websocket.js';
import { classify } from './edge-worker-client.js';
import { setMood, setSpeechBubble } from './agents.js';
import { getOrRefreshToken } 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';
}
// ── Input bar ─────────────────────────────────────────────────────────────────
export function initUI() {
if (uiInitialized) return;
uiInitialized = true;
initInputBar();
}
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() {}