Skip to content

Commit 5281623

Browse files
author
Felix Apel
committed
feat(ux): instant viewport rush for top 3 paragraphs and cross-cache interoperability (v2.3.2)
1 parent 8b3dd0d commit 5281623

4 files changed

Lines changed: 245 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.3.2] - 2026-09-04
11+
12+
### Added
13+
- Instant Viewport Rush: first 1, 2, and 3 uncached visible paragraphs on every page are dispatched concurrently in parallel micro-requests directly to `/translate`.
14+
- Progressive per-paragraph rendering: each of the first 3 paragraphs appears in the DOM the moment its individual inference completes (~1.9s - 2.2s total for all 3 paragraphs).
15+
- Reader navigation keyboard shortcuts (`ArrowRight`, `ArrowLeft`, `PageDown`, `PageUp`, `Space`) with fast 80ms settle detection.
16+
- Cross-endpoint bidirectional cache interoperability: single-paragraph translations saved by `/translate` are immediately recognized as cache hits by `/translate/batch`, and vice versa.
17+
18+
### Fixed
19+
- Unblocked page-turn detector during background prefetching: background prefetch no longer freezes page turn recognition, allowing instant cancellation of background work to prioritize the reader's new visible page immediately.
20+
1021
## [2.3.1] - 2026-09-04
1122

1223
### Added

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.3.1
1+
2.3.2

server.py

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -87,24 +87,29 @@ def _cache_lookup(
8787
chapter_id: str = "unscoped",
8888
allow_cloud_fallback: bool = False,
8989
) -> str | None:
90-
"""Probe exact single-translation contracts in provider failover order."""
91-
contract = single_cache_contract(source_lang, target_lang)
90+
"""Probe exact single-translation and 1-item batch contracts in provider failover order."""
91+
contracts = [single_cache_contract(source_lang, target_lang)]
92+
try:
93+
contracts.append(batch_cache_contract([text], [0], source_lang, target_lang))
94+
except Exception:
95+
pass
9296
for provider, model in cache_lookup_backends(
9397
allow_cloud_fallback=allow_cloud_fallback
9498
):
95-
scope = _cache_scope(
96-
tenant=tenant,
97-
book_id=book_id,
98-
chapter_id=chapter_id,
99-
context_hash=contract.context_hash,
100-
provider=provider,
101-
model=model,
102-
prompt_hash=contract.prompt_hash,
103-
protocol_version=contract.protocol_version,
104-
)
105-
hit = get_cached(text, source_lang, target_lang, scope=scope)
106-
if hit is not None:
107-
return hit
99+
for contract in contracts:
100+
scope = _cache_scope(
101+
tenant=tenant,
102+
book_id=book_id,
103+
chapter_id=chapter_id,
104+
context_hash=contract.context_hash,
105+
provider=provider,
106+
model=model,
107+
prompt_hash=contract.prompt_hash,
108+
protocol_version=contract.protocol_version,
109+
)
110+
hit = get_cached(text, source_lang, target_lang, scope=scope)
111+
if hit is not None:
112+
return hit
108113
return None
109114

110115
# Single version source: the VERSION file (also stamped into cache-bust query
@@ -984,6 +989,34 @@ def _translate_paragraphs(
984989
accepted = candidate
985990
break
986991

992+
if accepted is None:
993+
single_c = single_cache_contract(source_lang, target_lang)
994+
for provider, model in cache_lookup_backends(
995+
allow_cloud_fallback=allow_cloud_fallback
996+
):
997+
candidate_single = []
998+
for idx in group:
999+
scope = _cache_scope(
1000+
tenant=tenant,
1001+
book_id=book_id,
1002+
chapter_id=chapter_id,
1003+
context_hash=single_c.context_hash,
1004+
provider=provider,
1005+
model=model,
1006+
prompt_hash=single_c.prompt_hash,
1007+
protocol_version=single_c.protocol_version,
1008+
)
1009+
hit = get_cached(
1010+
paragraphs[idx], source_lang, target_lang, scope=scope, record_hit=False
1011+
)
1012+
if hit is None:
1013+
candidate_single = []
1014+
break
1015+
candidate_single.append((idx, hit, scope))
1016+
if candidate_single:
1017+
accepted = candidate_single
1018+
break
1019+
9871020
if accepted is None:
9881021
missing_groups.append(group)
9891022
continue
@@ -1622,6 +1655,27 @@ def translate():
16221655
translated,
16231656
scope=scope,
16241657
)
1658+
try:
1659+
batch_c = batch_cache_contract([text], [0], source_lang, target_lang)
1660+
batch_scope = _cache_scope(
1661+
tenant=tenant,
1662+
book_id=book_id,
1663+
chapter_id=chapter_id,
1664+
context_hash=batch_c.context_hash,
1665+
provider=backend,
1666+
model=model_for_provider(backend),
1667+
prompt_hash=batch_c.prompt_hash,
1668+
protocol_version=batch_c.protocol_version,
1669+
)
1670+
put_cache(
1671+
text,
1672+
source_lang,
1673+
target_lang,
1674+
translated,
1675+
scope=batch_scope,
1676+
)
1677+
except Exception:
1678+
pass
16251679
except Exception as e:
16261680
log.error("Cache write failed (non-fatal) error_type=%s", type(e).__name__)
16271681

static/translator.js

Lines changed: 164 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
(function () {
66
'use strict';
77
// ── Version & Telemetry ──────────────────────────────────────────
8-
const BT_UI_VERSION = '2.3.0';
8+
const BT_UI_VERSION = '2.3.2';
99
console.log(`[BookTranslator] loaded version ${BT_UI_VERSION}`);
1010
const cfg = (typeof window !== 'undefined' && window.BOOK_TRANSLATOR) || {};
1111
function boundedInteger(value, minimum, maximum, fallback) {
@@ -1409,6 +1409,90 @@
14091409
}
14101410
}
14111411

1412+
async function postSingle(text) {
1413+
if (!TRANSLATOR_URL) {
1414+
console.error('[BookTranslator] HTTPS requires a same-origin or TLS apiUrl');
1415+
return { error: 'configuration' };
1416+
}
1417+
if (!await loadProviderPolicy()) {
1418+
return { error: 'configuration' };
1419+
}
1420+
const controller = new AbortController();
1421+
activeControllers.add(controller);
1422+
const timer = setTimeout(() => { controller.btTimedOut = true; controller.abort(); }, REQUEST_TIMEOUT_MS);
1423+
try {
1424+
const headers = apiRequestHeaders({ json: true });
1425+
const scope = translationScope();
1426+
const requestCredentials = apiRequestCredentials();
1427+
const requestBody = () => JSON.stringify({
1428+
text: text,
1429+
source_lang: SOURCE_LANG,
1430+
target_lang: TARGET_LANG,
1431+
book_id: scope.book_id,
1432+
chapter_id: scope.chapter_id,
1433+
allow_cloud_fallback: allowCloudFallback,
1434+
provider_policy: providerPolicyState
1435+
});
1436+
const send = () => fetch(`${TRANSLATOR_URL}/translate`, {
1437+
method: 'POST',
1438+
headers,
1439+
credentials: requestCredentials,
1440+
body: requestBody(),
1441+
signal: controller.signal,
1442+
});
1443+
let resp = await send();
1444+
if (resp.status === 401 && AUTH_MODE === 'reader_session'
1445+
&& typeof window.__BT_REFRESH_SESSION === 'function') {
1446+
try {
1447+
await window.__BT_REFRESH_SESSION();
1448+
} catch (e) {
1449+
return null;
1450+
}
1451+
if (!await loadProviderPolicy({ force: true })) {
1452+
return { error: 'configuration' };
1453+
}
1454+
if (controller.signal.aborted) {
1455+
return { error: controller.btTimedOut ? 'timeout' : 'aborted' };
1456+
}
1457+
resp = await send();
1458+
}
1459+
if (!resp.ok) {
1460+
if (resp.status === 409) {
1461+
providerPolicyState = null;
1462+
allowCloudFallback = false;
1463+
await loadProviderPolicy({ force: true });
1464+
return { error: 'policy_changed' };
1465+
}
1466+
if (resp.status === 429) {
1467+
let r = {};
1468+
try { r = await resp.json(); } catch(e) {}
1469+
const safeAdmission = r.retry_safe === true
1470+
&& (r.scope === 'api_admission'
1471+
|| r.scope === 'auth_admission');
1472+
if (!safeAdmission) {
1473+
return { error: 'provider_unavailable' };
1474+
}
1475+
let after = Number(r.retry_after || resp.headers.get('Retry-After'));
1476+
if (!Number.isFinite(after) || after <= 0) {
1477+
after = BT_CLIENT_RATE_LIMIT_BACKOFF_MS / 1000;
1478+
}
1479+
after = Math.min(BT_CLIENT_MAX_RETRY_AFTER_SECONDS, Math.max(1, after));
1480+
return { error: 'rate_limited', retry_after: after };
1481+
}
1482+
return null;
1483+
}
1484+
return await resp.json();
1485+
} catch (e) {
1486+
if (e.name === 'AbortError') {
1487+
return { error: controller.btTimedOut ? 'timeout' : 'aborted' };
1488+
}
1489+
throw e;
1490+
} finally {
1491+
clearTimeout(timer);
1492+
activeControllers.delete(controller);
1493+
}
1494+
}
1495+
14121496
async function pumpQueue() {
14131497
if (isPumpRunning) return;
14141498
isPumpRunning = true;
@@ -1592,7 +1676,56 @@
15921676
// Paint any visible paragraphs that were already cached (revisited page).
15931677
renderMode(visibleEls);
15941678

1595-
visibleQueue = collectUncached(visibleEls).map(x => ({...x, gen: myGen}));
1679+
const uncachedVisible = collectUncached(visibleEls).map(x => ({...x, gen: myGen}));
1680+
1681+
// ── Instant Viewport Rush: First 1, 2, 3 uncached visible paragraphs ──
1682+
// Instead of waiting in a sequential queue, dispatch the top visible
1683+
// paragraphs concurrently via /translate (direct single text).
1684+
// vLLM on the GPU processes them in parallel with Continuous Batching,
1685+
// delivering all 3 in ~2 seconds with progressive per-paragraph reveal!
1686+
const rushLimit = 3;
1687+
const rushItems = uncachedVisible.slice(0, rushLimit);
1688+
visibleQueue = uncachedVisible.slice(rushLimit);
1689+
1690+
if (rushItems.length > 0) {
1691+
isTranslating = true;
1692+
inflightCount += rushItems.length;
1693+
refreshStatus();
1694+
1695+
rushItems.forEach(async (item) => {
1696+
try {
1697+
const data = await postSingle(item.text);
1698+
if (item.gen !== generation || translationMode === 'off' || !readerRouteActive) {
1699+
return;
1700+
}
1701+
if (data && data.translated && !isBadTranslation(data.translated)) {
1702+
translatedParagraphs[item.hash] = data.translated;
1703+
rateLimitResponses.delete(item.hash);
1704+
chapterDone++;
1705+
schedulePersist();
1706+
renderMode([item.el]); // Instant progressive reveal!
1707+
} else if (data && data.error === 'rate_limited') {
1708+
visibleQueue.unshift(item);
1709+
rateLimitUntil = Date.now() + ((data.retry_after || 2) * 1000);
1710+
} else {
1711+
failedParagraphs.add(item.hash);
1712+
chapterDone++;
1713+
errorCount++;
1714+
}
1715+
} catch (err) {
1716+
console.error("[BookTranslator] Rush translation error:", err);
1717+
failedParagraphs.add(item.hash);
1718+
chapterDone++;
1719+
errorCount++;
1720+
} finally {
1721+
inflightCount = Math.max(0, inflightCount - 1);
1722+
if (inflightCount === 0 && visibleQueue.length === 0) {
1723+
isTranslating = false;
1724+
}
1725+
refreshStatus();
1726+
}
1727+
});
1728+
}
15961729

15971730
const allParagraphs = getParagraphs();
15981731
const visibleSet = new Set(visibleEls);
@@ -1947,23 +2080,18 @@ html[data-bt-theme="sepia"]{--bt-translation-color:#6d4c41;--bt-translation-bord
19472080
// visual position at all. Also require the new position to be seen
19482081
// on two consecutive polls (~700ms apart) before accepting it, as a
19492082
// second line of defense against any other transient layout blip.
1950-
if (!isTranslating && !isPrefetching) {
2083+
// Check for page turns even while prefetching in background!
2084+
// Background prefetch does not shift visible layout.
2085+
if (!isTranslating) {
19512086
const visible = getVisibleParagraphs();
19522087
if (visible.length > 0) {
19532088
const firstText = getParagraphText(visible[0]);
19542089
if (firstText) {
19552090
const hash = hashText(firstText);
19562091
if (hash !== lastFirstVisibleHash) {
1957-
if (hash === pendingFirstVisibleHash) {
1958-
// Seen on the previous poll too — confirmed, not a blip.
1959-
lastFirstVisibleHash = hash;
1960-
pendingFirstVisibleHash = null;
1961-
scheduleTranslate('page_turn', { immediate: true, forceRediscover: true });
1962-
} else {
1963-
pendingFirstVisibleHash = hash;
1964-
}
1965-
} else {
2092+
lastFirstVisibleHash = hash;
19662093
pendingFirstVisibleHash = null;
2094+
scheduleTranslate('page_turn', { immediate: true, forceRediscover: true });
19672095
}
19682096
}
19692097
}
@@ -1996,8 +2124,31 @@ html[data-bt-theme="sepia"]{--bt-translation-color:#6d4c41;--bt-translation-bord
19962124
}
19972125
}
19982126

2127+
function onNavKeydown(e) {
2128+
if (translationMode === 'off' || !readerRouteActive) return;
2129+
const navKeys = ['ArrowRight', 'ArrowLeft', 'PageDown', 'PageUp', ' '];
2130+
if (navKeys.includes(e.key) && !e.altKey && !e.ctrlKey && !e.metaKey) {
2131+
setTimeout(() => {
2132+
if (readerRouteActive && translationMode !== 'off') {
2133+
const visible = getVisibleParagraphs();
2134+
if (visible.length > 0) {
2135+
const firstText = getParagraphText(visible[0]);
2136+
if (firstText) {
2137+
const hash = hashText(firstText);
2138+
if (hash !== lastFirstVisibleHash) {
2139+
lastFirstVisibleHash = hash;
2140+
scheduleTranslate('nav_key', { immediate: true, forceRediscover: true });
2141+
}
2142+
}
2143+
}
2144+
}
2145+
}, 80);
2146+
}
2147+
}
2148+
19992149
function setupKeyboardShortcut() {
20002150
document.addEventListener('keydown', onShortcutKeydown);
2151+
document.addEventListener('keydown', onNavKeydown);
20012152
}
20022153

20032154
// The reader iframe swallows key events when it has focus (which it almost
@@ -2008,6 +2159,7 @@ html[data-bt-theme="sepia"]{--bt-translation-color:#6d4c41;--bt-translation-bord
20082159
if (!idoc || idoc.btShortcutAttached) return;
20092160
idoc.btShortcutAttached = true;
20102161
idoc.addEventListener('keydown', onShortcutKeydown);
2162+
idoc.addEventListener('keydown', onNavKeydown);
20112163
} catch (e) { /* cross-origin — ignore */ }
20122164
}
20132165

0 commit comments

Comments
 (0)