TextPhantom_OCR_API2 / ai_prompt.js
maxza258's picture
Upload 23 files
ebf054f verified
Raw
History Blame
34.7 kB
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();