|
108 | 108 | return { results: data.results || [], routing: data.routing || null, trace: data.trace || null }; |
109 | 109 | } |
110 | 110 |
|
111 | | - // /query returns 503 ("Retriever not configured") during the post-cold-start |
112 | | - // warm-up while the model + index load in the background. That is "wait", |
113 | | - // not "failed": retry until ready, surfacing an honest notice. Any other |
114 | | - // non-OK status, or 503 past the budget, throws. |
115 | | - const warmupDeadline = performance.now() + 120000; |
| 111 | + // The server wires the retriever during lifespan startup, so a /query 503 |
| 112 | + // is either Cloud Run still routing to a starting instance (transient) or |
| 113 | + // "Retriever not configured" — a corpus that failed to load, which no |
| 114 | + // amount of waiting fixes. Retry both briefly (transient 503s are real), |
| 115 | + // but say which one is happening; give the permanent case a short budget. |
| 116 | + const start = performance.now(); |
116 | 117 | while (true) { |
117 | 118 | const res = await fetch("/query", { |
118 | 119 | method: "POST", |
|
124 | 125 | return { results: data.results || [], routing: data.routing || null, trace: null }; |
125 | 126 | } |
126 | 127 | const detail = await res.text(); |
127 | | - if (res.status === 503 && performance.now() < warmupDeadline) { |
| 128 | + const noCorpus = detail.includes("Retriever not configured"); |
| 129 | + const budget = noCorpus ? 20000 : 120000; |
| 130 | + if (res.status === 503 && performance.now() - start < budget) { |
128 | 131 | onStatus && |
129 | | - onStatus( |
130 | | - "Server is warming up after a cold start. The first query loads the model and index and can take a minute or two. Retrying automatically…" |
131 | | - ); |
| 132 | + onStatus(noCorpus |
| 133 | + ? "The server reports no corpus is loaded. Retrying briefly in case it is still starting…" |
| 134 | + : "Server is warming up after a cold start. The first query can take a minute or two. Retrying automatically…"); |
132 | 135 | await new Promise((r) => setTimeout(r, 3000)); |
133 | 136 | continue; |
134 | 137 | } |
|
223 | 226 | // the top retrieved chunks; at most two extra pages. |
224 | 227 | function referencedFigurePages(question, chunks, figureIndex) { |
225 | 228 | if (!Array.isArray(figureIndex) || figureIndex.length === 0) return []; |
226 | | - const refs = [...question.matchAll(/\b(fig(?:ure)?\.?|table)\s*(\d+)\b/gi)]; |
| 229 | + // Plural-aware: "figures 2 and 3" / "tables 1, 2" carry one keyword for a |
| 230 | + // list of numbers, so capture the whole number list and split it. |
| 231 | + const refs = []; |
| 232 | + for (const m of question.matchAll(/\b(figs?\.?|figures?|tables?)\s*(\d+(?:\s*(?:,|and|&|–|-)\s*\d+)*)\b/gi)) { |
| 233 | + const isTable = /^t/i.test(m[1]); |
| 234 | + for (const num of m[2].match(/\d+/g) || []) refs.push({ isTable, num }); |
| 235 | + } |
227 | 236 | if (refs.length === 0) return []; |
228 | 237 | const papers = [...new Set(chunks.slice(0, 3).map((c) => c.paper_id))]; |
229 | 238 | const out = []; |
230 | | - for (const [, kindRaw, num] of refs) { |
231 | | - const re = /^t/i.test(kindRaw) |
| 239 | + for (const { isTable, num } of refs) { |
| 240 | + const re = isTable |
232 | 241 | ? new RegExp(`^table\\.?\\s*${num}\\b`, "i") |
233 | 242 | : new RegExp(`^fig(?:ure)?\\.?\\s*${num}\\b`, "i"); |
234 | 243 | for (const paperId of papers) { |
|
263 | 272 |
|
264 | 273 | const content = []; |
265 | 274 | const seenPages = new Set(); |
| 275 | + // Page images average ~0.5 MB as base64; with the ctx slider at 16 an |
| 276 | + // uncapped loop could inline 15+ MB and blow provider payload limits or |
| 277 | + // the demo relay's read timeout. Six chunk pages plus the two referenced- |
| 278 | + // figure extras keeps the body well under that. |
| 279 | + let chunkImages = 0; |
266 | 280 | for (const c of chunks) { |
267 | 281 | content.push({ |
268 | 282 | type: "text", |
269 | 283 | text: `[chunk ${c.chunk_id}] paper=${c.paper_id} pages=${(c.page_numbers || []).join(",")}\n${c.text || ""}`, |
270 | 284 | }); |
271 | 285 | if (useImages && Array.isArray(c.page_numbers)) { |
272 | 286 | for (const page of c.page_numbers) { |
| 287 | + if (chunkImages >= 6) break; |
273 | 288 | const key = `${c.paper_id}:${page}`; |
274 | 289 | if (seenPages.has(key)) continue; |
275 | 290 | seenPages.add(key); |
276 | 291 | const dataUrl = await imageToDataUrl(pageImageUrl(c.paper_id, page)); |
277 | 292 | if (dataUrl) { |
| 293 | + chunkImages += 1; |
278 | 294 | // Label the image so visual claims have a citable id (the system |
279 | 295 | // prompt directs figure descriptions at these, not text chunks). |
280 | 296 | content.push({ type: "text", text: `[page image ${c.paper_id}::p${page}::page]` }); |
|
321 | 337 | if (!line.startsWith("data:")) continue; |
322 | 338 | const payload = line.slice(5).trim(); |
323 | 339 | if (payload === "[DONE]") continue; |
| 340 | + let obj = null; |
324 | 341 | try { |
325 | | - const obj = JSON.parse(payload); |
326 | | - const delta = obj.choices?.[0]?.delta?.content || ""; |
327 | | - if (delta) { |
328 | | - acc += delta; |
329 | | - onDelta(delta); |
330 | | - } |
331 | | - if (obj.usage) usage = obj.usage; |
| 342 | + obj = JSON.parse(payload); |
332 | 343 | } catch { |
333 | | - // heartbeat / partial — skip |
| 344 | + continue; // heartbeat / partial — skip |
| 345 | + } |
| 346 | + // OpenRouter delivers mid-stream failures as an error frame on an |
| 347 | + // HTTP-200 stream (common on free-tier endpoints under load). |
| 348 | + // Swallowing it would render a finished, silently empty answer. |
| 349 | + if (obj.error) { |
| 350 | + const err = new Error(obj.error.message || "The model provider failed mid-answer."); |
| 351 | + err.code = "stream_error"; |
| 352 | + throw err; |
334 | 353 | } |
| 354 | + const delta = obj.choices?.[0]?.delta?.content || ""; |
| 355 | + if (delta) { |
| 356 | + acc += delta; |
| 357 | + onDelta(delta); |
| 358 | + } |
| 359 | + if (obj.usage) usage = obj.usage; |
335 | 360 | } |
336 | 361 | } |
337 | 362 | return { text: acc, usage }; |
|
378 | 403 | err.code = "demo_quota"; |
379 | 404 | throw err; |
380 | 405 | } |
| 406 | + if (res.status === 502) { |
| 407 | + // The server tried the whole free-model chain and every endpoint was |
| 408 | + // down (free-pool churn). Keyless visitors have no key to blame. |
| 409 | + const err = new Error("The free demo models are all busy right now. Retry in a minute, or add your own OpenRouter key (top-right)."); |
| 410 | + err.code = "demo_down"; |
| 411 | + throw err; |
| 412 | + } |
381 | 413 | if (!res.ok || !res.body) { |
382 | 414 | throw new Error(`${res.status} ${res.statusText}: ${await res.text()}`); |
383 | 415 | } |
|
0 commit comments