Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
Add a bottom-sheet History panel that shows completed jobs in reverse chronological order with expandable results. - New history.js module: persists up to 50 jobs in localStorage (timmy_history_v1), renders rows with prompt/cost/relative-time, smooth expand/collapse animation, pull-to-refresh and refresh button - index.html: History panel HTML + CSS (bottom sheet slides up from bottom edge), "⏱ HISTORY" button added to top-buttons bar - payment.js: calls addHistoryEntry() when a Lightning job reaches complete or rejected state; tracks currentRequest across async flow - session.js: calls addHistoryEntry() after each session request completes, computing cost from balance delta - main.js: imports and calls initHistoryPanel() on first init Fixes #31 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
237 lines
7.4 KiB
JavaScript
237 lines
7.4 KiB
JavaScript
/**
|
|
* payment.js — Lightning-gated job submission panel for Timmy Tower World.
|
|
*
|
|
* Flow:
|
|
* 1. User clicks "⚡ SUBMIT JOB" → panel slides in
|
|
* 2. Enters a request → POST /api/jobs → eval invoice shown
|
|
* 3. Pays eval invoice (stub: click "Simulate Payment") → agent orbs animate
|
|
* 4. Work invoice appears → pay it
|
|
* 5. Result appears in panel + chat feed
|
|
*/
|
|
|
|
import { getOrRefreshToken } from './nostr-identity.js';
|
|
import { addHistoryEntry } from './history.js';
|
|
|
|
const API_BASE = '/api';
|
|
const POLL_INTERVAL_MS = 2000;
|
|
const POLL_TIMEOUT_MS = 60000;
|
|
|
|
let panel = null;
|
|
let closeBtn = null;
|
|
let currentJobId = null;
|
|
let currentRequest = '';
|
|
let pollTimer = null;
|
|
|
|
export function initPaymentPanel() {
|
|
panel = document.getElementById('payment-panel');
|
|
closeBtn = document.getElementById('payment-close');
|
|
|
|
if (!panel) return;
|
|
|
|
closeBtn?.addEventListener('click', closePanel);
|
|
|
|
document.getElementById('job-submit-btn')?.addEventListener('click', submitJob);
|
|
document.getElementById('pay-eval-btn')?.addEventListener('click', () => payInvoice('eval'));
|
|
document.getElementById('pay-work-btn')?.addEventListener('click', () => payInvoice('work'));
|
|
document.getElementById('new-job-btn')?.addEventListener('click', resetPanel);
|
|
|
|
document.getElementById('open-panel-btn')?.addEventListener('click', openPanel);
|
|
}
|
|
|
|
export function openPanel() {
|
|
if (!panel) return;
|
|
panel.classList.add('open');
|
|
document.getElementById('job-input')?.focus();
|
|
}
|
|
|
|
function closePanel() {
|
|
if (!panel) return;
|
|
panel.classList.remove('open');
|
|
stopPolling();
|
|
}
|
|
|
|
function resetPanel() {
|
|
stopPolling();
|
|
currentJobId = null;
|
|
setStep('input');
|
|
document.getElementById('job-input').value = '';
|
|
document.getElementById('job-error').textContent = '';
|
|
}
|
|
|
|
function setStep(step) {
|
|
document.querySelectorAll('[data-step]').forEach(el => {
|
|
el.style.display = el.dataset.step === step ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
function setStatus(msg, color = '#00ff41') {
|
|
const el = document.getElementById('job-status');
|
|
if (el) { el.textContent = msg; el.style.color = color; }
|
|
}
|
|
|
|
function setError(msg) {
|
|
const el = document.getElementById('job-error');
|
|
if (el) { el.textContent = msg; el.style.color = '#ff4444'; }
|
|
}
|
|
|
|
async function submitJob() {
|
|
const input = document.getElementById('job-input');
|
|
const request = input?.value?.trim();
|
|
if (!request) { setError('Enter a request first.'); return; }
|
|
if (request.length > 500) { setError('Max 500 characters.'); return; }
|
|
|
|
setError('');
|
|
setStatus('Creating job…', '#ffaa00');
|
|
document.getElementById('job-submit-btn').disabled = true;
|
|
|
|
try {
|
|
const token = await getOrRefreshToken('/api');
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (token) headers['X-Nostr-Token'] = token;
|
|
|
|
const res = await fetch(`${API_BASE}/jobs`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ request }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { setError(data.error || 'Failed to create job.'); return; }
|
|
|
|
currentJobId = data.jobId;
|
|
currentRequest = request;
|
|
showEvalInvoice(data.evalInvoice);
|
|
} catch (err) {
|
|
setError('Network error: ' + err.message);
|
|
} finally {
|
|
document.getElementById('job-submit-btn').disabled = false;
|
|
}
|
|
}
|
|
|
|
function showEvalInvoice(invoice) {
|
|
setStep('eval-invoice');
|
|
document.getElementById('eval-amount').textContent = invoice.amountSats + ' sats';
|
|
document.getElementById('eval-payment-request').textContent = invoice.paymentRequest || '';
|
|
document.getElementById('eval-hash').dataset.hash = invoice.paymentHash || '';
|
|
setStatus('⚡ Awaiting eval payment…', '#ffaa00');
|
|
}
|
|
|
|
function showWorkInvoice(invoice) {
|
|
setStep('work-invoice');
|
|
document.getElementById('work-amount').textContent = invoice.amountSats + ' sats';
|
|
document.getElementById('work-payment-request').textContent = invoice.paymentRequest || '';
|
|
document.getElementById('work-hash').dataset.hash = invoice.paymentHash || '';
|
|
setStatus('⚡ Awaiting work payment…', '#ffaa00');
|
|
}
|
|
|
|
function showResult(result, state) {
|
|
stopPolling();
|
|
setStep('result');
|
|
if (state === 'rejected') {
|
|
setStatus('Request rejected by AI judge', '#ff6600');
|
|
document.getElementById('job-result').textContent = result || 'Request was rejected.';
|
|
document.getElementById('result-label').textContent = 'REJECTION REASON';
|
|
} else {
|
|
setStatus('✓ Complete', '#00ff88');
|
|
document.getElementById('job-result').textContent = result;
|
|
document.getElementById('result-label').textContent = 'AI RESULT';
|
|
}
|
|
}
|
|
|
|
async function payInvoice(type) {
|
|
const hashEl = document.getElementById(type + '-hash');
|
|
const hash = hashEl?.dataset.hash;
|
|
if (!hash) { setError('No payment hash — using real Lightning?'); return; }
|
|
|
|
const btn = document.getElementById('pay-' + type + '-btn');
|
|
if (btn) btn.disabled = true;
|
|
setStatus('Simulating payment…', '#ffaa00');
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/dev/stub/pay/${hash}`, { method: 'POST' });
|
|
const data = await res.json();
|
|
if (!data.ok) { setError('Payment simulation failed.'); return; }
|
|
startPolling();
|
|
} catch (err) {
|
|
setError('Payment error: ' + err.message);
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
stopPolling();
|
|
setStatus('Processing…', '#ffaa00');
|
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
|
|
async function poll() {
|
|
if (!currentJobId) return;
|
|
try {
|
|
const token = await getOrRefreshToken('/api');
|
|
const pollHeaders = token ? { 'X-Nostr-Token': token } : {};
|
|
const res = await fetch(`${API_BASE}/jobs/${currentJobId}`, { headers: pollHeaders });
|
|
const data = await res.json();
|
|
const { state, workInvoice, result, reason, costLedger, completedAt } = data;
|
|
|
|
if (state === 'awaiting_work_payment' && workInvoice) {
|
|
showWorkInvoice(workInvoice);
|
|
stopPolling();
|
|
return;
|
|
}
|
|
if (state === 'complete') {
|
|
addHistoryEntry({
|
|
jobId: currentJobId,
|
|
request: currentRequest,
|
|
costSats: costLedger?.workAmountSats ?? costLedger?.actualAmountSats ?? 0,
|
|
result,
|
|
state: 'complete',
|
|
completedAt: completedAt ?? new Date().toISOString(),
|
|
});
|
|
showResult(result, 'complete');
|
|
return;
|
|
}
|
|
if (state === 'rejected') {
|
|
addHistoryEntry({
|
|
jobId: currentJobId,
|
|
request: currentRequest,
|
|
costSats: 0,
|
|
result: reason,
|
|
state: 'rejected',
|
|
completedAt: completedAt ?? new Date().toISOString(),
|
|
});
|
|
showResult(reason, 'rejected');
|
|
return;
|
|
}
|
|
if (state === 'failed') {
|
|
setError('Job failed: ' + (data.errorMessage || 'unknown error'));
|
|
stopPolling();
|
|
return;
|
|
}
|
|
} catch {
|
|
/* network hiccup — keep polling */
|
|
}
|
|
if (Date.now() > deadline) {
|
|
setError('Timed out waiting for response.');
|
|
stopPolling();
|
|
return;
|
|
}
|
|
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
|
|
}
|
|
|
|
pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
|
|
}
|
|
|
|
function stopPolling() {
|
|
clearTimeout(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
|
|
function copyToClipboard(elId) {
|
|
const el = document.getElementById(elId);
|
|
if (!el) return;
|
|
navigator.clipboard.writeText(el.textContent).catch(() => {});
|
|
const btn = el.nextElementSibling;
|
|
if (btn) { btn.textContent = 'COPIED'; setTimeout(() => { btn.textContent = 'COPY'; }, 1500); }
|
|
}
|
|
|
|
window._timmyCopy = copyToClipboard;
|