import { normalizeUrl } from "../shared/url.js"; import { ensureApiDefaults } from "../shared/api_defaults.js"; import { AI_PROMPT_MAX_CHARS, makePromptKey, migratePromptMap, normalizeAiModel, normalizePrompt } from "../shared/prompt.js"; import { filterImageFiles, saveLocalSession, sortLocalPages, toLocalPageRecord } from "../shared/local_gallery.js"; const MODES = [ { id: "lens_images", name: "Google Lens (image)", needLang: true }, { id: "lens_text", name: "Google Lens (text)", needLang: true }, ]; const FALLBACK_LANGS = [ { code: "en", name: "English" }, { code: "th", name: "Thai" }, { code: "ja", name: "Japanese" }, { code: "ko", name: "Korean" }, { code: "zh-CN", name: "Chinese (Simplified)" }, { code: "zh-TW", name: "Chinese (Traditional)" }, { code: "vi", name: "Vietnamese" }, { code: "id", name: "Indonesian" }, { code: "ms", name: "Malay" }, { code: "tl", name: "Tagalog" }, { code: "fil", name: "Filipino" }, { code: "hi", name: "Hindi" }, { code: "bn", name: "Bengali" }, { code: "ur", name: "Urdu" }, { code: "ta", name: "Tamil" }, { code: "te", name: "Telugu" }, { code: "ml", name: "Malayalam" }, { code: "mr", name: "Marathi" }, { code: "gu", name: "Gujarati" }, { code: "kn", name: "Kannada" }, { code: "pa", name: "Punjabi" }, { code: "ne", name: "Nepali" }, { code: "si", name: "Sinhala" }, { code: "my", name: "Myanmar (Burmese)" }, { code: "km", name: "Khmer" }, { code: "lo", name: "Lao" }, { code: "jv", name: "Javanese" }, { code: "su", name: "Sundanese" }, { code: "es", name: "Spanish" }, { code: "fr", name: "French" }, { code: "de", name: "German" }, { code: "it", name: "Italian" }, { code: "pt", name: "Portuguese" }, { code: "nl", name: "Dutch" }, { code: "pl", name: "Polish" }, { code: "ro", name: "Romanian" }, { code: "ru", name: "Russian" }, { code: "uk", name: "Ukrainian" }, { code: "cs", name: "Czech" }, { code: "sk", name: "Slovak" }, { code: "sl", name: "Slovenian" }, { code: "hr", name: "Croatian" }, { code: "sr", name: "Serbian" }, { code: "bs", name: "Bosnian" }, { code: "bg", name: "Bulgarian" }, { code: "mk", name: "Macedonian" }, { code: "el", name: "Greek" }, { code: "tr", name: "Turkish" }, { code: "hu", name: "Hungarian" }, { code: "fi", name: "Finnish" }, { code: "sv", name: "Swedish" }, { code: "da", name: "Danish" }, { code: "no", name: "Norwegian" }, { code: "et", name: "Estonian" }, { code: "lv", name: "Latvian" }, { code: "lt", name: "Lithuanian" }, { code: "is", name: "Icelandic" }, { code: "ga", name: "Irish" }, { code: "cy", name: "Welsh" }, { code: "mt", name: "Maltese" }, { code: "sq", name: "Albanian" }, { code: "hy", name: "Armenian" }, { code: "ka", name: "Georgian" }, { code: "az", name: "Azerbaijani" }, { code: "kk", name: "Kazakh" }, { code: "ky", name: "Kyrgyz" }, { code: "tg", name: "Tajik" }, { code: "uz", name: "Uzbek" }, { code: "tk", name: "Turkmen" }, { code: "mn", name: "Mongolian" }, { code: "ar", name: "Arabic" }, { code: "fa", name: "Persian" }, { code: "iw", name: "Hebrew" }, { code: "ps", name: "Pashto" }, { code: "ug", name: "Uyghur" }, { code: "ku", name: "Kurdish (Kurmanji)" }, { code: "sw", name: "Swahili" }, { code: "am", name: "Amharic" }, { code: "ha", name: "Hausa" }, { code: "ig", name: "Igbo" }, { code: "yo", name: "Yoruba" }, { code: "zu", name: "Zulu" }, { code: "xh", name: "Xhosa" }, { code: "so", name: "Somali" }, { code: "rw", name: "Kinyarwanda" }, { code: "mg", name: "Malagasy" }, { code: "af", name: "Afrikaans" }, { code: "ca", name: "Catalan" }, { code: "eu", name: "Basque" }, { code: "gl", name: "Galician" }, { code: "eo", name: "Esperanto" }, { code: "be", name: "Belarusian" }, { code: "ceb", name: "Cebuano" }, { code: "co", name: "Corsican" }, { code: "fy", name: "Frisian" }, { code: "haw", name: "Hawaiian" }, { code: "hmn", name: "Hmong" }, { code: "ht", name: "Haitian Creole" }, { code: "lb", name: "Luxembourgish" }, { code: "la", name: "Latin" }, { code: "mi", name: "Maori" }, { code: "or", name: "Odia (Oriya)" }, { code: "gd", name: "Scots Gaelic" }, { code: "sm", name: "Samoan" }, { code: "sn", name: "Shona" }, { code: "st", name: "Sesotho" }, { code: "sd", name: "Sindhi" }, { code: "tt", name: "Tatar" }, { code: "yi", name: "Yiddish" }, { code: "ny", name: "Chichewa" } ]; const FALLBACK_SOURCES = [ { id: "original", name: "Original" }, { id: "translated", name: "Translated" }, { id: "ai", name: "Ai" }, ]; const HEALTH_PATH = "/health"; const META_PATH = "/meta"; const AI_RESOLVE_PATH = "/ai/resolve"; const AI_PROMPT_DEFAULT_PATH = "/ai/prompt/default"; const WARMUP_PATH = "/warmup"; const HEALTH_TIMEOUT_MS = 5000; const AI_META_TIMEOUT_MS = 8000; const PROMPT_TIMEOUT_MS = 8000; const WARMUP_TIMEOUT_MS = 2500; const RETRY_DELAYS_MS = [600, 1200, 2500, 5000]; const modeSel = document.getElementById("mode"); const langSel = document.getElementById("lang"); const sourcesSel = document.getElementById("sources"); const langWrap = document.getElementById("lang-wrap"); const sourcesWrap = document.getElementById("sources-wrap"); const aiKeyWrap = document.getElementById("ai-key-wrap"); const aiKeyInput = document.getElementById("ai-key"); const aiModelWrap = document.getElementById("ai-model-wrap"); const aiModelSel = document.getElementById("ai-model"); const aiPromptWrap = document.getElementById("ai-prompt-wrap"); const aiPromptInput = document.getElementById("ai-prompt"); const aiPromptCountEl = document.getElementById("ai-prompt-count"); const aiPromptResetBtn = document.getElementById("ai-prompt-reset"); const apiInput = document.getElementById("api-url"); const emojiEl = document.getElementById("api-status-emoji"); const resetBtn = document.getElementById("reset-api"); const openLocalImagesBtn = document.getElementById("open-local-images"); const openLocalFolderBtn = document.getElementById("open-local-folder"); const localImagesInput = document.getElementById("local-images-input"); const localFolderInput = document.getElementById("local-folder-input"); const runStatusTextEl = document.getElementById("run-status-text"); const runProgressEl = document.querySelector(".run-progress"); const runProgressBarEl = document.getElementById("run-progress-bar"); const runStatusSubEl = document.getElementById("run-status-sub"); aiPromptResetBtn?.addEventListener("click", async () => { await resetPromptForLang(langSel.value); }); let userInteractedApi = false; let lastApiOk = false; let lastSavedApiUrl = ""; let retryTimer = null; let metaCache = null; let modelDirty = false; let aiMetaSeq = 0; let promptSeq = 0; let lastResolvedProvider = ""; let lastResolvedKey = ""; let desiredLang = "en"; let desiredSources = "translated"; let desiredAiModel = "auto"; let aiPromptByLangState = {}; let aiPromptDefaultsByLang = {}; let aiPromptDirtyByLang = {}; let pendingApiSave = false; let pendingAiSave = false; let aiResolveDebounce = null; let apiDefaults = { defaultApiUrl: "", resetApiUrl: "", fetchedAt: 0 }; const inFlight = { seq: 0, controller: null, }; function updatePromptCount(text = null) { if (!aiPromptCountEl) return; const s = typeof text === "string" ? text : String(aiPromptInput?.value || ""); aiPromptCountEl.textContent = `${s.length}/${AI_PROMPT_MAX_CHARS}`; } function buildUrl(path, params = null) { const base = normalizeUrl(apiInput?.value); if (!base) return ""; const u = new URL(`${base}${path}`); if (params && typeof params === "object") { for (const [k, v] of Object.entries(params)) { const vv = String(v ?? "").trim(); if (vv) u.searchParams.set(k, vv); } } return u.toString(); } function setEmojiStatus(type, detail) { if (!emojiEl) return; if (type === "loading") { emojiEl.textContent = "⏳"; emojiEl.title = detail || "Checking API..."; } else if (type === "ok") { emojiEl.textContent = "✅"; emojiEl.title = detail || "Online"; } else { emojiEl.textContent = "❌"; emojiEl.title = detail || "Offline / Not reachable"; } } function renderBatchStatus(b) { if (!runStatusTextEl || !runProgressEl || !runProgressBarEl) return; const batch = b && typeof b === "object" ? b : null; const stats = batch && batch.stats && typeof batch.stats === "object" ? batch.stats : null; const total = Number(stats?.total) || 0; const finished = Number(stats?.finished) || 0; const msg = typeof batch?.message === "string" && batch.message ? batch.message : "Idle"; runStatusTextEl.textContent = msg; const pct = total ? Math.max(0, Math.min(100, Math.round((finished / total) * 100))) : 0; runProgressBarEl.style.width = pct + "%"; runProgressEl.setAttribute("aria-valuenow", String(pct)); if (runStatusSubEl) { if (total) { const pass = Number(batch?.pass) || 1; const stage = typeof batch?.stage === "string" ? batch.stage : ""; const extra = stage ? `• ${stage}` : ""; runStatusSubEl.textContent = `pass ${pass} • ${finished}/${total} ${extra}`.trim(); } else { runStatusSubEl.textContent = ""; } } } function initRunStatus() { try { chrome.runtime.sendMessage({ type: "GET_BATCH_STATUS" }, (resp) => { renderBatchStatus(resp?.batch); }); } catch {} try { chrome.runtime.onMessage.addListener((msg) => { if (!msg || typeof msg !== "object") return; if (msg.type === "BATCH_STATUS_UPDATE") renderBatchStatus(msg.batch); }); } catch {} } const warmedApi = new Set(); async function warmupApi(base) { const b = normalizeUrl(base); if (!b || warmedApi.has(b)) return; warmedApi.add(b); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), WARMUP_TIMEOUT_MS); try { await fetch(`${b}${WARMUP_PATH}`, { method: "GET", cache: "no-store", signal: controller.signal, }); } catch { } finally { clearTimeout(timeout); } } function abortInFlight() { if (inFlight.controller) { try { inFlight.controller.abort(); } catch {} } inFlight.controller = null; } function scheduleRetry(url, attemptIndex) { clearTimeout(retryTimer); if (attemptIndex >= RETRY_DELAYS_MS.length) return; const delay = RETRY_DELAYS_MS[attemptIndex]; retryTimer = setTimeout(() => { if (normalizeUrl(apiInput.value) === url) checkHealth(url, attemptIndex); }, delay); } async function parseHealth(res) { try { const ct = res.headers.get("content-type") || ""; if (ct.includes("application/json")) return await res.json(); } catch {} try { const txt = await res.text(); return { ok: /\bok\b/i.test(txt), raw: txt }; } catch { return null; } } async function checkHealthOnce(url, seq) { const controller = new AbortController(); inFlight.controller = controller; const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); try { const res = await fetch(`${url}${HEALTH_PATH}`, { method: "GET", headers: { accept: "application/json, text/plain;q=0.8" }, cache: "no-store", signal: controller.signal, }); if (!res.ok) throw new Error(`status ${res.status}`); const data = await parseHealth(res); if (!data || !data.ok) throw new Error("unhealthy"); if (seq !== inFlight.seq) return false; return true; } finally { clearTimeout(timeout); } } async function checkHealth(url, attemptIndex = 0) { const cleaned = normalizeUrl(url); if (!cleaned) return; inFlight.seq += 1; const seq = inFlight.seq; abortInFlight(); setEmojiStatus( userInteractedApi ? "loading" : "loading", userInteractedApi ? "Checking API..." : "Waiting…", ); try { const healthy = await checkHealthOnce(cleaned, seq); if (seq !== inFlight.seq) return; lastApiOk = healthy; if (healthy) { clearTimeout(retryTimer); setEmojiStatus("ok", "Online"); warmupApi(cleaned); refreshMeta(cleaned); } else { setEmojiStatus("error", userInteractedApi ? "Health failed" : "Waiting…"); scheduleRetry(cleaned, attemptIndex); } } catch (err) { if (seq !== inFlight.seq) return; lastApiOk = false; const msg = err && err.name === "AbortError" ? "Timed out" : err?.message || "Offline"; setEmojiStatus( userInteractedApi ? "error" : "loading", userInteractedApi ? msg : "Waiting…", ); scheduleRetry(cleaned, attemptIndex); } } async function fetchJson(url, body, timeoutMs) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { method: body ? "POST" : "GET", headers: body ? { "Content-Type": "application/json", accept: "application/json" } : { accept: "application/json" }, cache: "no-store", body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (!res.ok) throw new Error(`status ${res.status}`); return await res.json(); } finally { clearTimeout(timeout); } } function setSelectOptions( sel, list, { valueKey = "id", labelKey = "name", keepValue = "" } = {}, ) { const prev = keepValue || sel.value || ""; sel.innerHTML = ""; const items = Array.isArray(list) ? list : []; for (const it of items) { const opt = document.createElement("option"); opt.value = String(it?.[valueKey] ?? ""); opt.textContent = String(it?.[labelKey] ?? opt.value); sel.appendChild(opt); } const canKeep = [...sel.options].some((o) => o.value === prev); if (canKeep) sel.value = prev; } function populateModes() { setSelectOptions(modeSel, MODES, { valueKey: "id", labelKey: "name", keepValue: modeSel.value, }); } function toggleUi() { const modeId = modeSel.value || "lens_text"; const isText = modeId === "lens_text"; sourcesWrap.style.display = isText ? "" : "none"; const source = (sourcesSel.value || "").trim() || "translated"; const needLang = MODES.find((m) => m.id === modeId)?.needLang ?? true; const showLang = needLang && !(isText && source === "original"); langWrap.style.display = showLang ? "" : "none"; const showAi = isText && source === "ai"; const hasEnv = Boolean(metaCache?.has_env_ai_key); const hasKey = (aiKeyInput.value || "").trim().length > 0; const canConfigureAi = hasKey || hasEnv; aiKeyWrap.style.display = showAi ? "" : "none"; aiModelWrap.style.display = showAi && canConfigureAi ? "" : "none"; aiPromptWrap.style.display = showAi && canConfigureAi ? "" : "none"; } async function refreshMeta(baseUrl) { const url = `${baseUrl}${META_PATH}`; try { const data = await fetchJson(url, null, HEALTH_TIMEOUT_MS); if (data && data.ok) { metaCache = data; const langs = Array.isArray(data.languages) && data.languages.length ? data.languages : FALLBACK_LANGS; const sources = Array.isArray(data.sources) && data.sources.length ? data.sources : FALLBACK_SOURCES; const beforeLang = langSel.value; const beforeSources = sourcesSel.value; setSelectOptions(langSel, langs, { valueKey: "code", labelKey: "name", keepValue: desiredLang || beforeLang, }); setSelectOptions(sourcesSel, sources, { valueKey: "id", labelKey: "name", keepValue: desiredSources || beforeSources, }); const afterLang = langSel.value; const afterSources = sourcesSel.value; const patch = {}; if (afterLang && afterLang !== desiredLang) { desiredLang = afterLang; patch.lang = afterLang; } if (afterSources && afterSources !== desiredSources) { desiredSources = afterSources; patch.sources = afterSources; } if (Object.keys(patch).length) await chrome.storage.local.set(patch); toggleUi(); } } catch {} } async function ensureAiAvailableOrFallback() { const modeId = modeSel.value || "lens_text"; if (modeId !== "lens_text") return true; const source = (sourcesSel.value || "").trim() || "translated"; if (source !== "ai") return true; const hasEnv = Boolean(metaCache?.has_env_ai_key); const hasKey = (aiKeyInput.value || "").trim().length > 0; if (hasKey || hasEnv) return true; toggleUi(); return false; } function setModelOptions(models, { keepValue = "", strict = true } = {}) { const prev = (keepValue || aiModelSel.value || desiredAiModel || "auto").trim() || "auto"; aiModelSel.innerHTML = ""; const base = [{ id: "auto", name: "auto" }]; const list = (Array.isArray(models) ? models : []) .map((m) => ({ id: String(m || ""), name: String(m || ""), })) .filter((m) => m.id) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), ); const canKeep = prev && [...new Set(list.map((m) => m.id))].includes(prev); if (!strict && prev && prev !== "auto" && !canKeep) list.unshift({ id: prev, name: prev }); for (const it of base.concat(list)) { const opt = document.createElement("option"); opt.value = it.id; opt.textContent = it.name; aiModelSel.appendChild(opt); } aiModelSel.value = canKeep ? prev : "auto"; } async function refreshAiMeta({ forcePrompt = false } = {}) { const modeId = modeSel.value || "lens_text"; if (modeId !== "lens_text") return; const ok = await ensureAiAvailableOrFallback(); if (!ok) return; const base = normalizeUrl(apiInput.value); if (!base) return; const source = (sourcesSel.value || "").trim() || "translated"; if (source !== "ai") { setModelOptions([], { keepValue: "auto" }); return; } const seq = ++aiMetaSeq; try { const lang = langSel.value || "en"; const aiKey = (aiKeyInput.value || "").trim(); const currentModel = (aiModelSel.value || "").trim() || desiredAiModel || "auto"; const data = await fetchJson( `${base}${AI_RESOLVE_PATH}`, { api_key: aiKey, model: currentModel, lang }, AI_META_TIMEOUT_MS, ); if (seq !== aiMetaSeq) return; if (!data || !data.ok) { setModelOptions([], { keepValue: currentModel, strict: false }); toggleUi(); return; } const provider = String(data.provider || "").trim(); const keyChanged = aiKey !== lastResolvedKey; const providerChanged = Boolean( lastResolvedProvider && provider && provider !== lastResolvedProvider && keyChanged, ); if (provider) lastResolvedProvider = provider; lastResolvedKey = aiKey; const models = Array.isArray(data.models) ? data.models : []; const preferred = (modelDirty ? (aiModelSel.value || "").trim() : (desiredAiModel || "").trim()) || currentModel || "auto"; setModelOptions(models, { keepValue: preferred, strict: true }); const optionValues = [...aiModelSel.options].map((o) => o.value); let nextModel = (aiModelSel.value || "").trim() || "auto"; if (!optionValues.includes(nextModel) || nextModel === "") { const suggested = String(data.model || "").trim(); if (suggested && optionValues.includes(suggested)) nextModel = suggested; else nextModel = "auto"; } else if ( providerChanged && nextModel !== "auto" && !optionValues.includes(nextModel) ) { nextModel = "auto"; } if ((aiModelSel.value || "").trim() !== nextModel) aiModelSel.value = nextModel; if (nextModel !== currentModel) { desiredAiModel = nextModel; await chrome.storage.local.set({ aiKey, aiModel: nextModel }); } else { await chrome.storage.local.set({ aiKey }); } if (forcePrompt) await applyPromptForLang(lang, { forceFetch: true }); toggleUi(); } catch {} } function canUseAiUi() { const modeId = modeSel.value || "lens_text"; if (modeId !== "lens_text") return false; const source = (sourcesSel.value || "").trim() || "translated"; return source === "ai"; } async function fetchDefaultPromptForLang(lang, model = 'auto') { const l = (lang || 'en').trim() || 'en'; const m2 = normalizeAiModel(model); const url = buildUrl(AI_PROMPT_DEFAULT_PATH, { lang: l, model: m2 }); if (!url) return ''; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), PROMPT_TIMEOUT_MS); const resp = await fetch(url, { signal: ctrl.signal }).catch(() => null); clearTimeout(timer); const data = resp ? await resp.json().catch(() => null) : null; const p2 = data && data.ok ? String(data.prompt_editable_default || '').trim() : ''; return normalizePrompt(p2); } async function resetPromptForLang(lang) { if (!canUseAiUi()) return; const l = (lang || desiredLang || langSel.value || 'en').trim() || 'en'; const model = normalizeAiModel(desiredAiModel); const key = makePromptKey(l, model); const seq = ++promptSeq; const cached = String(aiPromptDefaultsByLang[key] || '').trim(); const def = cached ? normalizePrompt(cached) : await fetchDefaultPromptForLang(l, model); if (seq !== promptSeq) return; aiPromptDefaultsByLang[key] = def; aiPromptByLangState[key] = def; aiPromptDirtyByLang[key] = false; aiPromptInput.value = def; updatePromptCount(def); await chrome.storage.local.set({ aiPromptByLang: aiPromptByLangState }); chrome.runtime.sendMessage({ type: 'AI_SETTINGS_CHANGED' }); } async function applyPromptForLang(lang, { forceFetch = false } = {}) { if (!canUseAiUi()) return; const l = (lang || desiredLang || langSel.value || 'en').trim() || 'en'; const model = normalizeAiModel(desiredAiModel); const key = makePromptKey(l, model); if (aiPromptDirtyByLang[key]) return; if (Object.prototype.hasOwnProperty.call(aiPromptByLangState, key) && !forceFetch) { const saved = String(aiPromptByLangState[key] || ''); aiPromptInput.value = saved; updatePromptCount(saved); return; } if (Object.prototype.hasOwnProperty.call(aiPromptDefaultsByLang, key) && !forceFetch) { const def = String(aiPromptDefaultsByLang[key] || ''); aiPromptInput.value = def; updatePromptCount(def); return; } const def = await fetchDefaultPromptForLang(l, model); aiPromptDefaultsByLang[key] = def; aiPromptInput.value = def; updatePromptCount(def); } async function flushPromptForLang(lang, model = null) { if (!canUseAiUi()) return; const l = (lang || desiredLang || langSel.value || 'en').trim() || 'en'; const m2 = normalizeAiModel(model || desiredAiModel); const key = makePromptKey(l, m2); if (!aiPromptDirtyByLang[key]) return; const prompt = normalizePrompt(String(aiPromptInput.value || '')); aiPromptByLangState[key] = prompt; aiPromptDirtyByLang[key] = false; await chrome.storage.local.set({ aiPromptByLang: aiPromptByLangState }); } let debounceTimer = null; function scheduleSaveApi(raw) { clearTimeout(debounceTimer); pendingApiSave = true; debounceTimer = setTimeout(async () => { pendingApiSave = false; const normalized = normalizeUrl(raw); if (!normalized) return; if (normalized === lastSavedApiUrl) { checkHealth(normalized); return; } userInteractedApi = true; await chrome.storage.local.set({ customApiUrl: normalized }); lastSavedApiUrl = normalized; chrome.runtime.sendMessage({ type: "API_URL_CHANGED" }); setEmojiStatus("loading", "Checking API..."); checkHealth(normalized); refreshAiMeta({ forcePrompt: false }); }, 800); } let aiDebounce = null; function scheduleSaveAi() { clearTimeout(aiDebounce); pendingAiSave = true; aiDebounce = setTimeout(async () => { pendingAiSave = false; const modeId = modeSel.value || "lens_text"; if (modeId !== "lens_text") { const aiKey = (aiKeyInput.value || "").trim(); const aiModel = normalizeAiModel((aiModelSel.value || "").trim() || desiredAiModel || "auto"); desiredAiModel = aiModel; await chrome.storage.local.set({ aiKey, aiModel }); chrome.runtime.sendMessage({ type: "AI_SETTINGS_CHANGED" }); return; } const source = (sourcesSel.value || "").trim() || "translated"; const lang = desiredLang || langSel.value || "en"; const aiKey = (aiKeyInput.value || "").trim(); const aiModel = normalizeAiModel((aiModelSel.value || "").trim() || desiredAiModel || "auto"); desiredAiModel = aiModel; const key = makePromptKey(lang, aiModel); if (aiPromptDirtyByLang[key]) { const prompt = normalizePrompt(String(aiPromptInput.value || "")); aiPromptByLangState[key] = prompt; aiPromptDirtyByLang[key] = false; } await chrome.storage.local.set({ aiKey, aiModel, aiPromptByLang: aiPromptByLangState, }); chrome.runtime.sendMessage({ type: "AI_SETTINGS_CHANGED" }); modelDirty = true; toggleUi(); if (source === "ai") refreshAiMeta({ forcePrompt: false }); }, 400); } function scheduleResolveAiMeta({ immediate = false } = {}) { if (aiResolveDebounce) clearTimeout(aiResolveDebounce); const run = () => { if ((modeSel.value || "lens_text") !== "lens_text") return; if ((sourcesSel.value || "").trim() !== "ai") return; refreshAiMeta({ forcePrompt: false }); }; if (immediate) { run(); return; } aiResolveDebounce = setTimeout(run, 350); } function resetFileInput(input) { if (input) input.value = ""; } async function openLocalViewerFromFiles(fileList, sourceLabel) { const images = filterImageFiles(fileList); if (!images.length) return; const session = await saveLocalSession({ id: crypto.randomUUID(), createdAt: Date.now(), title: sourceLabel, pages: sortLocalPages(images.map((file, index) => toLocalPageRecord(file, index))), }); const url = chrome.runtime.getURL( `viewer/viewer.html?sid=${encodeURIComponent(session.id)}`, ); await chrome.tabs.create({ url }); window.close(); } async function handleLocalPickerChange(input, sourceLabel) { const files = [...(input?.files || [])]; resetFileInput(input); await openLocalViewerFromFiles(files, sourceLabel); } function handleWsStatus(status) { if (lastApiOk) return; if (status === "connected") setEmojiStatus("ok", "WS connected"); else if (status === "connecting") setEmojiStatus("loading", "Connecting…"); else setEmojiStatus("error", "WS disconnected"); } async function loadSettings() { populateModes(); setEmojiStatus("loading", "Initializing…"); const stored = await chrome.storage.local.get([ "mode", "lang", "sources", "customApiUrl", "aiKey", "aiModel", "aiPromptByLang", ]); modeSel.value = stored.mode || "lens_text"; desiredLang = typeof stored.lang === "string" && stored.lang ? stored.lang : "en"; desiredSources = typeof stored.sources === "string" && stored.sources ? stored.sources : "translated"; desiredAiModel = typeof stored.aiModel === "string" && stored.aiModel ? stored.aiModel : "auto"; setSelectOptions(langSel, FALLBACK_LANGS, { valueKey: "code", labelKey: "name", keepValue: desiredLang, }); setSelectOptions(sourcesSel, FALLBACK_SOURCES, { valueKey: "id", labelKey: "name", keepValue: desiredSources, }); langSel.value = desiredLang; sourcesSel.value = desiredSources; const storedCustom = String(stored.customApiUrl || ""); lastSavedApiUrl = storedCustom; apiInput.value = storedCustom; if (storedCustom) userInteractedApi = true; const aiKey = String(stored.aiKey || ""); aiPromptByLangState = stored.aiPromptByLang && typeof stored.aiPromptByLang === "object" ? stored.aiPromptByLang : {}; const mig = migratePromptMap(aiPromptByLangState); aiPromptByLangState = mig.map; if (mig.changed) await chrome.storage.local.set({ aiPromptByLang: aiPromptByLangState }); const key = makePromptKey(desiredLang, desiredAiModel); const prompt = Object.prototype.hasOwnProperty.call(aiPromptByLangState, key) ? String(aiPromptByLangState[key] || "") : ""; aiKeyInput.value = aiKey; setModelOptions([], { keepValue: desiredAiModel }); aiPromptInput.value = prompt; updatePromptCount(prompt); toggleUi(); const initial = normalizeUrl(apiInput.value); if (initial) checkHealth(initial); ensureApiDefaults() .then((d) => { apiDefaults = d || apiDefaults; if (storedCustom) return; const def = apiDefaults.defaultApiUrl || ""; if (!def) return; if (!normalizeUrl(apiInput.value)) { apiInput.value = def; checkHealth(def); } }) .catch(() => {}); if ((sourcesSel.value || "").trim() === "ai" && modeSel.value === "lens_text") { applyPromptForLang(desiredLang, { forceFetch: false }).catch(() => {}); refreshAiMeta({ forcePrompt: false }); } chrome.runtime.sendMessage({ type: "GET_API_STATUS" }, (resp) => { if (chrome.runtime.lastError) return; if (resp && typeof resp.ok === "boolean" && resp.ok) { lastApiOk = true; setEmojiStatus("ok", "Online"); } }); chrome.runtime.sendMessage({ type: "GET_WS_STATUS" }, (resp) => { if (chrome.runtime.lastError) return; if (resp && typeof resp.status === "string") handleWsStatus(resp.status); }); } window.addEventListener("offline", () => { lastApiOk = false; setEmojiStatus("error", "No internet"); }); window.addEventListener("online", () => { if (apiInput.value) checkHealth(apiInput.value); }); window.addEventListener("pagehide", () => { try { if (pendingApiSave) { const normalized = normalizeUrl(apiInput.value); if (normalized) chrome.storage.local.set({ customApiUrl: normalized }); } const modeId = modeSel.value || "lens_text"; const aiKey = (aiKeyInput.value || "").trim(); const aiModel = normalizeAiModel((aiModelSel.value || "").trim() || desiredAiModel || "auto"); desiredAiModel = aiModel; if (modeId === "lens_text") { const lang = desiredLang || langSel.value || "en"; const key = makePromptKey(lang, aiModel); const needSave = pendingAiSave || Boolean(aiPromptDirtyByLang[key]); if (aiPromptDirtyByLang[key]) { const prompt = normalizePrompt(String(aiPromptInput.value || "")); aiPromptByLangState[key] = prompt; aiPromptDirtyByLang[key] = false; } if (needSave) chrome.storage.local.set({ aiKey, aiModel, aiPromptByLang: aiPromptByLangState, }); } else if (pendingAiSave) { chrome.storage.local.set({ aiKey, aiModel }); } } catch {} }); chrome.runtime.onMessage?.addListener((msg) => { if (msg.type === "WS_STATUS_UPDATE") handleWsStatus(msg.status); else if (msg.type === "API_STATUS_UPDATE") { if (typeof msg.ok === "boolean") { lastApiOk = msg.ok; if (msg.ok) setEmojiStatus("ok", "Online"); else setEmojiStatus( userInteractedApi ? "error" : "loading", userInteractedApi ? "API unhealthy" : "Waiting…", ); } } }); modeSel.addEventListener("change", async () => { await chrome.storage.local.set({ mode: modeSel.value }); modelDirty = false; toggleUi(); await applyPromptForLang(desiredLang, { forceFetch: false }); refreshAiMeta({ forcePrompt: false }); }); langSel.addEventListener("change", async () => { const prevLang = desiredLang; desiredLang = langSel.value || desiredLang; if (canUseAiUi()) await flushPromptForLang(prevLang, desiredAiModel); await chrome.storage.local.set({ lang: desiredLang }); modelDirty = false; await applyPromptForLang(desiredLang, { forceFetch: false }); toggleUi(); refreshAiMeta({ forcePrompt: false }); }); sourcesSel.addEventListener("change", async () => { if (canUseAiUi()) await flushPromptForLang(desiredLang, desiredAiModel); modelDirty = false; const ok = await ensureAiAvailableOrFallback(); desiredSources = sourcesSel.value || desiredSources; await chrome.storage.local.set({ sources: desiredSources }); toggleUi(); if (ok) await applyPromptForLang(desiredLang, { forceFetch: false }); refreshAiMeta({ forcePrompt: false }); chrome.runtime.sendMessage({ type: "AI_SETTINGS_CHANGED" }); }); apiInput.addEventListener("input", (e) => scheduleSaveApi(e.target.value)); apiInput.addEventListener("blur", (e) => scheduleSaveApi(e.target.value)); aiKeyInput.addEventListener("input", () => { modelDirty = false; scheduleSaveAi(); scheduleResolveAiMeta(); }); aiKeyInput.addEventListener("blur", () => { scheduleSaveAi(); scheduleResolveAiMeta({ immediate: true }); }); aiModelSel.addEventListener("change", async () => { const prevModel = desiredAiModel; desiredAiModel = normalizeAiModel(aiModelSel.value || prevModel); if (canUseAiUi()) await flushPromptForLang(desiredLang, prevModel); modelDirty = true; await applyPromptForLang(desiredLang, { forceFetch: false }); scheduleSaveAi(); }); aiPromptInput.addEventListener("input", () => { const key = makePromptKey(desiredLang, desiredAiModel); aiPromptDirtyByLang[key] = true; updatePromptCount(); scheduleSaveAi(); }); aiPromptInput.addEventListener("blur", async () => { const key = makePromptKey(desiredLang, desiredAiModel); aiPromptDirtyByLang[key] = true; updatePromptCount(); await flushPromptForLang(desiredLang, desiredAiModel); scheduleSaveAi(); }); openLocalImagesBtn?.addEventListener("click", () => { resetFileInput(localImagesInput); localImagesInput?.click(); }); openLocalFolderBtn?.addEventListener("click", () => { resetFileInput(localFolderInput); localFolderInput?.click(); }); localImagesInput?.addEventListener("change", async () => { await handleLocalPickerChange(localImagesInput, "images"); }); localFolderInput?.addEventListener("change", async () => { await handleLocalPickerChange(localFolderInput, "folder"); }); resetBtn.addEventListener("click", () => { ensureApiDefaults({ force: true }).then((d) => { apiDefaults = d; const def = apiDefaults.resetApiUrl || apiDefaults.defaultApiUrl || ""; apiInput.value = def; const normalized = normalizeUrl(def); chrome.storage.local.set({ customApiUrl: normalized }); lastSavedApiUrl = normalized; userInteractedApi = Boolean(normalized); setEmojiStatus("loading", "Reset to default"); chrome.runtime.sendMessage({ type: "API_URL_CHANGED" }); checkHealth(normalized); apiInput.focus(); }); }); loadSettings(); initRunStatus();