XmLLM / frontend /static /index.html
Claude
Integrate PaddleOCR and redesign UI with Xerox Star aesthetic
862cbed unverified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XmLLM β€” Document Structure Engine</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Chicago&display=swap');
:root {
--bg: #a8a8a8;
--window-bg: #ffffff;
--titlebar: #000000;
--titlebar-text: #ffffff;
--border: #000000;
--text: #000000;
--button-bg: #ffffff;
--button-shadow: #555555;
--select-bg: #000000;
--select-text: #ffffff;
--disabled: #888888;
--success: #006600;
--error: #cc0000;
--accent: #000000;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Geneva", "Chicago", "Charcoal", "Lucida Grande", "Helvetica", monospace;
font-size: 12px;
background: var(--bg);
background-image:
linear-gradient(45deg, #9e9e9e 25%, transparent 25%),
linear-gradient(-45deg, #9e9e9e 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #9e9e9e 75%),
linear-gradient(-45deg, transparent 75%, #9e9e9e 75%);
background-size: 4px 4px;
background-position: 0 0, 0 2px, 2px -2px, -2px 0px;
color: var(--text);
min-height: 100vh;
padding: 8px;
}
/* ── Desktop icons ─────────────────────────────────── */
.desktop {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.desktop-icon {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 80px;
padding: 6px 4px;
cursor: pointer;
border: 2px solid transparent;
user-select: none;
}
.desktop-icon:hover { border: 2px dotted var(--border); }
.desktop-icon.active {
background: var(--select-bg);
color: var(--select-text);
border: 2px solid var(--border);
}
.desktop-icon .icon-img {
width: 48px;
height: 48px;
border: 2px solid var(--border);
background: var(--window-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.desktop-icon.active .icon-img {
background: var(--select-bg);
color: var(--select-text);
border-color: var(--select-text);
}
.desktop-icon .icon-label {
font-size: 10px;
text-align: center;
word-break: break-word;
}
/* ── Window chrome ─────────────────────────────────── */
.window {
background: var(--window-bg);
border: 2px solid var(--border);
box-shadow: 3px 3px 0 var(--border);
max-width: 760px;
margin: 0 auto;
}
.titlebar {
background: var(--titlebar);
color: var(--titlebar-text);
padding: 3px 8px;
font-size: 12px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
cursor: default;
user-select: none;
}
.titlebar .close-box {
width: 14px;
height: 14px;
border: 1px solid var(--titlebar-text);
display: inline-block;
cursor: pointer;
}
.titlebar .title-text { flex: 1; text-align: center; }
.titlebar .stripes {
flex: 0 0 40px;
height: 10px;
background: repeating-linear-gradient(
0deg,
var(--titlebar-text) 0px, var(--titlebar-text) 1px,
var(--titlebar) 1px, var(--titlebar) 3px
);
}
.window-body { padding: 16px; }
/* ── Menu bar ──────────────────────────────────────── */
.menubar {
background: var(--window-bg);
border-bottom: 2px solid var(--border);
padding: 2px 8px;
font-size: 11px;
display: flex;
gap: 16px;
}
.menubar span { cursor: default; font-weight: bold; }
/* ── Buttons ───────────────────────────────────────── */
button, .btn {
font-family: inherit;
font-size: 12px;
background: var(--button-bg);
border: 2px solid var(--border);
padding: 4px 16px;
cursor: pointer;
box-shadow: 2px 2px 0 var(--button-shadow);
user-select: none;
}
button:active, .btn:active {
box-shadow: none;
transform: translate(2px, 2px);
}
button:disabled {
color: var(--disabled);
border-color: var(--disabled);
box-shadow: 1px 1px 0 var(--disabled);
cursor: not-allowed;
}
button.primary {
background: var(--accent);
color: var(--titlebar-text);
font-weight: bold;
border-radius: 6px;
padding: 6px 24px;
}
button.primary:active {
background: #333;
}
/* ── Form elements ─────────────────────────────────── */
label {
font-size: 11px;
font-weight: bold;
display: block;
margin-bottom: 2px;
}
input[type="file"] {
font-family: inherit;
font-size: 11px;
}
/* ── Drop zone ─────────────────────────────────────── */
.drop-zone {
border: 3px dashed var(--border);
padding: 32px 16px;
text-align: center;
cursor: pointer;
margin-bottom: 12px;
transition: background 0.15s;
}
.drop-zone:hover, .drop-zone.dragover {
background: #e0e0e0;
}
.drop-zone .icon { font-size: 36px; margin-bottom: 8px; }
.drop-zone .hint { font-size: 11px; color: var(--disabled); }
.drop-zone .filename {
font-weight: bold;
margin-top: 8px;
word-break: break-all;
}
/* ── Status / progress ─────────────────────────────── */
.status-bar {
background: var(--window-bg);
border-top: 2px solid var(--border);
padding: 3px 8px;
font-size: 10px;
color: var(--disabled);
display: flex;
justify-content: space-between;
}
.progress-text {
font-weight: bold;
padding: 8px;
text-align: center;
font-size: 11px;
}
/* ── Result card ───────────────────────────────────── */
.result-area {
border: 2px solid var(--border);
padding: 12px;
margin-top: 12px;
background: #f5f5f5;
}
.result-area h3 {
font-size: 12px;
border-bottom: 1px solid var(--border);
padding-bottom: 4px;
margin-bottom: 8px;
}
.result-row {
display: flex;
gap: 12px;
margin-bottom: 8px;
align-items: center;
}
.result-label { font-weight: bold; min-width: 80px; font-size: 11px; }
.result-value { font-size: 11px; }
.tag {
display: inline-block;
border: 1px solid var(--border);
padding: 1px 6px;
font-size: 10px;
font-weight: bold;
}
.tag.ok { background: #c6f5c6; }
.tag.no { background: #f0f0f0; color: var(--disabled); }
.download-btn {
display: inline-block;
font-family: inherit;
font-size: 11px;
background: var(--window-bg);
border: 2px solid var(--border);
padding: 3px 12px;
cursor: pointer;
text-decoration: none;
color: var(--text);
box-shadow: 2px 2px 0 var(--button-shadow);
margin: 2px;
}
.download-btn:active {
box-shadow: none;
transform: translate(2px, 2px);
}
/* ── Event log ─────────────────────────────────────── */
.event-log {
font-family: "Monaco", "Courier New", monospace;
font-size: 10px;
background: var(--window-bg);
border: 2px inset var(--border);
padding: 6px;
max-height: 200px;
overflow-y: auto;
white-space: pre;
margin-top: 8px;
}
/* ── Jobs table ────────────────────────────────────── */
table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
th {
text-align: left;
font-weight: bold;
border-bottom: 2px solid var(--border);
padding: 4px 6px;
background: #e8e8e8;
}
td {
padding: 3px 6px;
border-bottom: 1px solid #ccc;
}
tr:hover td { background: #e8e8f0; }
.status-badge {
display: inline-block;
padding: 1px 6px;
font-size: 10px;
font-weight: bold;
border: 1px solid;
}
.status-badge.succeeded { background: #c6f5c6; border-color: var(--success); }
.status-badge.failed { background: #f5c6c6; border-color: var(--error); }
.status-badge.partial_success { background: #f5eec6; border-color: #886600; }
.status-badge.running { background: #c6e0f5; border-color: #004488; }
.status-badge.queued { background: #e8e8e8; border-color: var(--disabled); }
.hidden { display: none; }
/* ── Responsive ────────────────────────────────────── */
@media (max-width: 600px) {
.desktop { justify-content: center; }
.window { margin: 0 4px; }
}
</style>
</head>
<body>
<!-- ═══ Desktop Icons ═══ -->
<div class="desktop">
<div class="desktop-icon active" onclick="showPage('scan')">
<div class="icon-img">&#128196;</div>
<div class="icon-label">Scan Document</div>
</div>
<div class="desktop-icon" onclick="showPage('jobs')">
<div class="icon-img">&#128193;</div>
<div class="icon-label">Documents</div>
</div>
<div class="desktop-icon" onclick="showPage('advanced')">
<div class="icon-img">&#9881;</div>
<div class="icon-label">Advanced</div>
</div>
</div>
<!-- ═══ SCAN WINDOW ═══ -->
<div id="page-scan" class="window">
<div class="titlebar">
<span class="close-box"></span>
<span class="stripes"></span>
<span class="title-text">Scan Document</span>
<span class="stripes"></span>
</div>
<div class="menubar">
<span>File</span>
<span>Edit</span>
<span>View</span>
</div>
<div class="window-body">
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('image-input').click()">
<div class="icon">&#128444;</div>
<div><b>Drop an image here</b></div>
<div class="hint">or click to browse &mdash; PNG, JPEG, TIFF, WebP</div>
<div class="filename hidden" id="file-label"></div>
<input type="file" id="image-input" accept=".png,.jpg,.jpeg,.tiff,.tif,.webp,.bmp" style="display:none">
</div>
<div style="text-align:center;">
<button class="primary" id="btn-scan" onclick="runScan()" disabled>
&#9654; Run OCR Pipeline
</button>
</div>
<div class="progress-text hidden" id="progress">Processing&hellip;</div>
<div class="result-area hidden" id="result-area">
<h3>&#9745; Result</h3>
<div class="result-row">
<span class="result-label">Status</span>
<span class="result-value" id="res-status"></span>
</div>
<div class="result-row">
<span class="result-label">Duration</span>
<span class="result-value" id="res-duration"></span>
</div>
<div class="result-row">
<span class="result-label">Exports</span>
<span class="result-value" id="res-exports"></span>
</div>
<div class="result-row" id="res-downloads-row">
<span class="result-label">Download</span>
<span class="result-value" id="res-downloads"></span>
</div>
<div class="event-log hidden" id="event-log"></div>
<div style="margin-top:6px;text-align:right;">
<button onclick="toggleLog()">Show Log</button>
</div>
</div>
</div>
<div class="status-bar">
<span id="statusbar-left">Ready</span>
<span>XmLLM v0.1.0</span>
</div>
</div>
<!-- ═══ JOBS WINDOW ═══ -->
<div id="page-jobs" class="window hidden">
<div class="titlebar">
<span class="close-box"></span>
<span class="stripes"></span>
<span class="title-text">Documents</span>
<span class="stripes"></span>
</div>
<div class="menubar">
<span>File</span>
<span>View</span>
</div>
<div class="window-body">
<table>
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Source</th>
<th>ALTO</th>
<th>PAGE</th>
<th>Duration</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="jobs-table">
<tr><td colspan="7" style="text-align:center;color:#888;">Loading&hellip;</td></tr>
</tbody>
</table>
</div>
<div class="status-bar">
<span id="jobs-count">-</span>
<span>XmLLM v0.1.0</span>
</div>
</div>
<!-- ═══ ADVANCED WINDOW (raw payload) ═══ -->
<div id="page-advanced" class="window hidden">
<div class="titlebar">
<span class="close-box"></span>
<span class="stripes"></span>
<span class="title-text">Advanced &mdash; Raw Payload</span>
<span class="stripes"></span>
</div>
<div class="menubar">
<span>File</span>
</div>
<div class="window-body">
<p style="margin-bottom:12px;font-size:11px;color:#555;">
Upload a raw OCR JSON payload manually (for providers other than PaddleOCR).
</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px;">
<div>
<label>Raw Payload JSON</label>
<input type="file" id="payload-file" accept=".json">
</div>
<div>
<label>Provider Family</label>
<select id="provider-family" style="font-family:inherit;font-size:11px;padding:2px;width:100%;border:2px solid var(--border);">
<option value="word_box_json">word_box_json (PaddleOCR)</option>
<option value="line_box_json">line_box_json</option>
<option value="text_only">text_only (mLLM)</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px;">
<div>
<label>Provider ID</label>
<input type="text" id="provider-id" value="paddleocr" style="font-family:inherit;font-size:11px;padding:2px;width:100%;border:2px solid var(--border);">
</div>
<div>
<label>Image Width (px)</label>
<input type="number" id="img-width" value="2480" style="font-family:inherit;font-size:11px;padding:2px;width:100%;border:2px solid var(--border);">
</div>
<div>
<label>Image Height (px)</label>
<input type="number" id="img-height" value="3508" style="font-family:inherit;font-size:11px;padding:2px;width:100%;border:2px solid var(--border);">
</div>
</div>
<button class="primary" id="btn-run-advanced" onclick="runAdvanced()">Run Pipeline</button>
<span id="adv-status" style="margin-left:8px;font-size:11px;color:#888;"></span>
</div>
<div class="status-bar">
<span>Manual mode</span>
<span>XmLLM v0.1.0</span>
</div>
</div>
<script>
const API = '';
/* ── Page switching ── */
function showPage(name) {
['scan','jobs','advanced'].forEach(p => {
document.getElementById('page-' + p).classList.add('hidden');
});
document.getElementById('page-' + name).classList.remove('hidden');
document.querySelectorAll('.desktop-icon').forEach(d => d.classList.remove('active'));
event.currentTarget.classList.add('active');
if (name === 'jobs') loadJobs();
}
/* ── File drop zone ── */
const dropZone = document.getElementById('drop-zone');
const imageInput = document.getElementById('image-input');
['dragenter','dragover'].forEach(e => {
dropZone.addEventListener(e, ev => { ev.preventDefault(); dropZone.classList.add('dragover'); });
});
['dragleave','drop'].forEach(e => {
dropZone.addEventListener(e, ev => { ev.preventDefault(); dropZone.classList.remove('dragover'); });
});
dropZone.addEventListener('drop', ev => {
if (ev.dataTransfer.files.length) {
imageInput.files = ev.dataTransfer.files;
onFileSelected();
}
});
imageInput.addEventListener('change', onFileSelected);
function onFileSelected() {
const f = imageInput.files[0];
if (!f) return;
const label = document.getElementById('file-label');
label.textContent = f.name + ' (' + (f.size / 1024).toFixed(0) + ' KB)';
label.classList.remove('hidden');
document.getElementById('btn-scan').disabled = false;
document.getElementById('statusbar-left').textContent = 'Image loaded: ' + f.name;
}
/* ── Main scan (image β†’ OCR β†’ XML) ── */
async function runScan() {
const f = imageInput.files[0];
if (!f) return;
const btn = document.getElementById('btn-scan');
const progress = document.getElementById('progress');
const result = document.getElementById('result-area');
btn.disabled = true;
progress.classList.remove('hidden');
result.classList.add('hidden');
document.getElementById('statusbar-left').textContent = 'Running OCR pipeline\u2026';
const fd = new FormData();
fd.append('image', f);
try {
const r = await fetch(API + '/ocr', { method: 'POST', body: fd });
const data = await r.json();
if (!r.ok) {
progress.textContent = 'Error: ' + (data.detail || r.statusText);
progress.style.color = 'var(--error)';
document.getElementById('statusbar-left').textContent = 'Error';
btn.disabled = false;
return;
}
progress.classList.add('hidden');
progress.style.color = '';
progress.textContent = 'Processing\u2026';
btn.disabled = false;
showResult(data);
document.getElementById('statusbar-left').textContent = 'Done \u2014 ' + data.status;
} catch(e) {
progress.textContent = 'Network error: ' + e.message;
progress.style.color = 'var(--error)';
btn.disabled = false;
}
}
function showResult(data) {
const area = document.getElementById('result-area');
area.classList.remove('hidden');
document.getElementById('res-status').innerHTML =
'<span class="status-badge ' + data.status + '">' + data.status.toUpperCase() + '</span>';
document.getElementById('res-duration').textContent =
data.duration_ms ? Math.round(data.duration_ms) + ' ms' : '\u2014';
document.getElementById('res-exports').innerHTML =
'<span class="tag ' + (data.has_alto ? 'ok' : 'no') + '">ALTO ' + (data.has_alto ? '\u2713' : '\u2717') + '</span> ' +
'<span class="tag ' + (data.has_page_xml ? 'ok' : 'no') + '">PAGE ' + (data.has_page_xml ? '\u2713' : '\u2717') + '</span>';
const dl = document.getElementById('res-downloads');
const id = data.job_id;
dl.innerHTML = '';
if (data.has_alto)
dl.innerHTML += '<a class="download-btn" href="' + API + '/jobs/' + id + '/alto">&#128196; ALTO XML</a>';
if (data.has_page_xml)
dl.innerHTML += '<a class="download-btn" href="' + API + '/jobs/' + id + '/pagexml">&#128196; PAGE XML</a>';
dl.innerHTML += '<a class="download-btn" href="' + API + '/jobs/' + id + '/canonical" target="_blank">&#128203; Canonical JSON</a>';
// Load event log
fetch(API + '/jobs/' + id + '/logs').then(r => r.json()).then(events => {
document.getElementById('event-log').textContent = events.map(e =>
'[' + e.status.padEnd(9) + '] ' + e.step.padEnd(20) + ' ' +
(e.duration_ms ? Math.round(e.duration_ms) + 'ms' : e.message || '')
).join('\n');
}).catch(() => {});
}
function toggleLog() {
const log = document.getElementById('event-log');
log.classList.toggle('hidden');
}
/* ── Jobs list ── */
async function loadJobs() {
try {
const r = await fetch(API + '/jobs');
const jobs = await r.json();
const tbody = document.getElementById('jobs-table');
if (!jobs.length) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#888;">No documents yet</td></tr>';
document.getElementById('jobs-count').textContent = '0 documents';
return;
}
tbody.innerHTML = jobs.map(j => `<tr>
<td style="font-family:monospace;font-size:10px;">${j.job_id.slice(-8)}</td>
<td><span class="status-badge ${j.status}">${j.status}</span></td>
<td>${j.source_filename || '\u2014'}</td>
<td><span class="tag ${j.has_alto ? 'ok' : 'no'}">${j.has_alto ? '\u2713' : '\u2717'}</span></td>
<td><span class="tag ${j.has_page_xml ? 'ok' : 'no'}">${j.has_page_xml ? '\u2713' : '\u2717'}</span></td>
<td>${j.duration_ms ? Math.round(j.duration_ms) + 'ms' : '\u2014'}</td>
<td>
${j.has_alto ? `<a class="download-btn" href="${API}/jobs/${j.job_id}/alto" style="font-size:10px;">ALTO</a>` : ''}
${j.has_page_xml ? `<a class="download-btn" href="${API}/jobs/${j.job_id}/pagexml" style="font-size:10px;">PAGE</a>` : ''}
</td>
</tr>`).join('');
document.getElementById('jobs-count').textContent = jobs.length + ' document' + (jobs.length > 1 ? 's' : '');
} catch(e) {
document.getElementById('jobs-table').innerHTML =
'<tr><td colspan="7" style="color:var(--error);">Failed to load</td></tr>';
}
}
/* ── Advanced (raw payload) ── */
async function runAdvanced() {
const fileInput = document.getElementById('payload-file');
if (!fileInput.files.length) { alert('Select a payload JSON file'); return; }
const btn = document.getElementById('btn-run-advanced');
const status = document.getElementById('adv-status');
btn.disabled = true;
status.textContent = 'Running\u2026';
const fd = new FormData();
fd.append('raw_payload_file', fileInput.files[0]);
const params = new URLSearchParams({
provider_id: document.getElementById('provider-id').value,
provider_family: document.getElementById('provider-family').value,
image_width: document.getElementById('img-width').value,
image_height: document.getElementById('img-height').value,
});
try {
const r = await fetch(API + '/jobs?' + params, { method: 'POST', body: fd });
const data = await r.json();
btn.disabled = false;
status.textContent = r.ok
? '\u2713 ' + data.status + (data.duration_ms ? ' (' + Math.round(data.duration_ms) + 'ms)' : '')
: 'Error: ' + (data.detail || r.statusText);
} catch(e) {
btn.disabled = false;
status.textContent = 'Error: ' + e.message;
}
}
</script>
</body>
</html>