From dde8406ec831b42b3162d272267f0db1540ecf06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:41:02 +0000 Subject: [PATCH 1/2] Update AI Overhaul plugin for 0.9.0 --- plugins/AIOverhaul/AIButton.js | 898 +++++++ plugins/AIOverhaul/AIButtonIntegration.js | 110 + plugins/AIOverhaul/AIOverhaul.yml | 44 + plugins/AIOverhaul/BackendBase.js | 228 ++ plugins/AIOverhaul/BackendHealth.js | 200 ++ plugins/AIOverhaul/InteractionTracker.js | 1867 +++++++++++++ plugins/AIOverhaul/PageContext.js | 308 +++ plugins/AIOverhaul/PluginSettings.js | 2484 ++++++++++++++++++ plugins/AIOverhaul/RecommendationUtils.js | 710 +++++ plugins/AIOverhaul/RecommendedScenes.js | 1863 +++++++++++++ plugins/AIOverhaul/SimilarScenes.js | 785 ++++++ plugins/AIOverhaul/SimilarTabIntegration.js | 161 ++ plugins/AIOverhaul/TaskDashboard.js | 347 +++ plugins/AIOverhaul/VersionInfo.js | 177 ++ plugins/AIOverhaul/css/AIOverhaul.css | 339 +++ plugins/AIOverhaul/css/SimilarScenes.css | 83 + plugins/AIOverhaul/css/recommendedscenes.css | 257 ++ plugins/AIOverhaul/plugin_setup.py | 386 +++ 18 files changed, 11247 insertions(+) create mode 100644 plugins/AIOverhaul/AIButton.js create mode 100644 plugins/AIOverhaul/AIButtonIntegration.js create mode 100644 plugins/AIOverhaul/AIOverhaul.yml create mode 100644 plugins/AIOverhaul/BackendBase.js create mode 100644 plugins/AIOverhaul/BackendHealth.js create mode 100644 plugins/AIOverhaul/InteractionTracker.js create mode 100644 plugins/AIOverhaul/PageContext.js create mode 100644 plugins/AIOverhaul/PluginSettings.js create mode 100644 plugins/AIOverhaul/RecommendationUtils.js create mode 100644 plugins/AIOverhaul/RecommendedScenes.js create mode 100644 plugins/AIOverhaul/SimilarScenes.js create mode 100644 plugins/AIOverhaul/SimilarTabIntegration.js create mode 100644 plugins/AIOverhaul/TaskDashboard.js create mode 100644 plugins/AIOverhaul/VersionInfo.js create mode 100644 plugins/AIOverhaul/css/AIOverhaul.css create mode 100644 plugins/AIOverhaul/css/SimilarScenes.css create mode 100644 plugins/AIOverhaul/css/recommendedscenes.css create mode 100644 plugins/AIOverhaul/plugin_setup.py diff --git a/plugins/AIOverhaul/AIButton.js b/plugins/AIOverhaul/AIButton.js new file mode 100644 index 00000000..8fe148c0 --- /dev/null +++ b/plugins/AIOverhaul/AIButton.js @@ -0,0 +1,898 @@ +(function(){ +// AIButton (MinimalAIButton) +// Contract: +// - Provides a single floating/contextual button that lists available AI actions for current page context. +// - No polling: actions fetched on open + context change; task progress via shared websocket + global cache. +// - Supports multiple concurrent parent/controller tasks; shows aggregate count or single progress ring. +// - Exposes global aliases: window.AIButton & window.MinimalAIButton for integrations to mount. +// - Debug logging gated by window.AIDebug = true. +// - Assumes backend REST under /api/v1 and websocket under /api/v1/ws/tasks (with legacy fallback /ws/tasks). +// - Only parent/controller task IDs are tracked in activeTasks; child task events still drive progress inference. +const showFullDetailsModal = (payload, type = "success") => { + const modalId = `ai-details-modal-${Date.now()}`; + const overlay = document.createElement("div"); + overlay.id = modalId; + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 20000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease-out; + `; + const modal = document.createElement("div"); + modal.style.cssText = ` + background: #1a1a1a; + border: 1px solid ${type === "success" ? "rgba(72, 180, 97, 0.3)" : "rgba(220, 53, 69, 0.3)"}; + border-radius: 8px; + padding: 24px; + max-width: 80vw; + max-height: 80vh; + overflow: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + animation: slideUp 0.3s ease-out; + `; + const header = document.createElement("div"); + header.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + `; + const title = document.createElement("h3"); + title.textContent = "Full Details"; + title.style.cssText = ` + margin: 0; + color: ${type === "success" ? "#d4edda" : "#f8d7da"}; + font-size: 18px; + font-weight: 600; + `; + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.style.cssText = ` + background: transparent; + border: none; + color: ${type === "success" ? "#d4edda" : "#f8d7da"}; + font-size: 28px; + font-weight: bold; + line-height: 1; + padding: 0; + width: 32px; + height: 32px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; + transition: opacity 0.2s; + `; + closeButton.onmouseenter = () => { + closeButton.style.opacity = "1"; + }; + closeButton.onmouseleave = () => { + closeButton.style.opacity = "0.8"; + }; + const content = document.createElement("pre"); + content.style.cssText = ` + margin: 0; + color: #e0e0e0; + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-wrap: break-word; + font-family: 'Courier New', monospace; + background: rgba(0, 0, 0, 0.3); + padding: 16px; + border-radius: 4px; + overflow-x: auto; + `; + content.textContent = JSON.stringify(payload, null, 2); + const closeModal = () => { + overlay.style.animation = "fadeOut 0.2s ease-out"; + modal.style.animation = "slideDown 0.3s ease-out"; + setTimeout(() => { + if (overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + }, 300); + }; + closeButton.onclick = closeModal; + overlay.onclick = (e) => { + if (e.target === overlay) + closeModal(); + }; + header.appendChild(title); + header.appendChild(closeButton); + modal.appendChild(header); + modal.appendChild(content); + overlay.appendChild(modal); + // Add modal animations if not already present + if (!document.getElementById("ai-modal-styles")) { + const style = document.createElement("style"); + style.id = "ai-modal-styles"; + style.textContent = ` + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes fadeOut { + from { opacity: 1; } + to { opacity: 0; } + } + @keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + @keyframes slideDown { + from { + transform: translateY(0); + opacity: 1; + } + to { + transform: translateY(20px); + opacity: 0; + } + } + `; + document.head.appendChild(style); + } + document.body.appendChild(overlay); +}; +const showToast = (options) => { + const { message, type = "success", link, timeout, fullDetails } = options; + const toastId = `ai-toast-${Date.now()}`; + const toast = document.createElement("div"); + toast.id = toastId; + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: ${type === "success" ? "#2d5016" : "#5a1a1a"}; + color: ${type === "success" ? "#d4edda" : "#f8d7da"}; + padding: 12px 20px; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 10000; + font-size: 14px; + line-height: 1.4; + max-width: 400px; + word-wrap: break-word; + animation: slideIn 0.3s ease-out; + border: 1px solid ${type === "success" ? "rgba(72, 180, 97, 0.3)" : "rgba(220, 53, 69, 0.3)"}; + display: flex; + flex-direction: column; + gap: 8px; + `; + // Add animation keyframes if not already present + if (!document.getElementById("ai-toast-styles")) { + const style = document.createElement("style"); + style.id = "ai-toast-styles"; + style.textContent = ` + @keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + @keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } + } + `; + document.head.appendChild(style); + } + // Create dismiss button + const dismissButton = document.createElement("button"); + dismissButton.textContent = "×"; + dismissButton.style.cssText = ` + background: transparent; + border: none; + color: ${type === "success" ? "#d4edda" : "#f8d7da"}; + font-size: 20px; + font-weight: bold; + line-height: 1; + padding: 0; + width: 20px; + height: 20px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0.8; + transition: opacity 0.2s; + `; + dismissButton.onmouseenter = () => { + dismissButton.style.opacity = "1"; + }; + dismissButton.onmouseleave = () => { + dismissButton.style.opacity = "0.8"; + }; + // Create top row container (message + link + dismiss button) + const topRow = document.createElement("div"); + topRow.style.cssText = ` + display: flex; + align-items: center; + gap: 12px; + flex: 1; + `; + // Create message container + const messageContainer = document.createElement("div"); + messageContainer.style.cssText = ` + flex: 1; + word-wrap: break-word; + display: flex; + gap: 8px; + align-items: center; + `; + const messageText = document.createElement("div"); + messageText.textContent = message; + messageContainer.appendChild(messageText); + // Add link if provided + if (link) { + const linkElement = document.createElement("a"); + linkElement.href = link.url; + linkElement.textContent = link.text; + linkElement.style.cssText = ` + color: ${type === "success" ? "#90ee90" : "#ffb3b3"}; + text-decoration: underline; + cursor: pointer; + font-weight: 500; + `; + linkElement.onmouseenter = () => { + linkElement.style.opacity = "0.8"; + }; + linkElement.onmouseleave = () => { + linkElement.style.opacity = "1"; + }; + messageContainer.appendChild(linkElement); + } + topRow.appendChild(messageContainer); + topRow.appendChild(dismissButton); + // Add "show full details" button if fullDetails provided (on separate row) + if (fullDetails !== undefined) { + const detailsButton = document.createElement("button"); + detailsButton.textContent = "show full details"; + detailsButton.style.cssText = ` + background: transparent; + border: 1px solid ${type === "success" ? "#90ee90" : "#ffb3b3"}; + color: ${type === "success" ? "#90ee90" : "#ffb3b3"}; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + width: 100%; + transition: background 0.2s, opacity 0.2s; + `; + detailsButton.onmouseenter = () => { + detailsButton.style.background = type === "success" ? "rgba(144, 238, 144, 0.2)" : "rgba(255, 179, 179, 0.2)"; + }; + detailsButton.onmouseleave = () => { + detailsButton.style.background = "transparent"; + }; + detailsButton.onclick = (e) => { + e.stopPropagation(); + showFullDetailsModal(fullDetails, type); + }; + toast.appendChild(topRow); + toast.appendChild(detailsButton); + } + else { + toast.appendChild(topRow); + } + document.body.appendChild(toast); + // Dismiss function + let dismissTimeout = null; + const dismissToast = () => { + if (dismissTimeout) { + clearTimeout(dismissTimeout); + dismissTimeout = null; + } + toast.style.animation = "slideOut 0.3s ease-out"; + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 300); + }; + dismissButton.onclick = dismissToast; + // Auto-dismiss after timeout if provided + if (timeout && timeout > 0) { + dismissTimeout = window.setTimeout(() => { + dismissToast(); + }, timeout); + } +}; +// ---- Small internal helpers (pure / non-visual) ---- +const sanitizeBackendBase = (value) => { + if (typeof value !== "string") + return ""; + const trimmed = value.trim(); + if (!trimmed) + return ""; + const cleaned = trimmed.replace(/\/$/, ""); + try { + if (typeof location !== "undefined" && location.origin) { + const origin = location.origin.replace(/\/$/, ""); + if (cleaned === origin) + return ""; + } + } + catch { } + return cleaned; +}; +const getBackendBase = () => { + const fn = window.AIDefaultBackendBase; + if (typeof fn !== "function") + throw new Error("AIDefaultBackendBase not initialized. Ensure backendBase is loaded first."); + return sanitizeBackendBase(fn()); +}; +const debugEnabled = () => !!window.AIDebug; +const dlog = (...a) => { + if (debugEnabled()) + console.log("[AIButton]", ...a); +}; +const getSharedApiKey = () => { + try { + const helper = window.AISharedApiKeyHelper; + if (helper && typeof helper.get === "function") { + const value = helper.get(); + if (typeof value === "string") + return value.trim(); + } + } + catch { } + const raw = window.AI_SHARED_API_KEY; + return typeof raw === "string" ? raw.trim() : ""; +}; +const withSharedHeaders = (init) => { + const helper = window.AISharedApiKeyHelper; + if (helper && typeof helper.withHeaders === "function") { + return helper.withHeaders(init || {}); + } + const key = getSharedApiKey(); + if (!key) + return init || {}; + const next = { ...(init || {}) }; + const headers = new Headers((init === null || init === void 0 ? void 0 : init.headers) || {}); + headers.set("x-ai-api-key", key); + next.headers = headers; + return next; +}; +const appendSharedKeyQuery = (url) => { + var _a; + const helper = window.AISharedApiKeyHelper; + if (helper && typeof helper.appendQuery === "function") { + return helper.appendQuery(url); + } + const key = getSharedApiKey(); + if (!key) + return url; + const hasProto = /^https?:\/\//i.test(url) || /^wss?:\/\//i.test(url); + try { + const resolved = new URL(url, hasProto ? undefined : ((_a = window.location) === null || _a === void 0 ? void 0 : _a.origin) || undefined); + resolved.searchParams.set("api_key", key); + return resolved.toString(); + } + catch { + const sep = url.includes("?") ? "&" : "?"; + return `${url}${sep}api_key=${encodeURIComponent(key)}`; + } +}; +const parseActionsChanged = (prev, next) => { + if (!prev || prev.length !== next.length) + return true; + for (let i = 0; i < next.length; i++) { + const p = prev[i]; + const n = next[i]; + if (p.id !== n.id || p.label !== n.label || p.result_kind !== n.result_kind) + return true; + } + return false; +}; +const computeSingleProgress = (activeIds) => { + if (activeIds.length !== 1) + return null; + try { + const g = window; + const tid = activeIds[0]; + const cache = g.__AI_TASK_CACHE__ || {}; + const tasks = Object.values(cache); + const children = tasks.filter((t) => t.group_id === tid); + if (!children.length) + return 0; // show ring at 0%, matches previous UX + let done = 0, running = 0, queued = 0, failed = 0, cancelled = 0; // cancelled intentionally excluded from denominator + for (const c of children) { + switch (c.status) { + case "completed": + done++; + break; + case "running": + running++; + break; + case "queued": + queued++; + break; + case "failed": + failed++; + break; + case "cancelled": + cancelled++; + break; + } + } + const effectiveTotal = done + running + queued + failed; + if (!effectiveTotal) + return 0; + const weighted = done + failed + running * 0.5; + return Math.min(1, weighted / effectiveTotal); + } + catch { + return null; + } +}; +const ensureTaskWebSocket = (backendBase) => { + const g = window; + dlog("ensureWS invoked"); + if (g.__AI_TASK_WS__ && g.__AI_TASK_WS__.readyState === 1) + return g.__AI_TASK_WS__; + if (g.__AI_TASK_WS_INIT__) + return g.__AI_TASK_WS__; + g.__AI_TASK_WS_INIT__ = true; + const base = backendBase.replace(/^http/, "ws"); + const paths = [`${base}/api/v1/ws/tasks`, `${base}/ws/tasks`].map((candidate) => appendSharedKeyQuery(candidate)); + for (const url of paths) { + try { + dlog("Attempt WS connect", url); + const sock = new WebSocket(url); + g.__AI_TASK_WS__ = sock; + wireSocket(sock); + return sock; + } + catch (e) { + if (debugEnabled()) + console.warn("[AIButton] WS connect failed candidate", url, e); + } + } + g.__AI_TASK_WS_INIT__ = false; + return null; +}; +function wireSocket(sock) { + const g = window; + if (!g.__AI_TASK_WS_LISTENERS__) + g.__AI_TASK_WS_LISTENERS__ = {}; + if (!g.__AI_TASK_ANY_LISTENERS__) + g.__AI_TASK_ANY_LISTENERS__ = []; + if (!g.__AI_TASK_CACHE__) + g.__AI_TASK_CACHE__ = {}; + sock.onopen = () => { + dlog("WS open", sock.url); + }; + sock.onmessage = (evt) => { + var _a; + dlog("WS raw message", evt.data); + try { + const m = JSON.parse(evt.data); + const task = m.task || ((_a = m.data) === null || _a === void 0 ? void 0 : _a.task) || m.data || m; + if (!(task === null || task === void 0 ? void 0 : task.id)) { + dlog("Message without task id ignored", m); + return; + } + g.__AI_TASK_CACHE__[task.id] = task; + const ls = g.__AI_TASK_WS_LISTENERS__[task.id]; + if (ls) + ls.forEach((fn) => fn(task)); + const anyLs = g.__AI_TASK_ANY_LISTENERS__; + if (anyLs && anyLs.length) + anyLs.forEach((fn) => { + try { + fn(task); + } + catch { } + }); + } + catch (err) { + if (debugEnabled()) + console.error("[AIButton] Failed parse WS message", err); + } + }; + const cleanup = (ev) => { + if (debugEnabled()) + console.warn("[AIButton] WS closed/error", ev === null || ev === void 0 ? void 0 : ev.code, ev === null || ev === void 0 ? void 0 : ev.reason); + if (window.__AI_TASK_WS__ === sock) + window.__AI_TASK_WS__ = null; + window.__AI_TASK_WS_INIT__ = false; + }; + sock.onclose = cleanup; + sock.onerror = cleanup; +} +const MinimalAIButton = () => { + var _a, _b; + const React = ((_a = window.PluginApi) === null || _a === void 0 ? void 0 : _a.React) || window.React; + if (!React) { + console.error("[AIButton] React not found on window.PluginApi.React"); + return null; + } + const pageAPI = window.AIPageContext; + if (!pageAPI) { + console.error("[AIButton] AIPageContext missing on window"); + return null; + } + const [context, setContext] = React.useState(pageAPI.get()); + const [showTooltip, setShowTooltip] = React.useState(false); + const [openMenu, setOpenMenu] = React.useState(false); + const [loadingActions, setLoadingActions] = React.useState(false); + const [actions, setActions] = React.useState([]); + const [activeTasks, setActiveTasks] = React.useState([]); + const [recentlyFinished, setRecentlyFinished] = React.useState([]); // retained for potential future UX + const [backendBase, setBackendBase] = React.useState(() => getBackendBase()); + React.useEffect(() => { + const updateBase = (event) => { + const customEvent = event; + const detail = customEvent === null || customEvent === void 0 ? void 0 : customEvent.detail; + if (typeof detail === "string") { + setBackendBase(sanitizeBackendBase(detail)); + } + else { + setBackendBase(getBackendBase()); + } + }; + updateBase(); + window.addEventListener("AIBackendBaseUpdated", updateBase); + return () => window.removeEventListener("AIBackendBaseUpdated", updateBase); + }, []); + const actionsRef = React.useRef(null); + React.useEffect(() => pageAPI.subscribe((ctx) => setContext(ctx)), []); + const refetchActions = React.useCallback(async (ctx, opts = {}) => { + if (!backendBase) { + if (!opts.silent) + setLoadingActions(false); + setActions([]); + return; + } + if (!opts.silent) + setLoadingActions(true); + try { + const res = await fetch(`${backendBase}/api/v1/actions/available`, withSharedHeaders({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + context: { + page: ctx.page, + entityId: ctx.entityId, + isDetailView: ctx.isDetailView, + selectedIds: ctx.selectedIds || [], + visibleIds: ctx.visibleIds || [], + }, + }), + })); + if (!res.ok) + throw new Error("Failed to load actions"); + const data = await res.json(); + if (parseActionsChanged(actionsRef.current, data)) { + actionsRef.current = data; + setActions(data); + } + } + catch { + if (!opts.silent) + setActions([]); + } + finally { + if (!opts.silent) + setLoadingActions(false); + } + }, [backendBase]); + React.useEffect(() => { + refetchActions(context); + }, [context, refetchActions]); + const executeAction = React.useCallback(async (actionId) => { + var _a, _b, _c; + dlog("Execute action", actionId, "context", context); + ensureTaskWebSocket(backendBase); + try { + const g = window; + let liveContext = context; + try { + if (pageAPI.forceRefresh) + pageAPI.forceRefresh(); + if (pageAPI.get) { + liveContext = pageAPI.get(); + setContext(liveContext); + } + } + catch { + /* fall back to current state */ + } + const actionMeta = (_a = actionsRef.current) === null || _a === void 0 ? void 0 : _a.find((a) => a.id === actionId); + const resultKind = (actionMeta === null || actionMeta === void 0 ? void 0 : actionMeta.result_kind) || "none"; + const res = await fetch(`${backendBase}/api/v1/actions/submit`, withSharedHeaders({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action_id: actionId, + context: { + page: liveContext.page, + entityId: liveContext.entityId, + isDetailView: liveContext.isDetailView, + selectedIds: liveContext.selectedIds || [], + visibleIds: liveContext.visibleIds || [], + }, + params: {}, + }), + })); + if (!res.ok) { + let message = "Submit failed"; + try { + const err = await res.json(); + if (err === null || err === void 0 ? void 0 : err.detail) { + if (typeof err.detail === "string") { + message = err.detail; + } + else if (typeof ((_b = err.detail) === null || _b === void 0 ? void 0 : _b.message) === "string") { + message = err.detail.message; + } + } + } + catch { } + throw new Error(message); + } + // Close menu and show success toast after successful POST + setOpenMenu(false); + const toastMsg = `Action ${actionId} started`; + showToast({ message: toastMsg, type: "success", timeout: 1500 }); + const { task_id: taskId } = await res.json(); + if (!g.__AI_TASK_WS_LISTENERS__) + g.__AI_TASK_WS_LISTENERS__ = {}; + if (!g.__AI_TASK_WS_LISTENERS__[taskId]) + g.__AI_TASK_WS_LISTENERS__[taskId] = []; + setActiveTasks((prev) => prev.includes(taskId) ? prev : [...prev, taskId]); + const finalize = (t) => { + if (t.status === "completed") { + if (resultKind === "dialog" || resultKind === "notification") { + const result = t.result; + let message = ""; + // Check if it's a single scene result + if (result && + typeof result === "object" && + "scene_id" in result && + "tags_applied" in result) { + const singleResult = result; + const tagsCount = singleResult.tags_applied || 0; + const sceneId = singleResult.scene_id; + console.log("got single tag results", singleResult); + message = `Applied ${tagsCount} tag${tagsCount !== 1 ? "s" : ""} to scene`; + // Construct scene URL from current origin + const sceneUrl = `${window.location.origin}/scenes/${sceneId}/`; + showToast({ message, type: "success", link: { url: sceneUrl, text: "view" }, fullDetails: t.result }); + return; // Early return to avoid showing toast twice + } + // Check if it's a multiple scenes result + else if (result && + typeof result === "object" && + "scenes_completed" in result) { + const multiResult = result; + const scenesCount = multiResult.scenes_completed || 0; + const scenesFailed = multiResult.scenes_failed || 0; + console.log("got multiple tag results", multiResult); + let messageSuccessPart = `${scenesCount} scene${scenesCount !== 1 ? "s" : ""} tagged`; + let messageFailedPart = `${scenesFailed} scene${scenesFailed !== 1 ? "s" : ""} failed`; + let fullMessage = ""; + if (scenesFailed > 0 && scenesCount > 0) { + fullMessage = `${messageSuccessPart}, ${messageFailedPart}`; + } + else if (scenesFailed > 0) { + fullMessage = messageFailedPart; + } + else { + fullMessage = messageSuccessPart; + } + message = fullMessage; + // No link for multi-scene tagging (no way to construct list page from array of IDs) + showToast({ message, type: "success", fullDetails: t.result }); + return; // Early return to avoid showing toast twice + } + // Fallback for other result types + else { + message = `Action ${actionId} completed`; + } + if (message) { + showToast({ message, type: "success", fullDetails: t.result }); + } + } + } + else if (t.status === "failed") { + showToast({ + message: `Action ${actionId} failed: ${t.error || "unknown error"}. Is the nsfw_ai_model_server (usually port 8000) running?`, + type: "error", + fullDetails: { error: t.error, task: t }, + }); + } + setActiveTasks((prev) => prev.filter((id) => id !== t.id)); + setRecentlyFinished((prev) => [t.id, ...prev].slice(0, 20)); + }; + const listener = (t) => { + if (t.id !== taskId) + return; + if (["completed", "failed", "cancelled"].includes(t.status)) { + finalize(t); + g.__AI_TASK_WS_LISTENERS__[taskId] = (g.__AI_TASK_WS_LISTENERS__[taskId] || []).filter((fn) => fn !== listener); + } + }; + g.__AI_TASK_WS_LISTENERS__[taskId].push(listener); + if ((_c = g.__AI_TASK_CACHE__) === null || _c === void 0 ? void 0 : _c[taskId]) + listener(g.__AI_TASK_CACHE__[taskId]); + } + catch (e) { + setOpenMenu(false); + showToast({ + message: `Action ${actionId} failed: ${e.message}. Is the nsfw_ai_model_server (usually port 8000) running?`, + type: "error", + }); + } + }, [backendBase, context, pageAPI]); + // Any-task listener for progress updates + React.useEffect(() => { + const g = window; + if (!g.__AI_TASK_ANY_LISTENERS__) + g.__AI_TASK_ANY_LISTENERS__ = []; + const listener = (t) => { + if (!activeTasks.length) + return; + if (activeTasks.includes(t.id) || activeTasks.includes(t.group_id)) + setProgressVersion((v) => v + 1); + }; + g.__AI_TASK_ANY_LISTENERS__.push(listener); + return () => { + g.__AI_TASK_ANY_LISTENERS__ = (g.__AI_TASK_ANY_LISTENERS__ || []).filter((fn) => fn !== listener); + }; + }, [activeTasks]); + const [progressVersion, setProgressVersion] = React.useState(0); // triggers re-render on child task activity + const singleProgress = computeSingleProgress(activeTasks); + const progressPct = singleProgress != null ? Math.round(singleProgress * 100) : null; + const toggleMenu = () => { + if (!openMenu) { + let liveContext = context; + try { + if (pageAPI.forceRefresh) + pageAPI.forceRefresh(); + if (pageAPI.get) { + liveContext = pageAPI.get(); + setContext(liveContext); + } + } + catch { + /* best effort */ + } + refetchActions(liveContext, { silent: true }); + } + setOpenMenu((o) => !o); + }; + const getButtonIcon = () => { + switch (context.page) { + case "scenes": + return "🎬"; + case "galleries": + case "images": + return "🖼️"; + case "performers": + return "⭐"; + default: + return "🤖"; + } + }; + // Map page keys to more compact labels where necessary (e.g. 'performers' -> 'Actors') + const getButtonLabel = () => { + if (!context || !context.page) + return "AI"; + switch (context.page) { + case "performers": + return "Actors"; + default: + return context.page; + } + }; + const colorClass = context.isDetailView + ? "ai-btn--detail" + : `ai-btn--${context.page}`; + // Build children (unchanged structure / classes) + const elems = []; + const activeCount = activeTasks.length; + const progressRing = singleProgress != null && activeCount === 1 + ? React.createElement("div", { + key: "ring", + className: "ai-btn__progress-ring", + style: { ["--ai-progress"]: `${progressPct}%` }, + }) + : null; + elems.push(React.createElement("button", { + key: "ai-btn", + className: `ai-btn ${colorClass}` + + (singleProgress != null ? " ai-btn--progress" : ""), + onClick: toggleMenu, + onMouseEnter: () => setShowTooltip(true), + onMouseLeave: () => setShowTooltip(false), + disabled: loadingActions, + }, [ + progressRing, + React.createElement("div", { key: "icon", className: "ai-btn__icon" }, activeCount === 0 + ? getButtonIcon() + : activeCount === 1 && progressPct != null + ? `${progressPct}%` + : "⏳"), + React.createElement("div", { key: "lbl", className: "ai-btn__label" }, String(getButtonLabel() || "AI").toUpperCase()), + activeCount > 1 && + React.createElement("span", { key: "badge", className: "ai-btn__badge" }, String(activeCount)), + ])); + if (showTooltip && !openMenu) { + elems.push(React.createElement("div", { key: "tip", className: "ai-btn__tooltip" }, [ + React.createElement("div", { key: "main", className: "ai-btn__tooltip-main" }, context.contextLabel), + React.createElement("div", { key: "detail", className: "ai-btn__tooltip-detail" }, context.detailLabel || ""), + context.entityId && + React.createElement("div", { key: "id", className: "ai-btn__tooltip-id" }, `ID: ${context.entityId}`), + ((_b = context.selectedIds) === null || _b === void 0 ? void 0 : _b.length) && + React.createElement("div", { key: "sel", className: "ai-btn__tooltip-sel" }, `Selected: ${context.selectedIds.length}`), + ])); + } + if (openMenu) { + elems.push(React.createElement("div", { key: "menu", className: "ai-actions-menu" }, [ + loadingActions && + React.createElement("div", { key: "loading", className: "ai-actions-menu__status" }, "Loading actions..."), + !loadingActions && + actions.length === 0 && + React.createElement("div", { key: "none", className: "ai-actions-menu__status" }, "No actions"), + !loadingActions && + actions.map((a) => { + var _a, _b; + return React.createElement("button", { + key: a.id, + onClick: () => executeAction(a.id), + className: "ai-actions-menu__item", + }, [ + React.createElement("span", { key: "svc", className: "ai-actions-menu__svc" }, ((_b = (_a = a.service) === null || _a === void 0 ? void 0 : _a.toUpperCase) === null || _b === void 0 ? void 0 : _b.call(_a)) || a.service), + React.createElement("span", { key: "albl", style: { flexGrow: 1 } }, a.label), + a.result_kind === "dialog" && + React.createElement("span", { key: "rk", className: "ai-actions-menu__rk" }, "↗"), + ]); + }), + ])); + } + return React.createElement("div", { + className: "minimal-ai-button", + style: { position: "relative", display: "inline-block" }, + }, elems); +}; +window.MinimalAIButton = MinimalAIButton; +window.AIButton = MinimalAIButton; // alias for integrations expecting AIButton +if (!window.__AI_BUTTON_LOADED__) { + window.__AI_BUTTON_LOADED__ = true; + if (window.AIDebug) + console.log("[AIButton] Component loaded and globals registered"); +} +MinimalAIButton; +})(); + diff --git a/plugins/AIOverhaul/AIButtonIntegration.js b/plugins/AIOverhaul/AIButtonIntegration.js new file mode 100644 index 00000000..29aacdf2 --- /dev/null +++ b/plugins/AIOverhaul/AIButtonIntegration.js @@ -0,0 +1,110 @@ +(function(){ +// ============================================================================= +// Unified Integration for AI Button + Task Dashboard +// - Injects MinimalAIButton into MainNavBar.UtilityItems +// - Registers /plugins/ai-tasks route mounting TaskDashboard +// - Adds SettingsToolsSection entry linking to the dashboard +// - Adds simple "AI" nav utility link (in case button not visible) +// - All logging gated by window.AIDebug +// ============================================================================= +(function () { + var _a, _b, _c; + const g = window; + const PluginApi = g.PluginApi; + if (!PluginApi) { + console.warn('[AIIntegration] PluginApi not ready'); + return; + } + const React = PluginApi.React; + const debug = !!g.AIDebug; + const dlog = (...a) => { if (debug) + console.log('[AIIntegration]', ...a); }; + // Helper to safely get components + const Button = ((_b = (_a = PluginApi.libraries) === null || _a === void 0 ? void 0 : _a.Bootstrap) === null || _b === void 0 ? void 0 : _b.Button) || ((p) => React.createElement('button', p, p.children)); + const { Link, NavLink } = ((_c = PluginApi.libraries) === null || _c === void 0 ? void 0 : _c.ReactRouterDOM) || {}; + function getMinimalButton() { return g.MinimalAIButton || g.AIButton; } + function getTaskDashboard() { return g.TaskDashboard || g.AITaskDashboard; } + function getPluginSettings() { return g.AIPluginSettings; } + // Main nav utility items: inject AI button + nav link + try { + PluginApi.patch.before('MainNavBar.UtilityItems', function (props) { + const MinimalAIButton = getMinimalButton(); + const children = [props.children]; + if (MinimalAIButton) { + children.push(React.createElement('div', { key: 'ai-btn-wrap', style: { marginRight: 8, display: 'flex', alignItems: 'center' } }, React.createElement(MinimalAIButton))); + } + return [{ children }]; + }); + dlog('Patched MainNavBar.UtilityItems'); + } + catch (e) { + if (debug) + console.warn('[AIIntegration] main nav patch failed', e); + } + // Register dashboard route + try { + PluginApi.register.route('/plugins/ai-tasks', () => { + const Dash = getTaskDashboard(); + return Dash ? React.createElement(Dash, {}) : React.createElement('div', { style: { padding: 16 } }, 'Loading AI Tasks...'); + }); + dlog('Registered /plugins/ai-tasks route'); + } + catch (e) { + if (debug) + console.warn('[AIIntegration] route register failed', e); + } + // Register settings route (event-driven, no polling) + try { + const SettingsWrapper = () => { + const [Comp, setComp] = React.useState(() => getPluginSettings()); + React.useEffect(() => { + if (Comp) + return; // already there + const handler = () => { + const found = getPluginSettings(); + if (found) { + if (debug) + console.debug('[AIIntegration] AIPluginSettingsReady event captured'); + setComp(() => found); + } + }; + window.addEventListener('AIPluginSettingsReady', handler); + // one immediate async attempt (in case script loaded right after) + setTimeout(handler, 0); + return () => window.removeEventListener('AIPluginSettingsReady', handler); + }, [Comp]); + const C = Comp; + return C ? React.createElement(C, {}) : React.createElement('div', { style: { padding: 16 } }, 'Loading AI Overhaul Settings...'); + }; + PluginApi.register.route('/plugins/ai-settings', () => React.createElement(SettingsWrapper)); + dlog('Registered /plugins/ai-settings route (event)'); + } + catch (e) { + if (debug) + console.warn('[AIIntegration] settings route register failed', e); + } + // Settings tools entry + try { + PluginApi.patch.before('SettingsToolsSection', function (props) { + var _a; + const Setting = (_a = PluginApi.components) === null || _a === void 0 ? void 0 : _a.Setting; + if (!Setting) + return props; + return [{ children: (React.createElement(React.Fragment, null, + props.children, + React.createElement(Setting, { heading: Link ? React.createElement(Link, { to: "/plugins/ai-tasks" }, + React.createElement(Button, null, "AI Tasks")) : React.createElement(Button, { onClick: () => (location.href = '/plugins/ai-tasks') }, 'AI Tasks') }), + React.createElement(Setting, { heading: Link ? React.createElement(Link, { to: "/plugins/ai-settings" }, + React.createElement(Button, null, "AI Overhaul Settings")) : React.createElement(Button, { onClick: () => (location.href = '/plugins/ai-settings') }, 'AI Overhaul Settings') }))) }]; + }); + dlog('Patched SettingsToolsSection'); + } + catch (e) { + if (debug) + console.warn('[AIIntegration] settings tools patch failed', e); + } + if (debug) + console.log('[AIIntegration] Unified integration loaded'); +})(); +})(); + diff --git a/plugins/AIOverhaul/AIOverhaul.yml b/plugins/AIOverhaul/AIOverhaul.yml new file mode 100644 index 00000000..9e8f86d5 --- /dev/null +++ b/plugins/AIOverhaul/AIOverhaul.yml @@ -0,0 +1,44 @@ +name: AIOverhaul +description: AI Overhaul for Stash with a full plugin engine included to install and manage asynchronous stash plugins for AI or other purposes. +version: 0.9.0 +ui: + javascript: + - VersionInfo.js + - BackendBase.js + - BackendHealth.js + - PageContext.js + - RecommendationUtils.js + - AIButton.js + - TaskDashboard.js + - PluginSettings.js # ensure settings component registers before integration + - RecommendedScenes.js + - SimilarScenes.js + - SimilarTabIntegration.js + - InteractionTracker.js + - AIButtonIntegration.js # integration last after components + css: + - css/AIOverhaul.css + - css/recommendedscenes.css + - css/SimilarScenes.css + csp: + connect-src: + - http://localhost:4153 + - ws://localhost:4153 + - https://localhost:4153 + # Add additional urls here for the stash-ai-server if your browser is not on the same host +interface: raw +exec: + - python + - "{pluginDir}/plugin_setup.py" +tasks: + - name: Setup AI Overhaul Plugin settings + description: Use to set automatically set AI Overhaul Plugin settings + defaultArgs: + mode: plugin_setup +settings: + backend_base_url: + displayName: Backend Base URL Override + type: STRING + capture_events: + displayName: Capture Interaction Events + type: BOOLEAN \ No newline at end of file diff --git a/plugins/AIOverhaul/BackendBase.js b/plugins/AIOverhaul/BackendBase.js new file mode 100644 index 00000000..3794d143 --- /dev/null +++ b/plugins/AIOverhaul/BackendBase.js @@ -0,0 +1,228 @@ +(function(){ +// Shared helper to determine the backend base URL used by the frontend. +// Exposes a default export and also attaches to window.AIDefaultBackendBase for +// non-module consumers in the minimal build. +getSharedApiKey; +defaultBackendBase; +const PLUGIN_NAME = 'AIOverhaul'; +// Local default to keep the UI functional before plugin config loads. +const DEFAULT_BACKEND_BASE = 'http://localhost:4153'; +const CONFIG_QUERY = `query AIOverhaulPluginConfig($ids: [ID!]) { + configuration { + plugins(include: $ids) + } +}`; +const SHARED_KEY_EVENT = 'AISharedApiKeyUpdated'; +const SHARED_KEY_HEADER = 'x-ai-api-key'; +const SHARED_KEY_QUERY = 'api_key'; +const SHARED_KEY_STORAGE = 'ai_shared_api_key'; +let configLoaded = false; +let configLoading = false; +let sharedApiKeyValue = ''; +function getOrigin() { + try { + if (typeof location !== 'undefined' && location.origin) { + return location.origin.replace(/\/$/, ''); + } + } + catch { } + return ''; +} +function normalizeBase(raw) { + if (typeof raw !== 'string') + return null; + const trimmed = raw.trim(); + if (!trimmed) + return ''; + const cleaned = trimmed.replace(/\/$/, ''); + const origin = getOrigin(); + if (origin && cleaned === origin) { + return ''; + } + return cleaned; +} +function interpretBool(raw) { + if (typeof raw === 'boolean') + return raw; + if (typeof raw === 'number') + return raw !== 0; + if (typeof raw === 'string') { + const lowered = raw.trim().toLowerCase(); + if (!lowered) + return false; + if (['1', 'true', 'yes', 'on'].includes(lowered)) + return true; + if (['0', 'false', 'no', 'off'].includes(lowered)) + return false; + } + return null; +} +function normalizeSharedKey(raw) { + if (typeof raw !== 'string') + return ''; + return raw.trim(); +} +function setSharedApiKey(raw) { + const normalized = normalizeSharedKey(raw); + if (normalized === sharedApiKeyValue) + return; + sharedApiKeyValue = normalized; + try { + if (normalized) { + try { + sessionStorage.setItem(SHARED_KEY_STORAGE, normalized); + } + catch { } + } + else { + try { + sessionStorage.removeItem(SHARED_KEY_STORAGE); + } + catch { } + } + window.AI_SHARED_API_KEY = normalized; + window.dispatchEvent(new CustomEvent(SHARED_KEY_EVENT, { detail: normalized })); + } + catch { } +} +function getSharedApiKey() { + if (sharedApiKeyValue) + return sharedApiKeyValue; + try { + const stored = sessionStorage.getItem(SHARED_KEY_STORAGE); + if (typeof stored === 'string' && stored.trim()) { + sharedApiKeyValue = stored.trim(); + return sharedApiKeyValue; + } + } + catch { } + try { + const globalValue = window.AI_SHARED_API_KEY; + if (typeof globalValue === 'string') { + sharedApiKeyValue = globalValue.trim(); + return sharedApiKeyValue; + } + } + catch { } + return ''; +} +function withSharedKeyHeaders(init) { + const key = getSharedApiKey(); + if (!key) + return init ? init : {}; + const next = { ...(init || {}) }; + const headers = new Headers((init === null || init === void 0 ? void 0 : init.headers) || {}); + headers.set(SHARED_KEY_HEADER, key); + next.headers = headers; + return next; +} +function appendSharedApiKeyQuery(url) { + const key = getSharedApiKey(); + if (!key) + return url; + try { + const base = getOrigin() || undefined; + const resolved = new URL(url, url.startsWith('http://') || url.startsWith('https://') || url.startsWith('ws://') || url.startsWith('wss://') ? undefined : base); + resolved.searchParams.set(SHARED_KEY_QUERY, key); + return resolved.toString(); + } + catch { + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}${SHARED_KEY_QUERY}=${encodeURIComponent(key)}`; + } +} +function applyPluginConfig(base, captureEvents, sharedKey) { + if (base !== undefined) { + const normalized = normalizeBase(base); + if (normalized !== null) { + const value = normalized || ''; + try { + window.AI_BACKEND_URL = value; + window.dispatchEvent(new CustomEvent('AIBackendBaseUpdated', { detail: value })); + } + catch { } + } + } + if (captureEvents !== undefined && captureEvents !== null) { + const normalized = !!captureEvents; + try { + window.__AI_INTERACTIONS_ENABLED__ = normalized; + } + catch { } + try { + const tracker = window.stashAIInteractionTracker; + if (tracker) { + if (typeof tracker.setEnabled === 'function') + tracker.setEnabled(normalized); + else if (typeof tracker.configure === 'function') + tracker.configure({ enabled: normalized }); + } + } + catch { } + } + if (sharedKey !== undefined) { + setSharedApiKey(sharedKey); + } +} +async function loadPluginConfig() { + var _a, _b, _c, _d, _e, _f, _g, _h; + if (configLoaded || configLoading) + return; + configLoading = true; + try { + const resp = await fetch('/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ query: CONFIG_QUERY, variables: { ids: [PLUGIN_NAME] } }), + }); + if (!resp.ok) + return; + const payload = await resp.json().catch(() => null); + const plugins = (_b = (_a = payload === null || payload === void 0 ? void 0 : payload.data) === null || _a === void 0 ? void 0 : _a.configuration) === null || _b === void 0 ? void 0 : _b.plugins; + if (plugins && typeof plugins === 'object') { + const entry = plugins[PLUGIN_NAME]; + if (entry && typeof entry === 'object') { + const backendBase = (_d = (_c = entry.backend_base_url) !== null && _c !== void 0 ? _c : entry.backendBaseUrl) !== null && _d !== void 0 ? _d : entry.backendBaseURL; + const captureEvents = (_f = (_e = entry.capture_events) !== null && _e !== void 0 ? _e : entry.captureEvents) !== null && _f !== void 0 ? _f : entry.captureEventsEnabled; + const sharedKey = (_h = (_g = entry.shared_api_key) !== null && _g !== void 0 ? _g : entry.sharedApiKey) !== null && _h !== void 0 ? _h : entry.sharedKey; + applyPluginConfig(backendBase, interpretBool(captureEvents), typeof sharedKey === 'string' ? sharedKey : undefined); + } + } + } + catch { } + finally { + configLoaded = true; + configLoading = false; + } +} +function defaultBackendBase() { + try { + if (!configLoaded) + loadPluginConfig(); + } + catch { } + if (typeof window.AI_BACKEND_URL === 'string') { + const explicit = normalizeBase(window.AI_BACKEND_URL); + if (explicit !== null && explicit !== undefined) { + return explicit; + } + return ''; + } + return DEFAULT_BACKEND_BASE; +} +// Also attach as a global so files that are executed before this module can still +// use the shared function when available. +try { + window.AIDefaultBackendBase = defaultBackendBase; + defaultBackendBase.loadPluginConfig = loadPluginConfig; + defaultBackendBase.applyPluginConfig = applyPluginConfig; + window.AISharedApiKeyHelper = { + get: getSharedApiKey, + withHeaders: withSharedKeyHeaders, + appendQuery: appendSharedApiKeyQuery, + }; +} +catch { } +})(); + diff --git a/plugins/AIOverhaul/BackendHealth.js b/plugins/AIOverhaul/BackendHealth.js new file mode 100644 index 00000000..b2663965 --- /dev/null +++ b/plugins/AIOverhaul/BackendHealth.js @@ -0,0 +1,200 @@ +(function(){ +// Shared backend connectivity tracking & notice helpers for the AI Overhaul frontend. +// Each bundle is built as an isolated IIFE, so we expose a small global helper +// (`window.AIBackendHealth`) that provides three core pieces: +// • reportOk / reportError for callers performing fetches +// • useBackendHealth hook for React components to subscribe to status changes +// • buildNotice helper to render a consistent user-facing outage banner +// The goal is to provide a single, user-friendly experience whenever the +// backend cannot be reached instead of bespoke inline error badges. +(function initBackendHealth() { + const w = window; + const listeners = new Set(); + const EVENT_NAME = 'AIBackendHealthChange'; + function now() { return Date.now ? Date.now() : new Date().getTime(); } + function getOrigin() { + try { + if (typeof location !== 'undefined' && location.origin) { + return location.origin.replace(/\/$/, ''); + } + } + catch (_) { } + return ''; + } + function normalizeBase(base) { + if (base === undefined || base === null) + return current.backendBase || ''; + try { + const str = String(base || '').trim(); + if (!str) + return ''; + const cleaned = str.replace(/\/$/, ''); + const origin = getOrigin(); + return origin && cleaned === origin ? '' : cleaned; + } + catch (_) { + return ''; + } + } + function fallbackBase() { + try { + const fn = (w.AIDefaultBackendBase || w.defaultBackendBase); + if (typeof fn === 'function') { + const base = fn(); + if (typeof base === 'string') { + const normalized = normalizeBase(base); + if (normalized) + return normalized; + } + } + } + catch (_) { } + return ''; + } + function emit(state) { + listeners.forEach((fn) => { + try { + fn(state); + } + catch (err) { + if (w.AIDebug) + console.warn('[BackendHealth] listener error', err); + } + }); + try { + w.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: state })); + } + catch (_) { } + } + let current = { + status: 'idle', + backendBase: fallbackBase(), + lastUpdated: now(), + message: undefined, + lastError: undefined + }; + function update(partial) { + var _a, _b; + const next = { + ...current, + ...partial, + backendBase: normalizeBase((_b = (_a = partial.backendBase) !== null && _a !== void 0 ? _a : current.backendBase) !== null && _b !== void 0 ? _b : fallbackBase()), + lastUpdated: now() + }; + const changed = next.status !== current.status || + next.backendBase !== current.backendBase || + next.message !== current.message || + next.lastError !== current.lastError; + current = next; + if (changed) + emit(current); + } + function describeErrorMessage(message, baseHint) { + const baseLabel = baseHint ? baseHint : (current.backendBase || fallbackBase()); + const prefix = "Can't reach the AI Overhaul backend"; + const suffix = baseLabel ? ` at ${baseLabel}.` : '.'; + const detail = message ? (message.endsWith('.') ? message : `${message}.`) : ''; + const instruction = ' Check that the AI server is running and update the URL under Settings → Tools → AI Overhaul Settings.'; + return `${prefix}${suffix}${detail ? ` ${detail}` : ''}${instruction}`; + } + function reportOk(base) { + const baseUrl = normalizeBase(base); + update({ status: 'ok', backendBase: baseUrl, message: undefined, lastError: undefined, details: undefined }); + } + function reportChecking(base) { + const baseUrl = normalizeBase(base); + update({ status: 'checking', backendBase: baseUrl }); + } + function reportError(base, message, details) { + const baseUrl = normalizeBase(base); + const friendly = describeErrorMessage(message, baseUrl || undefined); + const lastError = typeof details === 'string' ? details : (details && details.message) ? details.message : message; + update({ status: 'error', backendBase: baseUrl, message: friendly, lastError, details }); + } + function subscribe(fn) { + listeners.add(fn); + fn(current); + return () => listeners.delete(fn); + } + function getReact() { + var _a; + return ((_a = w.PluginApi) === null || _a === void 0 ? void 0 : _a.React) || w.React; + } + function useBackendHealth() { + const React = getReact(); + if (!React || !React.useState || !React.useEffect) { + // React may not be ready yet; return the latest state directly + return current; + } + const { useEffect, useState } = React; + const [state, setState] = useState(current); + useEffect(() => subscribe(setState), []); + return state; + } + function buildNotice(state, options = {}) { + const React = getReact(); + if (!React || !React.createElement) + return null; + const snapshot = state || current; + if (!snapshot || snapshot.status !== 'error') + return null; + const retryHandler = options.onRetry; + const message = options.messageOverride || snapshot.message || describeErrorMessage(snapshot.lastError, snapshot.backendBase); + const containerStyle = options.dense ? { + padding: '8px 12px', + borderRadius: 6, + marginBottom: 12, + background: 'rgba(120,0,0,0.35)', + border: '1px solid rgba(255,80,80,0.4)', + color: '#ffd7d7', + fontSize: '13px' + } : { + padding: '12px 16px', + borderRadius: 8, + margin: '12px 0', + background: 'rgba(120,0,0,0.35)', + border: '1px solid rgba(255,80,80,0.4)', + color: '#ffd7d7', + fontSize: '14px', + lineHeight: 1.5, + boxShadow: '0 0 0 1px rgba(0,0,0,0.2) inset' + }; + const children = [ + React.createElement('div', { key: 'title', style: { fontWeight: 600, marginBottom: 6 } }, "Can't reach AI Overhaul backend"), + React.createElement('div', { key: 'body', style: { whiteSpace: 'pre-wrap' } }, message) + ]; + if (retryHandler) { + children.push(React.createElement('div', { key: 'actions', style: { marginTop: options.dense ? 8 : 12 } }, React.createElement('button', { + type: 'button', + onClick: retryHandler, + style: { + background: '#c33', + color: '#fff', + border: '1px solid rgba(255,255,255,0.25)', + borderRadius: 4, + padding: options.dense ? '4px 10px' : '6px 14px', + cursor: 'pointer', + fontSize: options.dense ? '12px' : '13px' + } + }, options.retryLabel || 'Retry now'))); + } + return React.createElement('div', { + key: options.key || 'ai-backend-offline', + className: options.className || 'ai-backend-offline-alert', + style: containerStyle + }, children); + } + const api = { + reportOk, + reportChecking, + reportError, + useBackendHealth, + buildNotice, + getState: () => current, + subscribe, + EVENT_NAME + }; + w.AIBackendHealth = api; +})(); +})(); + diff --git a/plugins/AIOverhaul/InteractionTracker.js b/plugins/AIOverhaul/InteractionTracker.js new file mode 100644 index 00000000..ba857a07 --- /dev/null +++ b/plugins/AIOverhaul/InteractionTracker.js @@ -0,0 +1,1867 @@ +(function(){ +// ============================================================================= +// InteractionTracker - Core user interaction & consumption analytics +// ============================================================================= +// Purpose: Collect ONLY events useful for recommendation systems while keeping +// implementation lightweight and decoupled from UI components. +// +// Design Goals: +// * Minimal public API; internal batching + robustness. +// * Data model optimized for downstream recommendation pipelines. +// * Focus on scenes (video consumption), images, galleries. Extendable. +// * Session-scoped (tab/sessionStorage) with soft continuation if same tab. +// * Graceful offline: localStorage queue + retry. sendBeacon on unload. +// * Avoid over-emitting: aggregate watch segments; throttle progress. +// * No dependency on legacy messy trackers; selectively inspired only. +// +// NOTE: This file intentionally avoids React imports so it can be built as a +// standalone IIFE like other integration utilities. +// ============================================================================= +void 0; +const NUMERIC_ENTITY_TYPES = new Set(['scene', 'image', 'gallery']); +function hashToUint32(value) { + let hash = 0x811c9dc5; // FNV-1a 32-bit offset basis + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + const out = hash >>> 0; + return out === 0 ? 1 : out; // avoid zero to keep sentinel-free +} +function normalizeEntityIdForEvent(entityType, entityId) { + if (entityId === null || entityId === undefined) + throw new Error(`missing entity id for ${entityType}`); + if (typeof entityId === 'number' && Number.isFinite(entityId)) { + return Math.trunc(entityId); + } + const raw = String(entityId).trim(); + if (!raw) + throw new Error(`missing entity id for ${entityType}`); + if (NUMERIC_ENTITY_TYPES.has(entityType)) { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) + throw new Error(`expected numeric entity id for ${entityType}, received ${entityId}`); + return Math.trunc(parsed); + } + return hashToUint32(`${entityType}:${raw}`); +} +// Resolve backend base using the shared helper when available. +function _resolveBackendBase() { + const globalFn = window.AIDefaultBackendBase; + if (typeof globalFn !== 'function') + throw new Error('AIDefaultBackendBase not initialized. Ensure backendBase is loaded first.'); + return globalFn(); +} +function getSharedApiKey() { + try { + const helper = window.AISharedApiKeyHelper; + if (helper && typeof helper.get === 'function') { + const value = helper.get(); + if (typeof value === 'string') + return value.trim(); + } + } + catch { } + const raw = window.AI_SHARED_API_KEY; + return typeof raw === 'string' ? raw.trim() : ''; +} +function withSharedKeyHeaders(init) { + const helper = window.AISharedApiKeyHelper; + if (helper && typeof helper.withHeaders === 'function') { + return helper.withHeaders(init || {}); + } + const key = getSharedApiKey(); + if (!key) + return init ? { ...init } : {}; + const next = { ...(init || {}) }; + const headers = new Headers((init === null || init === void 0 ? void 0 : init.headers) || {}); + headers.set('x-ai-api-key', key); + next.headers = headers; + return next; +} +// ------------------------------ Tracker Class ------------------------------ +class InteractionTracker { + static get instance() { return this._instance || (this._instance = new InteractionTracker()); } + constructor() { + this.queue = []; + this.flushTimer = null; + this.pageVisibilityHandler = null; + this.beforeUnloadHandler = null; + this.lastEntityView = null; + this.initialized = false; + this.lastScenePageEntered = null; // track current scene page for leave events + this.lastLibrarySearchSignature = null; // dedupe library_search emissions + this.lastSceneViewSceneId = null; // dedupe rapid successive scene_view emissions + this.lastSceneViewAt = null; // epoch ms of last accepted scene_view + this.lastDetailKey = null; // prevent duplicate view events + this.videoJsRetryTimers = new Map(); + this.playerReinstrumentTimers = new WeakMap(); + this.videoJsHooksInstalled = false; + this.pendingVideoJsPlayers = new Set(); + this.trackedVideoJsPlayers = new WeakSet(); + this.videoJsDomObserver = null; + this.videoJsFallbackActiveFor = null; + this.videoJsFallbackTimer = null; + this.videoJsPrimaryIds = ['VideoJsPlayer']; + this.flushInFlight = false; + this.cfg = this.buildConfig({}); + this.sessionId = this.ensureSession(); + this.clientId = this.ensureClientId(); + this.restoreQueue(); + this.bootstrap(); + } + configure(partial) { + this.cfg = this.buildConfig(partial); + } + buildConfig(partial) { + var _a; + const resolved = ((_a = partial.endpoint) !== null && _a !== void 0 ? _a : _resolveBackendBase()).replace(/\/$/, ''); + let storedEnabled = false; + try { + const flag = window.__AI_INTERACTIONS_ENABLED__; + if (typeof flag === 'boolean') + storedEnabled = flag; + } + catch { } + const base = { + endpoint: resolved, + batchPath: '/api/v1/interactions/sync', + sendIntervalMs: 5000, + maxBatchSize: 40, + progressThrottleMs: 5000, + immediateTypes: ['session_start', 'scene_watch_complete'], + localStorageKey: 'ai_overhaul_event_queue', + maxQueueLength: 1000, + debug: false, // default off; can be toggled via enableInteractionDebug() + autoDetect: true, + integratePageContext: true, + videoAutoInstrument: true, + enabled: storedEnabled + }; + const merged = { ...base, ...partial }; + if (partial.enabled !== undefined) + merged.enabled = !!partial.enabled; + try { + window.__AI_INTERACTIONS_ENABLED__ = merged.enabled; + } + catch { } + return merged; + } + ensureSession() { + let id = sessionStorage.getItem('ai_overhaul_session_id'); + if (!id) { + id = 'sess_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); + sessionStorage.setItem('ai_overhaul_session_id', id); + } + return id; + } + ensureClientId() { + try { + let id = localStorage.getItem('ai_overhaul_client_id'); + if (!id) { + id = 'client_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); + localStorage.setItem('ai_overhaul_client_id', id); + } + return id; + } + catch (e) { + return 'client_unknown'; + } + } + bootstrap() { + if (this.initialized) + return; + this.initialized = true; + this.trackInternal('session_start', 'session', 'session', { started_at: Date.now() }); + this.startFlushTimer(); + this.installLifecycleHandlers(); + if (this.cfg.autoDetect) + this.tryAutoDetect(); + if (this.cfg.integratePageContext) + this.tryIntegratePageContext(); + this.installVideoJsHooks(); + // Capture library search from URL on init (e.g., /scenes?search=...) + try { + this.scanForLibrarySearch(); + } + catch (e) { /* ignore */ } + try { + this.installLibraryListeners(); + } + catch (e) { /* ignore */ } + } + // Lightweight debounce helper + debounce(fn, wait = 300) { + let t = null; + return (...args) => { if (t) + clearTimeout(t); t = setTimeout(() => fn(...args), wait); }; + } + // Install listeners to detect library search inputs and filter changes + installLibraryListeners() { + try { + // remove previous listeners if any by storing on window (best-effort cleanup) + if (window.__ai_lib_listeners_installed) + return; + window.__ai_lib_listeners_installed = true; + const collectFilters = (target) => { + const out = {}; + try { + // If we have a target, prefer scanning its nearest filter-related ancestor + const findFilterContainer = (el) => { + let node = el; + while (node) { + const cls = (node.className || '').toString().toLowerCase(); + if (cls && /filter|filters|filter-panel|facets|facets-panel|sidebar|search-controls/.test(cls)) + return node; + node = node.parentElement; + } + return null; + }; + let scope = document; + if (target) { + const container = findFilterContainer(target); + if (container) + scope = container; + } + // Collect inputs/selects within the chosen scope + const nodes = Array.from(scope.querySelectorAll('input,select')); + for (const n of nodes) { + const name = (n.name || n.getAttribute('data-filter') || n.id || '').toString(); + const cls = (n.className || '').toString().toLowerCase(); + // Accept anything that looks like a filter control or has an explicit data-filter + const likely = name || cls || n.getAttribute('data-filter'); + if (!likely) + continue; + if (!(name.toLowerCase().includes('filter') || cls.includes('filter') || cls.includes('tag') || cls.includes('performer') || name.toLowerCase().includes('tag') || n.hasAttribute('data-filter'))) { + // If we're scoped to a container, accept any control inside it + if (scope === document) + continue; // global scan should still be conservative + } + const key = name || n.id || (n.getAttribute('data-filter') || cls || 'filter'); + if (n.type === 'checkbox') { + out[key] = n.checked; + } + else if (n.type === 'radio') { + if (n.checked) + out[key] = n.value; + } + else { + out[key] = n.value; + } + } + } + catch (e) { /* ignore */ } + return out; + }; + // Input handler for text search boxes + const onInput = this.debounce((ev) => { + try { + const t = ev.target; + if (!t) + return; + const val = (t.value || '').trim(); + if (val.length < 2) + return; + // Heuristic: only treat as library search if on a library page + const p = location.pathname || ''; + if (p.match(/\/scenes(\/|$)/i)) { + this.trackLibrarySearch('scenes', val, { source: 'input', page_url: location.href }); + } + else if (p.match(/\/images(\/|$)/i)) { + this.trackLibrarySearch('images', val, { source: 'input', page_url: location.href }); + } + else if (p.match(/\/galleries(\/|$)/i)) { + this.trackLibrarySearch('galleries', val, { source: 'input', page_url: location.href }); + } + else if (p.match(/\/performers(\/|$)/i)) { + this.trackLibrarySearch('performers', val, { source: 'input', page_url: location.href }); + } + else if (p.match(/\/tags(\/|$)/i)) { + this.trackLibrarySearch('tags', val, { source: 'input', page_url: location.href }); + } + } + catch (e) { /* ignore */ } + }, 600); + document.addEventListener('input', (ev) => { + try { + const target = ev.target; + if (!target) + return; + // Only consider text inputs likely to be search boxes + const isText = target.tagName === 'INPUT' && (target.type === 'text' || target.type === 'search'); + const placeholder = (target.placeholder || '').toLowerCase(); + const name = (target.name || '').toLowerCase(); + if (isText && (placeholder.includes('search') || name.includes('search') || target.className.toLowerCase().includes('search'))) { + onInput(ev); + } + } + catch (e) { } + }, true); + // Change handler for selects/checkboxes used as filters + const onChange = this.debounce((ev) => { + var _a, _b; + try { + const p = location.pathname || ''; + let lib = null; + if (p.match(/\/scenes(\/|$)/i)) + lib = 'scenes'; + else if (p.match(/\/images(\/|$)/i)) + lib = 'images'; + else if (p.match(/\/galleries(\/|$)/i)) + lib = 'galleries'; + else if (p.match(/\/performers(\/|$)/i)) + lib = 'performers'; + else if (p.match(/\/tags(\/|$)/i)) + lib = 'tags'; + if (!lib) + return; + const target = (ev && ev.target) || null; + let filters = collectFilters(target); + // If no filters found, try to derive a single-control filter from the changed element. + // This helps cases where the performers page uses controls without explicit "filter" names/classes. + if (Object.keys(filters).length === 0 && target) { + try { + const el = target; + if (el) { + let key = (el.getAttribute('name') || el.getAttribute('data-filter') || el.id || el.className || '').toString(); + key = key.trim() || (el.getAttribute('data-filter') || el.id || el.className || 'filter'); + let value = null; + if (el.tagName && el.tagName.toLowerCase() === 'input') { + const inp = el; + if (inp.type === 'checkbox') + value = inp.checked; + else if (inp.type === 'radio') { + if (inp.checked) + value = inp.value; + } + else + value = inp.value; + } + else if (el.tagName && el.tagName.toLowerCase() === 'select') { + value = el.value; + } + else { + // fallback: try dataset or text + value = (_b = (_a = el.value) !== null && _a !== void 0 ? _a : el.dataset) !== null && _b !== void 0 ? _b : null; + } + if (value !== null && value !== undefined && !(typeof value === 'string' && String(value).trim() === '')) { + filters = { [String(key)]: value }; + } + } + } + catch (e) { /* ignore */ } + } + if (Object.keys(filters).length === 0) + return; + this.trackLibrarySearch(lib, undefined, { source: 'filters', filters, page_url: location.href }); + } + catch (e) { /* ignore */ } + }, 400); + document.addEventListener('change', (ev) => { + try { + const target = ev.target; + if (!target) + return; + const tag = target.tagName.toLowerCase(); + if (tag === 'select' || (tag === 'input' && (target.type === 'checkbox' || target.type === 'radio'))) { + onChange(ev); + } + } + catch (e) { } + }, true); + // Re-scan on navigation via history API + const hookNav = (orig) => { + return function (...args) { + const res = orig.apply(this, args); + try { + setTimeout(() => { var _a, _b; (_b = (_a = window.stashAIInteractionTracker) === null || _a === void 0 ? void 0 : _a.scanForLibrarySearch) === null || _b === void 0 ? void 0 : _b.call(_a); }, 100); + } + catch { } + return res; + }; + }; + const origPush = history.pushState; + const origReplace = history.replaceState; + history.pushState = hookNav(origPush); + history.replaceState = hookNav(origReplace); + window.addEventListener('popstate', () => { try { + this.scanForLibrarySearch(); + } + catch { } }); + } + catch (e) { + // swallow errors; this is non-essential + } + } + tryAutoDetect() { + const run = () => { + try { + const url = window.location.href; + let sceneId = null; + const sceneMatch = url.match(/scenes\/(\d+)/i); + if (sceneMatch) + sceneId = sceneMatch[1]; + const params = new URLSearchParams(window.location.search); + if (!sceneId && params.get('sceneId')) + sceneId = params.get('sceneId'); + if (sceneId) { + this.log('auto-detect scene id', sceneId); + this.trackSceneView(sceneId); + if (this.cfg.videoAutoInstrument) + this.ensureVideoInstrumentation(sceneId); + } + else { + this.log('auto-detect: no scene id pattern matched'); + } + } + catch (e) { + this.log('auto-detect failed', e); + } + }; + if (document.readyState === 'loading') + document.addEventListener('DOMContentLoaded', run); + else + run(); + } + installVideoJsHooks() { + var _a; + if (this.videoJsHooksInstalled) + return; + const attachIfReady = (candidate) => { + if (!candidate || typeof candidate.hook !== 'function') + return false; + if (this.videoJsHooksInstalled) + return true; + this.videoJsHooksInstalled = true; + try { + candidate.hook('setup', (player) => { + try { + this.handleVideoJsPlayerRegistration(player); + } + catch (err) { + if (this.cfg.debug) + this.log('videojs setup hook error', err); + } + }); + } + catch (err) { + if (this.cfg.debug) + this.log('videojs hook registration failed', err); + } + this.instrumentExistingVideoJsPlayers(); + return true; + }; + if (attachIfReady(window.videojs)) + return; + const descriptor = Object.getOwnPropertyDescriptor(window, 'videojs'); + if (descriptor && !descriptor.configurable) { + window.addEventListener('videojsready', (event) => { + var _a; + const value = (_a = event === null || event === void 0 ? void 0 : event.detail) !== null && _a !== void 0 ? _a : window.videojs; + attachIfReady(value); + }); + } + else { + let stored = descriptor === null || descriptor === void 0 ? void 0 : descriptor.value; + const originalGetter = descriptor === null || descriptor === void 0 ? void 0 : descriptor.get; + const originalSetter = descriptor === null || descriptor === void 0 ? void 0 : descriptor.set; + Object.defineProperty(window, 'videojs', { + configurable: true, + enumerable: (_a = descriptor === null || descriptor === void 0 ? void 0 : descriptor.enumerable) !== null && _a !== void 0 ? _a : true, + get() { + if (originalGetter) + return originalGetter.call(window); + return stored; + }, + set(value) { + if (originalSetter) { + originalSetter.call(window, value); + } + else { + stored = value; + } + const current = originalGetter ? originalGetter.call(window) : value; + attachIfReady(current); + }, + }); + const initial = originalGetter ? originalGetter.call(window) : stored; + attachIfReady(initial); + } + if (!this.videoJsHooksInstalled) { + let tries = 0; + const maxTries = 40; + const retry = () => { + if (attachIfReady(window.videojs)) + return; + if (tries >= maxTries) + return; + tries += 1; + setTimeout(retry, 250); + }; + retry(); + } + } + activateVideoJsFallbackMonitor(sceneId) { + const requested = sceneId !== null && sceneId !== void 0 ? sceneId : null; + const scope = () => document.body || document.documentElement; + const observerHandler = (mutations) => { + for (const mut of mutations) { + mut.addedNodes.forEach(node => this.scanNodeForVideoJsPlayers(node)); + } + }; + if (this.videoJsDomObserver) { + this.videoJsFallbackActiveFor = requested; + const target = scope(); + if (target) + this.scanNodeForVideoJsPlayers(target); + this.refreshVideoJsFallbackTimer(); + return; + } + try { + const target = scope(); + if (!target) + return; + this.videoJsDomObserver = new MutationObserver(observerHandler); + this.videoJsDomObserver.observe(target, { childList: true, subtree: true }); + this.videoJsFallbackActiveFor = requested; + this.scanNodeForVideoJsPlayers(target); + this.refreshVideoJsFallbackTimer(); + } + catch (err) { + if (this.cfg.debug) + this.log('videoJs fallback observer attach failed', err); + } + } + deactivateVideoJsFallbackMonitor(sceneId) { + if (!this.videoJsDomObserver) + return; + if (sceneId && this.videoJsFallbackActiveFor && this.videoJsFallbackActiveFor !== sceneId) + return; + try { + this.videoJsDomObserver.disconnect(); + } + catch { } + this.videoJsDomObserver = null; + this.videoJsFallbackActiveFor = null; + if (this.videoJsFallbackTimer !== null) { + window.clearTimeout(this.videoJsFallbackTimer); + this.videoJsFallbackTimer = null; + } + } + refreshVideoJsFallbackTimer() { + if (this.videoJsFallbackTimer !== null) { + window.clearTimeout(this.videoJsFallbackTimer); + } + this.videoJsFallbackTimer = window.setTimeout(() => { + this.videoJsFallbackTimer = null; + if (this.pendingVideoJsPlayers.size > 0) { + this.refreshVideoJsFallbackTimer(); + return; + } + this.deactivateVideoJsFallbackMonitor(); + }, 30000); + } + scanNodeForVideoJsPlayers(node) { + var _a; + if (!(node instanceof HTMLElement)) + return; + const roots = []; + if (this.isVideoJsRoot(node)) + roots.push(node); + (_a = node.querySelectorAll) === null || _a === void 0 ? void 0 : _a.call(node, '.video-js, video-js').forEach(el => { + if (el instanceof HTMLElement && this.isVideoJsRoot(el)) + roots.push(el); + }); + for (const root of roots) { + const player = this.extractVideoJsPlayer(root); + if (player && this.shouldInstrumentVideoJsPlayer(player, { root })) { + this.handleVideoJsPlayerRegistration(player, { root }); + } + } + } + isVideoJsRoot(el) { + var _a; + if (!el) + return false; + if ((_a = el.classList) === null || _a === void 0 ? void 0 : _a.contains('video-js')) + return true; + if (el.tagName && el.tagName.toUpperCase() === 'VIDEO-JS') + return true; + if (el.hasAttribute && el.hasAttribute('data-vjs-player')) + return true; + return false; + } + extractVideoJsPlayer(el) { + if (!el) + return null; + const root = this.resolveVideoJsRootElement(el); + if (!root) + return null; + const candidate = root.player || root.player_ || root.Player || null; + if (candidate && typeof candidate.ready === 'function') + return candidate; + const vjs = window.videojs; + if (vjs) { + try { + if (typeof vjs.getPlayer === 'function') { + const fromId = root.id ? vjs.getPlayer(root.id) : null; + if (fromId) + return fromId; + const viaRoot = vjs.getPlayer(root); + if (viaRoot) + return viaRoot; + } + } + catch { } + } + // schedule a short retry in case player is attached asynchronously + setTimeout(() => { + const later = root.player || root.player_; + if (later && typeof later.ready === 'function') + this.handleVideoJsPlayerRegistration(later); + }, 0); + return null; + } + resolveVideoJsRootElement(el) { + var _a; + if (this.isVideoJsRoot(el)) + return el; + const found = (_a = el.querySelector) === null || _a === void 0 ? void 0 : _a.call(el, '.video-js, video-js'); + return found instanceof HTMLElement ? found : null; + } + getVideoJsRootFromPlayer(player) { + if (!player) + return null; + try { + const el = typeof player.el === 'function' ? player.el() : player.el; + if (el instanceof HTMLElement) + return el; + } + catch { } + try { + const alt = player.el_; + if (alt instanceof HTMLElement) + return alt; + } + catch { } + return null; + } + getVideoJsPlayerId(player) { + if (!player) + return null; + try { + if (typeof player.id === 'function') { + const id = player.id(); + if (id) + return String(id); + } + } + catch { } + try { + const id = player.id_; + if (id) + return String(id); + } + catch { } + return null; + } + isPrimaryVideoJsRoot(root) { + var _a, _b; + if (!root) + return false; + const lowerId = (root.id || '').toLowerCase(); + for (const id of this.videoJsPrimaryIds) { + if (lowerId === id.toLowerCase()) + return true; + if (root.closest && root.closest(`#${id}`)) + return true; + } + const marker = ((_a = root.getAttribute) === null || _a === void 0 ? void 0 : _a.call(root, 'data-player-id')) || ((_b = root.getAttribute) === null || _b === void 0 ? void 0 : _b.call(root, 'data-vjs-player-id')); + if (marker) { + const lower = marker.toLowerCase(); + for (const id of this.videoJsPrimaryIds) { + if (lower.includes(id.toLowerCase())) + return true; + } + } + return false; + } + shouldInstrumentVideoJsPlayer(player, ctx) { + var _a, _b, _c; + if (!player) + return false; + const primaryIds = this.videoJsPrimaryIds; + const playerId = (_b = (_a = this.getVideoJsPlayerId(player)) === null || _a === void 0 ? void 0 : _a.toLowerCase()) !== null && _b !== void 0 ? _b : null; + if (playerId && primaryIds.some(id => playerId === id.toLowerCase() || playerId.includes(id.toLowerCase()))) + return true; + const root = (_c = ctx === null || ctx === void 0 ? void 0 : ctx.root) !== null && _c !== void 0 ? _c : this.getVideoJsRootFromPlayer(player); + if (root && this.isPrimaryVideoJsRoot(root)) + return true; + const tech = this.getVideoJsTechElement(player); + if (tech === null || tech === void 0 ? void 0 : tech.id) { + const techId = tech.id.toLowerCase(); + if (primaryIds.some(id => techId === id.toLowerCase() || techId.includes(id.toLowerCase()))) + return true; + } + return false; + } + instrumentExistingVideoJsPlayers() { + try { + const players = this.collectVideoJsPlayers(); + for (const player of players) { + const root = this.getVideoJsRootFromPlayer(player); + if (!this.shouldInstrumentVideoJsPlayer(player, { root })) + continue; + this.handleVideoJsPlayerRegistration(player); + } + } + catch (err) { + if (this.cfg.debug) + this.log('instrumentExistingVideoJsPlayers failed', err); + } + } + handleVideoJsPlayerRegistration(player, ctx) { + var _a; + if (!player) + return; + const initialRoot = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.root) !== null && _a !== void 0 ? _a : this.getVideoJsRootFromPlayer(player); + if (!this.shouldInstrumentVideoJsPlayer(player, { root: initialRoot })) { + if (this.cfg.debug) + this.log('skipping non-primary videojs player', { playerId: this.getVideoJsPlayerId(player) }); + return; + } + if (this.trackedVideoJsPlayers.has(player)) + return; + this.trackedVideoJsPlayers.add(player); + try { + player.on('dispose', () => { + this.pendingVideoJsPlayers.delete(player); + }); + } + catch { } + player.ready(() => { + var _a; + const readyRoot = (_a = this.getVideoJsRootFromPlayer(player)) !== null && _a !== void 0 ? _a : initialRoot; + if (!this.shouldInstrumentVideoJsPlayer(player, { root: readyRoot })) { + if (this.cfg.debug) + this.log('skipping non-primary videojs player (ready)', { playerId: this.getVideoJsPlayerId(player) }); + return; + } + const sceneId = this.resolveSceneIdFromContext(); + if (!sceneId) { + this.pendingVideoJsPlayers.add(player); + return; + } + const success = this.instrumentSceneWithVideoJs(sceneId, { player }); + if (!success) { + this.pendingVideoJsPlayers.add(player); + this.queuePlayerReinstrument(sceneId, player); + } + else { + this.pendingVideoJsPlayers.delete(player); + } + }); + } + resolveSceneIdFromContext() { + var _a; + if ((_a = this.currentScene) === null || _a === void 0 ? void 0 : _a.sceneId) + return this.currentScene.sceneId; + if (this.lastScenePageEntered) + return this.lastScenePageEntered; + return this.extractSceneIdFromLocation(); + } + extractSceneIdFromLocation() { + try { + const url = window.location.href; + const match = url.match(/scenes\/(\d+)/i); + if (match) + return match[1]; + const params = new URLSearchParams(window.location.search); + const fromParam = params.get('sceneId') || params.get('id'); + return fromParam || null; + } + catch { + return null; + } + } + flushPendingVideoJsPlayers(sceneId) { + if (!this.pendingVideoJsPlayers.size) + return; + for (const player of Array.from(this.pendingVideoJsPlayers)) { + const success = this.instrumentSceneWithVideoJs(sceneId, { player }); + if (success) { + this.pendingVideoJsPlayers.delete(player); + } + else { + this.queuePlayerReinstrument(sceneId, player); + } + } + } + tryIntegratePageContext() { + const attach = () => { + const api = window.AIPageContext; + if (!api || typeof api.subscribe !== 'function') { + this.log('PageContext not ready, retrying...'); + setTimeout(attach, 1000); + return; + } + api.subscribe((ctx) => this.handlePageContext(ctx)); + this.log('subscribed to AIPageContext'); + try { + this.handlePageContext(api.get()); + } + catch { } + }; + attach(); + } + handlePageContext(ctx) { + if (!ctx) + return; + if (!ctx.isDetailView || !ctx.entityId) + return; + const key = ctx.page + ':' + ctx.entityId; + if (key === this.lastDetailKey) + return; + this.lastDetailKey = key; + switch (ctx.page) { + case 'scenes': + this.trackSceneView(ctx.entityId, { from: 'PageContext' }); + if (this.cfg.videoAutoInstrument) + this.ensureVideoInstrumentation(ctx.entityId); + break; + case 'images': + this.trackImageView(ctx.entityId, { title: ctx.detailLabel }); + break; + case 'galleries': + this.trackGalleryView(ctx.entityId, { title: ctx.detailLabel }); + break; + default: + break; + } + } + ensureVideoInstrumentation(sceneId) { + this.flushPendingVideoJsPlayers(sceneId); + const existing = this.currentScene; + if (existing && existing.sceneId === sceneId && existing.video && existing.player) + return; + if (this.instrumentSceneWithVideoJs(sceneId)) { + this.deactivateVideoJsFallbackMonitor(sceneId); + return; + } + this.activateVideoJsFallbackMonitor(sceneId); + this.scheduleSceneVideoRetry(sceneId, 1); + } + scheduleSceneVideoRetry(sceneId, attempt) { + this.cancelSceneVideoRetry(sceneId); + const maxAttempts = 12; + if (attempt > maxAttempts) { + this.log('videojs instrumentation failed after retries', { sceneId, attempt }); + if (typeof console !== 'undefined' && console.error) { + console.error('[InteractionTracker] videojs instrumentation failed', { sceneId, attempt }); + } + this.deactivateVideoJsFallbackMonitor(sceneId); + return; + } + this.activateVideoJsFallbackMonitor(sceneId); + const delay = Math.min(1200, 150 * attempt); + const handle = window.setTimeout(() => { + this.videoJsRetryTimers.delete(sceneId); + const success = this.instrumentSceneWithVideoJs(sceneId, { attempt }); + if (!success) + this.scheduleSceneVideoRetry(sceneId, attempt + 1); + }, delay); + this.videoJsRetryTimers.set(sceneId, handle); + } + cancelSceneVideoRetry(sceneId) { + const handle = this.videoJsRetryTimers.get(sceneId); + if (handle !== undefined) { + window.clearTimeout(handle); + this.videoJsRetryTimers.delete(sceneId); + } + } + instrumentSceneWithVideoJs(sceneId, opts) { + var _a, _b, _c; + const attempt = (_a = opts === null || opts === void 0 ? void 0 : opts.attempt) !== null && _a !== void 0 ? _a : 0; + const player = (_c = (_b = opts === null || opts === void 0 ? void 0 : opts.player) !== null && _b !== void 0 ? _b : this.getDefaultVideoJsPlayer()) !== null && _c !== void 0 ? _c : this.resolveActiveVideoJsPlayer(); + if (!player) { + if (attempt > 0) + this.log('videojs player unavailable', { sceneId, attempt }); + return false; + } + if (typeof player.isDisposed === 'function' && player.isDisposed()) { + this.log('videojs player disposed', { sceneId }); + return false; + } + const tech = this.getVideoJsTechElement(player); + if (!tech) { + this.log('videojs tech element missing', { sceneId, attempt }); + return false; + } + this.cancelSceneVideoRetry(sceneId); + this.instrumentSceneVideo(sceneId, tech, player); + this.deactivateVideoJsFallbackMonitor(sceneId); + return true; + } + getDefaultVideoJsPlayer() { + try { + const vjs = window.videojs; + if (!vjs || typeof vjs.getPlayer !== 'function') + return null; + const player = vjs.getPlayer('VideoJsPlayer'); + if (player) + return player; + } + catch { } + return null; + } + collectVideoJsPlayers() { + const vjs = window.videojs; + if (!vjs) + return []; + const seen = new Set(); + const out = []; + if (typeof vjs.getPlayers === 'function') { + try { + const players = vjs.getPlayers(); + if (players && typeof players === 'object') { + for (const key of Object.keys(players)) { + const player = players[key]; + if (player && !seen.has(player)) { + seen.add(player); + out.push(player); + } + } + } + } + catch { } + } + try { + const wrappers = Array.from(document.querySelectorAll('.video-js')); + for (const wrapper of wrappers) { + let player = null; + if (typeof vjs.getPlayer === 'function') { + try { + player = vjs.getPlayer(wrapper); + } + catch { } + } + if (!player) { + try { + player = typeof vjs === 'function' ? vjs(wrapper) : null; + } + catch { } + } + if (player && !seen.has(player)) { + seen.add(player); + out.push(player); + } + } + } + catch { } + return out; + } + resolveActiveVideoJsPlayer() { + var _a, _b; + const primary = this.getDefaultVideoJsPlayer(); + if (primary && (!(typeof primary.isDisposed === 'function') || !primary.isDisposed())) + return primary; + const candidates = this.collectVideoJsPlayers().filter(p => { + try { + return !(typeof p.isDisposed === 'function' && p.isDisposed()); + } + catch { + return true; + } + }).filter(p => this.shouldInstrumentVideoJsPlayer(p)); + if (!candidates.length) + return null; + const scored = candidates.map(player => ({ player, score: this.scoreVideoJsPlayer(player) })); + scored.sort((a, b) => b.score - a.score); + return (_b = (_a = scored[0]) === null || _a === void 0 ? void 0 : _a.player) !== null && _b !== void 0 ? _b : null; + } + scoreVideoJsPlayer(player) { + let score = 0; + try { + const el = typeof player.el === 'function' ? player.el() : player.el; + score += this.isElementLikelyVisible(el) ? 40 : -30; + } + catch { } + try { + if (typeof player.paused === 'function' && !player.paused()) + score += 25; + } + catch { } + try { + const ready = typeof player.readyState === 'function' ? player.readyState() : player.readyState; + if (typeof ready === 'number' && ready >= 2) + score += 10; + } + catch { } + try { + const source = typeof player.currentSource === 'function' ? player.currentSource() : null; + const src = (source === null || source === void 0 ? void 0 : source.src) || (typeof player.currentSrc === 'function' ? player.currentSrc() : ''); + if (src) { + score += 5; + if (/transcode|stream|m3u8/i.test(src)) + score += 5; + } + } + catch { } + try { + if (typeof player.hasStarted === 'function' && player.hasStarted()) + score += 10; + } + catch { } + return score; + } + isElementLikelyVisible(el) { + var _a; + try { + if (!el) + return false; + if (!(el instanceof HTMLElement)) + return true; + if (!el.isConnected) + return false; + const style = window.getComputedStyle(el); + if (style) { + if (style.display === 'none' || style.visibility === 'hidden') + return false; + if (style.opacity !== undefined && parseFloat(style.opacity) === 0) + return false; + } + const rect = (_a = el.getBoundingClientRect) === null || _a === void 0 ? void 0 : _a.call(el); + if (rect && (rect.width < 2 || rect.height < 2)) + return false; + return true; + } + catch { + return true; + } + } + getVideoJsTechElement(player) { + if (!player) + return null; + const extract = (candidate) => { + if (!candidate) + return null; + if (candidate instanceof HTMLVideoElement) + return candidate; + try { + const el = typeof candidate.el === 'function' ? candidate.el() : candidate.el; + if (el instanceof HTMLVideoElement) + return el; + if (el && el.querySelector) { + const nested = el.querySelector('video'); + if (nested instanceof HTMLVideoElement) + return nested; + } + } + catch { } + try { + const elAlt = candidate.el_; + if (elAlt instanceof HTMLVideoElement) + return elAlt; + if (elAlt && elAlt.querySelector) { + const nested = elAlt.querySelector('video'); + if (nested instanceof HTMLVideoElement) + return nested; + } + } + catch { } + return null; + }; + let tech = null; + try { + tech = typeof player.tech === 'function' ? player.tech(true) : player.tech_; + } + catch { } + const viaTech = extract(tech); + if (viaTech) + return viaTech; + const viaPlayer = extract(player); + if (viaPlayer) + return viaPlayer; + try { + const root = typeof player.el === 'function' ? player.el() : player.el; + if (root && root.querySelector) { + const candidate = root.querySelector('video'); + if (candidate instanceof HTMLVideoElement) + return candidate; + } + } + catch { } + return null; + } + readCurrentTime(player, fallbackVideo) { + try { + if (player && typeof player.currentTime === 'function') { + const val = player.currentTime(); + if (typeof val === 'number' && Number.isFinite(val)) + return val; + } + } + catch { } + try { + if (fallbackVideo && typeof fallbackVideo.currentTime === 'number' && Number.isFinite(fallbackVideo.currentTime)) { + return fallbackVideo.currentTime; + } + } + catch { } + return null; + } + readDuration(player, fallbackVideo) { + try { + if (player && typeof player.duration === 'function') { + const val = player.duration(); + if (typeof val === 'number' && Number.isFinite(val) && val >= 0) + return val; + } + } + catch { } + try { + if (fallbackVideo && typeof fallbackVideo.duration === 'number' && Number.isFinite(fallbackVideo.duration) && fallbackVideo.duration >= 0) { + return fallbackVideo.duration; + } + } + catch { } + return null; + } + getPlaybackSnapshot(state) { + var _a, _b; + const position = this.readCurrentTime(state.player, (_a = state.video) !== null && _a !== void 0 ? _a : null); + let duration = this.readDuration(state.player, (_b = state.video) !== null && _b !== void 0 ? _b : null); + if (!duration && state.duration && Number.isFinite(state.duration)) { + duration = state.duration; + } + if (duration && (!state.duration || Math.abs(state.duration - duration) > 0.5)) { + state.duration = duration; + } + const percent = duration && position !== null && position !== undefined && duration > 0 + ? (position / duration) * 100 + : undefined; + return { + position: position === null ? undefined : position, + duration: duration === null ? undefined : duration, + percent, + }; + } + isPlayerPaused(state) { + try { + if (state.player && typeof state.player.paused === 'function') { + return !!state.player.paused(); + } + } + catch { } + if (state.video) { + try { + return state.video.paused; + } + catch { } + } + return false; + } + isPlayerSeeking(state) { + try { + if (state.player && typeof state.player.seeking === 'function') { + return !!state.player.seeking(); + } + } + catch { } + if (state.video) { + try { + return state.video.seeking; + } + catch { } + } + return false; + } + attachVideoJsWatcher(state, sceneId, player) { + if (!player || typeof player.on !== 'function') + return; + if (state.player === player && state.playerDispose) + return; + if (state.playerDispose) + this.detachVideoJsWatcher(state); + const events = ['sourceset', 'loadstart', 'techloadstart', 'playerreset']; + const handler = () => { this.queuePlayerReinstrument(sceneId, player); }; + for (const evt of events) { + try { + player.on(evt, handler); + } + catch { } + } + try { + player.on('ready', handler); + } + catch { } + state.player = player; + state.playerDispose = () => { + for (const evt of events) { + try { + player.off(evt, handler); + } + catch { } + } + try { + player.off('ready', handler); + } + catch { } + this.cancelPlayerReinstrument(player); + state.playerDispose = null; + state.player = null; + }; + } + detachVideoJsWatcher(state) { + if (!state) + return; + const dispose = state.playerDispose; + if (dispose) { + try { + dispose(); + } + catch { } + } + if (state.player) + this.cancelPlayerReinstrument(state.player); + state.playerDispose = null; + state.player = null; + } + queuePlayerReinstrument(sceneId, player, attempt = 0) { + var _a; + if (!player) + return; + this.cancelPlayerReinstrument(player); + this.activateVideoJsFallbackMonitor(sceneId); + try { + if (this.currentScene && this.currentScene.sceneId === sceneId) { + // Close out any active segment before the tech swap replaces the