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:
@@ -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);
|
||||
|
||||
507
artifacts/api-server/src/routes/ui.ts
Normal file
507
artifacts/api-server/src/routes/ui.ts
Normal 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 & 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;
|
||||
Reference in New Issue
Block a user