Make the demo user interface accessible through the API

Add a new UI route to serve the interactive demo interface at `/api/ui`.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 3fb69144-fc09-46cf-8560-9b4bc828c60f
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/sPDHkg8
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
alexpaynex
2026-03-18 18:06:44 +00:00
parent ade318a917
commit 0921fa1ca3
2 changed files with 509 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import jobsRouter from "./jobs.js";
import demoRouter from "./demo.js";
import devRouter from "./dev.js";
import testkitRouter from "./testkit.js";
import uiRouter from "./ui.js";
const router: IRouter = Router();
@@ -11,6 +12,7 @@ router.use(healthRouter);
router.use(jobsRouter);
router.use(demoRouter);
router.use(testkitRouter);
router.use(uiRouter);
if (process.env.NODE_ENV !== "production") {
router.use(devRouter);

View File

@@ -0,0 +1,507 @@
import { Router } from "express";
const router = Router();
router.get("/ui", (_req, res) => {
res.setHeader("Content-Type", "text/html");
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Timmy — Lightning AI Agent</title>
<style>
:root {
--bg: #0a0a0f;
--surface: #13131a;
--border: #2a2a3a;
--accent: #f7931a;
--accent2: #7b61ff;
--text: #e8e8f0;
--muted: #6b6b80;
--green: #00d4aa;
--red: #ff4d6d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'SF Mono', 'Fira Code', monospace;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
}
header {
text-align: center;
margin-bottom: 40px;
}
header h1 {
font-size: 2.2rem;
font-family: system-ui, sans-serif;
font-weight: 700;
letter-spacing: -1px;
}
header h1 span { color: var(--accent); }
header p {
color: var(--muted);
margin-top: 8px;
font-family: system-ui, sans-serif;
font-size: 0.95rem;
}
.badge {
display: inline-block;
background: #1a1a2e;
border: 1px solid var(--accent2);
color: var(--accent2);
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 4px;
margin-left: 8px;
vertical-align: middle;
letter-spacing: 1px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px;
width: 100%;
max-width: 640px;
margin-bottom: 16px;
}
.card h2 {
font-family: system-ui, sans-serif;
font-size: 1rem;
font-weight: 600;
margin-bottom: 16px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1px;
}
textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: system-ui, sans-serif;
font-size: 1rem;
padding: 14px;
resize: vertical;
min-height: 100px;
outline: none;
transition: border-color 0.2s;
}
textarea:focus { border-color: var(--accent2); }
.char-count {
text-align: right;
font-size: 0.75rem;
color: var(--muted);
margin-top: 6px;
}
.char-count.warn { color: var(--accent); }
.char-count.over { color: var(--red); }
button {
background: var(--accent);
color: #000;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-family: system-ui, sans-serif;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
margin-top: 16px;
width: 100%;
}
button:hover { opacity: 0.9; transform: translateY(-1px); }
button:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
button.secondary {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
}
button.pay-btn {
background: var(--green);
color: #000;
}
.pipeline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.step {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
color: var(--muted);
font-family: system-ui, sans-serif;
}
.step .dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--border);
flex-shrink: 0;
transition: background 0.4s;
}
.step.active .dot { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.step.done .dot { background: var(--green); }
.step.rejected .dot { background: var(--red); }
.step.active { color: var(--text); }
.step.done { color: var(--green); }
.step.rejected { color: var(--red); }
.arrow { color: var(--border); font-size: 0.8rem; }
.invoice-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-top: 12px;
}
.invoice-box .label {
font-size: 0.72rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
.invoice-box .amount {
font-size: 1.8rem;
font-weight: 700;
color: var(--accent);
font-family: system-ui, sans-serif;
}
.invoice-box .amount span {
font-size: 1rem;
color: var(--muted);
font-weight: 400;
}
.invoice-box .payment-request {
margin-top: 10px;
font-size: 0.68rem;
color: var(--muted);
word-break: break-all;
line-height: 1.4;
border-top: 1px solid var(--border);
padding-top: 10px;
}
.invoice-box .hash-line {
margin-top: 8px;
font-size: 0.72rem;
color: var(--accent2);
}
.result-box {
background: var(--bg);
border: 1px solid var(--green);
border-radius: 8px;
padding: 20px;
margin-top: 12px;
line-height: 1.7;
font-family: system-ui, sans-serif;
font-size: 0.95rem;
white-space: pre-wrap;
}
.rejected-box {
background: var(--bg);
border: 1px solid var(--red);
border-radius: 8px;
padding: 20px;
margin-top: 12px;
font-family: system-ui, sans-serif;
font-size: 0.95rem;
color: var(--red);
}
.status-line {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.85rem;
color: var(--muted);
margin-top: 12px;
font-family: system-ui, sans-serif;
}
.spinner {
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.hidden { display: none !important; }
.error-msg {
color: var(--red);
font-family: system-ui, sans-serif;
font-size: 0.88rem;
margin-top: 10px;
}
.mode-tag {
display: inline-block;
font-size: 0.68rem;
padding: 2px 6px;
border-radius: 4px;
margin-left: 6px;
vertical-align: middle;
}
.mode-stub { background: #1a1a2e; color: var(--accent2); border: 1px solid var(--accent2); }
.reset-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
margin-top: 8px;
}
</style>
</head>
<body>
<header>
<h1><span>⚡</span> Timmy <span class="badge">STUB MODE</span></h1>
<p>Lightning-gated AI agent — visual payment flow demo</p>
</header>
<div class="card" id="pipeline-card">
<div class="pipeline">
<div class="step" id="s-request"><div class="dot"></div>Request</div>
<div class="arrow">→</div>
<div class="step" id="s-eval"><div class="dot"></div>Eval fee</div>
<div class="arrow">→</div>
<div class="step" id="s-judge"><div class="dot"></div>Judge</div>
<div class="arrow">→</div>
<div class="step" id="s-work"><div class="dot"></div>Work fee</div>
<div class="arrow">→</div>
<div class="step" id="s-result"><div class="dot"></div>Result</div>
</div>
</div>
<!-- Step 1: Enter request -->
<div class="card" id="card-input">
<h2>Your request</h2>
<textarea id="request-input" placeholder="Ask Timmy anything — e.g. 'Explain how Lightning Network payment channels work'" maxlength="500"></textarea>
<div class="char-count" id="char-count">0 / 500</div>
<div id="input-error" class="error-msg hidden"></div>
<button id="submit-btn" onclick="createJob()">Create job &amp; get eval invoice →</button>
</div>
<!-- Step 2: Pay eval invoice -->
<div class="card hidden" id="card-eval">
<h2>Step 1 — Pay eval invoice <span class="mode-tag mode-stub">stub</span></h2>
<div class="invoice-box">
<div class="label">Amount due</div>
<div class="amount" id="eval-amount">—<span> sats</span></div>
<div class="payment-request" id="eval-pr"></div>
<div class="hash-line">hash: <span id="eval-hash"></span></div>
</div>
<p style="font-family:system-ui,sans-serif;font-size:0.82rem;color:var(--muted);margin-top:14px;">
In production this would be a scannable BOLT11 invoice. In stub mode, click below to simulate payment instantly.
</p>
<button class="pay-btn" id="pay-eval-btn" onclick="payEval()">⚡ Simulate eval payment</button>
<div class="status-line hidden" id="eval-polling"><div class="spinner"></div> Waiting for eval AI…</div>
</div>
<!-- Step 3: Rejected -->
<div class="card hidden" id="card-rejected">
<h2>Request rejected</h2>
<div class="rejected-box" id="rejected-reason"></div>
<button class="reset-btn" onclick="reset()">← Start over</button>
</div>
<!-- Step 4: Pay work invoice -->
<div class="card hidden" id="card-work">
<h2>Step 2 — Pay work invoice <span class="mode-tag mode-stub">stub</span></h2>
<div class="invoice-box">
<div class="label">Amount due</div>
<div class="amount" id="work-amount">—<span> sats</span></div>
<div class="payment-request" id="work-pr"></div>
<div class="hash-line">hash: <span id="work-hash"></span></div>
</div>
<p style="font-family:system-ui,sans-serif;font-size:0.82rem;color:var(--muted);margin-top:14px;">
Work fee is priced by request length. Click to simulate payment and Timmy will start working.
</p>
<button class="pay-btn" id="pay-work-btn" onclick="payWork()">⚡ Simulate work payment</button>
<div class="status-line hidden" id="work-polling"><div class="spinner"></div> Timmy is working…</div>
</div>
<!-- Step 5: Result -->
<div class="card hidden" id="card-result">
<h2>✓ Complete</h2>
<div class="result-box" id="result-text"></div>
<button class="reset-btn" onclick="reset()">← New job</button>
</div>
<script>
const BASE = window.location.origin;
let jobId = null;
let evalHash = null;
let workHash = null;
let pollTimer = null;
const $ = id => document.getElementById(id);
const show = id => $(id).classList.remove('hidden');
const hide = id => $(id).classList.add('hidden');
function setStep(name) {
const steps = ['s-request','s-eval','s-judge','s-work','s-result'];
const map = {
request: 0, awaiting_eval_payment: 1, evaluating: 2,
awaiting_work_payment: 3, executing: 3, complete: 4,
rejected: 2
};
const idx = map[name] ?? -1;
steps.forEach((s, i) => {
const el = $(s);
el.classList.remove('active','done','rejected');
if (i < idx) el.classList.add('done');
else if (i === idx) {
el.classList.add(name === 'rejected' && i === 2 ? 'rejected' : 'active');
}
});
}
$('request-input').addEventListener('input', () => {
const len = $('request-input').value.length;
const el = $('char-count');
el.textContent = len + ' / 500';
el.className = 'char-count' + (len > 450 ? (len >= 500 ? ' over' : ' warn') : '');
});
async function createJob() {
const req = $('request-input').value.trim();
hide('input-error');
if (!req) { showErr('input-error', 'Please enter a request.'); return; }
$('submit-btn').disabled = true;
$('submit-btn').textContent = 'Creating job…';
try {
const r = await fetch(BASE + '/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ request: req })
});
const data = await r.json();
if (!r.ok) { showErr('input-error', data.error || 'Failed to create job'); $('submit-btn').disabled = false; $('submit-btn').textContent = 'Create job & get eval invoice →'; return; }
jobId = data.jobId;
evalHash = data.evalInvoice.paymentHash;
$('eval-amount').innerHTML = data.evalInvoice.amountSats + '<span> sats</span>';
$('eval-pr').textContent = data.evalInvoice.paymentRequest;
$('eval-hash').textContent = evalHash;
hide('card-input');
show('card-eval');
setStep('awaiting_eval_payment');
} catch(e) {
showErr('input-error', 'Network error: ' + e.message);
$('submit-btn').disabled = false;
$('submit-btn').textContent = 'Create job & get eval invoice →';
}
}
async function payEval() {
$('pay-eval-btn').disabled = true;
$('pay-eval-btn').textContent = 'Paying…';
try {
const r = await fetch(BASE + '/api/dev/stub/pay/' + evalHash, { method: 'POST' });
if (!r.ok) { alert('Stub pay failed'); $('pay-eval-btn').disabled = false; return; }
show('eval-polling');
setStep('evaluating');
pollJob('eval');
} catch(e) {
alert('Error: ' + e.message);
$('pay-eval-btn').disabled = false;
}
}
async function payWork() {
$('pay-work-btn').disabled = true;
$('pay-work-btn').textContent = 'Paying…';
try {
const r = await fetch(BASE + '/api/dev/stub/pay/' + workHash, { method: 'POST' });
if (!r.ok) { alert('Stub pay failed'); $('pay-work-btn').disabled = false; return; }
show('work-polling');
setStep('executing');
pollJob('work');
} catch(e) {
alert('Error: ' + e.message);
$('pay-work-btn').disabled = false;
}
}
function pollJob(phase) {
clearInterval(pollTimer);
pollTimer = setInterval(async () => {
try {
const r = await fetch(BASE + '/api/jobs/' + jobId);
const data = await r.json();
const s = data.status;
if (phase === 'eval') {
if (s === 'rejected') {
clearInterval(pollTimer);
hide('card-eval');
$('rejected-reason').textContent = data.rejectionReason || 'Request was rejected.';
show('card-rejected');
setStep('rejected');
} else if (s === 'awaiting_work_payment') {
clearInterval(pollTimer);
workHash = data.workInvoice.paymentHash;
$('work-amount').innerHTML = data.workInvoice.amountSats + '<span> sats</span>';
$('work-pr').textContent = data.workInvoice.paymentRequest;
$('work-hash').textContent = workHash;
hide('card-eval');
show('card-work');
setStep('awaiting_work_payment');
}
} else if (phase === 'work') {
if (s === 'complete') {
clearInterval(pollTimer);
hide('card-work');
$('result-text').textContent = data.result;
show('card-result');
setStep('complete');
} else if (s === 'failed') {
clearInterval(pollTimer);
hide('card-work');
$('rejected-reason').textContent = 'Job failed: ' + (data.error || 'unknown error');
show('card-rejected');
}
}
} catch(e) { /* keep polling */ }
}, 1500);
}
function reset() {
clearInterval(pollTimer);
jobId = evalHash = workHash = null;
$('request-input').value = '';
$('char-count').textContent = '0 / 500';
$('submit-btn').disabled = false;
$('submit-btn').textContent = 'Create job & get eval invoice →';
$('pay-eval-btn').disabled = false;
$('pay-eval-btn').textContent = '⚡ Simulate eval payment';
$('pay-work-btn').disabled = false;
$('pay-work-btn').textContent = '⚡ Simulate work payment';
hide('eval-polling'); hide('work-polling');
hide('card-eval'); hide('card-rejected'); hide('card-work'); hide('card-result');
show('card-input');
document.querySelectorAll('.step').forEach(el => el.classList.remove('active','done','rejected'));
}
function showErr(id, msg) { $(id).textContent = msg; show(id); }
setStep('request');
$('s-request').classList.add('active');
</script>
</body>
</html>`);
});
export default router;