// TranslatedText — renders dynamic English text in the active language. // // Usage: // // Behaviour: // - English language → return text as-is (no API call). // - Other language → return cached translation if present, else fire // /api/translate in the background and re-render when it lands. // - Cache is keyed by `::` and persists across // page reloads (localStorage). Long pages with many dynamic strings // still only translate each unique string once per language. const _CACHE_PREFIX = 'aican-tr-'; const _MAX_LEN = 30000; const _inflight = new Map(); // dedupe concurrent calls for the same text+lang function _hash(s) { // Tiny non-cryptographic hash so cache keys stay short. let h = 0; for (let i = 0; i < s.length; i++) { h = ((h << 5) - h + s.charCodeAt(i)) | 0; } return Math.abs(h).toString(36); } function _cacheKey(code, text) { return `${_CACHE_PREFIX}${code}-${_hash(text)}-${text.length}`; } function _readCache(code, text) { try { return localStorage.getItem(_cacheKey(code, text)); } catch { return null; } } function _writeCache(code, text, value) { try { localStorage.setItem(_cacheKey(code, text), value); } catch {} } async function _fetchTranslation(code, name, text) { const key = `${code}::${text}`; if (_inflight.has(key)) return _inflight.get(key); const p = (async () => { try { const r = await window.AicanAPI.translate(text.slice(0, _MAX_LEN), code, name); const out = r?.translated_text || text; _writeCache(code, text, out); return out; } catch { return text; // fail open } finally { _inflight.delete(key); } })(); _inflight.set(key, p); return p; } // Some LLM fallbacks (Groq reasoning models, DeepSeek) emit a chain of // thought wrapped in ... before the real answer. Strip it // so the UI never shows the model talking to itself. Server-side does the // same — this is belt-and-braces in case an old cached response slips through. function _stripThinking(s) { if (!s || typeof s !== 'string') return s; let out = s.replace(/]*>[\s\S]*?<\/think\s*>/gi, ''); // Truncated mid-thought — drop the open tag + everything until first blank line if (/'); if (last >= 0) out = out.slice(last + ''.length); else out = out.replace(/]*>[\s\S]*?(\n\n|$)/gi, ''); } return out.trim(); } // React hook — returns the (possibly translated) string. function useTranslated(text) { const lang = window.useLanguage(); const code = lang.current.code; const name = lang.current.name; const safe = (text == null ? '' : String(text)); const initial = React.useMemo(() => { if (!safe || code === 'en') return safe; return _readCache(code, safe) || safe; }, [safe, code]); const [val, setVal] = React.useState(initial); React.useEffect(() => { if (!safe || code === 'en') { setVal(safe); return; } const cached = _readCache(code, safe); if (cached) { setVal(cached); return; } let alive = true; _fetchTranslation(code, name, safe).then(translated => { if (alive) setVal(translated); }); return () => { alive = false; }; }, [safe, code, name]); return val; } // Component wrapper — convenient when you don't need the raw value. const TranslatedText = ({ text, as = 'span', ...rest }) => { const value = useTranslated(text); return React.createElement(as, rest, _stripThinking(value)); }; window.TranslatedText = TranslatedText; window.useTranslated = useTranslated; window.stripThinking = _stripThinking;