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
563 lines
19 KiB
JavaScript
563 lines
19 KiB
JavaScript
/**
|
||
* session.js — Workshop Session Mode UI (Fund once, ask many)
|
||
*
|
||
* Flow:
|
||
* 1. User clicks "⚡ FUND SESSION" → left panel slides in
|
||
* 2. Picks amount (200–5000 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 200–10,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 200–10,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;
|
||
}
|