Claude commited on
Commit
2ba385f
·
unverified ·
1 Parent(s): 84ced9b

Sprint 11: minimal web frontend — upload, jobs, providers, exports

Browse files

Single-page HTML/JS frontend served via FastAPI StaticFiles:

Pages:
- Upload: select payload JSON, configure provider family/ID/dimensions,
run pipeline, see result (status, duration, export links, event log)
- Jobs: history table with status badges, export availability, download
links for ALTO/PAGE XML
- Providers: register new providers (ID, name, family, runtime, model),
list registered providers, delete

Features:
- Dark theme UI
- Status badges (succeeded/failed/partial_success/running/queued)
- Direct download links for ALTO XML, PAGE XML, canonical JSON
- Pipeline event log display with step names and durations
- Provider CRUD

Served at / with static assets at /static. Dockerfile updated to include
frontend directory. No build step needed — vanilla HTML/JS.

497 tests still passing.

https://claude.ai/code/session_01Cuzvc9Pjfo5u46eT3ta2Cg

Files changed (3) hide show
  1. Dockerfile +2 -1
  2. frontend/static/index.html +274 -0
  3. src/app/main.py +14 -0
Dockerfile CHANGED
@@ -11,8 +11,9 @@ WORKDIR /app
11
  COPY pyproject.toml README.md ./
12
  RUN pip install --no-cache-dir .
13
 
14
- # Copy application code
15
  COPY src/ src/
 
16
  COPY AGENTS.md ./
17
 
18
  # Default storage root — overridden in Space mode via /data
 
11
  COPY pyproject.toml README.md ./
12
  RUN pip install --no-cache-dir .
13
 
14
+ # Copy application code and frontend
15
  COPY src/ src/
16
+ COPY frontend/ frontend/
17
  COPY AGENTS.md ./
18
 
19
  # Default storage root — overridden in Space mode via /data
frontend/static/index.html ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>XmLLM — Document Structure Engine</title>
7
+ <style>
8
+ :root { --bg: #1a1a2e; --surface: #16213e; --primary: #0f3460; --accent: #e94560; --text: #eee; --muted: #888; --border: #2a2a4a; }
9
+ * { box-sizing: border-box; margin: 0; padding: 0; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
11
+ header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 1rem 2rem; display: flex; align-items: center; gap: 1rem; }
12
+ header h1 { font-size: 1.4rem; font-weight: 600; }
13
+ header h1 span { color: var(--accent); }
14
+ nav { display: flex; gap: .5rem; margin-left: auto; }
15
+ nav button { background: var(--primary); color: var(--text); border: 1px solid var(--border); padding: .4rem 1rem; border-radius: 4px; cursor: pointer; font-size: .85rem; }
16
+ nav button.active { background: var(--accent); border-color: var(--accent); }
17
+ main { max-width: 1200px; margin: 2rem auto; padding: 0 2rem; }
18
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; }
19
+ .card h2 { font-size: 1.1rem; margin-bottom: 1rem; color: var(--accent); }
20
+ label { display: block; font-size: .85rem; color: var(--muted); margin-bottom: .3rem; }
21
+ input, select { background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: .5rem; border-radius: 4px; width: 100%; margin-bottom: .8rem; font-size: .9rem; }
22
+ .row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
23
+ .row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; }
24
+ button.primary { background: var(--accent); color: white; border: none; padding: .6rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: .9rem; font-weight: 600; }
25
+ button.primary:disabled { opacity: .5; cursor: not-allowed; }
26
+ button.secondary { background: var(--primary); color: var(--text); border: 1px solid var(--border); padding: .4rem 1rem; border-radius: 4px; cursor: pointer; font-size: .85rem; }
27
+ .status { display: inline-block; padding: .15rem .6rem; border-radius: 12px; font-size: .75rem; font-weight: 600; }
28
+ .status.succeeded { background: #0a3d2a; color: #4ade80; }
29
+ .status.failed { background: #3d0a0a; color: #f87171; }
30
+ .status.partial_success { background: #3d2a0a; color: #fbbf24; }
31
+ .status.running { background: #0a2a3d; color: #60a5fa; }
32
+ .status.queued { background: #2a2a2a; color: #aaa; }
33
+ table { width: 100%; border-collapse: collapse; font-size: .85rem; }
34
+ th { text-align: left; color: var(--muted); font-weight: 500; padding: .5rem; border-bottom: 1px solid var(--border); }
35
+ td { padding: .5rem; border-bottom: 1px solid var(--border); }
36
+ tr:hover td { background: rgba(255,255,255,.03); }
37
+ .hidden { display: none; }
38
+ #log { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1rem; font-family: monospace; font-size: .8rem; max-height: 300px; overflow-y: auto; white-space: pre-wrap; color: var(--muted); }
39
+ .exports { display: flex; gap: .5rem; flex-wrap: wrap; }
40
+ .tag { background: var(--primary); padding: .2rem .5rem; border-radius: 3px; font-size: .75rem; }
41
+ .tag.yes { background: #0a3d2a; color: #4ade80; }
42
+ .tag.no { background: #2a2a2a; color: #666; }
43
+ #viewer-area { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; min-height: 400px; display: flex; align-items: center; justify-content: center; color: var(--muted); font-style: italic; }
44
+ .stats { display: flex; gap: 2rem; margin-bottom: 1rem; }
45
+ .stat { text-align: center; }
46
+ .stat .value { font-size: 1.5rem; font-weight: 700; color: var(--accent); }
47
+ .stat .label { font-size: .75rem; color: var(--muted); }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <header>
52
+ <h1><span>XmLLM</span> Document Structure Engine</h1>
53
+ <nav>
54
+ <button class="active" onclick="showPage('upload')">Upload</button>
55
+ <button onclick="showPage('jobs')">Jobs</button>
56
+ <button onclick="showPage('providers')">Providers</button>
57
+ </nav>
58
+ </header>
59
+
60
+ <main>
61
+ <!-- UPLOAD PAGE -->
62
+ <div id="page-upload">
63
+ <div class="card">
64
+ <h2>New Job</h2>
65
+ <div class="row">
66
+ <div>
67
+ <label>Raw Payload JSON</label>
68
+ <input type="file" id="payload-file" accept=".json">
69
+ </div>
70
+ <div>
71
+ <label>Provider Family</label>
72
+ <select id="provider-family">
73
+ <option value="word_box_json">word_box_json (PaddleOCR)</option>
74
+ <option value="line_box_json">line_box_json</option>
75
+ <option value="text_only">text_only (mLLM)</option>
76
+ </select>
77
+ </div>
78
+ </div>
79
+ <div class="row-3">
80
+ <div>
81
+ <label>Provider ID</label>
82
+ <input type="text" id="provider-id" value="paddleocr" placeholder="paddleocr">
83
+ </div>
84
+ <div>
85
+ <label>Image Width (px)</label>
86
+ <input type="number" id="img-width" value="2480">
87
+ </div>
88
+ <div>
89
+ <label>Image Height (px)</label>
90
+ <input type="number" id="img-height" value="3508">
91
+ </div>
92
+ </div>
93
+ <button class="primary" id="btn-run" onclick="runJob()">Run Pipeline</button>
94
+ <span id="run-status" style="margin-left:1rem;color:var(--muted)"></span>
95
+ </div>
96
+
97
+ <div class="card hidden" id="result-card">
98
+ <h2>Result</h2>
99
+ <div class="stats" id="result-stats"></div>
100
+ <div class="exports" id="result-exports"></div>
101
+ <div id="log" style="margin-top:1rem"></div>
102
+ </div>
103
+ </div>
104
+
105
+ <!-- JOBS PAGE -->
106
+ <div id="page-jobs" class="hidden">
107
+ <div class="card">
108
+ <h2>Job History</h2>
109
+ <table>
110
+ <thead><tr><th>ID</th><th>Status</th><th>Provider</th><th>File</th><th>ALTO</th><th>PAGE</th><th>Duration</th><th>Actions</th></tr></thead>
111
+ <tbody id="jobs-table"></tbody>
112
+ </table>
113
+ </div>
114
+ <div class="card hidden" id="job-detail-card">
115
+ <h2>Job Detail</h2>
116
+ <div id="job-detail"></div>
117
+ <div id="job-exports" style="margin-top:1rem"></div>
118
+ <div id="job-log" style="margin-top:1rem"></div>
119
+ </div>
120
+ </div>
121
+
122
+ <!-- PROVIDERS PAGE -->
123
+ <div id="page-providers" class="hidden">
124
+ <div class="card">
125
+ <h2>Register Provider</h2>
126
+ <div class="row-3">
127
+ <div><label>Provider ID</label><input id="prov-id" placeholder="my_paddle"></div>
128
+ <div><label>Display Name</label><input id="prov-name" placeholder="PaddleOCR Local"></div>
129
+ <div><label>Family</label>
130
+ <select id="prov-family">
131
+ <option value="word_box_json">word_box_json</option>
132
+ <option value="line_box_json">line_box_json</option>
133
+ <option value="text_only">text_only</option>
134
+ </select>
135
+ </div>
136
+ </div>
137
+ <div class="row">
138
+ <div><label>Runtime Type</label>
139
+ <select id="prov-runtime"><option>local</option><option>hub</option><option>api</option></select>
140
+ </div>
141
+ <div><label>Model ID / Path</label><input id="prov-model" placeholder="/models/paddle"></div>
142
+ </div>
143
+ <button class="primary" onclick="registerProvider()">Register</button>
144
+ <span id="prov-status" style="margin-left:1rem;color:var(--muted)"></span>
145
+ </div>
146
+ <div class="card">
147
+ <h2>Registered Providers</h2>
148
+ <table>
149
+ <thead><tr><th>ID</th><th>Name</th><th>Family</th><th>Runtime</th><th>Actions</th></tr></thead>
150
+ <tbody id="providers-table"></tbody>
151
+ </table>
152
+ </div>
153
+ </div>
154
+ </main>
155
+
156
+ <script>
157
+ const API = '';
158
+
159
+ function showPage(name) {
160
+ document.querySelectorAll('main > div').forEach(d => d.classList.add('hidden'));
161
+ document.getElementById('page-' + name).classList.remove('hidden');
162
+ document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
163
+ event.target.classList.add('active');
164
+ if (name === 'jobs') loadJobs();
165
+ if (name === 'providers') loadProviders();
166
+ }
167
+
168
+ async function runJob() {
169
+ const fileInput = document.getElementById('payload-file');
170
+ if (!fileInput.files.length) { alert('Select a payload JSON file'); return; }
171
+ const btn = document.getElementById('btn-run');
172
+ const status = document.getElementById('run-status');
173
+ btn.disabled = true; status.textContent = 'Running...';
174
+
175
+ const fd = new FormData();
176
+ fd.append('raw_payload_file', fileInput.files[0]);
177
+
178
+ const params = new URLSearchParams({
179
+ provider_id: document.getElementById('provider-id').value,
180
+ provider_family: document.getElementById('provider-family').value,
181
+ image_width: document.getElementById('img-width').value,
182
+ image_height: document.getElementById('img-height').value,
183
+ });
184
+
185
+ try {
186
+ const r = await fetch(API + '/jobs?' + params, { method: 'POST', body: fd });
187
+ const data = await r.json();
188
+ btn.disabled = false; status.textContent = '';
189
+ showResult(data);
190
+ } catch(e) {
191
+ btn.disabled = false; status.textContent = 'Error: ' + e.message;
192
+ }
193
+ }
194
+
195
+ function showResult(data) {
196
+ const card = document.getElementById('result-card');
197
+ card.classList.remove('hidden');
198
+ document.getElementById('result-stats').innerHTML = `
199
+ <div class="stat"><div class="value"><span class="status ${data.status}">${data.status}</span></div><div class="label">Status</div></div>
200
+ <div class="stat"><div class="value">${data.duration_ms ? Math.round(data.duration_ms) + 'ms' : '-'}</div><div class="label">Duration</div></div>
201
+ `;
202
+ const jobId = data.job_id;
203
+ document.getElementById('result-exports').innerHTML = `
204
+ <span class="tag ${data.has_alto ? 'yes' : 'no'}">ALTO ${data.has_alto ? '✓' : '✗'}</span>
205
+ <span class="tag ${data.has_page_xml ? 'yes' : 'no'}">PAGE ${data.has_page_xml ? '✓' : '✗'}</span>
206
+ ${data.has_alto ? `<a href="${API}/jobs/${jobId}/alto" class="secondary" style="color:var(--text);text-decoration:none;padding:.2rem .5rem;background:var(--primary);border-radius:3px;font-size:.75rem">Download ALTO</a>` : ''}
207
+ ${data.has_page_xml ? `<a href="${API}/jobs/${jobId}/pagexml" class="secondary" style="color:var(--text);text-decoration:none;padding:.2rem .5rem;background:var(--primary);border-radius:3px;font-size:.75rem">Download PAGE</a>` : ''}
208
+ <a href="${API}/jobs/${jobId}/canonical" target="_blank" class="secondary" style="color:var(--text);text-decoration:none;padding:.2rem .5rem;background:var(--primary);border-radius:3px;font-size:.75rem">Canonical JSON</a>
209
+ `;
210
+ if (data.error) {
211
+ document.getElementById('log').textContent = 'ERROR: ' + data.error;
212
+ } else {
213
+ fetch(API + '/jobs/' + jobId + '/logs').then(r => r.json()).then(events => {
214
+ document.getElementById('log').textContent = events.map(e =>
215
+ `[${e.status.padEnd(9)}] ${e.step.padEnd(20)} ${e.duration_ms ? Math.round(e.duration_ms) + 'ms' : e.message || ''}`
216
+ ).join('\n');
217
+ });
218
+ }
219
+ }
220
+
221
+ async function loadJobs() {
222
+ const r = await fetch(API + '/jobs');
223
+ const jobs = await r.json();
224
+ const tbody = document.getElementById('jobs-table');
225
+ tbody.innerHTML = jobs.map(j => `<tr>
226
+ <td style="font-family:monospace;font-size:.8rem">${j.job_id}</td>
227
+ <td><span class="status ${j.status}">${j.status}</span></td>
228
+ <td>${j.provider_id}</td>
229
+ <td>${j.source_filename || '-'}</td>
230
+ <td><span class="tag ${j.has_alto ? 'yes' : 'no'}">${j.has_alto ? '✓' : '✗'}</span></td>
231
+ <td><span class="tag ${j.has_page_xml ? 'yes' : 'no'}">${j.has_page_xml ? '✓' : '✗'}</span></td>
232
+ <td>${j.duration_ms ? Math.round(j.duration_ms) + 'ms' : '-'}</td>
233
+ <td>
234
+ ${j.has_alto ? `<a href="${API}/jobs/${j.job_id}/alto" class="secondary" style="font-size:.75rem">ALTO</a> ` : ''}
235
+ ${j.has_page_xml ? `<a href="${API}/jobs/${j.job_id}/pagexml" class="secondary" style="font-size:.75rem">PAGE</a>` : ''}
236
+ </td>
237
+ </tr>`).join('');
238
+ }
239
+
240
+ async function loadProviders() {
241
+ const r = await fetch(API + '/providers');
242
+ const provs = await r.json();
243
+ const tbody = document.getElementById('providers-table');
244
+ tbody.innerHTML = provs.map(p => `<tr>
245
+ <td>${p.provider_id}</td>
246
+ <td>${p.display_name}</td>
247
+ <td><span class="tag">${p.family}</span></td>
248
+ <td>${p.runtime_type}</td>
249
+ <td><button class="secondary" onclick="deleteProvider('${p.provider_id}')" style="font-size:.75rem">Delete</button></td>
250
+ </tr>`).join('');
251
+ }
252
+
253
+ async function registerProvider() {
254
+ const body = {
255
+ provider_id: document.getElementById('prov-id').value,
256
+ display_name: document.getElementById('prov-name').value,
257
+ family: document.getElementById('prov-family').value,
258
+ runtime_type: document.getElementById('prov-runtime').value,
259
+ model_id_or_path: document.getElementById('prov-model').value,
260
+ };
261
+ const r = await fetch(API + '/providers', {
262
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body),
263
+ });
264
+ document.getElementById('prov-status').textContent = r.ok ? 'Registered!' : 'Error';
265
+ loadProviders();
266
+ }
267
+
268
+ async function deleteProvider(id) {
269
+ await fetch(API + '/providers/' + id, { method: 'DELETE' });
270
+ loadProviders();
271
+ }
272
+ </script>
273
+ </body>
274
+ </html>
src/app/main.py CHANGED
@@ -6,10 +6,13 @@ FastAPI application entry point.
6
  from __future__ import annotations
7
 
8
  from contextlib import asynccontextmanager
 
9
  from typing import AsyncIterator
10
 
11
  from fastapi import FastAPI
12
  from fastapi.middleware.cors import CORSMiddleware
 
 
13
 
14
  from src.app.api import init_services, shutdown_services
15
  from src.app.api.routes_exports import router as exports_router
@@ -52,3 +55,14 @@ app.include_router(providers_router)
52
  app.include_router(jobs_router)
53
  app.include_router(exports_router)
54
  app.include_router(viewer_router)
 
 
 
 
 
 
 
 
 
 
 
 
6
  from __future__ import annotations
7
 
8
  from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
  from typing import AsyncIterator
11
 
12
  from fastapi import FastAPI
13
  from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import FileResponse
15
+ from fastapi.staticfiles import StaticFiles
16
 
17
  from src.app.api import init_services, shutdown_services
18
  from src.app.api.routes_exports import router as exports_router
 
55
  app.include_router(jobs_router)
56
  app.include_router(exports_router)
57
  app.include_router(viewer_router)
58
+
59
+ # -- Static frontend ----------------------------------------------------------
60
+
61
+ _FRONTEND_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" / "static"
62
+
63
+ if _FRONTEND_DIR.exists():
64
+ app.mount("/static", StaticFiles(directory=str(_FRONTEND_DIR)), name="static")
65
+
66
+ @app.get("/")
67
+ async def serve_frontend() -> FileResponse:
68
+ return FileResponse(str(_FRONTEND_DIR / "index.html"))