Refactors `runEvalInBackground` and `runWorkInBackground` to execute AI tasks asynchronously. Updates `pollJob` in `ui.ts` to handle 'evaluating', 'executing', and 'failed' states, and corrects `data.status` to `data.state` and `data.rejectionReason` to `data.reason`. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: ecf857ee-fa4d-47db-b4c1-b374ffb3815d Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/Q83Uqvu Replit-Helium-Checkpoint-Created: true
519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
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);
|
|
if (!r.ok) return; // keep polling on transient errors
|
|
const data = await r.json();
|
|
const s = data.state;
|
|
|
|
// Failed is terminal regardless of phase
|
|
if (s === 'failed') {
|
|
clearInterval(pollTimer);
|
|
hide('card-eval');
|
|
hide('card-work');
|
|
$('rejected-reason').textContent = 'Error: ' + (data.errorMessage || 'Something went wrong. Try again.');
|
|
show('card-rejected');
|
|
setStep('rejected');
|
|
return;
|
|
}
|
|
|
|
if (phase === 'eval') {
|
|
// evaluating = AI running in background, keep waiting
|
|
if (s === 'evaluating') return;
|
|
if (s === 'rejected') {
|
|
clearInterval(pollTimer);
|
|
hide('card-eval');
|
|
$('rejected-reason').textContent = data.reason || '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') {
|
|
// executing = AI running in background, keep waiting
|
|
if (s === 'executing') return;
|
|
if (s === 'complete') {
|
|
clearInterval(pollTimer);
|
|
hide('card-work');
|
|
$('result-text').textContent = data.result;
|
|
show('card-result');
|
|
setStep('complete');
|
|
}
|
|
}
|
|
} catch(e) { /* keep polling through network errors */ }
|
|
}, 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;
|