import { createLogger } from "./shared/logger.js"; import { normalizeUrl, toWs } from "./shared/url.js"; import { ensureApiDefaults } from "./shared/api_defaults.js"; import { makePromptKey, migratePromptMap, normalizeAiModel, normalizePrompt } from "./shared/prompt.js"; const { debug: log, info, warn, error } = createLogger("LensSW"); ensureApiDefaults().catch(() => {}); const t0 = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); const fmtMs = (ms) => ms.toFixed(1) + "ms"; function ev(tag, obj = {}) { info(`[EV] ${tag}`, obj); } function evWarn(tag, obj = {}) { warn(`[EV] ${tag}`, obj); } const PREFLIGHT_TIMEOUT_MS = 10000; const WS_OPEN_TIMEOUT_MS = 20000; const WS_RETRIES = 6; const MAX_FIRST_TRY_RETRIES = 2; const FIRST_TRY_GAP_MS = 3000; const WARMUP_PATH = "/warmup"; const WARMUP_TIMEOUT_MS = 2500; const WARMUP_TTL_MS = 20 * 60 * 1000; const SOFT_MAX_CONCURRENCY_DEFAULT = 15; let MAX_CONCURRENCY = 10; let softMaxConcurrency = SOFT_MAX_CONCURRENCY_DEFAULT; let forceSoftMaxConcurrency = false; let ws = null; let wsReady = false; let wsStatus = "idle"; let currentBase = null; let currentBatchId = null; let blockSendsBecauseWsEnded = false; let running = 0; const queue = []; const pendingByJob = new Map(); const pendingByImage = new Map(); function shouldPreferRest(base, mode, source) { const m = String(mode || "").trim(); if (m === "lens_images") return true; if (m === "lens_text") return true; try { const host = new URL(String(base || "")).hostname.toLowerCase(); if (!host) return true; if (host.endsWith(".hf.space")) return true; if (host.endsWith("huggingface.co")) return true; return true; } catch { return true; } } function effectiveMaxConcurrency() { const soft = Number(softMaxConcurrency) > 0 ? Number(softMaxConcurrency) : SOFT_MAX_CONCURRENCY_DEFAULT; const n = Number(MAX_CONCURRENCY) || 0; if (forceSoftMaxConcurrency) { if (n > 0 && n < soft) return n; return soft; } return n; } function aiSoftMaxConcurrencyFromKey(key) { const k = String(key || "").trim(); if (k.startsWith("hf_")) return 3; return 10; } function statusTextFor(code, fallback = "") { if (code === 200) return "OK"; if (code === 201) return "Created"; if (code === 202) return "Accepted"; if (code === 204) return "No Content"; if (code === 400) return "Bad Request"; if (code === 401) return "Unauthorized"; if (code === 403) return "Forbidden"; if (code === 404) return "Not Found"; if (code === 408) return "Request Timeout"; if (code === 409) return "Conflict"; if (code === 413) return "Payload Too Large"; if (code === 415) return "Unsupported Media Type"; if (code === 429) return "Too Many Requests"; if (code === 500) return "Internal Server Error"; if (code === 502) return "Bad Gateway"; if (code === 503) return "Service Unavailable"; if (code === 504) return "Gateway Timeout"; return fallback || ""; } function logImageRestAccessLine(base, fullUrl, statusCode, statusText = "") { try { const b = new URL(String(base || "")); const u = new URL(String(fullUrl || "")); const host = b.hostname === "localhost" ? "127.0.0.1" : b.hostname; const st = statusTextFor(Number(statusCode) || 0, statusText); info(`${host} - "GET ${u.pathname} HTTP/1.1" ${statusCode} ${st}`); } catch {} } const BATCH_TOAST_MIN_INTERVAL_MS = 350; const BATCH_RETRY_GAP_MS = 1800; const BATCH_TTL_MS = 20 * 60 * 1000; const batches = new Map(); let lastBatchStatus = null; function normImgSrc(src) { const s = String(src || "").trim(); if (!s) return ""; try { const u = new URL(s); u.hash = ""; return u.toString(); } catch { return s; } } function imageKeyFromPayload(payload) { const id = String(payload?.metadata?.image_id || "").trim(); if (id) return id; return normImgSrc(payload?.src); } function ensureBatch(batchId, tabId, frameId) { const id = String(batchId || ""); if (!id) return null; const now = Date.now(); let b = batches.get(id); if (!b) { b = { id, tabId: Number.isFinite(tabId) ? tabId : 0, frameId: Number(frameId) || 0, createdAt: now, pass: 1, total1: 0, total2: 0, lastToastTs: 0, retryScheduled: false, items: new Map(), }; batches.set(id, b); } else { if (Number.isFinite(tabId)) b.tabId = tabId; if (Number.isFinite(frameId)) b.frameId = Number(frameId) || 0; } return b; } function pruneBatches(now = Date.now()) { for (const [id, b] of batches.entries()) { if (!b || now - (b.createdAt || now) > BATCH_TTL_MS) batches.delete(id); } } function batchPassTotal(b) { if (!b) return 0; return b.pass === 2 ? Number(b.total2) || 0 : Number(b.total1) || 0; } function batchPassStats(b) { const pass = b?.pass || 1; const total = batchPassTotal(b); let queued = 0; let processing = 0; let inserting = 0; let done = 0; let error = 0; let aborted = 0; for (const it of b?.items?.values?.() || []) { if (!it || it.attempt !== pass) continue; const st = it.status; if (st === "queued") queued++; else if (st === "processing") processing++; else if (st === "inserting") inserting++; else if (st === "done") done++; else if (st === "error") error++; else if (st === "aborted") aborted++; } const finished = done + error + aborted; return { pass, total, queued, processing, inserting, done, error, aborted, finished, }; } function batchToast(b, text, ms = 2000, force = false) { if (!b || !b.tabId || !text) return; const now = Date.now(); if (!force && now - (b.lastToastTs || 0) < BATCH_TOAST_MIN_INTERVAL_MS) return; b.lastToastTs = now; sendToastToTab(b.tabId, b.frameId || 0, text, ms); } function batchUpdateToast(b, stage, force = false) { if (!b) return; pruneBatches(); const s = batchPassStats(b); const total = s.total; const head = b.pass === 2 ? "TextPhantom: รอบแก้ไข" : "TextPhantom:"; const parts = []; if (total) parts.push(`รับภาพ ${total}`); if (s.processing || s.inserting || s.queued) parts.push(`ประมวลผล ${s.processing + s.inserting}/${total}`); if (s.done) parts.push(`แทรกกลับ ${s.done}/${total}`); if (s.error) parts.push(`ผิดพลาด ${s.error}`); if (s.aborted) parts.push(`ยกเลิก ${s.aborted}`); const extra = stage ? `• ${stage}` : ""; const msg = `${head} ${parts.join(" | ")} ${extra}`.trim(); const ms = s.finished >= total && total ? 2400 : 60000; batchToast(b, msg, ms, force); lastBatchStatus = { id: b.id, tabId: b.tabId || 0, frameId: b.frameId || 0, pass: s.pass, stage: String(stage || ""), message: msg, stats: s, ts: Date.now(), }; try { chrome.runtime.sendMessage( { type: "BATCH_STATUS_UPDATE", batch: lastBatchStatus }, () => void chrome.runtime.lastError, ); } catch {} } function batchMark(batchId, imageKey, patch) { const b = batches.get(String(batchId || "")); if (!b) return null; const k = String(imageKey || "").trim(); if (!k) return b; const cur = b.items.get(k); if (!cur) return b; b.items.set(k, { ...cur, ...patch }); return b; } async function batchStopKeepAlive(b) { if (!b?.tabId) return; try { await sendToTab(b.tabId, { type: "TP_KEEPALIVE_STOP" }, b.frameId || 0); } catch {} } function batchFinalizeIfComplete(b) { if (!b) return; const s = batchPassStats(b); if (!s.total || s.finished < s.total) return; if (b.pass === 1) { if (b.retryScheduled) return; const failed = []; let permanentErr = 0; for (const [k, it] of b.items.entries()) { if (it?.attempt !== 1 || it.status !== "error") continue; if (it?.permanent) { permanentErr++; continue; } failed.push(k); } if (!failed.length) { const msg = permanentErr ? `เสร็จสิ้น (ผิดพลาดถาวร ${permanentErr})` : "เสร็จสิ้น"; batchUpdateToast(b, msg, true); batchStopKeepAlive(b); return; } b.retryScheduled = true; b.pass = 2; b.total2 = failed.length; for (const k of failed) { const it = b.items.get(k); if (!it) continue; const base = it.payload || null; const meta = base?.metadata && typeof base.metadata === "object" ? base.metadata : {}; const pipe = Array.isArray(meta.pipeline) ? meta.pipeline : []; const nextPayload = { ...base, metadata: { ...meta, pipeline: pipe.concat({ stage: "retry_failed_once", at: new Date().toISOString(), }), timestamp: new Date().toISOString(), }, }; b.items.set(k, { ...it, payload: nextPayload, attempt: 2, status: "queued", }); } batchUpdateToast( b, `พักก่อน แล้วลองแก้ไขอีกครั้ง ${failed.length} ภาพ`, true, ); addTask(async () => { await new Promise((r) => setTimeout(r, BATCH_RETRY_GAP_MS)); const again = []; for (const it of b.items.values()) { if (it?.attempt === 2 && it.status === "queued" && it.payload) again.push(it.payload); } batchUpdateToast(b, "เริ่มรอบแก้ไข", true); for (const pl of again) { let nextPayload = pl; let skip = false; try { const src = String(pl?.src || "").trim(); if (src && /^https?:/i.test(src) && !pl?.imageDataUri) { const pageUrl = pl?.context?.page_url || ""; const key = normImgSrc(src); pruneMdDataUriCache(); const cached = key ? mdDataUriCache.get(key) : null; const du = cached?.du && Date.now() - (cached.ts || 0) <= MD_DATAURI_CACHE_TTL_MS ? cached.du : await fetchImageDataUriFromUrl(src, pageUrl); if (du) { const meta = pl?.metadata && typeof pl.metadata === "object" ? pl.metadata : {}; const pipe = Array.isArray(meta.pipeline) ? meta.pipeline : []; nextPayload = { ...pl, imageDataUri: du, metadata: { ...meta, pipeline: pipe.concat({ stage: "retry_attach_datauri", at: new Date().toISOString(), }), timestamp: new Date().toISOString(), }, }; if (key && !cached?.du) mdDataUriCache.set(key, { du, ts: Date.now() }); const k = imageKeyFromPayload(nextPayload); if (k && b.items.has(k)) { const it = b.items.get(k); b.items.set(k, { ...it, payload: nextPayload }); } } } } catch (e) { const msg = String(e?.message || e); const cls = classifyJobError(msg); const k = imageKeyFromPayload(pl); if (k && b.items.has(k)) { const it = b.items.get(k); b.items.set(k, { ...it, lastError: msg, status: cls?.permanent ? "error" : it.status, permanent: !!cls?.permanent, }); if (cls?.permanent) { batchUpdateToast(b, "ผิดพลาด (ถาวร)"); batchFinalizeIfComplete(b); } } if (cls?.permanent) skip = true; } if (!skip) enqueue(nextPayload, b.tabId, b.frameId || 0); } }); return; } if (b.pass === 2) { const s2 = batchPassStats(b); const msg = s2.error > 0 ? `เสร็จสิ้น (ยังผิดพลาด ${s2.error})` : "เสร็จสิ้น"; batchUpdateToast(b, msg, true); batchStopKeepAlive(b); } return; } let settingsEpoch = 0; chrome.storage.onChanged.addListener((changes, areaName) => { if (areaName !== "local" || !changes) return; if (!changes.mode && !changes.lang && !changes.sources) return; settingsEpoch = (settingsEpoch + 1) >>> 0; }); const tabSessionById = new Map(); function bumpTabSession(tabId, href) { if (!Number.isFinite(tabId)) return ""; const id = crypto.randomUUID(); tabSessionById.set(tabId, { id, href: String(href || ""), ts: Date.now() }); return id; } function getTabSessionId(tabId) { return tabSessionById.get(tabId)?.id || ""; } function getTabSession(tabId) { return tabSessionById.get(tabId) || null; } function ensureTabSession(tabId, href) { const cur = getTabSession(tabId); const h = String(href || ""); if (!cur?.id) return bumpTabSession(tabId, h); if (h && String(cur.href || "") !== h) { tabSessionById.set(tabId, { ...cur, href: h, ts: Date.now() }); } return cur.id; } function isMangaDexPageUrl(u) { try { const host = new URL(String(u || "")).hostname.toLowerCase(); return host === "mangadex.org" || host.endsWith(".mangadex.org"); } catch { return false; } } function mdChapterIdFromUrl(u) { try { const url = new URL(String(u || "")); const m = String(url.pathname || "").match(/\/chapter\/([a-f0-9-]{8,})/i); return m ? String(m[1] || "") : ""; } catch { return ""; } } function isSameMangaDexChapter(a, b) { const ca = mdChapterIdFromUrl(a); const cb = mdChapterIdFromUrl(b); return !!ca && !!cb && ca === cb; } function cancelTabWork(tabId, reason = "navigation") { if (!Number.isFinite(tabId)) return; const msg = String(reason || "navigation"); for (const [jid, ctx] of Array.from(pendingByJob.entries())) { if ((ctx?.tabId || 0) !== tabId) continue; const batchId = String( ctx?.batchId || ctx?.metadata?.batch_id || "", ).trim(); const imageKey = String( ctx?.imageKey || ctx?.metadata?.image_id || "", ).trim(); const batch = batchId ? ensureBatch(batchId, tabId, ctx?.frameId || 0) : null; pendingByJob.delete(jid); if (ctx?.metadata?.image_id) pendingByImage.delete(ctx.metadata.image_id); if (batch && imageKey) { batchMark(batchId, imageKey, { status: "aborted", lastError: msg }); batchUpdateToast(batch, "ยกเลิก"); batchFinalizeIfComplete(batch); batchStopKeepAlive(batch); } } for (const [iid, rec] of Array.from(pendingByImage.entries())) { if ((rec?.tabId || 0) === tabId) pendingByImage.delete(iid); } for (const b of Array.from(batches.values())) { if ((b?.tabId || 0) !== tabId) continue; batchStopKeepAlive(b); } } const MD_CACHE_TTL_MS = 15 * 60 * 1000; const mdCacheByKey = new Map(); const MD_DATAURI_CACHE_TTL_MS = 15 * 60 * 1000; const MD_DATAURI_CACHE_MAX = 80; const mdDataUriCache = new Map(); const API_BASE_FALLBACK = ""; const KEEPALIVE_PORT_NAME = "TP_KEEPALIVE"; const keepAlivePorts = new Set(); function bytesToBase64(bytes) { let binary = ""; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); } return btoa(binary); } async function blobToDataUri(blob, mimeOverride) { const ab = await blob.arrayBuffer(); const mime = String(mimeOverride || blob.type || "application/octet-stream"); const b64 = bytesToBase64(new Uint8Array(ab)); return `data:${mime};base64,${b64}`; } async function fetchImageDataUriFromUrl(url, pageUrl) { const u = String(url || "").trim(); if (!u) return ""; const res = await fetch(u, { credentials: "include", redirect: "follow", cache: "force-cache", referrer: pageUrl || "about:client", }); if (!res.ok) throw new Error("HTTP " + res.status); const ct = String(res.headers.get("content-type") || ""); const mime = ct.split(";")[0].trim(); if (mime && !mime.toLowerCase().startsWith("image/")) { const body = await readLimitedText(res); throw new Error(`Not an image: ${mime}${body ? ` - ${body}` : ""}`); } const blob = await res.blob(); if (blob.size < 64) throw new Error("Image too small"); if (blob.size > 25 * 1024 * 1024) throw new Error("Image too large"); return await blobToDataUri(blob, mime || blob.type); } function classifyJobError(msg) { const m = String(msg || "").toLowerCase(); if (!m) return { permanent: false }; if (m.includes("no overlay data")) return { permanent: true }; if (m.includes("no image data")) return { permanent: true }; if (/\bhttp\s*(401|403|404|410)\b/.test(m) || /\b(401|403|404|410)\b/.test(m)) return { permanent: true }; if (m.includes("not an image")) return { permanent: true }; if ( m.includes("cannot identify image") || m.includes("image file is truncated") ) return { permanent: true }; if (m.includes("unsupported") && m.includes("image")) return { permanent: true }; if ( m.includes("rate limit") || m.includes("ratelimit") || m.includes("too many requests") || m.includes("http 429") || m.includes(" 429") ) return { permanent: false }; if ( m.includes("http 503") || m.includes(" 503") || m.includes("overloaded") || m.includes("temporarily") ) return { permanent: false }; if (m.includes("timeout")) return { permanent: false }; return { permanent: false }; } function mdKeyFromUrl(url) { try { const u = new URL(String(url || "")); const parts = u.pathname.split("/").filter(Boolean); for (let i = parts.length - 3; i >= 0; i--) { const seg = parts[i]; if (seg === "data" || seg === "data-saver") { const hash = parts[i + 1] || ""; const file = parts[i + 2] || ""; if (hash && file) return `md:${seg}/${hash}/${file}`; } } } catch {} return null; } function mdScopeFromMode(mode) { if (mode === "lens_text") return "text"; if (mode === "lens_images") return "images"; return String(mode || ""); } function mdCacheKey(mdKey, lang, mode) { const k = String(mdKey || ""); const l = String(lang || ""); const s = mdScopeFromMode(mode); if (!k || !l || !s) return ""; return k + "::" + l + "::" + s; } function stripImageFields(res) { if (!res || typeof res !== "object") return res; const out = { ...res }; delete out.imageDataUri; delete out.imageDataURI; delete out.image; delete out.imageUrl; delete out.image_url; delete out.imageURL; return out; } function pruneMdCache(now = Date.now()) { for (const [k, rec] of mdCacheByKey.entries()) { if (!rec || now - rec.ts > MD_CACHE_TTL_MS) mdCacheByKey.delete(k); } } function pruneMdDataUriCache(now = Date.now()) { for (const [k, rec] of mdDataUriCache.entries()) { if (!rec || now - rec.ts > MD_DATAURI_CACHE_TTL_MS) mdDataUriCache.delete(k); } while (mdDataUriCache.size > MD_DATAURI_CACHE_MAX) { const first = mdDataUriCache.keys().next().value; if (first === undefined) break; mdDataUriCache.delete(first); } } async function readLimitedText(res, limit = 1600) { try { const txt = await res.text(); const s = String(txt || "").trim(); if (!s) return ""; return s.length > limit ? s.slice(0, limit) + "…" : s; } catch { return ""; } } async function submitJobViaRest(base, payload) { const url = base.replace(/\/+$/, "") + "/translate"; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, cache: "force-cache", redirect: "follow", body: JSON.stringify(payload), }); if (!res.ok) { const body = await readLimitedText(res); throw new Error( `REST submit failed: HTTP ${res.status}${body ? ` - ${body}` : ""}`, ); } const data = await res.json(); if (!data?.id) throw new Error("REST submit failed: no id"); ev("job.submit.rest", { id: data.id }); return data.id; } async function pollJobViaRest( base, jid, { timeoutMs = 180000, intervalMs = 800 } = {}, ) { const start = Date.now(); const url = base.replace(/\/+$/, "") + "/translate/" + encodeURIComponent(jid); let lastSt = null; let ticks = 0; while (true) { const ctx0 = pendingByJob.get(jid); if (!ctx0) return; const curSess = ctx0?.tabId ? getTabSessionId(ctx0.tabId) : ""; const isStale = Boolean( ctx0?.sessionId && curSess && ctx0.sessionId !== curSess, ); if (isStale && !ctx0?.keepCacheOnStale) { pendingByJob.delete(jid); if (ctx0?.metadata?.image_id) pendingByImage.delete(ctx0.metadata.image_id); ev("job.done", { id: jid, dt: fmtMs(Date.now() - (ctx0?.startedAt || Date.now())), }); const batchId = String( ctx0?.batchId || ctx0?.metadata?.batch_id || "", ).trim(); const imageKey = String( ctx0?.imageKey || ctx0?.metadata?.image_id || "", ).trim(); const batch = batchId ? ensureBatch(batchId, ctx0.tabId || 0, ctx0.frameId || 0) : null; if (batch && imageKey) { batchMark(batchId, imageKey, { status: "aborted" }); batchUpdateToast(batch, "ยกเลิก", true); batchFinalizeIfComplete(batch); } return; } if (Date.now() - start > timeoutMs) throw new Error("REST poll timeout"); const res = await fetch(url, { cache: "no-store" }); if (!res.ok) { const body = await readLimitedText(res); throw new Error( `REST poll failed: HTTP ${res.status}${body ? ` - ${body}` : ""}`, ); } const data = await res.json(); const st = data?.status; const ctx = pendingByJob.get(jid); if (!ctx) return; const isImageMode = String(ctx?.mode || "") === "lens_images"; ticks++; if (st && st !== lastSt) { lastSt = st; ev("job.poll.status", { id: jid, status: st }); if (isImageMode) logImageRestAccessLine(base, url, res.status, res.statusText); } else if (ticks % 15 === 0) { ev("job.poll.tick", { id: jid, status: st || "" }); } if (st === "done") { ev("job.poll.done", { id: jid }); await handleResult(jid, data.result); return; } else if (st === "error") { handleJobError( jid, String(data?.result || data?.error || data?.message || "Unknown error"), ); return; } await new Promise((r) => setTimeout(r, intervalMs)); } } function runtimeBroadcast(msg) { try { chrome.runtime.sendMessage(msg, () => void chrome.runtime.lastError); } catch {} } const broadcast = runtimeBroadcast; function pingContent(tabId, frameId = 0) { return new Promise((resolve) => { try { chrome.tabs.sendMessage(tabId, { type: "TP_PING" }, { frameId }, () => { const ok = !chrome.runtime.lastError; resolve(ok); }); } catch { resolve(false); } }); } async function ensureContentScript(tabId) { const wait = (ms) => new Promise((r) => setTimeout(r, ms)); try { for (let i = 0; i < 8; i++) { const ok = await pingContent(tabId, 0); if (ok) return true; await wait(120); } return false; } catch (e) { evWarn("ping.fail", { tabId, message: e?.message || String(e) }); return false; } } async function sendToTab(tabId, message, frameId = 0) { const type = String(message?.type || ""); const opts = { frameId: Number(frameId) || 0 }; const attempt = (o) => new Promise((resolve) => { try { chrome.tabs.sendMessage(tabId, message, o, (resp) => { const err = chrome.runtime.lastError; resolve({ ok: !err, err: err?.message || null, resp: resp || null }); }); } catch (e) { resolve({ ok: false, err: e?.message || String(e), resp: null }); } }); ev("tab.send.begin", { tabId, frameId, type }); let r = await attempt(opts); if (r.ok) { ev("tab.send.ok", { tabId, frameId, type }); return true; } evWarn("tab.send.fail", { tabId, frameId, type, err: r.err || "" }); if (opts.frameId) { const r2 = await attempt({ frameId: 0 }); if (r2.ok) { ev("tab.send.ok.fallback", { tabId, frameId: 0, type }); return true; } evWarn("tab.send.fail.fallback", { tabId, type, err: r2.err || "" }); } const injected = await ensureContentScript(tabId); if (!injected) return false; r = await attempt(opts); if (r.ok) { ev("tab.send.ok.afterInject", { tabId, frameId, type }); return true; } evWarn("tab.send.fail.afterInject", { tabId, frameId, type, err: r.err || "", }); if (opts.frameId) { const r3 = await attempt({ frameId: 0 }); if (r3.ok) { ev("tab.send.ok.afterInject.fallback", { tabId, frameId: 0, type }); return true; } evWarn("tab.send.fail.afterInject.fallback", { tabId, type, err: r3.err || "", }); } return false; } async function requestFromTab(tabId, message, frameId = 0) { const type = String(message?.type || ""); const attempt = (o) => new Promise((resolve) => { try { chrome.tabs.sendMessage(tabId, message, o, (resp) => { const err = chrome.runtime.lastError; if (err) { evWarn("tab.request.fail", { tabId, frameId: o?.frameId ?? 0, type, err: err.message, }); resolve(null); return; } resolve(resp || null); }); } catch (e) { evWarn("tab.request.fail", { tabId, frameId: o?.frameId ?? 0, type, err: e?.message || String(e), }); resolve(null); } }); const primary = { frameId: Number(frameId) || 0 }; const resp = await attempt(primary); if (resp != null) return resp; if (primary.frameId) return await attempt({ frameId: 0 }); return null; } async function requestFromTabEnsured(tabId, message, frameId = 0) { const resp = await requestFromTab(tabId, message, frameId); if (resp != null) return resp; const ok = await ensureContentScript(tabId); if (!ok) return null; return await requestFromTab(tabId, message, frameId); } function sendToastToTab(tabId, frameId, text, ms = 1600) { if (!tabId || !text) return; const opts = { frameId: Number(frameId) || 0 }; try { chrome.tabs.sendMessage(tabId, { type: "TP_TOAST", text, ms }, opts, () => { void chrome.runtime.lastError; }); } catch {} } function setWsStatus(s) { if (wsStatus !== s) { wsStatus = s; try { broadcast({ type: "WS_STATUS_UPDATE", status: s }); } catch (e) {} info(`[WS] status -> ${s}`); } } const warmupByBase = new Map(); async function warmupApi(base) { const b = normalizeUrl(base); if (!b) return; const now = Date.now(); const last = warmupByBase.get(b) || 0; if (now - last < WARMUP_TTL_MS) return; warmupByBase.set(b, now); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), WARMUP_TIMEOUT_MS); try { await fetch(b.replace(/\/+$/, "") + WARMUP_PATH, { method: "GET", cache: "force-cache", signal: controller.signal, }); ev("api.warmup", { base: b }); } catch { } finally { clearTimeout(timeout); } } const getApiBase = () => new Promise((res) => { chrome.storage.local.get( { customApiUrl: "", apiUrlDefault: API_BASE_FALLBACK }, ({ customApiUrl, apiUrlDefault }) => { const base = normalizeUrl(customApiUrl) || normalizeUrl(apiUrlDefault) || API_BASE_FALLBACK; info("[getApiBase]", base); warmupApi(base); res(base); }, ); }); let healthCache = { ok: false, ts: 0, build: "" }; function onceOpen(socket) { return new Promise((resolve, reject) => { const onOpen = () => { cleanup(); resolve(); }; const onErr = (e) => { cleanup(); reject(e); }; const onClose = () => { cleanup(); reject(new Error("ws-closed-before-open")); }; function cleanup() { socket.removeEventListener("open", onOpen); socket.removeEventListener("error", onErr); socket.removeEventListener("close", onClose); } socket.addEventListener("open", onOpen); socket.addEventListener("error", onErr); socket.addEventListener("close", onClose); }); } function withTimeout(promise, ms, label = "op") { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error(label + "-timeout")), ms); promise.then( (v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); }, ); }); } let wsPromise = null; async function preflightWs(base, timeoutMs = PREFLIGHT_TIMEOUT_MS) { const tryOnce = async (url, method = "GET") => { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { await fetch(url, { method, cache: "no-store", signal: ctrl.signal }); return true; } catch (e) { return false; } finally { clearTimeout(t); } }; const ok1 = await tryOnce(base.replace(/\/$/, "") + "/health", "GET"); if (ok1) return true; const ok2 = await tryOnce(base.replace(/\/$/, "") + "/health", "HEAD"); return ok2; } async function connectWebSocketOnce() { const t_start = t0(); ev("ws.connect.begin"); const base = await getApiBase(); const wsUrl = toWs(base); if (!wsUrl) { setWsStatus("idle"); return false; } if ( ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) && currentBase === base ) { return ws.readyState === WebSocket.OPEN; } const reachable = await preflightWs(base, PREFLIGHT_TIMEOUT_MS); if (!reachable) { setWsStatus("offline"); evWarn("ws.preflight.fail", { base }); return false; } if (wsPromise) { try { await wsPromise; } catch (e) {} const ok = !!(ws && ws.readyState === WebSocket.OPEN); ev(ok ? "ws.connect.ok" : "ws.connect.fail", { dt: fmtMs(t0() - t_start) }); return ok; } wsPromise = (async () => { const TIMEOUT_MS = WS_OPEN_TIMEOUT_MS; const RETRIES = WS_RETRIES; let lastErr = null; for (let attempt = 0; attempt <= RETRIES; attempt++) { try { if (ws) { try { ws.close(); } catch (e) {} } currentBase = base; wsReady = false; setWsStatus("connecting"); blockSendsBecauseWsEnded = false; ws = new WebSocket(wsUrl); ws.addEventListener("open", () => { ev("ws.open", { url: wsUrl }); wsReady = true; setWsStatus("connected"); }); ws.addEventListener("message", (ev) => { try { const msg = JSON.parse(ev.data); switch (msg.type) { case "ack": break; case "result": void handleResult(msg.id, msg.result).catch((e) => evWarn("job.result.handle.fail", { id: msg.id, err: e?.message || String(e), }), ); break; case "error": handleJobError(msg.id, msg.error || "Unknown error"); break; default: warn("[ws] unknown msg", msg); } } catch (e) { error("WS parse error", e); } }); const onEnded = (evx) => { evWarn("ws.end", { code: evx?.code, reason: evx?.reason || evx?.message || evx, }); wsReady = false; blockSendsBecauseWsEnded = true; failAllPending( "Connection lost before all images finished. Please run the menu again.", ); setWsStatus("idle"); }; ws.addEventListener("close", onEnded); ws.addEventListener("error", onEnded); await withTimeout(onceOpen(ws), TIMEOUT_MS, "ws-open"); return true; } catch (e) { lastErr = e; try { if (ws) ws.close(); } catch (e2) {} ws = null; wsReady = false; setWsStatus("idle"); const base = 500; const jitter = Math.floor(Math.random() * 300); const waitMs = Math.min(5000, base * Math.pow(2, attempt)) + jitter; if (attempt < RETRIES) await new Promise((r) => setTimeout(r, waitMs)); } } throw lastErr || new Error("ws-failed"); })(); try { await wsPromise; } finally { wsPromise = null; } const ok = !!(ws && ws.readyState === WebSocket.OPEN); ev(ok ? "ws.connect.ok" : "ws.connect.fail", { dt: fmtMs(t0() - t_start) }); return ok; } function addTask(fn) { queue.push(fn); next(); } function next() { if (!queue.length) return; const max = effectiveMaxConcurrency(); if (max && running >= max) return; running++; const task = queue.shift(); Promise.resolve(task()) .catch(error) .finally(() => { running--; next(); }); } function failJobImmediately(tabId, jobId, imgUrl, message, frameId = 0) { if (tabId) { sendToTab( tabId, { type: "IMAGE_ERROR", original: imgUrl, message }, frameId, ); } } function failAllPending(message) { const jobIds = Array.from(pendingByJob.keys()); for (const jobId of jobIds) { const ctx = pendingByJob.get(jobId); const batchId = String( ctx?.batchId || ctx?.metadata?.batch_id || "", ).trim(); const imageKey = String( ctx?.imageKey || ctx?.metadata?.image_id || "", ).trim(); const batch = batchId ? ensureBatch(batchId, ctx?.tabId || 0, ctx?.frameId || 0) : null; failJobImmediately( ctx?.tabId, jobId, ctx?.imgUrl || null, message, ctx?.frameId || 0, ); pendingByJob.delete(jobId); ev("job.done", { id: jobId, dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())), }); if (batch && imageKey) { batchMark(batchId, imageKey, { status: "aborted", lastError: message }); batchUpdateToast(batch, "ยกเลิก"); batchFinalizeIfComplete(batch); } } pendingByImage.clear(); } function handleJobError(jobId, errMsg = "Unknown error") { const cls = classifyJobError(errMsg); const ctx = pendingByJob.get(jobId); const curSess = ctx?.tabId ? getTabSessionId(ctx.tabId) : ""; const isStale = Boolean( ctx?.sessionId && curSess && ctx.sessionId !== curSess, ); const batchId = String(ctx?.batchId || ctx?.metadata?.batch_id || "").trim(); const imageKey = String( ctx?.imageKey || ctx?.metadata?.image_id || "", ).trim(); const batch = batchId ? ensureBatch(batchId, ctx?.tabId || 0, ctx?.frameId || 0) : null; if (ctx?.tabId && !isStale) sendToTab( ctx.tabId, { type: "IMAGE_ERROR", original: ctx.imgUrl, message: errMsg }, ctx.frameId || 0, ); pendingByJob.delete(jobId); ev("job.done", { id: jobId, dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())), }); if (ctx?.metadata?.image_id) pendingByImage.delete(ctx.metadata.image_id); if (batch && imageKey) { batchMark(batchId, imageKey, { status: "error", lastError: errMsg, permanent: !!cls?.permanent, }); batchUpdateToast(batch, cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด"); batchFinalizeIfComplete(batch); } } async function handleResult(jobId, result) { let ctx = pendingByJob.get(jobId); if (!ctx && result?.metadata?.image_id) { const mapped = pendingByImage.get(result.metadata.image_id); ctx = typeof mapped === "string" ? pendingByJob.get(mapped) : mapped; } if (!ctx) { evWarn("job.ctx.missing", { id: jobId, metaImageId: String(result?.metadata?.image_id || ""), }); return; } const { imgUrl, tabId } = ctx; const frameId = ctx.frameId || 0; const mode = ctx.mode || ctx.metadata?.mode || null; const batchId = String(ctx?.batchId || ctx?.metadata?.batch_id || "").trim(); const imageKey = String( ctx?.imageKey || ctx?.metadata?.image_id || result?.metadata?.image_id || "", ).trim(); const batch = batchId ? ensureBatch(batchId, tabId, frameId) : null; ev("job.result.recv", { id: jobId, tabId, frameId, keys: Object.keys(result || {}), }); const curSess = getTabSessionId(tabId); const isSettingsStale = typeof ctx?.settingsEpoch === "number" && ctx.settingsEpoch !== settingsEpoch; const isStale = Boolean(ctx?.sessionId && curSess && ctx.sessionId !== curSess) || isSettingsStale; const newImg = result?.imageDataUri || result?.imageDataURI || result?.image || result?.imageUrl || result?.image_url || result?.imageURL || null; const aiHtml = result?.Ai?.aihtml || result?.ai?.aihtml || null; const translatedHtml = result?.translated?.translatedhtml || result?.translatedhtml || null; const originalHtml = result?.original?.originalhtml || result?.originalhtml || null; const hasHtml = !!(aiHtml || translatedHtml || originalHtml); const mdKey = mdKeyFromUrl(imgUrl); const lang = ctx.lang || ctx.metadata?.lang || null; const cacheKey = mdCacheKey(mdKey, lang, mode); if (cacheKey && (newImg || hasHtml)) { pruneMdCache(); const prev = mdCacheByKey.get(cacheKey) || {}; mdCacheByKey.set(cacheKey, { newImg: newImg || prev.newImg || null, result: hasHtml ? stripImageFields(result) : prev.result || null, ts: Date.now(), }); } if (isStale) { pendingByJob.delete(jobId); if (result?.metadata?.image_id) pendingByImage.delete(result.metadata.image_id); ev("job.done", { id: jobId, dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())), }); if (batch && imageKey) { if (ctx?.keepCacheOnStale) { batchMark(batchId, imageKey, { status: "done", cachedOnly: true }); batchUpdateToast(batch, "บันทึกแล้ว", true); batchFinalizeIfComplete(batch); } else { batchMark(batchId, imageKey, { status: "aborted" }); batchUpdateToast(batch, "ยกเลิก", true); batchFinalizeIfComplete(batch); } } return; } if (batch && imageKey) { batchMark(batchId, imageKey, { status: "inserting" }); batchUpdateToast(batch, "แทรกกลับ"); } let replaceResp = null; if (newImg && mode !== "lens_text") { ev("job.result.image", { id: jobId }); replaceResp = await requestFromTabEnsured( tabId, { type: "REPLACE_IMAGE", original: imgUrl, newSrc: newImg }, frameId, ); } let overlayResp = null; if (hasHtml) { ev("job.result.html", { id: jobId }); overlayResp = await requestFromTabEnsured( tabId, { type: "OVERLAY_HTML", original: imgUrl, result, mode: mode || "", source: ctx?.source || "", }, frameId, ); } let ok = true; let errMsg = ""; if (!hasHtml && !(newImg && mode !== "lens_text")) ok = false; if (newImg && mode !== "lens_text" && !replaceResp?.ok) { ok = false; errMsg = "DOM replace failed"; } if (hasHtml && !overlayResp?.ok) { ok = false; errMsg = "Overlay insert failed"; } if (!hasHtml) { const keys = Object.keys(result || {}); if (!newImg) { evWarn("job.result.none", { id: jobId, keys }); await requestFromTabEnsured( tabId, { type: "IMAGE_ERROR", original: imgUrl, message: "API returned no overlay data", }, frameId, ); ok = false; errMsg = "API returned no overlay data"; } else { ev("job.result.imageOnly", { id: jobId, mode: mode || "unknown" }); } } pendingByJob.delete(jobId); ev("job.done", { id: jobId, dt: fmtMs(Date.now() - (ctx?.startedAt || Date.now())), }); if (result?.metadata?.image_id) pendingByImage.delete(result.metadata.image_id); if (batch && imageKey) { if (ok) { batchMark(batchId, imageKey, { status: "done" }); batchUpdateToast(batch, "เสร็จ 1 ภาพ"); } else { const cls = classifyJobError(errMsg); batchMark(batchId, imageKey, { status: "error", lastError: errMsg || "Unknown error", permanent: !!cls?.permanent, }); batchUpdateToast(batch, cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด"); } batchFinalizeIfComplete(batch); } } async function processJob(payload, tabId, frameId = 0) { ev("job.enqueue", { type: payload?.type, menu: payload?.menu, src: payload?.src ? "url" : "none", }); if (!payload || typeof payload !== "object") return; if (!payload.metadata || typeof payload.metadata !== "object") payload.metadata = {}; const batchId = String( payload.metadata.batch_id || currentBatchId || "", ).trim(); if (batchId) payload.metadata.batch_id = batchId; const imageKey = imageKeyFromPayload(payload); const batch = batchId ? ensureBatch(batchId, tabId, frameId) : null; const pageUrl = payload?.context?.page_url || ""; const isMd = isMangaDexPageUrl(pageUrl); const originSession = String( payload?.context?.tp_tab_session || payload?.metadata?.tp_tab_session || "", ).trim(); const curSession = getTabSessionId(tabId); if (originSession && curSession && originSession !== curSession && !isMd) { if (batch && imageKey) { batchMark(batchId, imageKey, { status: "aborted", lastError: "navigation", }); batchUpdateToast(batch, "ยกเลิก"); batchFinalizeIfComplete(batch); batchStopKeepAlive(batch); } return; } if (batch && imageKey) { const it = batch.items.get(imageKey); if (it) batch.items.set(imageKey, { ...it, status: "processing" }); batchUpdateToast(batch, "ประมวลผล"); } const base = await getApiBase(); const preferRest = shouldPreferRest(base, payload?.mode, payload?.source); const src = String(payload?.src || "").trim(); const shouldPrefetchDataUri = !payload?.imageDataUri && Boolean(src) && (/^(?:https?:|blob:|data:|file:|chrome-extension:)/i.test(src) || (payload?.mode === "lens_text" && String(payload?.source || "").toLowerCase() === "ai")); if (shouldPrefetchDataUri) { const key = normImgSrc(src); pruneMdDataUriCache(); const cached = key ? mdDataUriCache.get(key) : null; if ( cached?.du && Date.now() - (cached.ts || 0) <= MD_DATAURI_CACHE_TTL_MS ) { payload.imageDataUri = cached.du; ev("image.datauri.cache.hit", { size: payload.imageDataUri.length }); } else { try { const du = src.startsWith("data:") ? src : await fetchImageDataUriFromUrl(src, pageUrl || ""); if (du) { payload.imageDataUri = du; if (key) mdDataUriCache.set(key, { du, ts: Date.now() }); const meta = payload.metadata && typeof payload.metadata === "object" ? payload.metadata : (payload.metadata = {}); const pipe = Array.isArray(meta.pipeline) ? meta.pipeline : []; meta.pipeline = pipe.concat({ stage: "prefetch_datauri", at: new Date().toISOString(), }); meta.timestamp = new Date().toISOString(); if (batch && imageKey) batchMark(batchId, imageKey, { payload }); ev("image.datauri.prefetch.ok", { size: du.length }); } } catch (e) { const msg = e?.message || String(e); const cls = classifyJobError(msg); evWarn("image.datauri.prefetch.fail", { err: msg, permanent: !!cls?.permanent, }); if (cls?.permanent) { if (payload?.metadata?.image_id) pendingByImage.delete(payload.metadata.image_id); if (batch && imageKey) { batchMark(batchId, imageKey, { status: "error", lastError: msg, permanent: true, }); batchUpdateToast(batch, "ผิดพลาด (ถาวร)"); batchFinalizeIfComplete(batch); } const jobId = crypto.randomUUID(); failJobImmediately(tabId, jobId, payload?.src || null, msg, frameId); return; } } } } if (blockSendsBecauseWsEnded && !preferRest) { const msg = "Connection closed. Please run the menu again."; if (batch && imageKey) { batchMark(batchId, imageKey, { status: "aborted", lastError: msg }); batchUpdateToast(batch, "ยกเลิก"); batchFinalizeIfComplete(batch); } const jobId = crypto.randomUUID(); failJobImmediately(tabId, jobId, payload?.src || null, msg, frameId); return; } const sessionId = getTabSessionId(tabId) || bumpTabSession(tabId, pageUrl); if (payload?.metadata?.image_id) { pendingByImage.set(payload.metadata.image_id, { imgUrl: payload.src, tabId, frameId, mode: payload?.mode || null, lang: payload?.lang || null, source: payload?.source || null, metadata: payload.metadata, imageKey, batchId, pageUrl, sessionId: originSession || sessionId, keepCacheOnStale: isMd, settingsEpoch, }); } if (preferRest) { let jid = ""; try { jid = await submitJobViaRest(base, payload); pendingByJob.set(jid, { imgUrl: payload.src, tabId, frameId, mode: payload?.mode || null, lang: payload?.lang || null, source: payload?.source || null, metadata: payload.metadata, startedAt: Date.now(), batchId, imageKey, pageUrl, sessionId: originSession || sessionId, keepCacheOnStale: isMd, settingsEpoch, }); await pollJobViaRest(base, jid); return; } catch (e) { const msg = e?.message || String(e); if (jid) { handleJobError(jid, msg); return; } if (payload?.metadata?.image_id) pendingByImage.delete(payload.metadata.image_id); if (batch && imageKey) { const cls = classifyJobError(msg); batchMark(batchId, imageKey, { status: "error", lastError: msg, permanent: !!cls?.permanent, }); batchUpdateToast(batch, cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด"); batchFinalizeIfComplete(batch); } const jobId = crypto.randomUUID(); failJobImmediately(tabId, jobId, payload?.src || null, msg, frameId); return; } } for (let attempt = 0; attempt <= MAX_FIRST_TRY_RETRIES; attempt++) { if (!wsReady || !ws || ws.readyState !== WebSocket.OPEN) { const connected = await connectWebSocketOnce(); if (!connected) { if (attempt < MAX_FIRST_TRY_RETRIES) { await new Promise((r) => setTimeout(r, FIRST_TRY_GAP_MS)); continue; } else { let jid = ""; try { jid = await submitJobViaRest(base, payload); pendingByJob.set(jid, { imgUrl: payload.src, tabId, frameId, mode: payload?.mode || null, lang: payload?.lang || null, source: payload?.source || null, metadata: payload.metadata, startedAt: Date.now(), batchId, imageKey, pageUrl, sessionId: originSession || sessionId, keepCacheOnStale: isMd, settingsEpoch, }); await pollJobViaRest(base, jid); return; } catch (e) { const msg = e?.message || String(e); if (jid) { handleJobError(jid, msg); return; } if (payload?.metadata?.image_id) pendingByImage.delete(payload.metadata.image_id); if (batch && imageKey) { const cls = classifyJobError(msg); batchMark(batchId, imageKey, { status: "error", lastError: msg, permanent: !!cls?.permanent, }); batchUpdateToast( batch, cls?.permanent ? "ผิดพลาด (ถาวร)" : "ผิดพลาด", ); batchFinalizeIfComplete(batch); } const jobId = crypto.randomUUID(); failJobImmediately( tabId, jobId, payload?.src || null, msg, frameId, ); return; } } } } const jobId = crypto.randomUUID(); pendingByJob.set(jobId, { imgUrl: payload.src, tabId, frameId, mode: payload?.mode || null, lang: payload?.lang || null, source: payload?.source || null, metadata: payload.metadata, startedAt: Date.now(), batchId, imageKey, pageUrl, sessionId: originSession || sessionId, keepCacheOnStale: isMd, settingsEpoch, }); try { ev("job.send", { id: jobId }); ws.send(JSON.stringify({ type: "job", id: jobId, payload })); return; } catch (e) { handleJobError(jobId, "Send failed: " + (e?.message || e)); if (attempt < MAX_FIRST_TRY_RETRIES) { await new Promise((r) => setTimeout(r, FIRST_TRY_GAP_MS)); continue; } else { return; } } } } const enqueue = (payload, tabId, frameId = 0) => { const expected = String( payload?.context?.tp_tab_session || payload?.metadata?.tp_tab_session || "", ).trim(); addTask(() => { const cur = getTabSessionId(tabId); if (expected && (!cur || expected !== cur)) return; return processJob(payload, tabId, frameId); }); }; function recreateMenus() { chrome.contextMenus.removeAll(() => { chrome.contextMenus.create({ id: "img_one", title: "🔍 Translate this image", contexts: ["image"], }); chrome.contextMenus.create({ id: "img_all", title: "🔍 Translate all images on page", contexts: ["page", "selection"], }); }); } async function getSettings() { return new Promise((res) => { chrome.storage.local.get( [ "mode", "lang", "sources", "maxConcurrency", "aiKey", "aiModel", "aiPromptByLang", "aiPrompt", ], (it) => { MAX_CONCURRENCY = Number(it.maxConcurrency) >= 0 ? Number(it.maxConcurrency) : 0; const lang = typeof it.lang === "string" ? it.lang : "en"; const aiModel = normalizeAiModel( typeof it.aiModel === "string" ? it.aiModel : "auto", ); const rawMap = it.aiPromptByLang && typeof it.aiPromptByLang === "object" ? it.aiPromptByLang : {}; const mig = migratePromptMap(rawMap); const map = mig.map; const key = makePromptKey(lang, aiModel); const autoKey = makePromptKey(lang, "auto"); let changed = mig.changed; let aiPrompt = Object.prototype.hasOwnProperty.call(map, key) ? String(map[key] || "") : ""; if (!aiPrompt && Object.prototype.hasOwnProperty.call(map, autoKey)) { aiPrompt = String(map[autoKey] || ""); if (key !== autoKey) { map[key] = aiPrompt; changed = true; } } if (!aiPrompt) { const legacy = typeof it.aiPrompt === "string" ? it.aiPrompt : ""; if (legacy) { aiPrompt = legacy; map[key] = legacy; changed = true; } } aiPrompt = normalizePrompt(aiPrompt); if (changed) { map[key] = aiPrompt; chrome.storage.local.set({ aiPromptByLang: map, aiPrompt: "" }); } res({ mode: typeof it.mode === "string" ? it.mode : "lens_images", lang, sources: typeof it.sources === "string" ? it.sources : "translated", aiKey: typeof it.aiKey === "string" ? it.aiKey : "", aiModel, aiPrompt, }); }, ); }); } chrome.contextMenus.onClicked.addListener(async (menuInfo, tab) => { ev("menu.click", { id: menuInfo.menuItemId }); try { if (tab?.id) await ensureContentScript(tab.id); const tabSessionId = tab?.id ? ensureTabSession(tab.id, tab?.url || "") : ""; const { mode, lang, sources, aiKey, aiModel, aiPrompt } = await getSettings(); const source = mode === "lens_text" ? sources || "translated" : "translated"; const aiPayload = mode === "lens_text" && source === "ai" ? { api_key: aiKey || "", model: aiModel || "auto", prompt: aiPrompt || "", } : null; forceSoftMaxConcurrency = mode === "lens_text" && source === "ai"; softMaxConcurrency = forceSoftMaxConcurrency ? aiSoftMaxConcurrencyFromKey(aiKey) : SOFT_MAX_CONCURRENCY_DEFAULT; ev("batch.concurrency", { soft: softMaxConcurrency, max: MAX_CONCURRENCY || "unlimited", }); currentBatchId = crypto.randomUUID(); blockSendsBecauseWsEnded = false; const base = await getApiBase(); const preferRest = shouldPreferRest(base, mode, source); if (!preferRest) { const connected = await connectWebSocketOnce(); if (!connected) { evWarn("ws.unavailable.fallback_rest"); } } let originalUrl = menuInfo.srcUrl; const frameId = Number(menuInfo.frameId) || 0; const scanFrameId = 0; sendToastToTab( tab?.id, menuInfo.menuItemId === "img_all" ? scanFrameId : frameId, menuInfo.menuItemId === "img_all" ? "TextPhantom: กำลังรับภาพ…" : "TextPhantom: กำลังประมวลผล…", 60000, ); if ( tab?.url?.includes("mangadex.org") && originalUrl?.startsWith("blob:") ) { try { const resp = await requestFromTab( tab.id, { type: "RESOLVE_AND_REPLACE_MANGADEX_BLOB", blobUrl: originalUrl }, frameId, ); if (resp?.resolved) originalUrl = resp.resolved; } catch (e) { warn("resolve MangaDex blob error", e); } } if (menuInfo.menuItemId === "img_one" && tab?.id) { try { await sendToTab( tab.id, { type: "TP_KEEPALIVE_START", ms: 10 * 60 * 1000 }, frameId, ); } catch {} let payload = null; const wantsContextPayload = !originalUrl || /^(?:blob:|data:|file:|chrome-extension:)/i.test(String(originalUrl || "")); if (wantsContextPayload) { try { const resp = await requestFromTab( tab.id, { type: "GET_CONTEXT_IMAGE_PAYLOAD" }, frameId, ); if (resp?.ok && resp?.payload) payload = resp.payload; } catch (e) { evWarn("context.payload.fail", { err: e?.message || String(e) }); } } const metadata0 = payload?.metadata && typeof payload.metadata === "object" ? payload.metadata : {}; const pipeline0 = Array.isArray(metadata0.pipeline) ? metadata0.pipeline : []; const imageId = String(metadata0.image_id || "").trim() || crypto.randomUUID(); const sourceUrl = payload?.src || originalUrl || null; payload = { ...(payload && typeof payload === "object" ? payload : {}), mode, lang, type: "image", src: sourceUrl, imageDataUri: typeof payload?.imageDataUri === "string" && payload.imageDataUri ? payload.imageDataUri : null, menu: "img_one", source, ai: aiPayload, context: { ...(payload?.context && typeof payload.context === "object" ? payload.context : {}), page_url: tab?.url || null, timestamp: new Date().toISOString(), tp_tab_session: tabSessionId, }, metadata: { ...metadata0, image_id: imageId, batch_id: currentBatchId, original_image_url: sourceUrl, position: metadata0.position || null, ocr_image: null, extra: null, pipeline: pipeline0.concat({ stage: "context_menu_single", at: new Date().toISOString(), }), timestamp: new Date().toISOString(), }, }; if ( !payload.imageDataUri && String(sourceUrl || "").startsWith("blob:") ) { try { const du = await fetchImageDataUriFromUrl(sourceUrl, tab?.url || null); if (du) payload.imageDataUri = du; ev("image.datauri.ok", { size: du ? du.length : 0 }); } catch (e) { evWarn("image.datauri.fail", { err: e?.message || String(e) }); } } if (!payload.src && !payload.imageDataUri) return; const b = ensureBatch(currentBatchId, tab.id, frameId); b.total1 = 1; const k = imageKeyFromPayload(payload); if (k) b.items.set(k, { payload, attempt: 1, status: "queued", lastError: "", }); batchUpdateToast(b, "รับภาพ", true); enqueue(payload, tab.id, frameId); return; } if (menuInfo.menuItemId === "img_all" && tab?.id) { try { await sendToTab( tab.id, { type: "TP_KEEPALIVE_START", ms: 10 * 60 * 1000 }, scanFrameId, ); } catch {} let images = []; let imagesFrameId = scanFrameId; try { images = await requestFromTab( tab.id, { type: "GET_IMAGES" }, scanFrameId, ); if ((!images || !images.length) && frameId) { const alt = await requestFromTab( tab.id, { type: "GET_IMAGES" }, frameId, ); if (Array.isArray(alt) && alt.length) { images = alt; imagesFrameId = frameId; } } } catch (e) { error("GET_IMAGES failed", e); } let payloads = (Array.isArray(images) ? images : []) .map((meta) => { const m = meta?.metadata || {}; const imageId = m.image_id || crypto.randomUUID(); const src = m.original_image_url || meta.src || ""; return { mode, lang, type: "image", src: src || null, imageDataUri: typeof meta?.imageDataUri === "string" && meta.imageDataUri ? meta.imageDataUri : typeof m.imageDataUri === "string" && m.imageDataUri ? m.imageDataUri : null, menu: "img_all", source, ai: aiPayload, context: { page_url: tab?.url || null, timestamp: new Date().toISOString(), tp_tab_session: tabSessionId, }, metadata: { image_id: imageId, batch_id: currentBatchId, original_image_url: src || null, position: m.position || null, ocr_image: null, extra: null, pipeline: (Array.isArray(m.pipeline) ? m.pipeline : []).concat({ stage: "context_menu_all", at: new Date().toISOString(), }), timestamp: new Date().toISOString(), }, }; }) .filter((p) => !!(p.src || p.imageDataUri)); const seen = new Set(); const uniq = []; for (const pl of payloads) { const k = imageKeyFromPayload(pl); if (!k || seen.has(k)) continue; seen.add(k); uniq.push(pl); } payloads = uniq; const b = ensureBatch(currentBatchId, tab.id, imagesFrameId); b.total1 = payloads.length; for (const pl of payloads) { const k = imageKeyFromPayload(pl); if (!k || b.items.has(k)) continue; b.items.set(k, { payload: pl, attempt: 1, status: "queued", lastError: "", }); } batchUpdateToast(b, "รับภาพ", true); payloads.forEach((p) => enqueue(p, tab.id, imagesFrameId)); return; } } catch (e) { error("[menu] handler error", e); } }); chrome.runtime.onConnect.addListener((port) => { if (!port || port.name !== KEEPALIVE_PORT_NAME) return; keepAlivePorts.add(port); const tabId = port.sender?.tab?.id; const frameId = port.sender?.frameId; try { port.onMessage.addListener(() => void 0); port.onDisconnect.addListener(() => { keepAlivePorts.delete(port); if (!Number.isFinite(tabId)) return; if (Number.isFinite(frameId) && frameId !== 0) return; bumpTabSession(tabId, ""); cancelTabWork(tabId, "page_unloaded"); }); } catch { keepAlivePorts.delete(port); } }); chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { if (!Number.isFinite(tabId)) return; if (changeInfo.status !== "loading") return; const href = changeInfo.url || tab?.url || ""; if (isMangaDexPageUrl(href)) return; bumpTabSession(tabId, href); cancelTabWork(tabId, "navigation"); }); chrome.tabs.onRemoved.addListener((tabId) => { cancelTabWork(tabId, "tab_closed"); tabSessionById.delete(tabId); }); chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const t = String(msg?.type || ""); if (t === "GET_WS_STATUS") { sendResponse({ status: wsStatus, ready: wsReady }); return true; } if (t === "GET_BATCH_STATUS") { sendResponse({ ok: true, batch: lastBatchStatus }); return true; } if (t === "GET_API_STATUS") { sendResponse({ ok: healthCache.ok, ts: healthCache.ts, build: healthCache.build, }); return true; } if (t === "API_URL_CHANGED") { try { ws?.close(1000, "api_url_changed"); } catch {} ws = null; wsReady = false; wsPromise = null; wsStatus = "disconnected"; blockSendsBecauseWsEnded = false; healthCache.ts = 0; connectWebSocketOnce().catch(() => {}); getApiBase() .then((b) => warmupApi(b)) .catch(() => {}); sendResponse({ ok: true }); return true; } if (t === "TP_CONTENT_READY") { const tabId = sender?.tab?.id; if (msg?.top && Number.isFinite(tabId)) { ensureTabSession(tabId, msg?.href); } ev("content.ready", { tabId: sender?.tab?.id || 0, frameId: sender?.frameId || 0, href: String(msg?.href || ""), ver: String(msg?.ver || ""), top: Boolean(msg?.top), }); sendResponse({ ok: true }); return true; } if (t === "TP_LOCATION_CHANGED") { const tabId = sender?.tab?.id; if (msg?.top && Number.isFinite(tabId)) { ensureTabSession(tabId, msg?.href); } sendResponse({ ok: true }); return true; } if (t === "TP_MD_CACHE_GET") { pruneMdCache(); const includeNewImg = Boolean(msg?.includeNewImg); const lang = typeof msg?.lang === "string" ? msg.lang : ""; const mode = typeof msg?.mode === "string" ? msg.mode : ""; if (!lang || !mode) { sendResponse({ items: {} }); return true; } const rawKeys = Array.isArray(msg?.keys) ? msg.keys : []; const keys = rawKeys.slice(0, includeNewImg ? 6 : 600); const items = {}; for (const k of keys) { const mdKey = String(k || ""); if (!mdKey) continue; const cacheKey = mdCacheKey(mdKey, lang, mode); if (!cacheKey) continue; const rec = mdCacheByKey.get(cacheKey); if (!rec) continue; const derivedNewImg = rec.newImg || rec?.result?.imageDataUri || rec?.result?.imageDataURI || rec?.result?.image || rec?.result?.imageUrl || rec?.result?.image_url || rec?.result?.imageURL || null; const hasNewImg = Boolean(derivedNewImg); items[mdKey] = { hasNewImg, result: stripImageFields(rec.result), ...(includeNewImg ? { newImg: derivedNewImg } : {}), }; } sendResponse({ items }); return true; } if (t === "TP_LOG") { const lvl = String(msg?.level || "info"); const data = msg?.data || {}; const line = "[content] " + String(msg?.msg || ""); if (lvl === "error") error(line, data); else if (lvl === "warn") warn(line, data); else info(line, data); sendResponse({ ok: true }); return true; } if (t === "CANCEL_BATCH") { try { const bid = String(msg.batchId || ""); if (bid && typeof pendingByJob?.keys === "function") { for (const [jid, rec] of Array.from(pendingByJob.entries())) { if (rec?.batchId === bid) pendingByJob.delete(jid); } } sendResponse({ success: true }); } catch (e) { sendResponse({ success: false, error: String((e && e.message) || e) }); } return true; } if (t === "fetchImageBlob") { (async () => { try { const u = String(msg.url || "").trim(); const res = await fetch(u, { credentials: "include", redirect: "follow", referrer: msg.pageUrl || "about:client", }); if (!res.ok) throw new Error("HTTP " + res.status); const blob = await res.blob(); const dataUrl = await blobToDataUri(blob); const comma = dataUrl.indexOf(","); sendResponse({ success: true, blobData: comma >= 0 ? dataUrl.slice(comma + 1) : "", mimeType: blob.type || "application/octet-stream", }); } catch (e) { warn("fetchImageBlob failed", e); sendResponse({ success: false, error: e && e.message ? e.message : String(e), }); } })(); return true; } return false; }); chrome.runtime.onInstalled.addListener(() => { recreateMenus(); getApiBase() .then((b) => warmupApi(b)) .catch(() => {}); }); chrome.runtime.onStartup?.addListener(() => { recreateMenus(); getApiBase() .then((b) => warmupApi(b)) .catch(() => {}); }); chrome.storage.local.get({ maxConcurrency: 0 }, ({ maxConcurrency }) => { MAX_CONCURRENCY = Number(maxConcurrency) >= 0 ? Number(maxConcurrency) : 0; const eff = effectiveMaxConcurrency(); info( "MAX_CONCURRENCY =", MAX_CONCURRENCY || "unlimited", "soft =", softMaxConcurrency, "forceSoft =", forceSoftMaxConcurrency, "effective =", eff || "unlimited", ); });