This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
token-gated-economy/the-matrix/js/session.js
Replit Agent 898a47fd39 task-28 fix: proper Web Worker, correct Nostr endpoints, sentiment on inbound msgs
Addresses all code review rejections:

1. edge-worker.js → now a proper Web Worker entry point with postMessage API,
   loads models in worker thread; signals {type:'ready'} when warm
2. edge-worker-client.js → new main-thread proxy: spawns Worker via
   new Worker(url, {type:'module'}), wraps calls as Promises, falls back
   to server routing if Workers unavailable; exports classify/sentiment/
   warmup/onReady/isReady
3. nostr-identity.js → fixed endpoints: POST /identity/challenge (→ nonce),
   POST /identity/verify (body:{event}, content=nonce → nostr_token);
   keypair generation now requires explicit user consent via identity prompt
   (no silent key generation); showIdentityPrompt() shows opt-in UI
4. ui.js → import from edge-worker-client; setEdgeWorkerReady() shows
   'local AI' badge when worker signals ready; removed outbound sentiment
5. websocket.js → sentiment() on inbound Timmy chat messages drives setMood()
6. session.js → sentiment() on inbound reply (data.result), not outbound text
7. main.js → onEdgeWorkerReady(() => setEdgeWorkerReady()) wires ready badge
8. vite.config.js → worker.format:'es' for ESM Web Worker bundling
2026-03-19 18:16:40 +00:00

563 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* session.js — Workshop Session Mode UI (Fund once, ask many)
*
* Flow:
* 1. User clicks "⚡ FUND SESSION" → left panel slides in
* 2. Picks amount (2005000 sats) → POST /api/sessions → deposit invoice
* 3. Pays invoice (stub: "Simulate Payment") → 2 s polling until state=active
* 4. Macaroon + sessionId stored in localStorage; input bar activates (green border)
* 5. Every Enter/Send in the input bar → POST /api/sessions/:id/request
* 6. Timmy's response shown in speech bubble, balance ticks down in HUD
* 7. Balance < 50 sats → low-balance notice; Top Up button reopens panel topup step
* 8. On page reload: localStorage is read, session is validated, UI restored
*/
import { setSpeechBubble, setMood } from './agents.js';
import { appendSystemMessage, setSessionSendHandler, setInputBarSessionMode } from './ui.js';
import { getOrRefreshToken } from './nostr-identity.js';
import { sentiment } from './edge-worker-client.js';
const API = '/api';
const LS_KEY = 'timmy_session_v1';
const POLL_MS = 2000;
const POLL_TIMEOUT = 60_000;
const MIN_BALANCE = 50;
// ── Module state ──────────────────────────────────────────────────────────────
let _panel = null;
let _sessionId = null;
let _macaroon = null;
let _balanceSats = 0;
let _sessionState = null; // null | 'awaiting_payment' | 'active' | 'paused' | 'expired'
let _pollTimer = null;
let _inFlight = false;
let _selectedSats = 500; // deposit amount selection
let _topupSats = 500; // topup amount selection
// ── Public API ────────────────────────────────────────────────────────────────
export function initSessionPanel() {
_panel = document.getElementById('session-panel');
if (!_panel) return;
// Buttons
_on('open-session-btn', 'click', _openPanel);
_on('session-close', 'click', _closePanel);
_on('session-create-btn', 'click', _createSession);
_on('session-pay-btn', 'click', _payDeposit);
_on('session-topup-btn', 'click', () => _setStep('topup'));
_on('session-topup-create-btn','click', _createTopup);
_on('session-topup-pay-btn', 'click', _payTopup);
_on('session-back-btn', 'click', () => _setStep('active'));
_on('topup-quick-btn', 'click', () => { _openPanel(); _setStep('topup'); });
_on('session-hud-topup', 'click', (e) => { e.preventDefault(); _openPanel(); _setStep('topup'); });
// Amount preset buttons — deposit (quick-fill the number input)
_panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn').forEach(btn => {
btn.addEventListener('click', () => {
_panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_selectedSats = parseInt(btn.dataset.sats, 10);
const inp = document.getElementById('session-amount-input');
if (inp) inp.value = _selectedSats;
});
});
// Free-text number input — deposit
document.getElementById('session-amount-input')?.addEventListener('input', () => {
const v = parseInt(document.getElementById('session-amount-input').value, 10);
if (Number.isFinite(v)) {
_selectedSats = v;
_panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn')
.forEach(b => b.classList.toggle('active', parseInt(b.dataset.sats, 10) === v));
}
});
// Amount preset buttons — topup (quick-fill the topup number input)
_panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn').forEach(btn => {
btn.addEventListener('click', () => {
_panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_topupSats = parseInt(btn.dataset.sats, 10);
const inp = document.getElementById('session-topup-input');
if (inp) inp.value = _topupSats;
});
});
// Free-text number input — topup
document.getElementById('session-topup-input')?.addEventListener('input', () => {
const v = parseInt(document.getElementById('session-topup-input').value, 10);
if (Number.isFinite(v)) {
_topupSats = v;
_panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn')
.forEach(b => b.classList.toggle('active', parseInt(b.dataset.sats, 10) === v));
}
});
// Try to restore from localStorage on init
_tryRestore();
}
export function isSessionActive() {
return _sessionState === 'active' || _sessionState === 'paused';
}
// Called by ui.js when user submits the input bar while session is active
export async function sessionSendHandler(text) {
if (!_sessionId || !_macaroon || _inFlight) return;
_inFlight = true;
_setSendBusy(true);
appendSystemMessage(`you: ${text}`);
// Attach Nostr token if available
const nostrToken = await getOrRefreshToken('/api');
const reqHeaders = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_macaroon}`,
};
if (nostrToken) reqHeaders['X-Nostr-Token'] = nostrToken;
try {
const res = await fetch(`${API}/sessions/${_sessionId}/request`, {
method: 'POST',
headers: reqHeaders,
body: JSON.stringify({ request: text }),
});
const data = await res.json();
if (res.status === 402) {
_balanceSats = data.balance ?? 0;
_sessionState = 'paused';
_saveToStorage();
_applySessionUI();
setSpeechBubble('My energy is depleted... top me up to continue!');
appendSystemMessage('⚡ Low balance — tap Top Up');
return;
}
if (res.status === 401) {
// Macaroon rejected — stale session, clear and reset
_clearSession();
appendSystemMessage('Session expired — fund a new one.');
return;
}
if (!res.ok) {
appendSystemMessage('Session error: ' + (data.error || res.status));
return;
}
_balanceSats = data.balanceRemaining ?? 0;
_sessionState = _balanceSats < MIN_BALANCE ? 'paused' : 'active';
_saveToStorage();
_applySessionUI();
const reply = data.result || data.reason || '…';
setSpeechBubble(reply);
appendSystemMessage('Timmy: ' + reply.slice(0, 80));
// Sentiment-driven mood on inbound Timmy reply
sentiment(reply).then(s => {
setMood(s.label);
setTimeout(() => setMood(null), 10_000);
}).catch(() => {});
// Update active-step balance if panel is open
_updateActiveStep();
} catch (err) {
appendSystemMessage('Session error: ' + err.message);
} finally {
_inFlight = false;
_setSendBusy(false);
}
}
// ── Panel open/close ──────────────────────────────────────────────────────────
function _openPanel() {
if (!_panel) return;
_panel.classList.add('open');
_clearError();
if (_sessionState === 'active') {
_setStep('active');
_updateActiveStep();
} else if (_sessionState === 'paused') {
_setStep('topup');
} else {
_setStep('fund');
}
}
function _closePanel() {
_panel?.classList.remove('open');
_stopPolling();
}
// ── Create session ────────────────────────────────────────────────────────────
async function _createSession() {
_clearError();
// Read from the number input (may differ from the last preset clicked)
const inp = document.getElementById('session-amount-input');
if (inp) {
const v = parseInt(inp.value, 10);
if (Number.isFinite(v)) _selectedSats = v;
}
if (_selectedSats < 200 || _selectedSats > 10_000) {
_setError('Amount must be 20010,000 sats.');
return;
}
_setStatus('fund', 'creating session…', '#ffaa00');
_btn('session-create-btn', true);
try {
const nostrToken = await getOrRefreshToken('/api');
const createHeaders = { 'Content-Type': 'application/json' };
if (nostrToken) createHeaders['X-Nostr-Token'] = nostrToken;
const res = await fetch(`${API}/sessions`, {
method: 'POST',
headers: createHeaders,
body: JSON.stringify({ amount_sats: _selectedSats }),
});
const data = await res.json();
if (!res.ok) { _setError(data.error || 'Failed to create session.'); return; }
_sessionId = data.sessionId;
// Show deposit invoice
const inv = data.invoice;
_el('session-invoice-amount').textContent = inv.amountSats + ' sats';
_el('session-invoice-pr').textContent = inv.paymentRequest || '';
const hashEl = _el('session-invoice-hash');
if (hashEl) hashEl.dataset.hash = inv.paymentHash || '';
_setStep('invoice');
_setStatus('invoice', '⚡ Awaiting deposit…', '#ffaa00');
} catch (err) {
_setError('Network error: ' + err.message);
} finally {
_btn('session-create-btn', false);
}
}
// ── Pay deposit (stub) ────────────────────────────────────────────────────────
async function _payDeposit() {
const hash = _el('session-invoice-hash')?.dataset.hash;
if (!hash) { _setError('No payment hash — use real Lightning?'); return; }
_btn('session-pay-btn', true);
_setStatus('invoice', 'Simulating payment…', '#ffaa00');
try {
const res = await fetch(`${API}/dev/stub/pay/${hash}`, { method: 'POST' });
const ok = (await res.json()).ok;
if (!ok) { _setError('Payment simulation failed.'); return; }
_startDepositPolling();
} catch (err) {
_setError('Payment error: ' + err.message);
} finally {
_btn('session-pay-btn', false);
}
}
// ── Deposit polling ───────────────────────────────────────────────────────────
function _startDepositPolling() {
_stopPolling();
const deadline = Date.now() + POLL_TIMEOUT;
async function poll() {
if (!_sessionId) return;
try {
const res = await fetch(`${API}/sessions/${_sessionId}`);
const data = await res.json();
if (data.state === 'active') {
_macaroon = data.macaroon;
_balanceSats = data.balanceSats;
_sessionState = 'active';
_saveToStorage();
_applySessionUI();
_closePanel(); // panel auto-closes; user types in input bar
appendSystemMessage(`Session active — ${_balanceSats} sats`);
return;
}
} catch { /* network hiccup */ }
if (Date.now() > deadline) {
_setError('Timed out waiting for payment.');
return;
}
_pollTimer = setTimeout(poll, POLL_MS);
}
_pollTimer = setTimeout(poll, POLL_MS);
}
// ── Topup ─────────────────────────────────────────────────────────────────────
async function _createTopup() {
if (!_sessionId || !_macaroon) return;
_clearError();
// Read from the topup number input
const inp = document.getElementById('session-topup-input');
if (inp) {
const v = parseInt(inp.value, 10);
if (Number.isFinite(v)) _topupSats = v;
}
if (_topupSats < 200 || _topupSats > 10_000) {
_setError('Topup amount must be 20010,000 sats.');
return;
}
_setStatus('topup', 'creating topup invoice…', '#ffaa00');
_btn('session-topup-create-btn', true);
try {
const res = await fetch(`${API}/sessions/${_sessionId}/topup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${_macaroon}`,
},
body: JSON.stringify({ amount_sats: _topupSats }),
});
const data = await res.json();
if (!res.ok) {
// Pending topup already exists — surface it
const pending = data.pendingTopup;
if (pending) {
_el('session-topup-pr').textContent = pending.paymentRequest || '';
const th = _el('session-topup-hash');
if (th) th.dataset.hash = pending.paymentHash || '';
_el('session-topup-pr-row').style.display = '';
_setStatus('topup', '⚡ Previous invoice still pending', '#ffaa00');
return;
}
_setError(data.error || 'Failed to create topup.');
return;
}
const inv = data.topup;
_el('session-topup-pr').textContent = inv.paymentRequest || '';
const th = _el('session-topup-hash');
if (th) th.dataset.hash = inv.paymentHash || '';
_el('session-topup-pr-row').style.display = '';
_setStatus('topup', '⚡ Awaiting topup payment…', '#ffaa00');
} catch (err) {
_setError('Topup error: ' + err.message);
} finally {
_btn('session-topup-create-btn', false);
}
}
async function _payTopup() {
const hash = _el('session-topup-hash')?.dataset.hash;
if (!hash) { _setError('No topup hash.'); return; }
_btn('session-topup-pay-btn', true);
_setStatus('topup', 'Simulating topup payment…', '#ffaa00');
try {
const res = await fetch(`${API}/dev/stub/pay/${hash}`, { method: 'POST' });
const ok = (await res.json()).ok;
if (!ok) { _setError('Topup simulation failed.'); return; }
_startTopupPolling();
} catch (err) {
_setError('Topup payment error: ' + err.message);
} finally {
_btn('session-topup-pay-btn', false);
}
}
function _startTopupPolling() {
_stopPolling();
const deadline = Date.now() + POLL_TIMEOUT;
const prevBalance = _balanceSats;
async function poll() {
if (!_sessionId) return;
try {
const res = await fetch(`${API}/sessions/${_sessionId}`);
const data = await res.json();
if (data.balanceSats > prevBalance || data.state === 'active') {
_balanceSats = data.balanceSats;
_macaroon = data.macaroon || _macaroon;
_sessionState = data.state === 'active' ? 'active' : _sessionState;
_saveToStorage();
_applySessionUI();
_setStep('active');
_updateActiveStep();
_setStatus('active', `⚡ Topped up! ${_balanceSats} sats`, '#22aa66');
return;
}
} catch { /* ignore */ }
if (Date.now() > deadline) { _setError('Topup timed out.'); return; }
_pollTimer = setTimeout(poll, POLL_MS);
}
_pollTimer = setTimeout(poll, POLL_MS);
}
// ── Restore from localStorage ─────────────────────────────────────────────────
async function _tryRestore() {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const { sessionId, macaroon, balanceSats } = JSON.parse(raw);
if (!sessionId || !macaroon) return;
// Validate the session is still live
const res = await fetch(`${API}/sessions/${sessionId}`);
if (!res.ok) { localStorage.removeItem(LS_KEY); return; }
const data = await res.json();
if (data.state !== 'active' && data.state !== 'paused') {
localStorage.removeItem(LS_KEY);
return;
}
_sessionId = sessionId;
_macaroon = data.macaroon || macaroon;
_balanceSats = data.balanceSats ?? balanceSats;
_sessionState = data.state;
_saveToStorage();
_applySessionUI();
appendSystemMessage(`Session restored (${_balanceSats} sats)`);
} catch {
// Ignore restore errors silently
}
}
// ── Session UI helpers ────────────────────────────────────────────────────────
function _applySessionUI() {
const anyActive = isSessionActive(); // true for both 'active' and 'paused'
const canSend = _sessionState === 'active';
// Show low-balance overlay whenever session exists but balance is too low
const lowBal = anyActive && _balanceSats < MIN_BALANCE;
// HUD balance: "Balance: X sats ⚡ Top Up"
const $hud = document.getElementById('session-hud');
if ($hud) {
$hud.style.display = anyActive ? '' : 'none';
const $span = document.getElementById('session-hud-balance');
if ($span) $span.textContent = `Balance: ${_balanceSats} sats`;
}
// Top button label
const $btn = document.getElementById('open-session-btn');
if ($btn) {
$btn.textContent = anyActive
? `SESSION: ${_balanceSats}`
: '⚡ FUND SESSION';
$btn.style.background = anyActive ? '#1a4a30' : '';
}
// Input bar green border + placeholder when session can send
setInputBarSessionMode(canSend, 'Ask Timmy (session active)…');
// Route input bar to session handler only when active (not paused)
setSessionSendHandler(canSend ? sessionSendHandler : null);
// Low balance notice above input bar
const $notice = document.getElementById('low-balance-notice');
if ($notice) $notice.style.display = lowBal ? '' : 'none';
}
function _updateActiveStep() {
const el = document.getElementById('session-active-amount');
if (el) {
el.textContent = _balanceSats + ' sats';
el.style.color = _balanceSats < MIN_BALANCE ? '#ff8844' : '#44dd88';
}
// Reset topup pr row visibility
const prRow = document.getElementById('session-topup-pr-row');
if (prRow) prRow.style.display = 'none';
}
function _clearSession() {
_sessionId = null;
_macaroon = null;
_balanceSats = 0;
_sessionState = null;
localStorage.removeItem(LS_KEY);
setInputBarSessionMode(false);
setSessionSendHandler(null);
const $hud = document.getElementById('session-hud');
if ($hud) $hud.style.display = 'none';
const $btn = document.getElementById('open-session-btn');
if ($btn) { $btn.textContent = '⚡ FUND SESSION'; $btn.style.background = ''; }
const $notice = document.getElementById('low-balance-notice');
if ($notice) $notice.style.display = 'none';
}
function _saveToStorage() {
try {
localStorage.setItem(LS_KEY, JSON.stringify({
sessionId: _sessionId,
macaroon: _macaroon,
balanceSats: _balanceSats,
}));
} catch { /* storage unavailable */ }
}
// ── DOM helpers ───────────────────────────────────────────────────────────────
function _el(id) { return document.getElementById(id); }
function _on(id, ev, fn) {
_el(id)?.addEventListener(ev, fn);
}
function _btn(id, disabled) {
const el = _el(id);
if (el) el.disabled = disabled;
}
function _setSendBusy(busy) {
const btn = document.getElementById('send-btn');
if (btn) btn.disabled = busy;
}
function _setStep(step) {
if (!_panel) return;
_panel.querySelectorAll('[data-session-step]').forEach(el => {
el.style.display = el.dataset.sessionStep === step ? '' : 'none';
});
}
function _setStatus(step, msg, color = '#22aa66') {
const el = document.getElementById(`session-status-${step}`);
if (el) { el.textContent = msg; el.style.color = color; }
}
function _setError(msg) {
const el = _el('session-error');
if (el) el.textContent = msg;
}
function _clearError() {
const el = _el('session-error');
if (el) el.textContent = '';
}
function _stopPolling() {
clearTimeout(_pollTimer);
_pollTimer = null;
}