Skip to content

Commit 0f5a20d

Browse files
fix(web): render figures on split-origin deploy
- page_image_url resolves against API host, not frontend origin - figure thumbs load same-origin; stop probing after first 404 - retry papers/health/figures fetches across Cloud Run cold start - model menu: key-gated group labels with info tooltips
1 parent a27162b commit 0f5a20d

4 files changed

Lines changed: 42 additions & 14 deletions

File tree

web/app/api.js

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,39 @@
4747

4848
// Pre-rendered figure/table thumbnail (scripts/render_figure_thumbs.py). The
4949
// file is keyed by chunk_id with ":" → "_" (mirrors the Docling crop name).
50-
// Small WebP served from the same /pages mount; callers fall back to a
51-
// full-page CSS-crop when a thumb is absent (e.g. a freshly uploaded paper).
50+
// Small WebP, served same-origin — bundled with the frontend on the split
51+
// deploy (Firebase), or from the backend /pages mount on the combined deploy.
52+
// Callers fall back to a full-page CSS-crop when a thumb is absent.
5253
function figThumbUrl(paperId, chunkId) {
5354
const safe = chunkId.replace(/:/g, "_");
54-
return `${API}/pages/${encodeURIComponent(paperId)}/thumbs/${encodeURIComponent(safe)}.webp`;
55+
return `/pages/${encodeURIComponent(paperId)}/thumbs/${encodeURIComponent(safe)}.webp`;
56+
}
57+
58+
// /figures + /papers return page_image_url root-relative (/pages/...). On the
59+
// split frontend (different origin than the API) it must resolve against the
60+
// API host, not window.location.
61+
function absPage(u) {
62+
return u && u.startsWith("/") ? `${API}${u}` : u;
63+
}
64+
65+
// Cloud Run scales to zero, so the first request after idle (or during a
66+
// redeploy) can transiently fail or 5xx while the container spins up. Retry a
67+
// few times so a cold start doesn't leave the tab empty with no recovery.
68+
async function fetchRetry(url, tries = 3) {
69+
for (let i = 0; ; i++) {
70+
try {
71+
const r = await fetch(url);
72+
if (r.ok || i >= tries - 1) return r;
73+
} catch (e) {
74+
if (i >= tries - 1) throw e;
75+
}
76+
await new Promise((f) => setTimeout(f, 1200 * (i + 1)));
77+
}
5578
}
5679

5780
async function loadPapers() {
5881
try {
59-
const r = await fetch(`${API}/papers`);
82+
const r = await fetchRetry(`${API}/papers`);
6083
return r.ok ? await r.json() : [];
6184
} catch {
6285
return [];
@@ -65,8 +88,8 @@
6588

6689
async function loadHealth() {
6790
try {
68-
const r = await fetch(`${API}/health`);
69-
return await r.json();
91+
const r = await fetchRetry(`${API}/health`);
92+
return r.ok ? await r.json() : {};
7093
} catch {
7194
return {};
7295
}
@@ -76,7 +99,7 @@
7699
// docling role/label. Used by the Figures gallery and the corpus counts.
77100
async function loadFigures(limit = 1000) {
78101
try {
79-
const r = await fetch(`${API}/figures?limit=${limit}`);
102+
const r = await fetchRetry(`${API}/figures?limit=${limit}`);
80103
return r.ok ? await r.json() : [];
81104
} catch {
82105
return [];
@@ -526,6 +549,7 @@
526549
supportsVision,
527550
pageImageUrl,
528551
figThumbUrl,
552+
absPage,
529553
loadPapers,
530554
loadHealth,
531555
loadFigures,

web/app/app.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,9 @@ function ConnectionControl({ apiKey, setApiKey, model, setModel, demoAvailable }
138138
<Icon name="check" size={14} className="model-row-check" />
139139
</button>
140140
}
141-
<div className="model-group-label">free · no charge to your key</div>
141+
<div className="model-group-label" title="No charge to your key, rate limits may apply"><span className="label-info">Free (OpenRouter key)<Icon name="info" size={12} /></span></div>
142142
{freeModels.map(modelRow)}
143-
<div className="model-group-label">premium · billed to your key</div>
143+
<div className="model-group-label" title="Billed to your key"><span className="label-info">Premium (OpenRouter key)<Icon name="info" size={12} /></span></div>
144144
{paidModels.map(modelRow)}
145145
</div>
146146
{!keyed &&

web/app/figures.jsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,13 @@ function splitCaptionData(text) {
6868
// Crop a 150-DPI page image to a figure's PDF-point bbox. Computes the crop
6969
// transform from the image's natural size on load; falls back to the full page
7070
// width until then (and when a chunk has no bbox).
71+
// Session flag: once a thumb 404s (thumbnails not baked into this deploy), every
72+
// FigCrop skips the thumb and renders the full-page crop directly — avoids a
73+
// storm of failed thumb requests across the gallery.
74+
let thumbsAbsent = false;
7175
function FigCrop({ url, bbox, fallbackH = 150, eager = false, thumb = null }) {
7276
const [s, setS] = useState(null);
73-
const [thumbFailed, setThumbFailed] = useState(false);
77+
const [thumbFailed, setThumbFailed] = useState(thumbsAbsent);
7478
const onLoad = (e) => {
7579
const img = e.target;
7680
const nW = img.naturalWidth, nH = img.naturalHeight;
@@ -93,7 +97,7 @@ function FigCrop({ url, bbox, fallbackH = 150, eager = false, thumb = null }) {
9397
return (
9498
<div className="fig-crop" style={{ position: "relative", width: "100%", overflow: "hidden", background: "var(--panel-2)" }}>
9599
<img src={thumb} alt="" loading={eager ? "eager" : "lazy"} decoding="async"
96-
onError={() => setThumbFailed(true)} style={{ width: "100%", display: "block" }} />
100+
onError={() => { thumbsAbsent = true; setThumbFailed(true); }} style={{ width: "100%", display: "block" }} />
97101
</div>
98102
);
99103
}
@@ -121,7 +125,7 @@ function FigureCard({ f, onOpen }) {
121125
const hasCap = name && !/^\[.+\]$/.test(name.trim());
122126
return (
123127
<button className="figure-card" onClick={() => onOpen(f)}>
124-
<FigCrop url={f.page_image_url} bbox={f.bbox} thumb={window.RAG.figThumbUrl(f.paper_id, f.chunk_id)} />
128+
<FigCrop url={window.RAG.absPage(f.page_image_url)} bbox={f.bbox} thumb={window.RAG.figThumbUrl(f.paper_id, f.chunk_id)} />
125129
<div className="figure-card-body">
126130
{hasCap
127131
? (name.includes("$") || name.includes("\\("))
@@ -192,7 +196,7 @@ function FigureLightbox({ f, onClose }) {
192196
<div className="lb-scrim" onClick={onClose}>
193197
<div className="lb rise" onClick={(e) => e.stopPropagation()}>
194198
<div className="lb-img" style={{ position: "relative" }}>
195-
<img ref={imgRef} src={f.page_image_url} alt={`page ${f.page_number}`} onLoad={place} style={{ display: "block", width: "100%", height: "auto" }} />
199+
<img ref={imgRef} src={window.RAG.absPage(f.page_image_url)} alt={`page ${f.page_number}`} onLoad={place} style={{ display: "block", width: "100%", height: "auto" }} />
196200
{ov && (
197201
<div className="pm-region visual" style={{ position: "absolute", top: ov.top + "px", left: ov.left + "px", width: ov.width + "px", height: ov.height + "px" }}>
198202
<span className="pm-region-tab">{figCategory(f)} · selected</span>

web/app/papers.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ function PaperDrawer({ p, figs, onClose }) {
7171
<div key={f.chunk_id} className="figthumb figthumb-click"
7272
onClick={() => setPageItem({ chunk_id: f.chunk_id, paper: f.paper_id, page: f.page_number, pages: [f.page_number], kind: "visual", bbox: f.bbox || null, text: f.caption || "" })}
7373
title="View source region on page">
74-
<FigCrop url={f.page_image_url} bbox={f.bbox} fallbackH={92} eager thumb={window.RAG.figThumbUrl(f.paper_id, f.chunk_id)} />
74+
<FigCrop url={window.RAG.absPage(f.page_image_url)} bbox={f.bbox} fallbackH={92} eager thumb={window.RAG.figThumbUrl(f.paper_id, f.chunk_id)} />
7575
<div className="figthumb-meta"><span className="mono">p.{f.page_number}</span> · {clip(drawerCaption(f.caption), 40)}</div>
7676
</div>
7777
))}

0 commit comments

Comments
 (0)