mirror of
https://github.com/stashapp/CommunityScripts.git
synced 2026-02-04 01:52:30 -06:00
Add AI Overhaul plugin (#645)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
c3de80e75e
commit
54ff5ca251
898
plugins/AIOverhaul/AIButton.js
Normal file
898
plugins/AIOverhaul/AIButton.js
Normal file
@ -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;
|
||||
})();
|
||||
|
||||
110
plugins/AIOverhaul/AIButtonIntegration.js
Normal file
110
plugins/AIOverhaul/AIButtonIntegration.js
Normal file
@ -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');
|
||||
})();
|
||||
})();
|
||||
|
||||
44
plugins/AIOverhaul/AIOverhaul.yml
Normal file
44
plugins/AIOverhaul/AIOverhaul.yml
Normal file
@ -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
|
||||
228
plugins/AIOverhaul/BackendBase.js
Normal file
228
plugins/AIOverhaul/BackendBase.js
Normal file
@ -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 { }
|
||||
})();
|
||||
|
||||
200
plugins/AIOverhaul/BackendHealth.js
Normal file
200
plugins/AIOverhaul/BackendHealth.js
Normal file
@ -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;
|
||||
})();
|
||||
})();
|
||||
|
||||
1867
plugins/AIOverhaul/InteractionTracker.js
Normal file
1867
plugins/AIOverhaul/InteractionTracker.js
Normal file
File diff suppressed because it is too large
Load Diff
308
plugins/AIOverhaul/PageContext.js
Normal file
308
plugins/AIOverhaul/PageContext.js
Normal file
@ -0,0 +1,308 @@
|
||||
(function(){
|
||||
// =============================================================================
|
||||
// Page Context Utility (Minimal Reset Version)
|
||||
// Exposes window.AIPageContext with detection + subscription helpers
|
||||
// =============================================================================
|
||||
detectPageContext;
|
||||
subscribe;
|
||||
// Enable verbose debug logging by setting window.AIPageContextDebug = true in the console
|
||||
function debugLog(...args) {
|
||||
if (window.AIPageContextDebug) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[AIPageContext]', ...args);
|
||||
}
|
||||
}
|
||||
const PAGE_DEFS = [
|
||||
{ key: 'scenes', segment: '/scenes', label: 'Scenes', detailLabel: id => id ? `Scene #${id}` : 'Scene Library' },
|
||||
{ key: 'galleries', segment: '/galleries', label: 'Galleries', detailLabel: id => id ? `Gallery #${id}` : 'Gallery Library' },
|
||||
{ key: 'images', segment: '/images', label: 'Images', detailLabel: id => id ? `Image #${id}` : 'Image Library' },
|
||||
{ key: 'groups', segment: '/groups', label: 'Groups', detailLabel: id => id ? `Group #${id}` : 'Group Library' },
|
||||
{ key: 'performers', segment: '/performers', label: 'Performers', detailLabel: id => id ? `Performer #${id}` : 'Performer Library' },
|
||||
{ key: 'studios', segment: '/studios', label: 'Studios', detailLabel: id => id ? `Studio #${id}` : 'Studio Library' },
|
||||
{ key: 'tags', segment: '/tags', label: 'Tags', detailLabel: id => id ? `Tag #${id}` : 'Tag Library' }
|
||||
];
|
||||
const ENTITY_PAGES = new Set(['scenes', 'performers', 'galleries', 'images', 'studios', 'tags', 'groups']);
|
||||
function extractId(path, segment) {
|
||||
const regex = new RegExp(`${segment}/(\\d+)`);
|
||||
const match = path.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
// Consolidated multi-select detection (cleaned up from legacy detectMultiSelectContext + earlier heuristic)
|
||||
// Returns an array of numeric IDs (as strings) for currently selected entities on list pages.
|
||||
// Detection strategy (in order):
|
||||
// 1. Checked selection checkboxes inside known card containers.
|
||||
// 2. Fallback to data-id on card containers.
|
||||
// 3. Fallback to elements marked with selected/is-selected classes carrying data-id.
|
||||
function collectSelectedIds(page) {
|
||||
try {
|
||||
const ids = new Set();
|
||||
// 1. Checked selection checkboxes inside cards
|
||||
const checkboxSelectors = [
|
||||
'.grid-card .card-check:checked',
|
||||
'.scene-card .card-check:checked',
|
||||
'.scene-grid-card .card-check:checked',
|
||||
'.scene-result input[type="checkbox"]:checked',
|
||||
'tr[data-id] input[type="checkbox"]:checked',
|
||||
'.performer-card .card-check:checked',
|
||||
'.gallery-card .card-check:checked',
|
||||
'.image-card .card-check:checked'
|
||||
].join(', ');
|
||||
const checked = document.querySelectorAll(checkboxSelectors);
|
||||
checked.forEach(cb => {
|
||||
const card = cb.closest('.grid-card, .scene-card, .scene-grid-card, .scene-result, tr[data-id], li[data-id], .performer-card, .gallery-card, .image-card');
|
||||
if (!card)
|
||||
return;
|
||||
// Prefer extracting from inner anchor href (stable route pattern)
|
||||
const link = card.querySelector('a[href*="/scenes/"], a[href*="/performers/"], a[href*="/galleries/"], a[href*="/images/"], a[href*="/studios/"], a[href*="/tags/"], a[href*="/groups/"]');
|
||||
if (link) {
|
||||
const href = link.getAttribute('href') || link.href;
|
||||
const m = href.match(/\/(scenes|performers|galleries|images|studios|tags|groups)\/(\d+)/);
|
||||
if (m)
|
||||
ids.add(m[2]);
|
||||
}
|
||||
// Fallback: data-id attribute on card
|
||||
if (card instanceof HTMLElement) {
|
||||
const dataId = card.getAttribute('data-id');
|
||||
if (dataId && /^\d+$/.test(dataId))
|
||||
ids.add(dataId);
|
||||
}
|
||||
});
|
||||
// 2. If none via checkboxes, look for cards explicitly marked selected with data-id
|
||||
if (ids.size === 0) {
|
||||
const attrSelected = document.querySelectorAll('[data-id].selected, [data-id].is-selected, .is-selected [data-id], tr[data-id].table-active, tr[data-id].selected');
|
||||
attrSelected.forEach(el => {
|
||||
const id = el.getAttribute('data-id');
|
||||
if (id && /^\d+$/.test(id))
|
||||
ids.add(id);
|
||||
});
|
||||
}
|
||||
// 3. (Optional) Checkbox pattern with data-id directly (legacy pattern)
|
||||
if (ids.size === 0) {
|
||||
const legacyChecked = document.querySelectorAll('input[type="checkbox"][data-id]:checked');
|
||||
legacyChecked.forEach(el => {
|
||||
const id = el.getAttribute('data-id');
|
||||
if (id && /^\d+$/.test(id))
|
||||
ids.add(id);
|
||||
});
|
||||
}
|
||||
const finalIds = ids.size ? Array.from(ids) : undefined;
|
||||
debugLog('collectSelectedIds', { page, count: (finalIds === null || finalIds === void 0 ? void 0 : finalIds.length) || 0, ids: finalIds });
|
||||
return finalIds;
|
||||
}
|
||||
catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function resolveElementId(card, expectedPage) {
|
||||
if (!(card instanceof HTMLElement))
|
||||
return null;
|
||||
// Prefer anchor href patterns (stable across layouts)
|
||||
const link = card.querySelector('a[href*="/scenes/"], a[href*="/performers/"], a[href*="/galleries/"], a[href*="/images/"], a[href*="/studios/"], a[href*="/tags/"], a[href*="/groups/"]');
|
||||
if (link) {
|
||||
const href = link.getAttribute('href') || link.href;
|
||||
const match = href.match(/\/(scenes|performers|galleries|images|studios|tags|groups)\/(\d+)/);
|
||||
if (match) {
|
||||
if (!expectedPage || expectedPage === 'markers' || !ENTITY_PAGES.has(expectedPage) || match[1] === expectedPage) {
|
||||
return match[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
const dataId = card.getAttribute('data-id');
|
||||
if (dataId && /^\d+$/.test(dataId))
|
||||
return dataId;
|
||||
return null;
|
||||
}
|
||||
function collectVisibleIds(page) {
|
||||
try {
|
||||
const ids = new Set();
|
||||
const cardSelectors = '.grid-card, .scene-card, .scene-grid-card, .scene-result, tr[data-id], li[data-id], .performer-card, .gallery-card, .image-card';
|
||||
document.querySelectorAll(cardSelectors).forEach(card => {
|
||||
const id = resolveElementId(card, page);
|
||||
if (id)
|
||||
ids.add(id);
|
||||
});
|
||||
// Fallback for anchors directly under list/table views
|
||||
if (ids.size === 0) {
|
||||
document.querySelectorAll('a[href*="/scenes/"], a[href*="/performers/"], a[href*="/galleries/"], a[href*="/images/"], a[href*="/studios/"], a[href*="/tags/"]').forEach(link => {
|
||||
const href = link.getAttribute('href') || link.href;
|
||||
const match = href.match(/\/(scenes|performers|galleries|images|studios|tags|groups)\/(\d+)/);
|
||||
if (match) {
|
||||
if (!page || page === 'markers' || !ENTITY_PAGES.has(page) || match[1] === page)
|
||||
ids.add(match[2]);
|
||||
}
|
||||
});
|
||||
}
|
||||
const finalIds = ids.size ? Array.from(ids) : undefined;
|
||||
debugLog('collectVisibleIds', { page, count: (finalIds === null || finalIds === void 0 ? void 0 : finalIds.length) || 0, ids: finalIds });
|
||||
return finalIds;
|
||||
}
|
||||
catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
// (Removed legacy detectMultiSelectContext in favor of unified collectSelectedIds)
|
||||
function detectPageContext() {
|
||||
const path = window.location.pathname;
|
||||
const cleanPath = path.split('?')[0];
|
||||
const segments = cleanPath.split('/').filter(Boolean); // e.g. performers / 1962 / scenes
|
||||
// Home / empty
|
||||
if (segments.length === 0 || segments[0] === 'home') {
|
||||
const ctx = {
|
||||
page: 'home',
|
||||
entityId: null,
|
||||
isDetailView: false,
|
||||
contextLabel: 'Home',
|
||||
detailLabel: 'Dashboard',
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
if (segments[0] === 'settings') {
|
||||
const ctx = {
|
||||
page: 'settings',
|
||||
entityId: null,
|
||||
isDetailView: false,
|
||||
contextLabel: 'Settings',
|
||||
detailLabel: 'Settings',
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
// Primary determination from first segment only
|
||||
const primarySegment = '/' + segments[0];
|
||||
let def = PAGE_DEFS.find(d => d.segment === primarySegment);
|
||||
// SPECIAL CASES:
|
||||
// Performer detail sub-routes like /performers/:id/scenes should remain performers
|
||||
if (segments[0] === 'performers' && segments[1] && /^\d+$/.test(segments[1])) {
|
||||
def = PAGE_DEFS.find(d => d.key === 'performers');
|
||||
}
|
||||
// Studios detail sub-routes /studios/:id/scenes
|
||||
if (segments[0] === 'studios' && segments[1] && /^\d+$/.test(segments[1])) {
|
||||
def = PAGE_DEFS.find(d => d.key === 'studios');
|
||||
}
|
||||
// Tags detail sub-routes /tags/:id/scenes
|
||||
if (segments[0] === 'tags' && segments[1] && /^\d+$/.test(segments[1])) {
|
||||
def = PAGE_DEFS.find(d => d.key === 'tags');
|
||||
}
|
||||
// MARKERS: treated as a virtual page when under /scenes/markers or /scenes?foo containing markers view.
|
||||
// If first segment is 'scenes' and second is 'markers' we expose page=markers (no entity detail)
|
||||
if (segments[0] === 'scenes' && segments[1] === 'markers') {
|
||||
const ctx = {
|
||||
page: 'markers',
|
||||
entityId: null,
|
||||
isDetailView: false,
|
||||
contextLabel: 'Markers',
|
||||
detailLabel: 'Markers Browser',
|
||||
selectedIds: collectSelectedIds('markers'),
|
||||
visibleIds: collectVisibleIds('markers')
|
||||
};
|
||||
debugLog('detectPageContext -> markers special', ctx, { segments });
|
||||
return ctx;
|
||||
}
|
||||
if (def) {
|
||||
// Determine detail ID (second segment numeric) ignoring trailing library-like segments
|
||||
let id = null;
|
||||
if (segments[1] && /^\d+$/.test(segments[1])) {
|
||||
id = segments[1];
|
||||
}
|
||||
else {
|
||||
id = extractId(cleanPath, def.segment);
|
||||
}
|
||||
const isDetail = !!id;
|
||||
const ctx = {
|
||||
page: def.key,
|
||||
entityId: id,
|
||||
isDetailView: isDetail,
|
||||
contextLabel: def.label,
|
||||
detailLabel: def.detailLabel(id),
|
||||
selectedIds: !isDetail ? collectSelectedIds(def.key) : undefined,
|
||||
visibleIds: !isDetail ? collectVisibleIds(def.key) : undefined
|
||||
};
|
||||
debugLog('detectPageContext -> match', ctx, { segments });
|
||||
return ctx;
|
||||
}
|
||||
const unknown = {
|
||||
page: 'unknown',
|
||||
entityId: null,
|
||||
isDetailView: false,
|
||||
contextLabel: 'Unknown Page',
|
||||
detailLabel: 'Unknown Location',
|
||||
selectedIds: undefined,
|
||||
visibleIds: undefined
|
||||
};
|
||||
debugLog('detectPageContext -> unknown', unknown, { segments });
|
||||
return unknown;
|
||||
}
|
||||
// Simple pub/sub for changes (future friendly)
|
||||
const listeners = [];
|
||||
let currentContext = detectPageContext();
|
||||
let refreshTimer;
|
||||
function notify() {
|
||||
listeners.forEach(l => {
|
||||
try {
|
||||
l(currentContext);
|
||||
}
|
||||
catch (_) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
function scheduleRefresh(delay = 75) {
|
||||
if (refreshTimer !== undefined) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
}
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = undefined;
|
||||
refreshContext();
|
||||
}, delay);
|
||||
}
|
||||
function hashIds(ids) {
|
||||
return ids && ids.length ? ids.slice().sort().join(',') : '';
|
||||
}
|
||||
function refreshContext() {
|
||||
const next = detectPageContext();
|
||||
const changed = (next.page !== currentContext.page ||
|
||||
next.entityId !== currentContext.entityId ||
|
||||
next.isDetailView !== currentContext.isDetailView ||
|
||||
hashIds(next.selectedIds) !== hashIds(currentContext.selectedIds) ||
|
||||
hashIds(next.visibleIds) !== hashIds(currentContext.visibleIds));
|
||||
if (changed) {
|
||||
debugLog('Context changed', { from: currentContext, to: next });
|
||||
currentContext = next;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
function subscribe(listener) {
|
||||
listeners.push(listener);
|
||||
// immediate sync
|
||||
listener(currentContext);
|
||||
return () => {
|
||||
const idx = listeners.indexOf(listener);
|
||||
if (idx >= 0)
|
||||
listeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
// Observe navigation changes
|
||||
window.addEventListener('popstate', () => scheduleRefresh(50));
|
||||
const mutationObserver = new MutationObserver(() => scheduleRefresh(100));
|
||||
mutationObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'aria-selected', 'data-selected', 'data-id', 'checked']
|
||||
});
|
||||
// Selection toggles often fire only change events; ensure we refresh context after them.
|
||||
document.addEventListener('change', (event) => {
|
||||
const target = event.target;
|
||||
if (!target)
|
||||
return;
|
||||
if (target.matches('input[type="checkbox"], input[type="radio"], [data-id]')) {
|
||||
scheduleRefresh(75);
|
||||
}
|
||||
}, true);
|
||||
// Expose on window
|
||||
;
|
||||
window.AIPageContext = {
|
||||
detect: detectPageContext,
|
||||
subscribe,
|
||||
get: () => currentContext,
|
||||
forceRefresh: () => refreshContext()
|
||||
};
|
||||
})();
|
||||
|
||||
2484
plugins/AIOverhaul/PluginSettings.js
Normal file
2484
plugins/AIOverhaul/PluginSettings.js
Normal file
File diff suppressed because it is too large
Load Diff
5
plugins/AIOverhaul/README.md
Normal file
5
plugins/AIOverhaul/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# AI Overhaul
|
||||
|
||||
|
||||
# For details around this plugin and using and configuring it, see the official documentation here:
|
||||
https://github.com/skier233/Stash-AIServer/wiki/AI-Overhaul-Installation-Instructions
|
||||
710
plugins/AIOverhaul/RecommendationUtils.js
Normal file
710
plugins/AIOverhaul/RecommendationUtils.js
Normal file
@ -0,0 +1,710 @@
|
||||
(function(){
|
||||
// Shared utilities for recommendation components
|
||||
// Extracted from RecommendedScenes.tsx for reuse in SimilarScenes.tsx
|
||||
(function () {
|
||||
const w = window;
|
||||
// Safer initialization - wait for everything to be ready
|
||||
function initializeRecommendationUtils() {
|
||||
const PluginApi = w.PluginApi;
|
||||
if (!PluginApi || !PluginApi.React) {
|
||||
console.warn('[RecommendationUtils] PluginApi or React not available');
|
||||
return;
|
||||
}
|
||||
// Validate React hooks are available
|
||||
if (!PluginApi.React.useState || !PluginApi.React.useMemo || !PluginApi.React.useEffect || !PluginApi.React.useRef) {
|
||||
console.warn('[RecommendationUtils] React hooks not available');
|
||||
return;
|
||||
}
|
||||
const React = PluginApi.React;
|
||||
const { useState, useMemo, useEffect, useRef } = React;
|
||||
// Upstream grid hooks copied from GridCard.tsx for exact parity
|
||||
function useDebounce(fn, delay) {
|
||||
const timeoutRef = useRef(null);
|
||||
return useMemo(() => (...args) => {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => fn(...args), delay);
|
||||
}, [fn, delay]);
|
||||
}
|
||||
function useResizeObserver(target, callback) {
|
||||
useEffect(() => {
|
||||
if (!target.current || typeof ResizeObserver === 'undefined')
|
||||
return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
if (entries && entries.length > 0) {
|
||||
callback(entries[0]);
|
||||
}
|
||||
});
|
||||
ro.observe(target.current);
|
||||
return () => ro.disconnect();
|
||||
}, [target, callback]);
|
||||
}
|
||||
function calculateCardWidth(containerWidth, preferredWidth) {
|
||||
const root = typeof window !== 'undefined' ? window.getComputedStyle(document.documentElement) : null;
|
||||
const containerPadding = root ? parseFloat(root.getPropertyValue('--ai-rec-container-padding')) : 30;
|
||||
const cardMargin = root ? parseFloat(root.getPropertyValue('--ai-rec-card-margin')) : 10;
|
||||
const maxUsableWidth = containerWidth - containerPadding;
|
||||
const maxElementsOnRow = Math.ceil(maxUsableWidth / preferredWidth);
|
||||
const width = maxUsableWidth / maxElementsOnRow - cardMargin;
|
||||
return width;
|
||||
}
|
||||
function useContainerDimensions(sensitivityThreshold = 20) {
|
||||
const target = useRef(null);
|
||||
const [dimension, setDimension] = useState({ width: 0, height: 0 });
|
||||
const debouncedSetDimension = useDebounce((entry) => {
|
||||
if (!entry.contentBoxSize || !entry.contentBoxSize.length)
|
||||
return;
|
||||
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0];
|
||||
let difference = Math.abs(dimension.width - width);
|
||||
if (difference > sensitivityThreshold) {
|
||||
setDimension({ width, height });
|
||||
}
|
||||
}, 50);
|
||||
useResizeObserver(target, debouncedSetDimension);
|
||||
useEffect(() => {
|
||||
if (target.current && dimension.width === 0) {
|
||||
const rect = target.current.getBoundingClientRect();
|
||||
if (rect.width > 0) {
|
||||
setDimension({ width: rect.width, height: rect.height });
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
return [target, dimension];
|
||||
}
|
||||
function useCardWidth(containerWidth, zoomIndex, zoomWidths) {
|
||||
return useMemo(() => {
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
if (isMobile)
|
||||
return undefined;
|
||||
const effectiveWidth = (containerWidth ? containerWidth : 1200);
|
||||
if (zoomIndex === undefined || zoomIndex < 0 || zoomIndex >= zoomWidths.length) {
|
||||
return undefined;
|
||||
}
|
||||
const preferredCardWidth = zoomWidths[zoomIndex];
|
||||
return calculateCardWidth(effectiveWidth, preferredCardWidth);
|
||||
}, [containerWidth, zoomIndex, zoomWidths]);
|
||||
}
|
||||
// Constraint Editor Component
|
||||
function ConstraintEditor({ tagId, constraint, tagName, value, fieldName, onSave, onCancel, allowedConstraintTypes, entity: popupEntity, compositeRawRef, popupPosition }) {
|
||||
const [localConstraint, setLocalConstraint] = React.useState(constraint);
|
||||
const localConstraintRef = React.useRef(localConstraint);
|
||||
React.useEffect(() => { localConstraintRef.current = localConstraint; }, [localConstraint]);
|
||||
const canceledRef = React.useRef(false);
|
||||
function lookupLocalName(id, forEntity) {
|
||||
try {
|
||||
const ent = forEntity || popupEntity || 'tag';
|
||||
const key = fieldName + '__' + (ent === 'performer' ? 'performerNameMap' : 'tagNameMap');
|
||||
const map = compositeRawRef && compositeRawRef.current ? (compositeRawRef.current[key] || {}) : {};
|
||||
return map[id] || (ent === 'performer' ? `Performer ${id}` : `Tag ${id}`);
|
||||
}
|
||||
catch (_) {
|
||||
return forEntity === 'performer' ? `Performer ${id}` : `Tag ${id}`;
|
||||
}
|
||||
}
|
||||
React.useEffect(() => { setLocalConstraint(constraint); }, [constraint]);
|
||||
const allConstraintTypes = [
|
||||
{ value: 'presence', label: 'Include/Exclude' },
|
||||
{ value: 'duration', label: 'Duration Filter' },
|
||||
{ value: 'overlap', label: 'Co-occurrence' },
|
||||
{ value: 'importance', label: 'Importance Weight' }
|
||||
];
|
||||
const constraintTypes = Array.isArray(allowedConstraintTypes) && allowedConstraintTypes.length > 0
|
||||
? allConstraintTypes.filter(ct => allowedConstraintTypes.includes(ct.value))
|
||||
: allConstraintTypes;
|
||||
const overlapTagData = React.useMemo(() => {
|
||||
if (localConstraint.type !== 'overlap')
|
||||
return { availableTags: [] };
|
||||
const allCoPrimaries = new Set();
|
||||
[...((value === null || value === void 0 ? void 0 : value.include) || []), ...((value === null || value === void 0 ? void 0 : value.exclude) || [])].forEach(id => {
|
||||
var _a, _b;
|
||||
const c = ((value === null || value === void 0 ? void 0 : value.constraints) || {})[id] || { type: 'presence' };
|
||||
if (c.type === 'overlap' && ((_b = (_a = c.overlap) === null || _a === void 0 ? void 0 : _a.coTags) === null || _b === void 0 ? void 0 : _b.length) > 0 && id !== tagId) {
|
||||
allCoPrimaries.add(id);
|
||||
}
|
||||
});
|
||||
const availableTags = [...((value === null || value === void 0 ? void 0 : value.include) || []), ...((value === null || value === void 0 ? void 0 : value.exclude) || [])]
|
||||
.filter(id => id !== tagId && !allCoPrimaries.has(id));
|
||||
return { availableTags };
|
||||
}, [localConstraint.type, value === null || value === void 0 ? void 0 : value.include, value === null || value === void 0 ? void 0 : value.exclude, value === null || value === void 0 ? void 0 : value.constraints, tagId]);
|
||||
function handleTypeChange(newType) {
|
||||
let nc = { type: newType };
|
||||
switch (newType) {
|
||||
case 'presence':
|
||||
nc.presence = 'include';
|
||||
break;
|
||||
case 'duration':
|
||||
nc.duration = { min: 10, max: 60, unit: 'percent' };
|
||||
break;
|
||||
case 'overlap':
|
||||
nc.overlap = { minDuration: 5, maxDuration: 30, unit: 'percent' };
|
||||
break;
|
||||
case 'importance':
|
||||
nc.importance = 0.5;
|
||||
break;
|
||||
}
|
||||
setLocalConstraint(nc);
|
||||
}
|
||||
function renderOptions() {
|
||||
var _a, _b, _c, _d, _e, _f, _g;
|
||||
switch (localConstraint.type) {
|
||||
case 'presence':
|
||||
return React.createElement('div', { className: 'constraint-options' }, [
|
||||
React.createElement('label', { key: 'lbl' }, 'Mode: '),
|
||||
React.createElement('select', { key: 'sel', value: localConstraint.presence || 'include', onChange: (e) => setLocalConstraint((p) => ({ ...p, presence: e.target.value })) }, [
|
||||
React.createElement('option', { key: 'inc', value: 'include' }, 'Include'),
|
||||
React.createElement('option', { key: 'exc', value: 'exclude' }, 'Exclude')
|
||||
])
|
||||
]);
|
||||
case 'duration':
|
||||
return React.createElement('div', { className: 'constraint-options' }, [
|
||||
React.createElement('div', { key: 'range' }, [
|
||||
React.createElement('label', { key: 'lbl' }, 'Duration: '),
|
||||
React.createElement('input', { key: 'min', type: 'number', placeholder: 'Min', value: ((_a = localConstraint.duration) === null || _a === void 0 ? void 0 : _a.min) || '', onChange: (e) => setLocalConstraint((p) => ({ ...p, duration: { ...p.duration, min: e.target.value ? Number(e.target.value) : undefined } })) }),
|
||||
React.createElement('span', { key: 'dash' }, ' - '),
|
||||
React.createElement('input', { key: 'max', type: 'number', placeholder: 'Max', value: ((_b = localConstraint.duration) === null || _b === void 0 ? void 0 : _b.max) || '', onChange: (e) => setLocalConstraint((p) => ({ ...p, duration: { ...p.duration, max: e.target.value ? Number(e.target.value) : undefined } })) })
|
||||
]),
|
||||
React.createElement('div', { key: 'unit' }, [
|
||||
React.createElement('label', { key: 'lbl' }, 'Unit: '),
|
||||
React.createElement('select', { key: 'sel', value: ((_c = localConstraint.duration) === null || _c === void 0 ? void 0 : _c.unit) || 'percent', onChange: (e) => setLocalConstraint((p) => ({ ...p, duration: { ...p.duration, unit: e.target.value } })) }, [
|
||||
React.createElement('option', { key: 'pct', value: 'percent' }, '% of video'),
|
||||
React.createElement('option', { key: 'sec', value: 'seconds' }, 'Seconds')
|
||||
])
|
||||
])
|
||||
]);
|
||||
case 'overlap': {
|
||||
const available = overlapTagData.availableTags;
|
||||
const selected = ((_d = localConstraint.overlap) === null || _d === void 0 ? void 0 : _d.coTags) || [];
|
||||
const entity = popupEntity || localConstraint._entity || 'tag';
|
||||
return React.createElement('div', { className: 'constraint-options' }, [
|
||||
React.createElement('div', { key: 'info' }, `Co-occurrence with other selected ${entity === 'performer' ? 'performers' : 'tags'}`),
|
||||
React.createElement('div', { key: 'selwrap' }, [
|
||||
React.createElement('label', { key: 'lbl' }, 'Selected for co-occurrence: '),
|
||||
React.createElement('div', { key: 'selected', className: 'constraint-selected-tags' }, selected.length ? selected.map((cid) => {
|
||||
const nm = lookupLocalName(cid, entity);
|
||||
return React.createElement('span', { key: cid, className: 'constraint-cochip-tag' }, [
|
||||
nm,
|
||||
React.createElement('button', { key: 'rm', onClick: () => { const n = selected.filter((i) => i !== cid); setLocalConstraint((p) => ({ ...p, overlap: { ...p.overlap, coTags: n } })); }, className: 'constraint-cochip-remove' }, '×')
|
||||
]);
|
||||
}) : React.createElement('span', { className: 'constraint-selected-empty' }, 'No tags selected for co-occurrence')),
|
||||
available.length ? React.createElement('div', { key: 'avail', className: 'constraint-available-tags' }, available.map((cid) => { if (selected.includes(cid))
|
||||
return null; const nm = lookupLocalName(cid, entity); return React.createElement('button', { key: cid, className: 'constraint-tag-button', onClick: () => { const n = [...selected, cid]; setLocalConstraint((p) => ({ ...p, overlap: { ...p.overlap, coTags: n } })); } }, nm); })) : null
|
||||
]),
|
||||
React.createElement('div', { key: 'range' }, [
|
||||
React.createElement('label', { key: 'lbl' }, 'Overlap duration: '),
|
||||
React.createElement('input', { key: 'min', type: 'number', placeholder: 'Min', value: ((_e = localConstraint.overlap) === null || _e === void 0 ? void 0 : _e.minDuration) || '', onChange: (e) => setLocalConstraint((p) => ({ ...p, overlap: { ...p.overlap, minDuration: e.target.value ? Number(e.target.value) : undefined } })) }),
|
||||
React.createElement('span', { key: 'dash' }, ' - '),
|
||||
React.createElement('input', { key: 'max', type: 'number', placeholder: 'Max', value: ((_f = localConstraint.overlap) === null || _f === void 0 ? void 0 : _f.maxDuration) || '', onChange: (e) => setLocalConstraint((p) => ({ ...p, overlap: { ...p.overlap, maxDuration: e.target.value ? Number(e.target.value) : undefined } })) })
|
||||
]),
|
||||
React.createElement('div', { key: 'unit' }, [
|
||||
React.createElement('label', { key: 'lbl' }, 'Unit: '),
|
||||
React.createElement('select', { key: 'sel', value: ((_g = localConstraint.overlap) === null || _g === void 0 ? void 0 : _g.unit) || 'percent', onChange: (e) => setLocalConstraint((p) => ({ ...p, overlap: { ...p.overlap, unit: e.target.value } })) }, [
|
||||
React.createElement('option', { key: 'pct', value: 'percent' }, '% of video'),
|
||||
React.createElement('option', { key: 'sec', value: 'seconds' }, 'Seconds')
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
case 'importance':
|
||||
return React.createElement('div', { className: 'constraint-options' }, [
|
||||
React.createElement('label', { key: 'lbl' }, 'Weight (0.0 - 1.0): '),
|
||||
React.createElement('input', { key: 'in', type: 'number', step: '0.1', min: '0', max: '1', value: localConstraint.importance || 0.5, onChange: (e) => setLocalConstraint((p) => ({ ...p, importance: Number(e.target.value) })) })
|
||||
]);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
React.useEffect(() => { return () => { try {
|
||||
if (!canceledRef.current)
|
||||
onSave(localConstraintRef.current);
|
||||
}
|
||||
catch (_) { } }; }, [onSave]);
|
||||
React.useEffect(() => { function onKey(e) { if (e.key === 'Escape') {
|
||||
canceledRef.current = true;
|
||||
onCancel();
|
||||
} } document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [onCancel]);
|
||||
return React.createElement('div', { className: 'constraint-popup-overlay' }, [
|
||||
React.createElement('div', { key: 'popup', className: 'constraint-popup', style: popupPosition ? { position: 'absolute', left: popupPosition.x, top: popupPosition.y, zIndex: 9999 } : {} }, [
|
||||
React.createElement('div', { key: 'title', className: 'constraint-title' }, `Configure: ${tagName || lookupLocalName(tagId)}`),
|
||||
React.createElement('div', { key: 'type', className: 'constraint-type' }, [
|
||||
React.createElement('label', { key: 'lbl' }, 'Type: '),
|
||||
React.createElement('select', { key: 'sel', value: localConstraint.type, onChange: (e) => handleTypeChange(e.target.value) }, constraintTypes.map(ct => React.createElement('option', { key: ct.value, value: ct.value }, ct.label)))
|
||||
]),
|
||||
renderOptions(),
|
||||
React.createElement('div', { key: 'actions', className: 'constraint-actions' }, [
|
||||
React.createElement('button', { key: 'save', className: 'btn-constraint btn-save', onClick: (e) => { e.stopPropagation(); onSave(localConstraint); } }, 'Save')
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
// Tag Selector Component
|
||||
function createTagSelector(options) {
|
||||
const { value: v, onChange, entity = 'tag', fieldName, label = entity === 'performer' ? 'Performers' : 'Tags', allowedConstraintTypes, allowedCombinationModes, initialTagCombination, compositeRawRef } = options;
|
||||
const include = Array.isArray(v) ? v : Array.isArray(v === null || v === void 0 ? void 0 : v.include) ? v.include : [];
|
||||
const exclude = Array.isArray(v) ? [] : Array.isArray(v === null || v === void 0 ? void 0 : v.exclude) ? v.exclude : [];
|
||||
const constraints = (v === null || v === void 0 ? void 0 : v.constraints) || {};
|
||||
// Combination mode logic
|
||||
const normalizeMode = (m) => (m == null ? undefined : String(m).toLowerCase());
|
||||
const allowedNorm = Array.isArray(allowedCombinationModes) && allowedCombinationModes.length > 0
|
||||
? allowedCombinationModes.map(normalizeMode).filter(Boolean)
|
||||
: [];
|
||||
const initLC = typeof initialTagCombination === 'string' ? normalizeMode(initialTagCombination) : undefined;
|
||||
const resolvedAllowedModes = (allowedNorm.length > 0 ? allowedNorm : (typeof initLC !== 'undefined' ? [initLC] : ['and', 'or']));
|
||||
const rawValueMode = (v && Object.prototype.hasOwnProperty.call(v, 'tag_combination')) ? v.tag_combination : undefined;
|
||||
const valueMode = normalizeMode(rawValueMode);
|
||||
const isValidMode = (m) => m === 'and' || m === 'or' || m === 'not-applicable';
|
||||
const initialMode = (isValidMode(valueMode) ? valueMode : (isValidMode(initLC) ? initLC : resolvedAllowedModes[0]));
|
||||
const [searchState, setSearchState] = React.useState({
|
||||
search: '',
|
||||
suggestions: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
showDropdown: false,
|
||||
combinationMode: initialMode
|
||||
});
|
||||
const instanceIdRef = React.useRef(null);
|
||||
if (!instanceIdRef.current) {
|
||||
try {
|
||||
w.__aiTagFallbackCounter = (w.__aiTagFallbackCounter || 0) + 1;
|
||||
instanceIdRef.current = w.__aiTagFallbackCounter;
|
||||
}
|
||||
catch (e) {
|
||||
instanceIdRef.current = Math.floor(Math.random() * 1000000);
|
||||
}
|
||||
}
|
||||
const [constraintPopup, setConstraintPopup] = React.useState(null);
|
||||
const nameMapKey = fieldName + '__' + (entity === 'performer' ? 'performerNameMap' : 'tagNameMap');
|
||||
if (!compositeRawRef.current[nameMapKey]) {
|
||||
compositeRawRef.current[nameMapKey] = {};
|
||||
}
|
||||
const tagNameMap = compositeRawRef.current[nameMapKey];
|
||||
function lookupName(id, forEntity) {
|
||||
const ent = forEntity || entity || 'tag';
|
||||
const key = fieldName + '__' + (ent === 'performer' ? 'performerNameMap' : 'tagNameMap');
|
||||
const map = compositeRawRef.current[key] || {};
|
||||
return map[id] || (ent === 'performer' ? `Performer ${id}` : `Tag ${id}`);
|
||||
}
|
||||
const debounceTimerRef = React.useRef(null);
|
||||
const tagInputRef = React.useRef(null);
|
||||
// Return the complete tag selector component
|
||||
return {
|
||||
lookupName,
|
||||
searchState,
|
||||
setSearchState,
|
||||
constraintPopup,
|
||||
setConstraintPopup,
|
||||
tagInputRef,
|
||||
instanceIdRef,
|
||||
resolvedAllowedModes,
|
||||
include,
|
||||
exclude,
|
||||
constraints
|
||||
};
|
||||
}
|
||||
// Advanced Tag Include/Exclude Selector with constraints (extracted from RecommendedScenes)
|
||||
function TagIncludeExclude({ value, onChange, fieldName, initialTagCombination, allowedConstraintTypes, allowedCombinationModes, entity = 'tag', compositeRawRef: extCompositeRef }) {
|
||||
const React = PluginApi.React;
|
||||
const compositeRef = extCompositeRef || React.useRef({});
|
||||
const v = value || {};
|
||||
const include = Array.isArray(v) ? v : Array.isArray(v.include) ? v.include : [];
|
||||
const exclude = Array.isArray(v) ? [] : Array.isArray(v.exclude) ? v.exclude : [];
|
||||
const constraints = v.constraints || {};
|
||||
const normalizeMode = (m) => (m == null ? undefined : String(m).toLowerCase());
|
||||
const allowedNorm = Array.isArray(allowedCombinationModes) && allowedCombinationModes.length > 0
|
||||
? allowedCombinationModes.map(normalizeMode).filter(Boolean)
|
||||
: [];
|
||||
const initLC = typeof initialTagCombination === 'string' ? normalizeMode(initialTagCombination) : undefined;
|
||||
const resolvedAllowedModes = (allowedNorm.length > 0 ? allowedNorm : (typeof initLC !== 'undefined' ? [initLC] : ['and', 'or']));
|
||||
const rawValueMode = (v && Object.prototype.hasOwnProperty.call(v, 'tag_combination')) ? v.tag_combination : undefined;
|
||||
const valueMode = normalizeMode(rawValueMode);
|
||||
const isValidMode = (m) => m === 'and' || m === 'or' || m === 'not-applicable';
|
||||
const initialMode = (isValidMode(valueMode) ? valueMode : (isValidMode(initLC) ? initLC : resolvedAllowedModes[0]));
|
||||
const [searchState, setSearchState] = React.useState({ search: '', suggestions: [], loading: false, error: null, showDropdown: false, combinationMode: initialMode });
|
||||
const instanceIdRef = React.useRef(null);
|
||||
if (!instanceIdRef.current) {
|
||||
try {
|
||||
w.__aiTagFallbackCounter = (w.__aiTagFallbackCounter || 0) + 1;
|
||||
instanceIdRef.current = w.__aiTagFallbackCounter;
|
||||
}
|
||||
catch (e) {
|
||||
instanceIdRef.current = Math.floor(Math.random() * 1000000);
|
||||
}
|
||||
}
|
||||
React.useEffect(() => { function onOtherOpen(ev) { try {
|
||||
const otherId = ev && ev.detail && ev.detail.id;
|
||||
if (otherId && otherId !== instanceIdRef.current) {
|
||||
setSearchState((prev) => ({ ...prev, showDropdown: false }));
|
||||
}
|
||||
}
|
||||
catch (_) { } } document.addEventListener('ai-tag-fallback-open', onOtherOpen); return () => document.removeEventListener('ai-tag-fallback-open', onOtherOpen); }, []);
|
||||
React.useEffect(() => { const externalModeRaw = v && Object.prototype.hasOwnProperty.call(v, 'tag_combination') ? v.tag_combination : undefined; const externalMode = normalizeMode(externalModeRaw); if (externalMode && externalMode !== searchState.combinationMode && (externalMode === 'and' || externalMode === 'or' || externalMode === 'not-applicable')) {
|
||||
setSearchState((prev) => ({ ...prev, combinationMode: externalMode }));
|
||||
} }, [v && v.tag_combination]);
|
||||
const [constraintPopup, setConstraintPopup] = React.useState(null);
|
||||
const nameMapKey = fieldName + '__' + (entity === 'performer' ? 'performerNameMap' : 'tagNameMap');
|
||||
if (!compositeRef.current[nameMapKey]) {
|
||||
compositeRef.current[nameMapKey] = {};
|
||||
}
|
||||
const tagNameMap = compositeRef.current[nameMapKey];
|
||||
function lookupName(id, forEntity) { const ent = forEntity || entity || 'tag'; const key = fieldName + '__' + (ent === 'performer' ? 'performerNameMap' : 'tagNameMap'); const map = compositeRef.current[key] || {}; return map[id] || (ent === 'performer' ? `Performer ${id}` : `Tag ${id}`); }
|
||||
const debounceTimerRef = React.useRef(null);
|
||||
function removeTag(id, list) { const nextInclude = list === 'include' ? include.filter(i => i !== id) : include; const nextExclude = list === 'exclude' ? exclude.filter(i => i !== id) : exclude; const nextConstraints = { ...constraints }; delete nextConstraints[id]; onChange({ include: nextInclude, exclude: nextExclude, constraints: nextConstraints, tag_combination: searchState.combinationMode }); }
|
||||
function updateTagConstraint(tagId, constraint) {
|
||||
const nextConstraints = { ...constraints };
|
||||
let nextInclude = [...include];
|
||||
let nextExclude = [...exclude];
|
||||
nextConstraints[tagId] = constraint;
|
||||
// If this is overlap with coTags, make sure those co-occurrence tags are included so they get hydrated
|
||||
if (constraint.type === 'overlap' && constraint.overlap && constraint.overlap.coTags) {
|
||||
constraint.overlap.coTags.forEach((coTagId) => {
|
||||
if (!nextInclude.includes(coTagId) && !nextExclude.includes(coTagId)) {
|
||||
nextInclude.push(coTagId);
|
||||
}
|
||||
});
|
||||
}
|
||||
// If this is a presence constraint, ensure tag is placed in the right set and removed from the other
|
||||
if (constraint.type === 'presence') {
|
||||
// remove from both then add to the selected list
|
||||
nextInclude = nextInclude.filter(id => id !== tagId);
|
||||
nextExclude = nextExclude.filter(id => id !== tagId);
|
||||
if (constraint.presence === 'exclude') {
|
||||
nextExclude.push(tagId);
|
||||
}
|
||||
else {
|
||||
nextInclude.push(tagId);
|
||||
}
|
||||
// store constraint and persist
|
||||
nextConstraints[tagId] = constraint;
|
||||
onChange({ include: nextInclude, exclude: nextExclude, constraints: nextConstraints, tag_combination: searchState.combinationMode });
|
||||
return;
|
||||
}
|
||||
// If this is an overlap constraint with coTags, remove those coTags from include/exclude lists
|
||||
if (constraint.type === 'overlap' && constraint.overlap && constraint.overlap.coTags) {
|
||||
const coTags = constraint.overlap.coTags;
|
||||
nextInclude = nextInclude.filter(id => !coTags.includes(id));
|
||||
nextExclude = nextExclude.filter(id => !coTags.includes(id));
|
||||
// Also remove constraints for the co-occurrence tags since they're now part of this tag's constraint
|
||||
coTags.forEach((coTagId) => {
|
||||
delete nextConstraints[coTagId];
|
||||
});
|
||||
}
|
||||
// Ensure primary tag is present in include list for non-presence constraints
|
||||
if (!nextInclude.includes(tagId) && !nextExclude.includes(tagId)) {
|
||||
nextInclude.push(tagId);
|
||||
}
|
||||
onChange({ include: nextInclude, exclude: nextExclude, constraints: nextConstraints, tag_combination: searchState.combinationMode });
|
||||
}
|
||||
function getTagConstraint(tagId) { return constraints[tagId] || { type: 'presence', presence: include.includes(tagId) ? 'include' : 'exclude' }; }
|
||||
function showConstraintPopup(tagId, event, popupEntity) { const rect = event.target.getBoundingClientRect(); setConstraintPopup({ tagId, entity: popupEntity || entity, position: { x: rect.left, y: rect.bottom + 5 } }); event.stopPropagation(); }
|
||||
const tagInputRef = React.useRef(null);
|
||||
function addTag(id, name) { if (!include.includes(id) && !exclude.includes(id)) {
|
||||
onChange({ include: [...include, id], exclude, constraints, tag_combination: searchState.combinationMode });
|
||||
} if (name)
|
||||
tagNameMap[id] = name; if (debounceTimerRef.current)
|
||||
clearTimeout(debounceTimerRef.current); setSearchState((prev) => ({ ...prev, search: '', suggestions: [], showDropdown: false })); }
|
||||
function search(term) { if (debounceTimerRef.current)
|
||||
clearTimeout(debounceTimerRef.current); setSearchState((prev) => ({ ...prev, search: term })); const q = term.trim(); const immediate = q === ''; const run = async () => { var _a, _b, _c, _d; setSearchState((prev) => ({ ...prev, loading: true, error: null })); try {
|
||||
let gql;
|
||||
if (entity === 'performer') {
|
||||
gql = q ? `query PerformerSuggest($term: String!) { findPerformers(filter: { per_page: 20 }, performer_filter: { name: { value: $term, modifier: INCLUDES } }) { performers { id name } } }` : `query PerformerSuggest { findPerformers(filter: { per_page: 20 }) { performers { id name } } }`;
|
||||
}
|
||||
else {
|
||||
gql = q ? `query TagSuggest($term: String!) { findTags(filter: { per_page: 20 }, tag_filter: { name: { value: $term, modifier: INCLUDES } }) { tags { id name } } }` : `query TagSuggest { findTags(filter: { per_page: 20 }) { tags { id name } } }`;
|
||||
}
|
||||
const variables = q ? { term: q } : {};
|
||||
const res = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: gql, variables }) });
|
||||
if (!res.ok)
|
||||
throw new Error('HTTP ' + res.status);
|
||||
const json = await res.json();
|
||||
if (json.errors)
|
||||
throw new Error(json.errors.map((e) => e.message).join('; '));
|
||||
const suggestions = entity === 'performer' ? (((_b = (_a = json === null || json === void 0 ? void 0 : json.data) === null || _a === void 0 ? void 0 : _a.findPerformers) === null || _b === void 0 ? void 0 : _b.performers) || []) : (((_d = (_c = json === null || json === void 0 ? void 0 : json.data) === null || _c === void 0 ? void 0 : _c.findTags) === null || _d === void 0 ? void 0 : _d.tags) || []);
|
||||
try {
|
||||
suggestions.forEach((s) => { const sid = parseInt(s.id, 10); if (!isNaN(sid))
|
||||
tagNameMap[sid] = s.name; });
|
||||
}
|
||||
catch (e) { }
|
||||
setSearchState((prev) => ({ ...prev, suggestions, loading: false, error: suggestions.length ? null : null }));
|
||||
}
|
||||
catch (e) {
|
||||
setSearchState((prev) => ({ ...prev, error: 'Search failed', loading: false }));
|
||||
} }; if (immediate)
|
||||
run();
|
||||
else {
|
||||
debounceTimerRef.current = setTimeout(run, 200);
|
||||
} }
|
||||
function onInputFocus() { if (!searchState.showDropdown) {
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('ai-tag-fallback-open', { detail: { id: instanceIdRef.current } }));
|
||||
}
|
||||
catch (e) { }
|
||||
setSearchState((prev) => ({ ...prev, showDropdown: true }));
|
||||
if (!searchState.suggestions.length && !searchState.loading) {
|
||||
search('');
|
||||
}
|
||||
} }
|
||||
React.useEffect(() => { function handleClickOutside(event) { const target = event.target; if (!target.closest('.ai-tag-fallback.unified')) {
|
||||
setSearchState((prev) => ({ ...prev, showDropdown: false }));
|
||||
} if (!target.closest('.constraint-popup') && !target.closest('.constraint-btn')) {
|
||||
setConstraintPopup(null);
|
||||
} } if (searchState.showDropdown || constraintPopup) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
} }, [searchState.showDropdown, constraintPopup]);
|
||||
function onKeyDown(e) { if (e.key === 'Enter') {
|
||||
if (searchState.suggestions.length) {
|
||||
const firstTag = searchState.suggestions[0];
|
||||
addTag(parseInt(firstTag.id, 10), firstTag.name);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const raw = searchState.search.trim();
|
||||
if (/^[0-9]+$/.test(raw)) {
|
||||
addTag(parseInt(raw, 10));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (e.key === 'Backspace' && !searchState.search) {
|
||||
e.preventDefault();
|
||||
if (include.length) {
|
||||
removeTag(include[include.length - 1], 'include');
|
||||
}
|
||||
else if (exclude.length) {
|
||||
removeTag(exclude[exclude.length - 1], 'exclude');
|
||||
}
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
if (constraintPopup) {
|
||||
setConstraintPopup(null);
|
||||
}
|
||||
else {
|
||||
setSearchState((prev) => ({ ...prev, showDropdown: false, search: '', suggestions: [] }));
|
||||
}
|
||||
} }
|
||||
const showCombinationToggle = resolvedAllowedModes.length > 0 && resolvedAllowedModes.every(m => m !== 'not-applicable');
|
||||
const toggleClickable = resolvedAllowedModes.length > 1;
|
||||
const combinationToggle = showCombinationToggle ? React.createElement('button', { key: 'combo-toggle', type: 'button', className: `combination-toggle ${searchState.combinationMode}${toggleClickable ? '' : ' disabled'}`, disabled: !toggleClickable, onClick: toggleClickable ? (e) => { e.stopPropagation(); const currentIdx = resolvedAllowedModes.indexOf(searchState.combinationMode); const nextIdx = (currentIdx + 1) % resolvedAllowedModes.length; const nextMode = resolvedAllowedModes[nextIdx]; setSearchState((prev) => ({ ...prev, combinationMode: nextMode })); onChange({ include, exclude, constraints, tag_combination: nextMode }); } : undefined, title: toggleClickable ? `Toggle combination mode (current: ${searchState.combinationMode})` : `Combination mode: ${searchState.combinationMode} (fixed)` }, (searchState.combinationMode ? String(searchState.combinationMode).toUpperCase() : '')) : null;
|
||||
// Enhanced chip rendering with co-occurrence grouping + constraint indicators
|
||||
const chips = [];
|
||||
const processedOverlapGroups = new Set();
|
||||
function createCoOccurrenceChip(primaryId, group, setType, chipEntity = 'tag') {
|
||||
const primaryName = lookupName(primaryId, chipEntity);
|
||||
const coTags = group.coTags || [];
|
||||
const allTagIds = [primaryId, ...coTags];
|
||||
const allTagNames = allTagIds.map((id) => lookupName(id, chipEntity));
|
||||
const min = group.minDuration || 0;
|
||||
const max = group.maxDuration || '∞';
|
||||
const unit = group.unit === 'percent' ? '%' : 's';
|
||||
const chipClass = `tag-chip overlap ${setType}`;
|
||||
const groupKey = allTagIds.slice().sort().join('-');
|
||||
return React.createElement('span', { key: `co-${setType}-${groupKey}`, className: `${chipClass} co-chip` }, [
|
||||
React.createElement('span', { key: 'constraint-prefix', className: 'co-constraint-info' }, `[${min}-${max}${unit}]`),
|
||||
React.createElement('span', { key: 'tags', className: 'co-tags' }, allTagNames.map((name, idx) => React.createElement('span', { key: allTagIds[idx], className: 'co-tag-item' }, [
|
||||
React.createElement('span', { key: 'n', className: 'co-tag-name', title: name }, name),
|
||||
React.createElement('button', { key: 'x', onClick: (e) => { e.stopPropagation(); const tagIdToRemove = allTagIds[idx]; if (tagIdToRemove === primaryId) {
|
||||
removeTag(primaryId, setType);
|
||||
}
|
||||
else {
|
||||
const updatedCoTags = coTags.filter((id) => id !== tagIdToRemove);
|
||||
updateTagConstraint(primaryId, { type: 'overlap', overlap: { ...group, coTags: updatedCoTags } });
|
||||
} }, className: 'co-tag-remove', title: `Remove ${name} from group` }, '×')
|
||||
]))),
|
||||
React.createElement('span', { key: 'actions', className: 'co-actions' }, [
|
||||
React.createElement('button', { key: 'gear', className: 'constraint-btn', onClick: (e) => showConstraintPopup(primaryId, e, entity), title: 'Configure group constraint' }, '⚙'),
|
||||
React.createElement('button', { key: 'remove-group', onClick: (e) => { e.stopPropagation(); removeTag(primaryId, setType); }, className: 'co-chip-remove', title: 'Remove entire group' }, '×')
|
||||
])
|
||||
]);
|
||||
}
|
||||
// Include chips
|
||||
include.forEach(id => {
|
||||
const constraint = getTagConstraint(id);
|
||||
if (constraint.type === 'overlap' && constraint.overlap) {
|
||||
const coTags = constraint.overlap.coTags || [];
|
||||
const groupKey = [id, ...coTags].slice().sort().join('-');
|
||||
if (processedOverlapGroups.has(groupKey))
|
||||
return; // already rendered
|
||||
processedOverlapGroups.add(groupKey);
|
||||
chips.push(createCoOccurrenceChip(id, constraint.overlap, 'include', entity));
|
||||
return;
|
||||
}
|
||||
const tagName = lookupName(id, entity);
|
||||
const chipClass = `tag-chip ${constraint.type === 'presence' ? 'include' : constraint.type}`;
|
||||
let constraintText = '';
|
||||
if (constraint.type === 'duration' && constraint.duration) {
|
||||
const min = constraint.duration.min || 0;
|
||||
const max = constraint.duration.max || '∞';
|
||||
const unit = constraint.duration.unit === 'percent' ? '%' : 's';
|
||||
constraintText = ` [${min}-${max}${unit}]`;
|
||||
}
|
||||
else if (constraint.type === 'importance' && constraint.importance !== undefined) {
|
||||
try {
|
||||
constraintText = ` [×${Number(constraint.importance).toFixed(1)}]`;
|
||||
}
|
||||
catch (_) {
|
||||
constraintText = ` [×${constraint.importance}]`;
|
||||
}
|
||||
}
|
||||
chips.push(React.createElement('span', { key: 'i' + id, className: `${chipClass} tag-chip-flex` }, [
|
||||
React.createElement('span', { key: 'text', className: 'tag-chip-text' }, tagName),
|
||||
constraintText ? React.createElement('span', { key: 'constraint', className: 'tag-chip-constraint' }, constraintText) : null,
|
||||
React.createElement('div', { key: 'actions', className: 'tag-chip-actions' }, [
|
||||
React.createElement('button', { key: 'gear', className: 'constraint-btn', onClick: (e) => showConstraintPopup(id, e, entity), title: 'Configure constraint' }, '⚙'),
|
||||
React.createElement('button', { key: 'x', onClick: (e) => { e.stopPropagation(); removeTag(id, 'include'); }, title: 'Remove', className: 'tag-chip-remove' }, '×')
|
||||
])
|
||||
].filter(Boolean)));
|
||||
});
|
||||
// Exclude chips
|
||||
exclude.forEach(id => {
|
||||
const constraint = getTagConstraint(id);
|
||||
if (constraint.type === 'overlap' && constraint.overlap) {
|
||||
const coTags = constraint.overlap.coTags || [];
|
||||
const groupKey = [id, ...coTags].slice().sort().join('-');
|
||||
if (processedOverlapGroups.has(groupKey))
|
||||
return;
|
||||
processedOverlapGroups.add(groupKey);
|
||||
chips.push(createCoOccurrenceChip(id, constraint.overlap, 'exclude', entity));
|
||||
return;
|
||||
}
|
||||
const tagName = lookupName(id, entity);
|
||||
const chipClass = `tag-chip ${constraint.type === 'presence' ? 'exclude' : constraint.type}`;
|
||||
let constraintText = '';
|
||||
if (constraint.type === 'duration' && constraint.duration) {
|
||||
const min = constraint.duration.min || 0;
|
||||
const max = constraint.duration.max || '∞';
|
||||
const unit = constraint.duration.unit === 'percent' ? '%' : 's';
|
||||
constraintText = ` [${min}-${max}${unit}]`;
|
||||
}
|
||||
else if (constraint.type === 'importance' && constraint.importance !== undefined) {
|
||||
try {
|
||||
constraintText = ` [×${Number(constraint.importance).toFixed(1)}]`;
|
||||
}
|
||||
catch (_) {
|
||||
constraintText = ` [×${constraint.importance}]`;
|
||||
}
|
||||
}
|
||||
chips.push(React.createElement('span', { key: 'e' + id, className: `${chipClass} tag-chip-flex` }, [
|
||||
React.createElement('span', { key: 'text', className: 'tag-chip-text' }, tagName),
|
||||
constraintText ? React.createElement('span', { key: 'constraint', className: 'tag-chip-constraint' }, constraintText) : null,
|
||||
React.createElement('div', { key: 'actions', className: 'tag-chip-actions' }, [
|
||||
React.createElement('button', { key: 'gear', className: 'constraint-btn', onClick: (e) => showConstraintPopup(id, e, entity), title: 'Configure constraint' }, '⚙'),
|
||||
React.createElement('button', { key: 'x', onClick: (e) => { e.stopPropagation(); removeTag(id, 'exclude'); }, title: 'Remove', className: 'tag-chip-remove' }, '×')
|
||||
])
|
||||
].filter(Boolean)));
|
||||
});
|
||||
const suggestionsList = (searchState.showDropdown || searchState.search) && (searchState.suggestions.length || searchState.loading || searchState.error) ? React.createElement('div', { className: 'suggestions-list', key: 'list' }, searchState.loading ? React.createElement('div', { className: 'empty-suggest' }, 'Searching…') : searchState.error ? React.createElement('div', { className: 'empty-suggest' }, searchState.error) : searchState.suggestions.length ? searchState.suggestions.map((tg) => React.createElement('div', { key: tg.id, onClick: (e) => { e.stopPropagation(); addTag(parseInt(tg.id, 10), tg.name); } }, tg.name + ' (#' + tg.id + ')')) : React.createElement('div', { className: 'empty-suggest' }, 'No matches')) : null;
|
||||
const constraintPopupEl = constraintPopup ? React.createElement('div', { className: 'constraint-popup', style: { left: constraintPopup.position.x + 'px', top: constraintPopup.position.y + 'px' }, onClick: (e) => e.stopPropagation() }, [React.createElement(ConstraintEditor, { key: 'editor', tagId: constraintPopup.tagId, constraint: constraintPopup.initialConstraint || getTagConstraint(constraintPopup.tagId), tagName: lookupName(constraintPopup.tagId, constraintPopup && constraintPopup.entity), value: v, fieldName: fieldName, entity: constraintPopup.entity, allowedConstraintTypes, compositeRawRef: compositeRef, onSave: (constraint) => { updateTagConstraint(constraintPopup.tagId, constraint); setConstraintPopup(null); }, onCancel: () => setConstraintPopup(null), onClose: () => setConstraintPopup(null) })]) : null;
|
||||
return React.createElement('div', { className: 'ai-tag-fallback unified w-100', onClick: () => { if (tagInputRef.current)
|
||||
tagInputRef.current.focus(); } }, [combinationToggle, chips.length ? chips : React.createElement('span', { key: 'ph', className: 'text-muted small' }, 'No tags'), React.createElement('input', { key: 'inp', type: 'text', className: 'tag-input', value: searchState.search, placeholder: 'Search tags…', onChange: (e) => search(e.target.value), onKeyDown, onFocus: onInputFocus, onClick: (e) => e.stopPropagation(), ref: tagInputRef }), suggestionsList, constraintPopupEl]);
|
||||
}
|
||||
// Build standardized config control rows (shared between RecommendedScenes & SimilarScenes)
|
||||
function buildConfigRows(params) {
|
||||
const { React, defs, configValues, updateConfigField, TagIncludeExclude, compositeRawRef, narrowTagWidth } = params;
|
||||
return defs.map(field => {
|
||||
var _a, _b, _c;
|
||||
const val = configValues[field.name];
|
||||
const id = 'cfg_' + field.name;
|
||||
let control = null;
|
||||
switch (field.type) {
|
||||
case 'number':
|
||||
control = React.createElement('input', { id, type: 'number', className: 'text-input form-control form-control-sm w-num', value: val !== null && val !== void 0 ? val : '', min: field.min, max: field.max, step: field.step || 1, onChange: (e) => updateConfigField(field.name, e.target.value === '' ? null : Number(e.target.value)) });
|
||||
break;
|
||||
case 'slider':
|
||||
control = React.createElement('div', { className: 'range-wrapper' }, [
|
||||
React.createElement('input', { key: 'rng', id, type: 'range', className: 'zoom-slider', value: (_a = val !== null && val !== void 0 ? val : field.default) !== null && _a !== void 0 ? _a : 0, min: field.min, max: field.max, step: field.step || 1, onChange: (e) => updateConfigField(field.name, Number(e.target.value)) }),
|
||||
React.createElement('div', { key: 'val', className: 'range-value' }, String((_b = val !== null && val !== void 0 ? val : field.default) !== null && _b !== void 0 ? _b : 0))
|
||||
]);
|
||||
break;
|
||||
case 'select':
|
||||
case 'enum':
|
||||
control = React.createElement('select', { id, className: 'input-control form-control form-control-sm w-select w-180', value: (_c = val !== null && val !== void 0 ? val : field.default) !== null && _c !== void 0 ? _c : '', onChange: (e) => updateConfigField(field.name, e.target.value) }, (field.options || []).map((o) => React.createElement('option', { key: o.value, value: o.value }, o.label || o.value)));
|
||||
break;
|
||||
case 'boolean':
|
||||
control = React.createElement('div', { className: 'custom-control custom-switch' }, [
|
||||
React.createElement('input', { key: 'chk', id, type: 'checkbox', className: 'custom-control-input', checked: !!val, onChange: (e) => updateConfigField(field.name, e.target.checked) }),
|
||||
React.createElement('label', { key: 'lb', htmlFor: id, className: 'custom-control-label' }, '')
|
||||
]);
|
||||
break;
|
||||
case 'text':
|
||||
control = React.createElement('input', { id, type: 'text', className: 'text-input form-control form-control-sm w-text w-180', value: val !== null && val !== void 0 ? val : '', placeholder: field.help || '', onChange: (e) => updateConfigField(field.name, e.target.value, { debounce: true, field }) });
|
||||
break;
|
||||
case 'search':
|
||||
control = React.createElement('div', { className: 'clearable-input-group search-term-input w-180' }, [
|
||||
React.createElement('input', { key: 'in', id, type: 'text', className: 'clearable-text-field form-control form-control-sm w-180', value: val !== null && val !== void 0 ? val : '', placeholder: field.help || 'Search…', onChange: (e) => updateConfigField(field.name, e.target.value, { debounce: true, field }) })
|
||||
]);
|
||||
break;
|
||||
case 'tags': {
|
||||
let includeIds = [];
|
||||
let excludeIds = [];
|
||||
let constraints = {};
|
||||
if (Array.isArray(val)) {
|
||||
includeIds = val;
|
||||
}
|
||||
else if (val && typeof val === 'object') {
|
||||
includeIds = Array.isArray(val.include) ? val.include : [];
|
||||
excludeIds = Array.isArray(val.exclude) ? val.exclude : [];
|
||||
constraints = val.constraints || {};
|
||||
}
|
||||
control = React.createElement('div', { className: 'w-tags' }, TagIncludeExclude ? React.createElement(TagIncludeExclude, { compositeRawRef, fieldName: field.name, value: { include: includeIds, exclude: excludeIds, constraints, tag_combination: val === null || val === void 0 ? void 0 : val.tag_combination }, onChange: (next) => updateConfigField(field.name, next), initialTagCombination: field.tag_combination, allowedConstraintTypes: field.constraint_types, allowedCombinationModes: field.allowed_combination_modes }) : React.createElement('div', { className: 'text-muted small' }, 'Tag selector unavailable'));
|
||||
break;
|
||||
}
|
||||
case 'performers': {
|
||||
let includeIds = [];
|
||||
let excludeIds = [];
|
||||
let constraints = {};
|
||||
if (Array.isArray(val)) {
|
||||
includeIds = val;
|
||||
}
|
||||
else if (val && typeof val === 'object') {
|
||||
includeIds = Array.isArray(val.include) ? val.include : [];
|
||||
excludeIds = Array.isArray(val.exclude) ? val.exclude : [];
|
||||
constraints = val.constraints || {};
|
||||
}
|
||||
control = React.createElement('div', { className: 'w-tags' }, TagIncludeExclude ? React.createElement(TagIncludeExclude, { compositeRawRef, fieldName: field.name, value: { include: includeIds, exclude: excludeIds, constraints, tag_combination: val === null || val === void 0 ? void 0 : val.tag_combination }, onChange: (next) => updateConfigField(field.name, next), initialTagCombination: field.tag_combination, allowedConstraintTypes: field.constraint_types, allowedCombinationModes: field.allowed_combination_modes, entity: 'performer' }) : React.createElement('div', { className: 'text-muted small' }, 'Performer selector unavailable'));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
control = React.createElement('div', { className: 'text-muted small' }, 'Unsupported: ' + field.type);
|
||||
}
|
||||
const showLabelAbove = true;
|
||||
const capWidth = (field.type === 'tags' || field.type === 'performers') ? (narrowTagWidth !== null && narrowTagWidth !== void 0 ? narrowTagWidth : 400) : (field.type === 'slider' ? 92 : (['text', 'search', 'select', 'enum'].includes(field.type) ? 180 : undefined));
|
||||
const labelStyle = capWidth ? { display: 'inline-block', width: capWidth + 'px', maxWidth: capWidth + 'px' } : undefined;
|
||||
const labelProps = { htmlFor: id, className: 'form-label d-flex justify-content-between mb-0', style: labelStyle };
|
||||
if (field.help)
|
||||
labelProps.title = field.help;
|
||||
const labelNode = showLabelAbove ? React.createElement('label', labelProps, [React.createElement('span', { key: 't', className: 'label-text' }, field.label || field.name)]) : null;
|
||||
const compactTypes = ['number', 'select', 'enum', 'boolean', 'slider', 'text', 'search', 'tags', 'performers'];
|
||||
const colClass = compactTypes.includes(field.type) ? 'col-auto mb-1' : 'col-lg-4 col-md-6 col-12 mb-1';
|
||||
return React.createElement('div', { key: field.name, className: colClass }, [
|
||||
React.createElement('div', { className: 'form-group mb-0' }, [
|
||||
labelNode,
|
||||
React.createElement('div', { key: 'ctrlwrap', style: labelStyle, className: 'control-wrap' }, control)
|
||||
])
|
||||
]);
|
||||
});
|
||||
}
|
||||
// Export utilities to global namespace
|
||||
w.AIRecommendationUtils = {
|
||||
useDebounce,
|
||||
useResizeObserver,
|
||||
calculateCardWidth,
|
||||
useContainerDimensions,
|
||||
useCardWidth,
|
||||
ConstraintEditor,
|
||||
createTagSelector,
|
||||
TagIncludeExclude,
|
||||
buildConfigRows
|
||||
};
|
||||
} // End initializeRecommendationUtils
|
||||
// Wait for dependencies and initialize
|
||||
function waitAndInitialize() {
|
||||
if (w.PluginApi && w.PluginApi.React) {
|
||||
console.log('[RecommendationUtils] Dependencies ready, initializing...');
|
||||
initializeRecommendationUtils();
|
||||
}
|
||||
else {
|
||||
console.log('[RecommendationUtils] Waiting for PluginApi and React...');
|
||||
setTimeout(waitAndInitialize, 100);
|
||||
}
|
||||
}
|
||||
waitAndInitialize();
|
||||
})();
|
||||
})();
|
||||
|
||||
1863
plugins/AIOverhaul/RecommendedScenes.js
Normal file
1863
plugins/AIOverhaul/RecommendedScenes.js
Normal file
File diff suppressed because it is too large
Load Diff
785
plugins/AIOverhaul/SimilarScenes.js
Normal file
785
plugins/AIOverhaul/SimilarScenes.js
Normal file
@ -0,0 +1,785 @@
|
||||
(function(){
|
||||
// SimilarScenes Component
|
||||
// Mimics the queue tab structure but uses the 'similar_scene' context
|
||||
// for scene-specific recommendations with dynamic inputs
|
||||
(function () {
|
||||
const w = window;
|
||||
// Safer initialization - wait for everything to be ready
|
||||
function initializeSimilarScenes() {
|
||||
const PluginApi = w.PluginApi;
|
||||
if (!PluginApi || !PluginApi.React) {
|
||||
console.warn('[SimilarScenes] PluginApi or React not available');
|
||||
return;
|
||||
}
|
||||
const React = PluginApi.React;
|
||||
// Validate React hooks are available
|
||||
if (!React.useState || !React.useMemo || !React.useEffect || !React.useRef || !React.useCallback) {
|
||||
console.warn('[SimilarScenes] React hooks not available');
|
||||
return;
|
||||
}
|
||||
const { useState, useMemo, useEffect, useRef, useCallback } = React;
|
||||
const getSharedApiKey = () => {
|
||||
try {
|
||||
const helper = w.AISharedApiKeyHelper;
|
||||
if (helper && typeof helper.get === 'function') {
|
||||
const value = helper.get();
|
||||
if (typeof value === 'string')
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
const raw = w.AI_SHARED_API_KEY;
|
||||
return typeof raw === 'string' ? raw.trim() : '';
|
||||
};
|
||||
const withSharedKeyHeaders = (init) => {
|
||||
const helper = w.AISharedApiKeyHelper;
|
||||
if (helper && typeof helper.withHeaders === 'function') {
|
||||
return helper.withHeaders(init || {});
|
||||
}
|
||||
const key = getSharedApiKey();
|
||||
if (!key)
|
||||
return init || {};
|
||||
const headers = { ...(init && init.headers ? init.headers : {}) };
|
||||
headers['x-ai-api-key'] = key;
|
||||
return { ...(init || {}), headers };
|
||||
};
|
||||
function log(...args) { if (w.AIDebug)
|
||||
console.log('[SimilarScenes]', ...args); }
|
||||
function warn(...args) { if (w.AIDebug)
|
||||
console.warn('[SimilarScenes]', ...args); }
|
||||
function normalizeScene(sc) {
|
||||
if (!sc || typeof sc !== 'object')
|
||||
return undefined;
|
||||
const arrayFields = ['performers', 'tags', 'markers', 'scene_markers', 'galleries', 'images', 'files', 'groups'];
|
||||
arrayFields.forEach(f => {
|
||||
if (sc[f] == null)
|
||||
sc[f] = [];
|
||||
else if (!Array.isArray(sc[f]))
|
||||
sc[f] = [sc[f]].filter(Boolean);
|
||||
});
|
||||
if (!sc.studio)
|
||||
sc.studio = null;
|
||||
if (sc.rating100 == null && typeof sc.rating === 'number')
|
||||
sc.rating100 = sc.rating * 20;
|
||||
if (sc.rating == null && typeof sc.rating100 === 'number')
|
||||
sc.rating = Math.round(sc.rating100 / 20);
|
||||
return sc;
|
||||
}
|
||||
const RECOMMENDATION_CONTEXT = 'similar_scene';
|
||||
// Similar to QueueViewer but for similar scenes
|
||||
const SimilarScenesViewer = (props) => {
|
||||
// Accept either `currentSceneId` (old API) or `sceneId` (integration passes this)
|
||||
let currentSceneId = props.currentSceneId || props.sceneId || null;
|
||||
if (currentSceneId != null)
|
||||
currentSceneId = String(currentSceneId);
|
||||
// Early return if no scene ID - don't call hooks
|
||||
if (!currentSceneId) {
|
||||
return React.createElement('div', { className: 'alert alert-warning' }, 'No scene ID provided for Similar tab');
|
||||
}
|
||||
const onSceneClicked = props.onSceneClicked;
|
||||
const [recommenders, setRecommenders] = useState(null);
|
||||
const [recommenderId, setRecommenderId] = useState(null);
|
||||
const [scenes, setScenes] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [configValues, setConfigValues] = useState({});
|
||||
const [offset, setOffset] = useState(0);
|
||||
const PAGE_SIZE = 20;
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const LS_SHOW_CONFIG_KEY = 'aiRec.showConfig';
|
||||
function readShowConfig() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_SHOW_CONFIG_KEY);
|
||||
if (raw == null)
|
||||
return true;
|
||||
return raw === '1' || raw === 'true';
|
||||
}
|
||||
catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const [showConfig, setShowConfig] = useState(() => readShowConfig());
|
||||
// Sync showConfig via localStorage + custom event (so changes affect other components in same window)
|
||||
useEffect(() => {
|
||||
function onStorage(e) { try {
|
||||
if (e.key === LS_SHOW_CONFIG_KEY) {
|
||||
const v = e.newValue;
|
||||
const next = v === '1' || v === 'true';
|
||||
setShowConfig(next);
|
||||
}
|
||||
}
|
||||
catch (_) { } }
|
||||
function onCustom(ev) { try {
|
||||
if (ev && ev.detail !== undefined)
|
||||
setShowConfig(Boolean(ev.detail));
|
||||
}
|
||||
catch (_) { } }
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener('aiRec.showConfig', onCustom);
|
||||
return () => { window.removeEventListener('storage', onStorage); window.removeEventListener('aiRec.showConfig', onCustom); };
|
||||
}, []);
|
||||
function toggleShowConfig() { const next = !showConfig; try {
|
||||
localStorage.setItem(LS_SHOW_CONFIG_KEY, next ? '1' : '0');
|
||||
}
|
||||
catch (_) { } try {
|
||||
window.dispatchEvent(new CustomEvent('aiRec.showConfig', { detail: next }));
|
||||
}
|
||||
catch (_) { } setShowConfig(next); }
|
||||
// Root ref for the tab content container (used to find the nearest scrollable parent)
|
||||
const componentRef = useRef(null);
|
||||
const scrollContainerRef = useRef(null);
|
||||
const pendingScrollRef = useRef(null);
|
||||
const getScrollContainer = useCallback(() => {
|
||||
try {
|
||||
const node = (componentRef === null || componentRef === void 0 ? void 0 : componentRef.current) || null;
|
||||
let el = node ? node.parentElement : null;
|
||||
while (el && el !== document.body) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const oy = style.overflowY || style.overflow || '';
|
||||
const scrollable = /(auto|scroll)/.test(oy);
|
||||
if (scrollable && el.scrollHeight > (el.clientHeight + 10)) {
|
||||
return el;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
catch (_) { }
|
||||
return document.scrollingElement || document.documentElement || window;
|
||||
}, [componentRef]);
|
||||
useEffect(() => {
|
||||
scrollContainerRef.current = getScrollContainer();
|
||||
}, [getScrollContainer]);
|
||||
const configCacheRef = useRef({});
|
||||
const configValuesRef = useRef({});
|
||||
const compositeRawRef = useRef({});
|
||||
const preferenceSaveTimerRef = useRef(null);
|
||||
const lastPersistedSnapshotRef = useRef(null);
|
||||
const serverSeedConfigRef = useRef({});
|
||||
useEffect(() => { configValuesRef.current = configValues; }, [configValues]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (preferenceSaveTimerRef.current) {
|
||||
clearTimeout(preferenceSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const currentRecommender = useMemo(() => { var _a; return (_a = (recommenders || [])) === null || _a === void 0 ? void 0 : _a.find((r) => r.id === recommenderId); }, [recommenders, recommenderId]);
|
||||
const sanitizeBase = useCallback((value) => {
|
||||
const origin = (() => {
|
||||
try {
|
||||
return typeof location !== 'undefined' && location.origin ? location.origin.replace(/\/$/, '') : '';
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
if (typeof value !== 'string')
|
||||
return '';
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed)
|
||||
return '';
|
||||
const cleaned = trimmed.replace(/\/$/, '');
|
||||
return origin && cleaned === origin ? '' : cleaned;
|
||||
}, []);
|
||||
const resolveBackendBase = useCallback(() => {
|
||||
const fn = w.AIDefaultBackendBase;
|
||||
if (typeof fn !== 'function')
|
||||
return '';
|
||||
try {
|
||||
const value = fn();
|
||||
return sanitizeBase(typeof value === 'string' ? value : '');
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
}, [sanitizeBase]);
|
||||
const [backendBase, setBackendBase] = useState(() => resolveBackendBase());
|
||||
useEffect(() => {
|
||||
const handler = (event) => {
|
||||
const next = typeof (event === null || event === void 0 ? void 0 : event.detail) === 'string' ? event.detail : resolveBackendBase();
|
||||
setBackendBase(sanitizeBase(next || ''));
|
||||
};
|
||||
try {
|
||||
window.addEventListener('AIBackendBaseUpdated', handler);
|
||||
}
|
||||
catch (_) { }
|
||||
const timer = !backendBase ? setTimeout(() => setBackendBase(resolveBackendBase() || ''), 0) : null;
|
||||
return () => {
|
||||
try {
|
||||
window.removeEventListener('AIBackendBaseUpdated', handler);
|
||||
}
|
||||
catch (_) { }
|
||||
if (timer)
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [backendBase, resolveBackendBase, sanitizeBase]);
|
||||
const backendHealthApi = w.AIBackendHealth;
|
||||
const backendHealthEvent = (backendHealthApi === null || backendHealthApi === void 0 ? void 0 : backendHealthApi.EVENT_NAME) || 'AIBackendHealthChange';
|
||||
const [backendHealthTick, setBackendHealthTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!backendHealthApi || !backendHealthEvent)
|
||||
return;
|
||||
const handler = () => setBackendHealthTick((t) => t + 1);
|
||||
try {
|
||||
window.addEventListener(backendHealthEvent, handler);
|
||||
}
|
||||
catch (_) { }
|
||||
return () => { try {
|
||||
window.removeEventListener(backendHealthEvent, handler);
|
||||
}
|
||||
catch (_) { } ; };
|
||||
}, [backendHealthApi, backendHealthEvent]);
|
||||
const backendHealthState = useMemo(() => {
|
||||
if (backendHealthApi && typeof backendHealthApi.getState === 'function') {
|
||||
return backendHealthApi.getState();
|
||||
}
|
||||
return null;
|
||||
}, [backendHealthApi, backendHealthTick]);
|
||||
function sanitizeConfigPayload(config) {
|
||||
const out = {};
|
||||
if (config && typeof config === 'object') {
|
||||
Object.keys(config).forEach(key => {
|
||||
const value = config[key];
|
||||
if (value !== undefined) {
|
||||
out[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function shouldPersistField(field) {
|
||||
if (!field)
|
||||
return true;
|
||||
if (typeof field.persist === 'undefined')
|
||||
return true;
|
||||
return Boolean(field.persist);
|
||||
}
|
||||
function isFieldPersistable(definition, fieldName) {
|
||||
if (!definition || !Array.isArray(definition.config))
|
||||
return false;
|
||||
const field = definition.config.find((f) => f.name === fieldName);
|
||||
if (!field)
|
||||
return false;
|
||||
return shouldPersistField(field);
|
||||
}
|
||||
function buildPersistableConfig(definition, values) {
|
||||
if (!definition || !Array.isArray(definition.config))
|
||||
return {};
|
||||
const out = {};
|
||||
definition.config.forEach((field) => {
|
||||
if (!shouldPersistField(field))
|
||||
return;
|
||||
if (values && Object.prototype.hasOwnProperty.call(values, field.name)) {
|
||||
const value = values[field.name];
|
||||
if (value !== undefined) {
|
||||
out[field.name] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
const persistPreference = useCallback(async () => {
|
||||
if (!backendBase || !recommenderId || !currentRecommender)
|
||||
return;
|
||||
const payload = {
|
||||
context: RECOMMENDATION_CONTEXT,
|
||||
recommenderId,
|
||||
config: sanitizeConfigPayload(buildPersistableConfig(currentRecommender, configValuesRef.current || {})),
|
||||
};
|
||||
const signature = JSON.stringify(payload);
|
||||
if (lastPersistedSnapshotRef.current === signature) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = `${backendBase}/api/v1/recommendations/preferences`;
|
||||
await fetch(url, withSharedKeyHeaders({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}));
|
||||
lastPersistedSnapshotRef.current = signature;
|
||||
}
|
||||
catch (err) {
|
||||
warn('Failed to persist similar-scene preference', err);
|
||||
}
|
||||
}, [backendBase, recommenderId, currentRecommender, withSharedKeyHeaders]);
|
||||
const schedulePreferencePersist = useCallback((reason, opts) => {
|
||||
if (!backendBase || !recommenderId || !currentRecommender)
|
||||
return;
|
||||
const delay = reason === 'recommender' ? 25 : ((opts === null || opts === void 0 ? void 0 : opts.debounce) ? 800 : 220);
|
||||
if (preferenceSaveTimerRef.current) {
|
||||
clearTimeout(preferenceSaveTimerRef.current);
|
||||
}
|
||||
preferenceSaveTimerRef.current = setTimeout(() => {
|
||||
preferenceSaveTimerRef.current = null;
|
||||
persistPreference();
|
||||
}, delay);
|
||||
}, [backendBase, recommenderId, currentRecommender, persistPreference]);
|
||||
// Discover available recommenders using the backend recommendations API
|
||||
const discoverRecommenders = useCallback(async () => {
|
||||
if (!backendBase)
|
||||
return;
|
||||
let reportedError = false;
|
||||
if (backendHealthApi && typeof backendHealthApi.reportChecking === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportChecking(backendBase);
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
const recContext = RECOMMENDATION_CONTEXT;
|
||||
const url = `${backendBase}/api/v1/recommendations/recommenders?context=${encodeURIComponent(recContext)}`;
|
||||
const response = await fetch(url, withSharedKeyHeaders());
|
||||
if (!response.ok) {
|
||||
if (backendHealthApi) {
|
||||
if ((response.status >= 500 || response.status === 0) && typeof backendHealthApi.reportError === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportError(backendBase, `HTTP ${response.status}`);
|
||||
reportedError = true;
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
else if (typeof backendHealthApi.reportOk === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportOk(backendBase);
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const contentType = response.headers && response.headers.get ? response.headers.get('content-type') || '' : '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
warn('discoverRecommenders: non-JSON response body (truncated):', text && text.slice ? text.slice(0, 512) : text);
|
||||
if (backendHealthApi && typeof backendHealthApi.reportError === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportError(backendBase, 'Server returned non-JSON response');
|
||||
reportedError = true;
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
setError('Failed to discover recommenders: server returned non-JSON response. See console for details.');
|
||||
setRecommenders(null);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data.recommenders)) {
|
||||
setRecommenders(data.recommenders);
|
||||
const savedId = typeof data.savedRecommenderId === 'string' ? data.savedRecommenderId : null;
|
||||
const savedDef = savedId ? data.recommenders.find((r) => r.id === savedId) : null;
|
||||
if (savedDef) {
|
||||
const seedConfig = sanitizeConfigPayload(data.savedConfig || {});
|
||||
serverSeedConfigRef.current[savedDef.id] = seedConfig;
|
||||
lastPersistedSnapshotRef.current = JSON.stringify({
|
||||
context: recContext,
|
||||
recommenderId: savedDef.id,
|
||||
config: seedConfig,
|
||||
});
|
||||
}
|
||||
const similarContextRec = data.recommenders.find((r) => { var _a; return (_a = r.contexts) === null || _a === void 0 ? void 0 : _a.includes(RECOMMENDATION_CONTEXT); });
|
||||
const nextId = (savedDef === null || savedDef === void 0 ? void 0 : savedDef.id) || (similarContextRec === null || similarContextRec === void 0 ? void 0 : similarContextRec.id) || null;
|
||||
if (nextId) {
|
||||
setRecommenderId((prev) => prev === nextId ? prev : nextId);
|
||||
if (w.AIDebug)
|
||||
log('Selected recommender for similar_scene:', nextId);
|
||||
}
|
||||
if (backendHealthApi && typeof backendHealthApi.reportOk === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportOk(backendBase);
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
}
|
||||
else {
|
||||
setRecommenders(null);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn('Failed to discover recommenders:', e && e.message ? e.message : e);
|
||||
setError('Failed to discover recommenders: ' + (e && e.message ? e.message : String(e)));
|
||||
if (!reportedError && backendHealthApi && typeof backendHealthApi.reportError === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportError(backendBase, e && e.message ? e.message : undefined, e);
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [backendBase, backendHealthApi]);
|
||||
// Fetch a page of similar scenes from the unified recommendations query endpoint
|
||||
const fetchPage = useCallback(async (pageOffset = 0, append = false) => {
|
||||
var _a;
|
||||
if (!backendBase || !recommenderId || !currentSceneId)
|
||||
return;
|
||||
let reportedError = false;
|
||||
try {
|
||||
if (backendHealthApi && typeof backendHealthApi.reportChecking === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportChecking(backendBase);
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Snapshot scroll metrics if appending, so we can preserve viewport position
|
||||
if (append) {
|
||||
const sc = scrollContainerRef.current || getScrollContainer();
|
||||
const prevTop = sc && typeof sc.scrollTop === 'number' ? sc.scrollTop : (typeof window !== 'undefined' ? window.scrollY : 0);
|
||||
const prevHeight = sc && typeof sc.scrollHeight === 'number' ? sc.scrollHeight : (((_a = document === null || document === void 0 ? void 0 : document.documentElement) === null || _a === void 0 ? void 0 : _a.scrollHeight) || 0);
|
||||
pendingScrollRef.current = { sc, prevTop, prevHeight };
|
||||
}
|
||||
else {
|
||||
pendingScrollRef.current = null;
|
||||
}
|
||||
const payload = {
|
||||
context: RECOMMENDATION_CONTEXT,
|
||||
recommenderId,
|
||||
seedSceneIds: [Number(currentSceneId)],
|
||||
config: configValuesRef.current || {},
|
||||
limit: PAGE_SIZE,
|
||||
offset: pageOffset
|
||||
};
|
||||
const url = `${backendBase}/api/v1/recommendations/query`;
|
||||
const response = await fetch(url, withSharedKeyHeaders({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}));
|
||||
if (!response.ok) {
|
||||
if (backendHealthApi) {
|
||||
if ((response.status >= 500 || response.status === 0) && typeof backendHealthApi.reportError === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportError(backendBase, `HTTP ${response.status}`);
|
||||
reportedError = true;
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
else if (typeof backendHealthApi.reportOk === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportOk(backendBase);
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const contentType = response.headers && response.headers.get ? response.headers.get('content-type') || '' : '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
const text = await response.text();
|
||||
warn('fetchPage: non-JSON response body (truncated):', text && text.slice ? text.slice(0, 512) : text);
|
||||
if (backendHealthApi && typeof backendHealthApi.reportError === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportError(backendBase, 'Server returned non-JSON response');
|
||||
reportedError = true;
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
throw new Error('Server returned non-JSON response');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.scenes && Array.isArray(data.scenes)) {
|
||||
const normalizedScenes = data.scenes.map(normalizeScene).filter(Boolean);
|
||||
setScenes((prev) => append ? prev.concat(normalizedScenes) : normalizedScenes);
|
||||
// Update offset and hasMore using API meta when present
|
||||
const meta = data.meta || {};
|
||||
if (typeof meta.hasMore === 'boolean') {
|
||||
setHasMore(Boolean(meta.hasMore));
|
||||
}
|
||||
else if (typeof meta.total === 'number') {
|
||||
const total = meta.total;
|
||||
const known = (append ? (scenes.length) : 0) + normalizedScenes.length;
|
||||
setHasMore(known < total);
|
||||
}
|
||||
else {
|
||||
setHasMore(false);
|
||||
}
|
||||
if (typeof meta.nextOffset === 'number') {
|
||||
setOffset(meta.nextOffset);
|
||||
}
|
||||
else {
|
||||
// Fall back to incrementing by page size
|
||||
setOffset(pageOffset + normalizedScenes.length);
|
||||
}
|
||||
// After DOM updates, restore scroll position to keep viewport stable
|
||||
if (append && pendingScrollRef.current) {
|
||||
const snap = pendingScrollRef.current;
|
||||
const restore = () => {
|
||||
var _a;
|
||||
try {
|
||||
const sc = snap.sc || scrollContainerRef.current || getScrollContainer();
|
||||
if (!sc)
|
||||
return;
|
||||
const newHeight = sc && typeof sc.scrollHeight === 'number' ? sc.scrollHeight : (((_a = document === null || document === void 0 ? void 0 : document.documentElement) === null || _a === void 0 ? void 0 : _a.scrollHeight) || 0);
|
||||
const delta = newHeight - (snap.prevHeight || 0);
|
||||
const baseTop = snap.prevTop || 0;
|
||||
if (typeof sc.scrollTop === 'number') {
|
||||
sc.scrollTop = baseTop + (delta > 0 ? delta : 0);
|
||||
}
|
||||
else if (typeof window !== 'undefined' && typeof window.scrollTo === 'function') {
|
||||
window.scrollTo({ top: baseTop + (delta > 0 ? delta : 0) });
|
||||
}
|
||||
}
|
||||
catch (_) { }
|
||||
finally {
|
||||
pendingScrollRef.current = null;
|
||||
}
|
||||
};
|
||||
// Wait two frames to ensure layout has settled
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
requestAnimationFrame(() => requestAnimationFrame(restore));
|
||||
}
|
||||
else {
|
||||
setTimeout(restore, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!append)
|
||||
setScenes([]);
|
||||
setHasMore(false);
|
||||
setError('No similar scenes found or unexpected data format');
|
||||
}
|
||||
if (backendHealthApi && typeof backendHealthApi.reportOk === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportOk(backendBase);
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn('Failed to fetch similar scenes:', e && e.message ? e.message : e);
|
||||
setError('Failed to load similar scenes: ' + (e && e.message ? e.message : String(e)));
|
||||
if (!append)
|
||||
setScenes([]);
|
||||
if (!reportedError && backendHealthApi && typeof backendHealthApi.reportError === 'function') {
|
||||
try {
|
||||
backendHealthApi.reportError(backendBase, e && e.message ? e.message : undefined, e);
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [recommenderId, currentSceneId, backendBase, PAGE_SIZE, backendHealthApi]);
|
||||
// Auto-discover recommenders on mount
|
||||
useEffect(() => {
|
||||
if (!backendBase)
|
||||
return;
|
||||
discoverRecommenders();
|
||||
}, [discoverRecommenders, backendBase]);
|
||||
// When the selected recommender changes, initialize config values from its defaults
|
||||
useEffect(() => {
|
||||
if (!currentRecommender)
|
||||
return;
|
||||
const defs = currentRecommender.config || [];
|
||||
const defaults = {};
|
||||
defs.forEach(f => {
|
||||
if (Object.prototype.hasOwnProperty.call(f, 'default'))
|
||||
defaults[f.name] = f.default;
|
||||
if (f.type === 'tags' || f.type === 'performers') {
|
||||
compositeRawRef.current[f.name] = '';
|
||||
}
|
||||
});
|
||||
const cached = configCacheRef.current[currentRecommender.id];
|
||||
let merged = cached ? { ...defaults, ...cached } : { ...defaults };
|
||||
const seed = serverSeedConfigRef.current[currentRecommender.id];
|
||||
if (seed) {
|
||||
merged = { ...merged, ...seed };
|
||||
delete serverSeedConfigRef.current[currentRecommender.id];
|
||||
}
|
||||
configCacheRef.current[currentRecommender.id] = merged;
|
||||
setConfigValues({ ...merged });
|
||||
configValuesRef.current = merged;
|
||||
schedulePreferencePersist('recommender');
|
||||
}, [currentRecommender, schedulePreferencePersist]);
|
||||
// Fetch first page when recommender or scene changes
|
||||
useEffect(() => {
|
||||
if (recommenderId && currentSceneId) {
|
||||
fetchPage(0, false);
|
||||
}
|
||||
// Intentionally exclude fetchPage from deps to avoid re-fetches when it changes identity
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [recommenderId, currentSceneId]);
|
||||
// Handle scene click
|
||||
const handleSceneClick = useCallback((sceneId, event) => {
|
||||
if (event)
|
||||
event.preventDefault();
|
||||
if (onSceneClicked) {
|
||||
onSceneClicked(sceneId);
|
||||
}
|
||||
else {
|
||||
// Default behavior: navigate to scene
|
||||
window.location.href = `/scenes/${sceneId}`;
|
||||
}
|
||||
}, [onSceneClicked]);
|
||||
// Render scene in queue list format (matching the Queue tab exactly)
|
||||
const renderQueueScene = useCallback((scene, index) => {
|
||||
var _a, _b, _c;
|
||||
const title = scene.title || `Scene ${scene.id}`;
|
||||
const studio = ((_a = scene.studio) === null || _a === void 0 ? void 0 : _a.name) || '';
|
||||
const performers = ((_b = scene.performers) === null || _b === void 0 ? void 0 : _b.map(p => p.name).join(', ')) || '';
|
||||
const screenshot = (_c = scene.paths) === null || _c === void 0 ? void 0 : _c.screenshot;
|
||||
const date = scene.date || scene.created_at || '';
|
||||
return React.createElement('li', {
|
||||
key: scene.id,
|
||||
className: 'my-2'
|
||||
}, React.createElement('a', {
|
||||
href: `/scenes/${scene.id}`,
|
||||
onClick: (e) => handleSceneClick(scene.id.toString(), e)
|
||||
}, React.createElement('div', {
|
||||
className: 'ml-1 d-flex align-items-center'
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'thumbnail',
|
||||
className: 'thumbnail-container'
|
||||
}, screenshot ? React.createElement('img', {
|
||||
loading: 'lazy',
|
||||
alt: title,
|
||||
src: screenshot
|
||||
}) : null),
|
||||
React.createElement('div', {
|
||||
key: 'details',
|
||||
className: 'queue-scene-details'
|
||||
}, [
|
||||
React.createElement('span', { key: 'title', className: 'queue-scene-title' }, title),
|
||||
React.createElement('span', { key: 'studio', className: 'queue-scene-studio' }, studio),
|
||||
React.createElement('span', { key: 'performers', className: 'queue-scene-performers' }, performers),
|
||||
React.createElement('span', { key: 'date', className: 'queue-scene-date' }, date)
|
||||
])
|
||||
])));
|
||||
}, [handleSceneClick]);
|
||||
// Render recommender selector when recommenders are available
|
||||
const renderRecommenderSelector = useCallback(() => {
|
||||
if (!recommenders || recommenders.length === 0)
|
||||
return null;
|
||||
// Prefer recommenders that advertise support for 'similar_scene'. If none do, fall back to all recommenders.
|
||||
const similarContextRecommenders = recommenders.filter((r) => (r.contexts || []).includes('similar_scene'));
|
||||
const candidates = similarContextRecommenders.length > 0 ? similarContextRecommenders : recommenders;
|
||||
// Ensure a default is selected
|
||||
if (!recommenderId && candidates.length > 0) {
|
||||
// Defer setting state until next microtask to avoid during render
|
||||
setTimeout(() => {
|
||||
try {
|
||||
setRecommenderId((prev) => prev || candidates[0].id);
|
||||
}
|
||||
catch (_) { }
|
||||
}, 0);
|
||||
}
|
||||
return React.createElement('div', { className: 'd-flex align-items-center' }, [
|
||||
React.createElement('label', { key: 'label', className: 'me-2 mb-0' }, 'Algorithm: '),
|
||||
React.createElement('select', {
|
||||
key: 'select',
|
||||
className: 'input-control form-control form-control-sm w-select w-180',
|
||||
value: recommenderId || '',
|
||||
onChange: (e) => setRecommenderId(e.target.value)
|
||||
}, candidates.map((rec) => React.createElement('option', { key: rec.id, value: rec.id }, rec.label || rec.id)))
|
||||
]);
|
||||
}, [recommenders, recommenderId]);
|
||||
// Config state update helper (simple debounce for text inputs)
|
||||
const textTimersRef = useRef({});
|
||||
const updateConfigField = useCallback((name, value, opts) => {
|
||||
setConfigValues((prev) => {
|
||||
const next = { ...prev, [name]: value };
|
||||
if (recommenderId) {
|
||||
configCacheRef.current[recommenderId] = next;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
configValuesRef.current = { ...configValuesRef.current, [name]: value };
|
||||
if (opts && opts.debounce) {
|
||||
if (textTimersRef.current[name])
|
||||
clearTimeout(textTimersRef.current[name]);
|
||||
textTimersRef.current[name] = setTimeout(() => {
|
||||
fetchPage(0, false);
|
||||
}, 300);
|
||||
}
|
||||
else {
|
||||
// immediate fetch
|
||||
fetchPage(0, false);
|
||||
}
|
||||
if (currentRecommender && isFieldPersistable(currentRecommender, name)) {
|
||||
schedulePreferencePersist('config', { debounce: !!(opts === null || opts === void 0 ? void 0 : opts.debounce) });
|
||||
}
|
||||
}, [fetchPage, recommenderId, currentRecommender, schedulePreferencePersist]);
|
||||
// Shared config panel using AIRecommendationUtils.buildConfigRows for parity
|
||||
const renderConfigPanel = useCallback(() => {
|
||||
if (!currentRecommender || !Array.isArray(currentRecommender.config) || !currentRecommender.config.length)
|
||||
return null;
|
||||
const defs = currentRecommender.config;
|
||||
const utils = w.AIRecommendationUtils || {};
|
||||
const buildRows = utils.buildConfigRows;
|
||||
const TagIncludeExclude = utils.TagIncludeExclude;
|
||||
if (!buildRows)
|
||||
return null;
|
||||
const rows = buildRows({ React, defs, configValues, updateConfigField, TagIncludeExclude, compositeRawRef, narrowTagWidth: 300 });
|
||||
return React.createElement('div', { className: 'card' }, [
|
||||
React.createElement('div', { key: 'header', className: 'card-header d-flex justify-content-between align-items-center' }, [
|
||||
React.createElement('span', { key: 'title' }, 'Configuration'),
|
||||
React.createElement('button', { key: 'toggle', type: 'button', className: 'btn btn-secondary btn-sm', onClick: () => toggleShowConfig() }, showConfig ? 'Hide' : 'Show')
|
||||
]),
|
||||
showConfig ? React.createElement('div', { key: 'body', className: 'card-body' }, [
|
||||
React.createElement('div', { key: 'rowwrap', className: 'd-flex flex-column gap-2' }, rows)
|
||||
]) : null
|
||||
]);
|
||||
}, [currentRecommender, configValues, updateConfigField, showConfig]);
|
||||
// Note: Zoom slider intentionally omitted for queue-style display
|
||||
const retryBackendProbe = useCallback(() => {
|
||||
discoverRecommenders();
|
||||
if (recommenderId && currentSceneId) {
|
||||
fetchPage(0, false);
|
||||
}
|
||||
}, [discoverRecommenders, fetchPage, recommenderId, currentSceneId]);
|
||||
const backendNotice = backendHealthApi && typeof backendHealthApi.buildNotice === 'function'
|
||||
? backendHealthApi.buildNotice(backendHealthState, { onRetry: retryBackendProbe, dense: true })
|
||||
: null;
|
||||
// Main render
|
||||
return React.createElement('div', {
|
||||
className: 'container-fluid similar-scenes-tab',
|
||||
ref: componentRef
|
||||
}, [
|
||||
backendNotice,
|
||||
// Algorithm selector (no surrounding background)
|
||||
React.createElement('div', { key: 'controls', className: 'd-flex align-items-center gap-3 mb-3 p-0' }, [
|
||||
renderRecommenderSelector()
|
||||
]),
|
||||
// Config panel separate block (full width) so it doesn't overflow out of the tab
|
||||
currentRecommender ? React.createElement('div', { key: 'configBlock', className: 'mb-3' }, [
|
||||
renderConfigPanel()
|
||||
]) : null,
|
||||
// Only show the big loading message when we don't have anything rendered yet
|
||||
(loading && scenes.length === 0) ? React.createElement('div', { key: 'loading', className: 'text-center text-muted py-3' }, 'Loading similar scenes...') : null,
|
||||
error ? React.createElement('div', { key: 'error', className: 'alert alert-danger' }, error) : null,
|
||||
!loading && !error && scenes.length === 0 ?
|
||||
React.createElement('div', { key: 'empty', className: 'text-center text-muted py-3' }, 'No similar scenes found') : null,
|
||||
// Keep rendering the list even while loading next page to avoid scroll jumps
|
||||
scenes.length > 0 ? (() => {
|
||||
// Use native queue list structure and CSS classes exactly as in the Queue tab
|
||||
return React.createElement('ul', {
|
||||
key: 'queue-list',
|
||||
className: '' // Use default ul styling, no custom classes
|
||||
}, scenes.map(renderQueueScene));
|
||||
})() : null,
|
||||
// Load more chevron button (centered)
|
||||
(hasMore || scenes.length >= PAGE_SIZE) ? (() => {
|
||||
const svg = React.createElement('svg', { 'aria-hidden': 'true', focusable: 'false', 'data-prefix': 'fas', 'data-icon': 'chevron-down', className: 'svg-inline--fa fa-chevron-down fa-icon', role: 'img', xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 448 512' }, React.createElement('path', { fill: 'currentColor', d: "M201.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 338.7 54.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z" }));
|
||||
const btn = React.createElement('button', { key: 'chev', type: 'button', className: 'btn btn-primary', disabled: !!loading, onClick: (e) => { e.preventDefault(); e.stopPropagation(); if (loading)
|
||||
return; const nextOffset = offset + PAGE_SIZE; fetchPage(nextOffset, true); } }, svg);
|
||||
return React.createElement('div', { key: 'load-more', className: 'd-flex justify-content-center my-3' }, [btn]);
|
||||
})() : null
|
||||
]);
|
||||
};
|
||||
// Export to global namespace for integration
|
||||
w.SimilarScenesViewer = SimilarScenesViewer;
|
||||
// Exported
|
||||
} // End initializeSimilarScenes
|
||||
// Wait for dependencies and initialize
|
||||
// Initialize immediately; SimilarTabIntegration resolves viewer at render time
|
||||
initializeSimilarScenes();
|
||||
})();
|
||||
})();
|
||||
|
||||
161
plugins/AIOverhaul/SimilarTabIntegration.js
Normal file
161
plugins/AIOverhaul/SimilarTabIntegration.js
Normal file
@ -0,0 +1,161 @@
|
||||
(function(){
|
||||
// Similar Tab Integration
|
||||
// Adds a "Similar" tab to the scene details page using PluginApi patches
|
||||
// Based on the official React component plugin example from Stash
|
||||
(function () {
|
||||
'use strict';
|
||||
const w = window;
|
||||
const PluginApi = w.PluginApi;
|
||||
// Basic sanity checks
|
||||
if (!PluginApi || !PluginApi.React) {
|
||||
console.warn('[SimilarTabIntegration] PluginApi or React not available');
|
||||
return;
|
||||
}
|
||||
const React = PluginApi.React;
|
||||
const { Nav, Tab } = PluginApi.libraries.Bootstrap;
|
||||
if (!Nav || !Tab) {
|
||||
console.warn('[SimilarTabIntegration] Bootstrap Nav/Tab components not available');
|
||||
return;
|
||||
}
|
||||
function initializePatches() {
|
||||
console.log('[SimilarTabIntegration] Registering patches...');
|
||||
// Final safety check - make sure everything is available
|
||||
if (!PluginApi.patch || !PluginApi.patch.before) {
|
||||
console.error('[SimilarTabIntegration] PluginApi.patch.before not available');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Add tab to navigation - insert before the Queue tab when possible
|
||||
PluginApi.patch.before("ScenePage.Tabs", function (props) {
|
||||
try {
|
||||
const childrenArray = props && props.children ? (Array.isArray(props.children) ? props.children.slice() : [props.children]) : [];
|
||||
// Helper to extract nested runtime event key from NavItem children.
|
||||
// In Stash 1.0+, the anchor rendered inside NavItem carries a data-rb-event-key attribute.
|
||||
function findNestedEventKey(c) {
|
||||
try {
|
||||
if (!c || !c.props)
|
||||
return undefined;
|
||||
// Direct props.eventKey
|
||||
if (c.props.eventKey)
|
||||
return c.props.eventKey;
|
||||
// data-event-key variants
|
||||
if (c.props['data-event-key'])
|
||||
return c.props['data-event-key'];
|
||||
if (c.props['data-eventKey'])
|
||||
return c.props['data-eventKey'];
|
||||
const ch = c.props.children;
|
||||
const candidates = Array.isArray(ch) ? ch : [ch];
|
||||
for (const item of candidates) {
|
||||
if (!item)
|
||||
continue;
|
||||
if (item.props) {
|
||||
// Check common RB attribute where React-Bootstrap stores the key
|
||||
if (item.props['data-rb-event-key'])
|
||||
return item.props['data-rb-event-key'];
|
||||
if (item.props['data-rb-eventKey'])
|
||||
return item.props['data-rb-eventKey'];
|
||||
if (item.props['data-event-key'])
|
||||
return item.props['data-event-key'];
|
||||
if (item.props.eventKey)
|
||||
return item.props.eventKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) { /* ignore */ }
|
||||
return undefined;
|
||||
}
|
||||
// Try to place after 'Details' tab by looking for the runtime event key used on the anchor
|
||||
let insertIndex = -1;
|
||||
const detailsIndex = childrenArray.findIndex((c) => {
|
||||
const ek = findNestedEventKey(c);
|
||||
return ek === 'scene-details-panel' || (ek && ek.toLowerCase().includes('details'));
|
||||
});
|
||||
if (detailsIndex >= 0) {
|
||||
insertIndex = detailsIndex + 1;
|
||||
console.log('[SimilarTabIntegration] Inserting Similar Nav.Item after Details at index', insertIndex);
|
||||
}
|
||||
else {
|
||||
// Fallback: detect Queue by runtime key and insert before it
|
||||
const queueIndex = childrenArray.findIndex((c) => {
|
||||
const ek = findNestedEventKey(c);
|
||||
return ek === 'scene-queue-panel' || (ek && ek.toLowerCase().includes('queue'));
|
||||
});
|
||||
if (queueIndex >= 0) {
|
||||
insertIndex = queueIndex;
|
||||
console.log('[SimilarTabIntegration] Inserting Similar Nav.Item before Queue at index', insertIndex);
|
||||
}
|
||||
}
|
||||
// If still not found, we will attempt to detect by legacy eventKey or append
|
||||
if (insertIndex < 0) {
|
||||
const targetEventKey = 'scene-queue-panel';
|
||||
insertIndex = childrenArray.findIndex((c) => {
|
||||
const ek = findNestedEventKey(c);
|
||||
return ek === targetEventKey;
|
||||
});
|
||||
}
|
||||
const navItem = React.createElement(Nav.Item, { key: 'similar-nav-item' }, React.createElement(Nav.Link, { eventKey: "similar-tab", key: 'similar-nav-link' }, "Similar"));
|
||||
// Insert navItem at the computed insertIndex (if >= 0) or append
|
||||
if (insertIndex >= 0) {
|
||||
childrenArray.splice(insertIndex, 0, navItem);
|
||||
console.log('[SimilarTabIntegration] Similar Nav.Item inserted at', insertIndex);
|
||||
}
|
||||
else {
|
||||
childrenArray.push(navItem);
|
||||
console.warn('[SimilarTabIntegration] Similar Nav.Item appended to end');
|
||||
}
|
||||
const newChildren = React.createElement(React.Fragment, null, ...childrenArray);
|
||||
return [{ children: newChildren }];
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[SimilarTabIntegration] Error in ScenePage.Tabs patch:', e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
// Add tab content - insert before the queue pane when possible
|
||||
PluginApi.patch.before("ScenePage.TabContent", function (props) {
|
||||
var _a;
|
||||
try {
|
||||
// Handle case where props is completely undefined
|
||||
if (!props) {
|
||||
console.warn('[SimilarTabIntegration] TabContent patch called with undefined props');
|
||||
return [];
|
||||
}
|
||||
const childrenArray = props.children ? (Array.isArray(props.children) ? props.children.slice() : [props.children]) : [];
|
||||
// Extract scene ID safely - sometimes props.scene is undefined during render
|
||||
const sceneId = ((_a = props.scene) === null || _a === void 0 ? void 0 : _a.id) || null;
|
||||
console.log('[SimilarTabIntegration] TabContent patch called with scene:', sceneId);
|
||||
// Only render if we have a scene ID and viewer is available
|
||||
const Viewer = w.SimilarScenesViewer;
|
||||
const content = (sceneId && Viewer) ?
|
||||
React.createElement(Viewer, { sceneId: sceneId, key: `similar-${sceneId}` }) :
|
||||
React.createElement('div', { className: 'similar-scenes-error' }, 'Loading scene data...');
|
||||
const pane = React.createElement(Tab.Pane, { eventKey: "similar-tab", key: `similar-pane-${sceneId || 'loading'}` }, content);
|
||||
const targetEventKey = 'scene-queue-panel';
|
||||
let insertIndex = childrenArray.findIndex((c) => c && c.props && (c.props.eventKey === targetEventKey || c.props['data-event-key'] === targetEventKey || c.props['data-eventKey'] === targetEventKey));
|
||||
if (insertIndex >= 0) {
|
||||
childrenArray.splice(insertIndex, 0, pane);
|
||||
console.log('[SimilarTabIntegration] Inserted Similar Tab.Pane before queue pane at index', insertIndex, 'for scene', sceneId);
|
||||
}
|
||||
else {
|
||||
childrenArray.push(pane);
|
||||
console.warn('[SimilarTabIntegration] Queue pane not found; appended Similar Tab.Pane to end for scene', sceneId);
|
||||
}
|
||||
const newChildren = React.createElement(React.Fragment, null, ...childrenArray);
|
||||
return [{ children: newChildren }];
|
||||
}
|
||||
catch (e) {
|
||||
console.error('[SimilarTabIntegration] Error in ScenePage.TabContent patch:', e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
console.log('[SimilarTabIntegration] Patches registered successfully');
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[SimilarTabIntegration] Error registering patches:', error);
|
||||
}
|
||||
}
|
||||
// Initialize immediately; viewer is resolved lazily at render time
|
||||
initializePatches();
|
||||
})();
|
||||
})();
|
||||
|
||||
347
plugins/AIOverhaul/TaskDashboard.js
Normal file
347
plugins/AIOverhaul/TaskDashboard.js
Normal file
@ -0,0 +1,347 @@
|
||||
(function(){
|
||||
// TaskDashboard (cleaned)
|
||||
// Minimal responsibilities:
|
||||
// - Show active top-level tasks (no children listed) with progress inferred from children.
|
||||
// - Manual history fetch.
|
||||
// - Cancel queued/running parent tasks (single base URL resolution).
|
||||
// - Expand failed history rows to view/copy error.
|
||||
function resolveBackendBase() {
|
||||
try {
|
||||
const globalFn = window.AIDefaultBackendBase;
|
||||
if (typeof globalFn === 'function') {
|
||||
const value = globalFn();
|
||||
if (typeof value === 'string')
|
||||
return value;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
try {
|
||||
const raw = window.AI_BACKEND_URL;
|
||||
if (typeof raw === 'string')
|
||||
return raw.replace(/\/$/, '');
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const debug = () => !!window.AIDebug;
|
||||
const dlog = (...a) => { if (debug())
|
||||
console.debug('[TaskDashboard]', ...a); };
|
||||
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 || {};
|
||||
const headers = { ...(init && init.headers ? init.headers : {}) };
|
||||
headers['x-ai-api-key'] = key;
|
||||
return { ...(init || {}), headers };
|
||||
}
|
||||
function appendSharedKeyQuery(url) {
|
||||
const helper = window.AISharedApiKeyHelper;
|
||||
if (helper && typeof helper.appendQuery === 'function') {
|
||||
return helper.appendQuery(url);
|
||||
}
|
||||
const key = getSharedApiKey();
|
||||
if (!key)
|
||||
return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}api_key=${encodeURIComponent(key)}`;
|
||||
}
|
||||
function ensureWS(baseHttp) {
|
||||
var _a, _b, _c, _d;
|
||||
const g = window;
|
||||
if (!baseHttp) {
|
||||
try {
|
||||
(_b = (_a = g.__AI_TASK_WS__) === null || _a === void 0 ? void 0 : _a.close) === null || _b === void 0 ? void 0 : _b.call(_a);
|
||||
}
|
||||
catch { }
|
||||
g.__AI_TASK_WS__ = null;
|
||||
g.__AI_TASK_WS_BASE__ = null;
|
||||
g.__AI_TASK_WS_INIT__ = false;
|
||||
return;
|
||||
}
|
||||
if (g.__AI_TASK_WS_BASE__ && g.__AI_TASK_WS_BASE__ !== baseHttp) {
|
||||
try {
|
||||
(_d = (_c = g.__AI_TASK_WS__) === null || _c === void 0 ? void 0 : _c.close) === null || _d === void 0 ? void 0 : _d.call(_c);
|
||||
}
|
||||
catch { }
|
||||
g.__AI_TASK_WS__ = null;
|
||||
g.__AI_TASK_WS_INIT__ = false;
|
||||
}
|
||||
if (g.__AI_TASK_WS__ && g.__AI_TASK_WS__.readyState === 1 && g.__AI_TASK_WS_BASE__ === baseHttp)
|
||||
return;
|
||||
if (g.__AI_TASK_WS_INIT__)
|
||||
return;
|
||||
g.__AI_TASK_WS_INIT__ = true;
|
||||
g.__AI_TASK_WS_BASE__ = baseHttp;
|
||||
const base = baseHttp.replace(/^http/, 'ws');
|
||||
const candidates = [`${base}/api/v1/ws/tasks`, `${base}/ws/tasks`];
|
||||
const urls = candidates.map((u) => appendSharedKeyQuery(u));
|
||||
let connected = false;
|
||||
for (const u of urls) {
|
||||
try {
|
||||
const sock = new WebSocket(u);
|
||||
g.__AI_TASK_WS__ = sock;
|
||||
if (!g.__AI_TASK_CACHE__)
|
||||
g.__AI_TASK_CACHE__ = {};
|
||||
if (!g.__AI_TASK_WS_LISTENERS__)
|
||||
g.__AI_TASK_WS_LISTENERS__ = {};
|
||||
if (!g.__AI_TASK_ANY_LISTENERS__)
|
||||
g.__AI_TASK_ANY_LISTENERS__ = [];
|
||||
sock.onmessage = (evt) => {
|
||||
var _a;
|
||||
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))
|
||||
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.forEach((fn) => { try {
|
||||
fn(task);
|
||||
}
|
||||
catch { } });
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
sock.onclose = () => { if (g.__AI_TASK_WS__ === sock)
|
||||
g.__AI_TASK_WS__ = null; g.__AI_TASK_WS_INIT__ = false; };
|
||||
connected = true;
|
||||
break;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (!connected) {
|
||||
g.__AI_TASK_WS_INIT__ = false;
|
||||
}
|
||||
}
|
||||
function listActiveParents(cache) {
|
||||
const tasks = Object.values(cache || {});
|
||||
return tasks.filter(t => !t.group_id && !['completed', 'failed', 'cancelled'].includes(t.status))
|
||||
.sort((a, b) => (a.submitted_at || 0) - (b.submitted_at || 0));
|
||||
}
|
||||
function computeProgress(task) {
|
||||
const g = window;
|
||||
const cache = g.__AI_TASK_CACHE__ || {};
|
||||
const children = Object.values(cache).filter((c) => c.group_id === task.id);
|
||||
if (!children.length)
|
||||
return null;
|
||||
let done = 0, running = 0, queued = 0, failed = 0, cancelled = 0;
|
||||
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);
|
||||
}
|
||||
const TaskDashboard = () => {
|
||||
var _a;
|
||||
const React = ((_a = window.PluginApi) === null || _a === void 0 ? void 0 : _a.React) || window.React;
|
||||
if (!React) {
|
||||
console.error('[TaskDashboard] React not found');
|
||||
return null;
|
||||
}
|
||||
const [backendBase, setBackendBase] = React.useState(() => resolveBackendBase());
|
||||
const [active, setActive] = React.useState([]);
|
||||
const [history, setHistory] = React.useState([]);
|
||||
const [loadingHistory, setLoadingHistory] = React.useState(false);
|
||||
const [filterService, setFilterService] = React.useState(null);
|
||||
const [expanded, setExpanded] = React.useState(new Set());
|
||||
const [cancelling, setCancelling] = React.useState(new Set());
|
||||
React.useEffect(() => { ensureWS(backendBase); }, [backendBase]);
|
||||
React.useEffect(() => {
|
||||
const handleBaseUpdate = () => {
|
||||
const next = resolveBackendBase();
|
||||
setBackendBase((prev) => (next === prev ? prev : next));
|
||||
};
|
||||
try {
|
||||
window.addEventListener('AIBackendBaseUpdated', handleBaseUpdate);
|
||||
}
|
||||
catch { }
|
||||
return () => { try {
|
||||
window.removeEventListener('AIBackendBaseUpdated', handleBaseUpdate);
|
||||
}
|
||||
catch { } };
|
||||
}, []);
|
||||
// Active tasks tracking
|
||||
React.useEffect(() => {
|
||||
const g = window;
|
||||
if (!g.__AI_TASK_ANY_LISTENERS__)
|
||||
g.__AI_TASK_ANY_LISTENERS__ = [];
|
||||
const pull = () => { const cache = g.__AI_TASK_CACHE__ || {}; setActive(listActiveParents(cache)); };
|
||||
pull();
|
||||
const listener = () => pull();
|
||||
g.__AI_TASK_ANY_LISTENERS__.push(listener);
|
||||
return () => { g.__AI_TASK_ANY_LISTENERS__ = (g.__AI_TASK_ANY_LISTENERS__ || []).filter((fn) => fn !== listener); };
|
||||
}, []);
|
||||
const fetchHistory = React.useCallback(async () => {
|
||||
if (!backendBase) {
|
||||
setLoadingHistory(false);
|
||||
setHistory([]);
|
||||
return;
|
||||
}
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const url = new URL(`${backendBase}/api/v1/tasks/history`);
|
||||
url.searchParams.set('limit', '50');
|
||||
if (filterService)
|
||||
url.searchParams.set('service', filterService);
|
||||
if (debug())
|
||||
dlog('Fetch history URL:', url.toString());
|
||||
const res = await fetch(url.toString(), withSharedKeyHeaders());
|
||||
if (!res.ok)
|
||||
return;
|
||||
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
||||
if (!ct.includes('application/json'))
|
||||
return;
|
||||
const data = await res.json();
|
||||
if (data && Array.isArray(data.history))
|
||||
setHistory(data.history);
|
||||
}
|
||||
finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}, [backendBase, filterService]);
|
||||
React.useEffect(() => { fetchHistory(); }, [fetchHistory]);
|
||||
function toggleExpand(id) { setExpanded((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); }
|
||||
function copyToClipboard(text) { var _a; try {
|
||||
(_a = navigator.clipboard) === null || _a === void 0 ? void 0 : _a.writeText(text);
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
window.prompt('Copy error text manually:', text);
|
||||
}
|
||||
catch { }
|
||||
} }
|
||||
async function cancelTask(id) {
|
||||
if (!backendBase) {
|
||||
alert('AI backend URL is not configured.');
|
||||
return;
|
||||
}
|
||||
setCancelling((prev) => { const n = new Set(prev); n.add(id); return n; });
|
||||
try {
|
||||
const res = await fetch(`${backendBase}/api/v1/tasks/${id}/cancel`, withSharedKeyHeaders({ method: 'POST' }));
|
||||
if (!res.ok)
|
||||
throw new Error('Cancel failed HTTP ' + res.status);
|
||||
}
|
||||
catch (e) {
|
||||
setCancelling((prev) => { const n = new Set(prev); n.delete(id); return n; });
|
||||
alert('Cancel failed: ' + (e.message || 'unknown'));
|
||||
}
|
||||
}
|
||||
const formatTs = (v) => v ? new Date(v * 1000).toLocaleTimeString() : '-';
|
||||
const services = Array.from(new Set(history.map(h => h.service).concat(active.map(a => a.service))));
|
||||
// ---- Render (structure & classNames intentionally unchanged) ----
|
||||
return React.createElement('div', { className: 'ai-task-dashboard' }, [
|
||||
React.createElement('div', { key: 'hdr', className: 'ai-task-dash__header' }, [
|
||||
React.createElement('h3', { key: 'title' }, 'AI Tasks'),
|
||||
React.createElement('div', { key: 'filters', className: 'ai-task-dash__filters' }, [
|
||||
React.createElement('select', { key: 'svc', value: filterService || '', onChange: (e) => setFilterService(e.target.value || null) }, [
|
||||
React.createElement('option', { key: 'all', value: '' }, 'All Services'),
|
||||
...services.map(s => React.createElement('option', { key: s, value: s }, s))
|
||||
]),
|
||||
React.createElement('button', { key: 'refresh', onClick: fetchHistory, disabled: loadingHistory }, loadingHistory ? 'Refreshing…' : 'Refresh')
|
||||
])
|
||||
]),
|
||||
React.createElement('div', { key: 'active', className: 'ai-task-dash__section' }, [
|
||||
React.createElement('h4', { key: 'lbl' }, 'Active'),
|
||||
active.length === 0 && React.createElement('div', { key: 'none', className: 'ai-task-dash__empty' }, 'No active tasks'),
|
||||
...active.map((t) => {
|
||||
const prog = computeProgress(t);
|
||||
const isCancelling = cancelling.has(t.id);
|
||||
return React.createElement('div', { key: t.id, className: 'ai-task-row' }, [
|
||||
React.createElement('div', { key: 'svc', className: 'ai-task-row__svc' }, t.service),
|
||||
React.createElement('div', { key: 'act', className: 'ai-task-row__action' }, t.action_id),
|
||||
React.createElement('div', { key: 'status', className: 'ai-task-row__status' }, t.status + (isCancelling ? ' (cancelling...)' : '')),
|
||||
React.createElement('div', { key: 'progress', className: 'ai-task-row__progress' }, prog != null ? `${Math.round(prog * 100)}%` : ''),
|
||||
React.createElement('div', { key: 'times', className: 'ai-task-row__times' }, formatTs(t.started_at)),
|
||||
(t.status === 'queued' || t.status === 'running') && React.createElement('button', { key: 'cancel', disabled: isCancelling, className: 'ai-task-row__cancel', onClick: () => cancelTask(t.id), style: { marginLeft: 8 } }, isCancelling ? 'Cancelling…' : 'Cancel')
|
||||
]);
|
||||
})
|
||||
]),
|
||||
React.createElement('div', { key: 'hist', className: 'ai-task-dash__section' }, [
|
||||
React.createElement('h4', { key: 'lbl' }, 'Recent History'),
|
||||
history.length === 0 && React.createElement('div', { key: 'none', className: 'ai-task-dash__empty' }, 'No recent tasks'),
|
||||
...history.map(h => {
|
||||
const isFailed = h.status === 'failed';
|
||||
const isExpanded = expanded.has(h.task_id);
|
||||
const rowClasses = ['ai-task-row', 'ai-task-row--history'];
|
||||
if (isFailed)
|
||||
rowClasses.push('ai-task-row--failed');
|
||||
if (isExpanded)
|
||||
rowClasses.push('ai-task-row--expanded');
|
||||
return React.createElement(React.Fragment, { key: h.task_id }, [
|
||||
React.createElement('div', { key: 'row', className: rowClasses.join(' '), onClick: () => { if (isFailed)
|
||||
toggleExpand(h.task_id); }, style: isFailed ? { cursor: 'pointer' } : undefined }, [
|
||||
React.createElement('div', { key: 'svc', className: 'ai-task-row__svc' }, h.service),
|
||||
React.createElement('div', { key: 'act', className: 'ai-task-row__action' }, h.action_id),
|
||||
React.createElement('div', { key: 'status', className: 'ai-task-row__status' }, h.status + (isFailed ? (isExpanded ? ' ▲' : ' ▼') : '')),
|
||||
React.createElement('div', { key: 'dur', className: 'ai-task-row__progress' }, h.duration_ms != null ? `${h.duration_ms}ms` : ''),
|
||||
React.createElement('div', { key: 'time', className: 'ai-task-row__times' }, formatTs(h.finished_at || h.started_at))
|
||||
]),
|
||||
isFailed && isExpanded && h.error && React.createElement('div', { key: 'err', className: 'ai-task-row__errorDetail' }, [
|
||||
React.createElement('pre', { key: 'pre', style: { margin: 0, whiteSpace: 'pre-wrap', fontSize: '12px', lineHeight: '1.3', background: '#330', color: '#fdd', padding: '6px', borderRadius: '4px', maxHeight: '200px', overflow: 'auto' } }, h.error),
|
||||
React.createElement('div', { key: 'btns', style: { marginTop: '4px', display: 'flex', gap: '8px' } }, [
|
||||
React.createElement('button', { key: 'copy', onClick: (e) => { e.stopPropagation(); copyToClipboard(h.error); } }, 'Copy Error'),
|
||||
React.createElement('button', { key: 'close', onClick: (e) => { e.stopPropagation(); toggleExpand(h.task_id); } }, 'Close')
|
||||
])
|
||||
])
|
||||
]);
|
||||
})
|
||||
])
|
||||
]);
|
||||
};
|
||||
window.TaskDashboard = TaskDashboard;
|
||||
window.AITaskDashboard = TaskDashboard;
|
||||
window.AITaskDashboardMount = function (container) {
|
||||
var _a, _b;
|
||||
const React = ((_a = window.PluginApi) === null || _a === void 0 ? void 0 : _a.React) || window.React;
|
||||
const ReactDOM = window.ReactDOM || ((_b = window.PluginApi) === null || _b === void 0 ? void 0 : _b.ReactDOM);
|
||||
if (!React || !ReactDOM) {
|
||||
console.error('[TaskDashboard] React or ReactDOM not available');
|
||||
return;
|
||||
}
|
||||
ReactDOM.render(React.createElement(TaskDashboard, {}), container);
|
||||
};
|
||||
TaskDashboard;
|
||||
})();
|
||||
|
||||
177
plugins/AIOverhaul/VersionInfo.js
Normal file
177
plugins/AIOverhaul/VersionInfo.js
Normal file
@ -0,0 +1,177 @@
|
||||
(function(){
|
||||
// Frontend version bootstrapper. Attempts to detect the running AI Overhaul
|
||||
// manifest version and exposes it globally so other bundles can read it.
|
||||
(function initFrontendVersion() {
|
||||
const GLOBAL_KEY = 'AIOverhaulFrontendVersion';
|
||||
const EVENT_NAME = 'AIFrontendVersionDetected';
|
||||
const PLUGIN_NAME = 'AIOverhaul';
|
||||
const GRAPHQL_PLUGIN_VERSION_QUERY = `
|
||||
query AIOverhaulPluginVersion {
|
||||
plugins {
|
||||
id
|
||||
name
|
||||
version
|
||||
}
|
||||
}
|
||||
`;
|
||||
const win = typeof window !== 'undefined' ? window : undefined;
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
function applyVersion(value) {
|
||||
if (!value)
|
||||
return;
|
||||
const normalized = String(value).trim();
|
||||
if (!normalized)
|
||||
return;
|
||||
if (typeof win[GLOBAL_KEY] === 'string' && win[GLOBAL_KEY] === normalized) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
win[GLOBAL_KEY] = normalized;
|
||||
}
|
||||
catch (_) { }
|
||||
try {
|
||||
win.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: normalized }));
|
||||
}
|
||||
catch (_) { }
|
||||
}
|
||||
function detectFromGlobals() {
|
||||
try {
|
||||
const existing = win[GLOBAL_KEY];
|
||||
if (typeof existing === 'string' && existing.trim()) {
|
||||
return existing.trim();
|
||||
}
|
||||
const api = win.PluginApi;
|
||||
if (api) {
|
||||
const manifest = api.manifest || api.pluginManifest || (api.plugin && api.plugin.manifest);
|
||||
if (manifest && typeof manifest.version === 'string') {
|
||||
const normalized = manifest.version.trim();
|
||||
if (normalized)
|
||||
return normalized;
|
||||
}
|
||||
if (api.plugin && typeof api.plugin.version === 'string') {
|
||||
const normalized = api.plugin.version.trim();
|
||||
if (normalized)
|
||||
return normalized;
|
||||
}
|
||||
if (api.plugins) {
|
||||
const named = api.plugins.AIOverhaul || api.plugins.aioverhaul;
|
||||
if (named) {
|
||||
if (named.manifest && typeof named.manifest.version === 'string') {
|
||||
const normalized = named.manifest.version.trim();
|
||||
if (normalized)
|
||||
return normalized;
|
||||
}
|
||||
if (typeof named.version === 'string') {
|
||||
const normalized = named.version.trim();
|
||||
if (normalized)
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const manifest = win.AIOverhaulManifest;
|
||||
if (manifest && typeof manifest.version === 'string') {
|
||||
const normalized = manifest.version.trim();
|
||||
if (normalized)
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
catch (_) { }
|
||||
return null;
|
||||
}
|
||||
function normalizeName(value) {
|
||||
if (typeof value !== 'string')
|
||||
return '';
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
async function fetchVersionFromStash() {
|
||||
var _a;
|
||||
try {
|
||||
const resp = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ query: GRAPHQL_PLUGIN_VERSION_QUERY }),
|
||||
});
|
||||
if (!resp.ok)
|
||||
return null;
|
||||
const payload = await resp.json().catch(() => null);
|
||||
const plugins = (_a = payload === null || payload === void 0 ? void 0 : payload.data) === null || _a === void 0 ? void 0 : _a.plugins;
|
||||
if (!Array.isArray(plugins))
|
||||
return null;
|
||||
const target = normalizeName(PLUGIN_NAME);
|
||||
for (const entry of plugins) {
|
||||
const id = normalizeName(entry === null || entry === void 0 ? void 0 : entry.id);
|
||||
const name = normalizeName(entry === null || entry === void 0 ? void 0 : entry.name);
|
||||
if (id === target || name === target) {
|
||||
const version = typeof (entry === null || entry === void 0 ? void 0 : entry.version) === 'string' ? entry.version.trim() : '';
|
||||
if (version)
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function parseYamlVersion(text) {
|
||||
if (!text)
|
||||
return null;
|
||||
const match = text.match(/^\s*version\s*:\s*([^\s#]+)/im);
|
||||
if (match && match[1]) {
|
||||
const value = match[1].trim();
|
||||
return value || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function resolveManifestUrl() {
|
||||
try {
|
||||
const currentScript = document.currentScript;
|
||||
if (currentScript && currentScript.src) {
|
||||
const url = new URL(currentScript.src, window.location.origin);
|
||||
const parts = url.pathname.split('/');
|
||||
parts.pop();
|
||||
url.pathname = [...parts, 'AIOverhaul.yml'].join('/');
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
catch (_) { }
|
||||
return 'AIOverhaul.yml';
|
||||
}
|
||||
async function fetchManifestVersion() {
|
||||
const manifestUrl = resolveManifestUrl();
|
||||
try {
|
||||
const resp = await fetch(manifestUrl, { credentials: 'same-origin' });
|
||||
if (!resp.ok)
|
||||
return null;
|
||||
const text = await resp.text();
|
||||
return parseYamlVersion(text || '');
|
||||
}
|
||||
catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
(async () => {
|
||||
const existing = detectFromGlobals();
|
||||
if (existing) {
|
||||
applyVersion(existing);
|
||||
return;
|
||||
}
|
||||
const stashVersion = await fetchVersionFromStash();
|
||||
if (stashVersion) {
|
||||
applyVersion(stashVersion);
|
||||
return;
|
||||
}
|
||||
const fetched = await fetchManifestVersion();
|
||||
if (fetched) {
|
||||
applyVersion(fetched);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
})();
|
||||
|
||||
339
plugins/AIOverhaul/css/AIOverhaul.css
Normal file
339
plugins/AIOverhaul/css/AIOverhaul.css
Normal file
@ -0,0 +1,339 @@
|
||||
/* =============================================================================
|
||||
AI Overhaul - Glassy Minimalist Theme
|
||||
============================================================================= */
|
||||
|
||||
/* Main Container Styling */
|
||||
.ai-overhaul-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.1),
|
||||
0 2px 16px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.ai-overhaul-container:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(0, 0, 0, 0.15),
|
||||
0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Card Styling */
|
||||
.ai-overhaul-card {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ai-overhaul-card-header {
|
||||
background: linear-gradient(135deg, rgba(74, 144, 226, 0.15), rgba(80, 200, 120, 0.15));
|
||||
border: none;
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 16px 24px;
|
||||
margin: -24px -24px 20px -24px;
|
||||
}
|
||||
|
||||
.ai-overhaul-card-title {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Form Controls */
|
||||
.ai-overhaul-input {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ai-overhaul-input:focus {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(74, 144, 226, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ai-overhaul-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Button Styling */
|
||||
.ai-overhaul-btn {
|
||||
background: linear-gradient(135deg, rgba(74, 144, 226, 0.8), rgba(80, 200, 120, 0.8));
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
padding: 10px 20px;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.ai-overhaul-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(74, 144, 226, 0.9), rgba(80, 200, 120, 0.9));
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(74, 144, 226, 0.3);
|
||||
}
|
||||
|
||||
.ai-overhaul-btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.ai-overhaul-btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* Endpoint Preview */
|
||||
.ai-overhaul-endpoint-preview {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* Status Indicators */
|
||||
.ai-overhaul-status-success {
|
||||
color: #4ade80;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ai-overhaul-status-error {
|
||||
color: #f87171;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Health Results Container */
|
||||
.ai-overhaul-health-results {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.ai-overhaul-fade-in {
|
||||
animation: aiOverhaulFadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes aiOverhaulFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.ai-overhaul-loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.ai-overhaul-title {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ai-overhaul-subtitle {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
AI Button Specific Styles
|
||||
============================================================================= */
|
||||
|
||||
.ai-overhaul-button-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.ai-overhaul-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
backdrop-filter: blur(15px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
min-width: 220px;
|
||||
overflow: hidden;
|
||||
animation: aiOverhaulDropdownFadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.ai-overhaul-dropdown-item {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ai-overhaul-dropdown-item:hover {
|
||||
background: rgba(74, 144, 226, 0.1);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.ai-overhaul-dropdown-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Button Processing State */
|
||||
.ai-overhaul-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dropdown Animation */
|
||||
@keyframes aiOverhaulDropdownFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Context-specific button styling */
|
||||
.ai-overhaul-button-container[data-context="image"] .ai-overhaul-btn {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(139, 92, 246, 0.8));
|
||||
}
|
||||
|
||||
.ai-overhaul-button-container[data-context="scene"] .ai-overhaul-btn {
|
||||
background: linear-gradient(135deg, rgba(236, 72, 153, 0.8), rgba(251, 146, 60, 0.8));
|
||||
}
|
||||
|
||||
.ai-overhaul-button-container[data-context="performer"] .ai-overhaul-btn {
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(59, 130, 246, 0.8));
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Inlined Minimal AI Button Styles (previously src/css/AIButton.css)
|
||||
============================================================================= */
|
||||
:root {
|
||||
--ai-btn-transition: 0.18s ease;
|
||||
--ai-btn-radius: 8px;
|
||||
--ai-btn-size: 48px;
|
||||
--ai-color-default: #374151;
|
||||
--ai-color-scenes: #3b82f6;
|
||||
--ai-color-galleries: #8b5cf6;
|
||||
--ai-color-images: #f59e0b;
|
||||
--ai-color-performers: #ef4444;
|
||||
--ai-color-studios: #0ea5e9;
|
||||
--ai-color-tags: #6366f1;
|
||||
--ai-color-markers: #f472b6;
|
||||
--ai-color-home: #6b7280;
|
||||
--ai-color-settings: #c5662f;
|
||||
--ai-color-detail: #10b981;
|
||||
}
|
||||
.minimal-ai-button { position: relative; display: inline-block; font-family: inherit; }
|
||||
.ai-btn { display:flex; flex-direction:column; align-items:center; justify-content:center; width:var(--ai-btn-size); height:var(--ai-btn-size); background:transparent; border:2px solid var(--ai-color-default); color:var(--ai-color-default); border-radius:var(--ai-btn-radius); cursor:pointer; transition: transform var(--ai-btn-transition), box-shadow var(--ai-btn-transition), background-color var(--ai-btn-transition), color var(--ai-btn-transition), border-color var(--ai-btn-transition); font-size:14px; font-weight:600; padding:4px; user-select:none; position:relative; }
|
||||
.ai-btn__icon { font-size:16px; line-height:1; margin-bottom:2px; }
|
||||
.ai-btn__label { font-size:8px; line-height:1; text-align:center; letter-spacing:.5px; }
|
||||
.ai-btn__badge { position:absolute; top:-6px; right:-6px; background:#ef4444; color:#fff; min-width:18px; height:18px; padding:0 4px; border-radius:10px; font-size:10px; display:flex; align-items:center; justify-content:center; font-weight:700; box-shadow:0 1px 4px rgba(0,0,0,0.35); pointer-events:none; }
|
||||
.ai-btn--progress { position:relative; }
|
||||
.ai-btn--progress::after { content:''; position:absolute; inset:0; border-radius:var(--ai-btn-radius); box-shadow:0 0 0 2px currentColor inset; opacity:.25; pointer-events:none; }
|
||||
.ai-btn__progress-ring { position:absolute; inset:-2px; border-radius:var(--ai-btn-radius); background:conic-gradient(currentColor var(--ai-progress,0), rgba(255,255,255,0.08) 0); display:flex; align-items:center; justify-content:center; mix-blend-mode:normal; opacity:.85; pointer-events:none; mask:radial-gradient(circle at 50% 50%, rgba(0,0,0,0) 48%, rgba(0,0,0,1) 49%); -webkit-mask:radial-gradient(circle at 50% 50%, rgba(0,0,0,0) 48%, rgba(0,0,0,1) 49%); }
|
||||
.ai-btn--progress .ai-btn__icon { font-size:13px; font-weight:600; position:relative; z-index:1; }
|
||||
.ai-btn--scenes { border-color:var(--ai-color-scenes); color:var(--ai-color-scenes); }
|
||||
.ai-btn--galleries { border-color:var(--ai-color-galleries); color:var(--ai-color-galleries); }
|
||||
.ai-btn--images { border-color:var(--ai-color-images); color:var(--ai-color-images); }
|
||||
.ai-btn--performers { border-color:var(--ai-color-performers); color:var(--ai-color-performers); }
|
||||
.ai-btn--studios { border-color:var(--ai-color-studios); color:var(--ai-color-studios); }
|
||||
.ai-btn--tags { border-color:var(--ai-color-tags); color:var(--ai-color-tags); }
|
||||
.ai-btn--markers { border-color:var(--ai-color-markers); color:var(--ai-color-markers); }
|
||||
.ai-btn--home { border-color:var(--ai-color-home); color:var(--ai-color-home); }
|
||||
.ai-btn--settings { border-color:var(--ai-color-settings); color:var(--ai-color-settings); }
|
||||
.ai-btn--detail { border-color:var(--ai-color-detail); color:var(--ai-color-detail); }
|
||||
.ai-btn:hover { transform:scale(1.05); box-shadow:0 2px 8px rgba(0,0,0,0.15); background-color:currentColor; color:#fff; }
|
||||
.ai-btn:active { transform:scale(0.94); }
|
||||
.ai-btn__tooltip { position:absolute; top:-64px; left:50%; transform:translateX(-50%); background:#1f2937; color:#fff; padding:8px 12px; border-radius:6px; font-size:12px; white-space:nowrap; z-index:1000; box-shadow:0 2px 8px rgba(0,0,0,0.25); border:1px solid #374151; display:flex; flex-direction:column; gap:2px; pointer-events:none; }
|
||||
.ai-btn__tooltip-main { font-weight:600; }
|
||||
.ai-btn__tooltip-detail { font-size:10px; opacity:.85; }
|
||||
.ai-btn__tooltip-id { font-size:10px; opacity:.6; }
|
||||
.ai-btn__tooltip-sel { font-size:10px; opacity:.75; color:#93c5fd; }
|
||||
.ai-actions-menu { position:absolute; top:56px; right:0; background:#1f2937; border:1px solid #374151; border-radius:8px; padding:6px 0; min-width:220px; z-index:1000; box-shadow:0 4px 12px rgba(0,0,0,0.3); }
|
||||
.ai-actions-menu__status { padding:8px 12px; color:#9ca3af; font-size:12px; }
|
||||
.ai-actions-menu__item { display:flex; align-items:center; gap:6px; width:100%; text-align:left; padding:8px 12px; background:transparent; border:none; color:#e5e7eb; font-size:13px; cursor:pointer; }
|
||||
.ai-actions-menu__item:hover { background:#374151; }
|
||||
.ai-actions-menu__item:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.ai-actions-menu__svc { opacity:.5; font-size:11px; letter-spacing:.5px; }
|
||||
.ai-actions-menu__rk { font-size:10px; color:#93c5fd; }
|
||||
.ai-actions-menu__exec { font-size:10px; }
|
||||
|
||||
/* =============================================================================
|
||||
Inlined Task Dashboard Styles (previously src/css/TaskDashboard.css)
|
||||
============================================================================= */
|
||||
.ai-task-dashboard { font-family: var(--font, sans-serif); padding:8px; color: var(--ai-fg, #eee); background: rgba(20,20,25,0.6); backdrop-filter: blur(6px); border:1px solid #333; border-radius:6px; max-width:860px; }
|
||||
.ai-task-dash__header { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; }
|
||||
.ai-task-dash__filters select, .ai-task-dash__filters button { margin-left:6px; background:#222; color:#ddd; border:1px solid #444; padding:4px 8px; border-radius:4px; cursor:pointer; }
|
||||
.ai-task-dash__filters button[disabled]{ opacity:.5; cursor:default; }
|
||||
.ai-task-dash__section { margin-top:12px; }
|
||||
.ai-task-row { display:grid; grid-template-columns:110px 1fr 90px 70px 120px; gap:8px; padding:4px 6px; border-bottom:1px solid #333; font-size:13px; align-items:center; }
|
||||
.ai-task-row:nth-child(odd){ background:rgba(255,255,255,0.02); }
|
||||
.ai-task-row__status { text-transform:capitalize; }
|
||||
.ai-task-row--history { opacity:.85; }
|
||||
.ai-task-dash__empty { font-style:italic; padding:4px 2px; color:#888; }
|
||||
.ai-task-row__progress { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Compact nav tabs in scene details to prevent wrapping */
|
||||
.scene-tabs .nav-tabs .nav-item .nav-link {
|
||||
padding: 6px 8px; /* much smaller padding */
|
||||
font-size: 0.8rem; /* smaller font */
|
||||
margin-right: 2px; /* minimal spacing */
|
||||
white-space: nowrap; /* prevent text wrapping within tabs */
|
||||
}
|
||||
|
||||
.scene-tabs .nav-tabs .nav-item {
|
||||
margin-bottom: 0; /* ensure no vertical spacing */
|
||||
flex-shrink: 0; /* prevent flex shrinking */
|
||||
}
|
||||
|
||||
/* More aggressive targeting - apply to all nav-tabs in scene areas */
|
||||
.ScenePage .nav-tabs .nav-item .nav-link,
|
||||
[class*="scene"] .nav-tabs .nav-item .nav-link {
|
||||
padding: 6px 6px !important;
|
||||
font-size: 0.92rem !important;
|
||||
margin-right: 2px !important;
|
||||
}
|
||||
83
plugins/AIOverhaul/css/SimilarScenes.css
Normal file
83
plugins/AIOverhaul/css/SimilarScenes.css
Normal file
@ -0,0 +1,83 @@
|
||||
/* Similar Scenes - queue-style list only */
|
||||
|
||||
/* Fix thumbnail sizing for queue-style list items in Similar tab */
|
||||
/* Target the thumbnail containers within the Similar tab specifically */
|
||||
.similar-scenes-tab .thumbnail-container {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px; /* spacing between thumbnail and details to match queue layout */
|
||||
}
|
||||
|
||||
.similar-scenes-tab .thumbnail-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
/* Remove list bullets and match queue styling */
|
||||
.similar-scenes-tab ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Match queue scene details styling exactly */
|
||||
.similar-scenes-tab .queue-scene-details {
|
||||
width: 245px;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.similar-scenes-tab .queue-scene-title {
|
||||
color: #f5f8fa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.similar-scenes-tab .queue-scene-studio,
|
||||
.similar-scenes-tab .queue-scene-performers,
|
||||
.similar-scenes-tab .queue-scene-date {
|
||||
color: hsla(0, 0%, 100%, 0.45);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.similar-scenes-loading,
|
||||
.similar-scenes-error,
|
||||
.similar-scenes-empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--bs-text-muted, #6c757d);
|
||||
}
|
||||
|
||||
.similar-scenes-error {
|
||||
color: var(--bs-danger, #dc3545);
|
||||
background-color: var(--bs-danger-bg-subtle, #f8d7da);
|
||||
border: 1px solid var(--bs-danger-border-subtle, #f5c2c7);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.similar-scenes-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.similar-scenes-loading:before {
|
||||
content: '';
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid var(--bs-border-color, #dee2e6);
|
||||
border-top-color: var(--bs-primary, #0d6efd);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
257
plugins/AIOverhaul/css/recommendedscenes.css
Normal file
257
plugins/AIOverhaul/css/recommendedscenes.css
Normal file
@ -0,0 +1,257 @@
|
||||
:root {
|
||||
--ai-rec-container-padding: 30px;
|
||||
--ai-rec-card-margin: 10px;
|
||||
--ai-input-bg: #24272b;
|
||||
--ai-input-border: #2f3337;
|
||||
--ai-input-focus-border: #3d4348;
|
||||
--ai-input-focus-ring: rgba(90,150,255,.15);
|
||||
/* Normal textbox colors (used as the canonical textbox style) */
|
||||
/* Ensure textbox variables mirror input variables so tag boxes match inputs */
|
||||
--ai-textbox-bg: var(--ai-input-bg);
|
||||
--ai-textbox-border: var(--ai-input-border);
|
||||
}
|
||||
.btn-constraint.btn-save {
|
||||
background: #2e7d32;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Constraint Editor: Co-occurrence selected tag chip */
|
||||
.constraint-cochip-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 1px 2px;
|
||||
padding: 2px 4px;
|
||||
background: #2a3f5f;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.constraint-cochip-remove {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
.ai-rec-config{font-size:12px;line-height:1.2}
|
||||
.ai-rec-config .config-row{margin-left:-4px;margin-right:-4px}
|
||||
.ai-rec-config .config-row>[class*="col-"]{padding-left:4px;padding-right:4px}
|
||||
.ai-rec-config .form-group{position:relative;margin-bottom:1px}
|
||||
.ai-rec-config .form-group label{font-weight:500;font-size:10px;margin-bottom:0;line-height:1.2;color:#999}
|
||||
.ai-rec-config .form-control,
|
||||
.ai-rec-config .form-control-sm{font-size:11px;padding:5.25px 8px;height:33.5px;min-height:33.5px}
|
||||
.ai-rec-config input[type=range]{height:16px;margin:0}
|
||||
.ai-rec-config .switch-inline{display:flex;align-items:center;gap:.25rem;height:33.5px}
|
||||
.ai-rec-config .custom-control.custom-switch{display:flex;align-items:center;gap:6px;min-height:33.5px}
|
||||
.ai-rec-config .custom-control-label{line-height:1.1}
|
||||
.ai-rec-config .range-wrapper{display:flex;align-items:center;gap:.25rem;height:33.5px;width:92px}
|
||||
.ai-rec-config .zoom-slider{width:100%;height:16px}
|
||||
|
||||
/* Global zoom slider styles for toolbar */
|
||||
.zoom-slider{width:100%;height:16px}
|
||||
.ai-rec-config .range-value{min-width:32px;text-align:center;font-size:10px;padding:1px 3px;background:#2c2f33;border:1px solid #373a3e;border-radius:2px;line-height:1.2;height:22px;display:flex;align-items:center;justify-content:center}
|
||||
.ai-rec-config .text-muted{font-size:10px}
|
||||
.ai-rec-config .w-num{width:72px}
|
||||
.ai-rec-config .w-180,.ai-rec-config .w-select,.ai-rec-config .w-text,.ai-rec-config .w-search{width:180px;max-width:180px}
|
||||
.ai-rec-config .w-tags{width:400px;max-width:400px}
|
||||
.ai-rec-config .label-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
/* Wrapper used in RecommendedScenes.tsx to align controls under their labels */
|
||||
.ai-rec-config .control-wrap{display:flex;align-items:center;justify-content:center;min-height:33.5px;height:auto}
|
||||
|
||||
/* Ensure inner switch fills the wrapper and its input is centered precisely */
|
||||
.ai-rec-config .control-wrap .custom-control.custom-switch{height:100%;align-items:center;display:flex}
|
||||
.ai-rec-config .control-wrap .custom-control-input{margin:0;align-self:center}
|
||||
.ai-rec-config .control-wrap .custom-control-label{display:flex;align-items:center;justify-content:center;height:100%}
|
||||
|
||||
|
||||
/* Force the switch pseudo-elements (Bootstrap's .custom-control-label::before/::after)
|
||||
to be vertically centered within the label's area. Use high specificity so theme
|
||||
overrides won't push the switch off-center. */
|
||||
.ai-rec-config .control-wrap .custom-control.custom-switch .custom-control-label::before,
|
||||
.ai-rec-config .control-wrap .custom-control.custom-switch .custom-control-label::after{
|
||||
top:50% !important;
|
||||
transform:translateY(-50%) !important;
|
||||
}
|
||||
|
||||
/* Ensure the label itself doesn't collapse baseline spacing that could push the switch up */
|
||||
.ai-rec-config .control-wrap .custom-control-label{line-height:1;padding:0}
|
||||
|
||||
/* Layout: center controls horizontally inside each config row */
|
||||
.ai-rec-config .config-row{display:flex;justify-content:center;align-items:center;gap:12px;flex-wrap:wrap;margin-left:0;margin-right:0}
|
||||
.ai-rec-config .config-row>[class*="col-"]{padding-left:4px;padding-right:4px;display:flex;align-items:center;justify-content:center}
|
||||
|
||||
.ai-tag-fallback{position:relative;/* use textbox vars for color + fallbacks for visual parity */
|
||||
background: var(--ai-textbox-bg) !important;
|
||||
/* exact computed values from canonical textbox to ensure pixel parity */
|
||||
background-color: rgba(16,22,26,.3) !important;
|
||||
color: #f5f8fa !important;
|
||||
border: 0 !important; /* canonical input uses inset box-shadow instead of an outer border */
|
||||
border-radius: 4px;
|
||||
padding: 5.25px 8px;
|
||||
font-size: 11px;
|
||||
/* allow grow vertically when chips wrap */
|
||||
min-height: 33.5px;
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
/* allow chips to wrap and grow the control vertically; align to top */
|
||||
align-items:flex-start;
|
||||
gap:4px;
|
||||
cursor:text;
|
||||
box-shadow: inset 0 0 0 1px rgba(16,22,26,.3), inset 0 1px 1px rgba(16,22,26,.4) !important;
|
||||
background-clip: padding-box;
|
||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Allow the outer tag control to appear transparent (so it visually matches inputs that are transparent)
|
||||
while keeping dropdowns readable. `.unified` indicates the 'use canonical textbox look' mode. */
|
||||
.ai-tag-fallback.unified{
|
||||
/* Use the intended semi-transparent fill (not fully transparent) so the box
|
||||
keeps the subtle background tint seen on other text inputs */
|
||||
background-color: rgba(16,22,26,.3) !important;
|
||||
background-image: none !important;
|
||||
color: #f5f8fa !important;
|
||||
/* preserve the inner stroke from the canonical textbox for pixel parity */
|
||||
box-shadow: inset 0 0 0 1px rgba(16,22,26,.3), inset 0 1px 1px rgba(16,22,26,.4) !important;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.ai-tag-fallback.unified:focus-within{border-color:var(--ai-input-focus-border);box-shadow:0 0 0 2px var(--ai-input-focus-ring)}
|
||||
.ai-tag-fallback .combination-toggle{padding:2px 8px;font-size:11px;line-height:1.1;border-radius:3px;border:1px solid transparent;cursor:pointer;font-weight:600;min-width:32px}
|
||||
.ai-tag-fallback .combination-toggle.disabled{opacity:.6;cursor:not-allowed}
|
||||
.ai-tag-fallback .combination-toggle.and{background:#1f3d23;border-color:#2d6a36;color:#8ee19b}
|
||||
.ai-tag-fallback .combination-toggle.or{background:#3d2a1f;border-color:#6a4a2d;color:#e2c19b}
|
||||
.ai-tag-fallback .mode-toggle{padding:2px 6px;font-size:11px;line-height:1.1;border-radius:3px;border:1px solid transparent;cursor:pointer;font-weight:600}
|
||||
.ai-tag-fallback .mode-toggle.include{background:#1f3d23;border-color:#2d6a36;color:#8ee19b}
|
||||
.ai-tag-fallback .mode-toggle.exclude{background:#4a1b1b;border-color:#a33;color:#f08a8a}
|
||||
.ai-tag-fallback .tag-chip{display:inline-flex;align-items:center;gap:2px;border-radius:3px;padding:2px 6px;font-size:11px;font-weight:500;border:1px solid;position:relative;max-width:250px}
|
||||
.ai-tag-fallback .tag-chip .chip-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
|
||||
.ai-tag-fallback .tag-chip .chip-actions{display:flex;gap:2px;flex-shrink:0;margin-left:4px}
|
||||
.ai-tag-fallback .tag-chip.include{background:#1f4d2a;border-color:#2e7d32;color:#cfe8d0}
|
||||
.ai-tag-fallback .tag-chip.exclude{background:#5c1f1f;border-color:#b33;color:#f5d0d0}
|
||||
.ai-tag-fallback .tag-chip.duration{background:#2a3f5f;border-color:#4a90e2;color:#cfe8ff}
|
||||
.ai-tag-fallback .tag-chip.overlap{background:#5f3f2a;border-color:#e2904a;color:#ffeacf}
|
||||
.ai-tag-fallback .tag-chip.importance{background:#5f2a5f;border-color:#9b4a9b;color:#f5d0f5}
|
||||
.ai-tag-fallback .tag-input{flex:1;min-width:120px;border:none;outline:none;background:transparent;color:inherit;padding:2px 4px;font-size:11px;line-height:1.2;height:auto}
|
||||
.ai-tag-fallback .tag-input::placeholder{color:rgba(255,255,255,0.4)}
|
||||
|
||||
.ai-tag-fallback .suggestions-list{position:absolute;z-index:1000;left:0;right:0;top:100%;margin-top:2px;max-height:220px;overflow:auto;border-radius:4px;box-shadow:0 8px 20px rgba(0,0,0,0.6);
|
||||
/* Dropdown/suggestions stay opaque and legible regardless of outer control transparency */
|
||||
background-color: rgba(16,22,26,0.95) !important;
|
||||
color: #f5f8fa !important;
|
||||
border: 1px solid rgba(255,255,255,0.03) !important;
|
||||
padding-top:6px;
|
||||
}
|
||||
.ai-tag-fallback .suggestions-list div{padding:5px 8px;cursor:pointer;font-size:11px}
|
||||
.ai-tag-fallback .suggestions-list div:hover{background:#2d3236}
|
||||
.ai-tag-fallback .empty-suggest{padding:6px 8px;font-size:11px;color:#889}
|
||||
|
||||
/* Tag chip button styles */
|
||||
.ai-tag-fallback .tag-chip button{background:transparent;border:none;cursor:pointer;padding:0 0 0 2px;font-size:13px;line-height:1;color:inherit}
|
||||
.ai-tag-fallback .tag-chip .constraint-btn{background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2);border-radius:2px;padding:1px 3px;font-size:9px;margin-left:2px;cursor:pointer}
|
||||
.ai-tag-fallback .tag-chip .constraint-btn:hover{background:rgba(255,255,255,0.2)}
|
||||
|
||||
.ai-tag-fallback .tag-dropdown{position:absolute;top:100%;left:0;right:0;border-top:none;border-radius:0 0 6px 6px;max-height:200px;overflow-y:auto;z-index:1000;
|
||||
background-color: rgba(16,22,26,0.95) !important;
|
||||
color: #f5f8fa !important;
|
||||
border: 1px solid rgba(255,255,255,0.04) !important;
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.7) !important;
|
||||
}
|
||||
|
||||
/* Higher-specificity fallback so other theme rules don't accidentally override these */
|
||||
.ai-tag-fallback.unified{
|
||||
/* use the intended semi-transparent fill so the control matches other text inputs */
|
||||
background-color: rgba(16,22,26,.3) !important;
|
||||
background-image: none !important;
|
||||
color: #f5f8fa !important;
|
||||
/* preserve the inner stroke so it still reads as an input */
|
||||
box-shadow: inset 0 0 0 1px rgba(16,22,26,.3), inset 0 1px 1px rgba(16,22,26,.4) !important;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.ai-tag-fallback.unified .suggestions-list,
|
||||
.ai-tag-fallback.unified .tag-dropdown{
|
||||
/* dropdowns remain opaque and legible */
|
||||
background-color: rgba(16,22,26,0.95) !important;
|
||||
color: #f5f8fa !important;
|
||||
border: 1px solid rgba(255,255,255,0.03) !important;
|
||||
}
|
||||
|
||||
/* Ensure the text inputs we use for config match the canonical input variables exactly
|
||||
This targets the specific class combination used by the textbox in the UI so it's
|
||||
precise and doesn't affect unrelated inputs. */
|
||||
/* NOTE: Removed .text-input.form-control override so global inputs keep their original theme styles.
|
||||
The tag selector now explicitly uses the canonical textbox variables (--ai-textbox-*) so it matches
|
||||
the intended textbox appearance without forcing other inputs to adopt tag styles. */
|
||||
.ai-tag-fallback .tag-dropdown-item{padding:4px 8px;cursor:pointer;color:#fff;font-size:12px}
|
||||
.ai-tag-fallback .tag-dropdown-item:hover{background:#2a2e33}
|
||||
.ai-tag-fallback .tag-dropdown-item.selected{background:#1a3d23;color:#8ee19b}
|
||||
|
||||
/* Constraint editor styles */
|
||||
.constraint-popup{position:fixed;z-index:1101;background:#1a1d21;border:1px solid #333;border-radius:6px;padding:8px;box-shadow:0 4px 12px rgba(0,0,0,0.6);font-size:11px;min-width:200px}
|
||||
.constraint-popup .constraint-title{font-weight:bold;margin-bottom:6px}
|
||||
.constraint-popup .constraint-type{margin-bottom:6px}
|
||||
.constraint-popup .constraint-type select{width:100%;padding:2px 4px;background:var(--ai-input-bg);border:1px solid var(--ai-input-border);border-radius:3px;color:#fff;font-size:11px}
|
||||
.constraint-popup .constraint-options{margin-bottom:6px}
|
||||
.constraint-popup .constraint-options input,.constraint-popup .constraint-options select{width:60px;padding:2px 4px;background:var(--ai-input-bg);border:1px solid var(--ai-input-border);border-radius:3px;color:#fff;font-size:10px;margin:1px}
|
||||
.constraint-popup .constraint-actions{display:flex;justify-content:flex-end;margin-top:8px;gap:4px}
|
||||
.constraint-popup .btn-constraint{padding:2px 6px;background:var(--ai-input-bg);border:1px solid var(--ai-input-border);border-radius:3px;color:#fff;cursor:pointer;font-size:10px}
|
||||
.constraint-popup .btn-constraint:hover{background:var(--ai-input-bg)}
|
||||
.constraint-popup .constraint-actions button{padding:3px 6px;font-size:10px;border:none;border-radius:3px;cursor:pointer}
|
||||
.constraint-popup .btn-save{background:#2e7d32;color:#fff}
|
||||
.constraint-popup .btn-cancel{background:#666;color:#fff}
|
||||
.constraint-popup .close-btn{background:transparent;border:none;color:#fff;cursor:pointer;font-size:14px;position:absolute;top:2px;right:4px;padding:2px 4px;border-radius:2px}
|
||||
|
||||
/* Co-occurrence chip styles (flex, horizontal growth) */
|
||||
.co-chip{display:flex;align-items:center;width:fit-content;min-width:0;padding:4px 8px;gap:6px}
|
||||
.co-chip .co-constraint-info{flex-shrink:0;font-size:10px;font-weight:bold;margin-right:4px}
|
||||
.co-chip .co-tags{display:flex;align-items:center;gap:6px;flex-wrap:nowrap;flex-shrink:0}
|
||||
.co-chip .co-tag-item{display:flex;align-items:center;white-space:nowrap;flex-shrink:0}
|
||||
.co-chip .co-tag-name{max-width:120px;overflow:hidden;text-overflow:ellipsis}
|
||||
.co-chip .co-tag-remove{background:transparent;border:none;color:#fff;margin-left:4px;cursor:pointer;font-size:12px;padding:0}
|
||||
.co-chip .co-actions{flex-shrink:0;display:flex;align-items:center;gap:8px}
|
||||
.co-chip .co-chip-remove{background:transparent;border:none;color:#fff;cursor:pointer;font-size:12px;padding:0}
|
||||
|
||||
/* Grid and scene styles */
|
||||
.scene-grid-loading,.scene-grid-error,.scene-grid-empty,.scene-grid-calculating{margin-top:24px}
|
||||
.scene-grid-error{color:#c66}
|
||||
.ai-rec-grid{gap:0 !important}
|
||||
.ai-rec-grid .scene-card{width:var(--ai-card-width) !important}
|
||||
.backend-status{margin-right:6px}
|
||||
|
||||
/* Generic tag chip styles for include/exclude */
|
||||
.tag-chip-flex{display:inline-flex;align-items:center;gap:4px;max-width:300px}
|
||||
.tag-chip-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
|
||||
.tag-chip-constraint{font-size:10px;color:#aaa;flex-shrink:0}
|
||||
.tag-chip-actions{display:flex;gap:2px;flex-shrink:0}
|
||||
.tag-chip-remove{background:transparent;border:none;cursor:pointer;padding:0 0 0 2px;font-size:13px;line-height:1;color:inherit}
|
||||
|
||||
/* Constraint editor selected tags area */
|
||||
.constraint-selected-tags{margin-bottom:6px;min-height:20px;border:1px solid #444;border-radius:3px;padding:2px}
|
||||
.constraint-selected-empty{color:#888;font-size:10px;padding:2px}
|
||||
.constraint-available-tags{display:flex;flex-wrap:wrap;gap:1px;margin-top:4px}
|
||||
.constraint-tag-button{padding:1px 4px;background:#2a3f5f;color:#fff;border:none;border-radius:2px;cursor:pointer;font-size:9px;line-height:12px}
|
||||
|
||||
/* Enhanced co-occurrence chip styles - moved from AIOverhaul.css */
|
||||
.tag-chip.overlap{display:flex;align-items:center;gap:6px;padding:6px 12px 6px 10px;border:2px solid;border-radius:8px;background:rgba(255,255,255,0.03);position:relative;width:fit-content;min-width:0;max-width:none}
|
||||
.co-occurrence-constraint-info{font-size:10px;opacity:0.8;font-weight:600;letter-spacing:0.5px;white-space:nowrap;margin-right:4px}
|
||||
.co-occurrence-tags{display:flex;flex-wrap:nowrap;gap:3px;align-items:center}
|
||||
.co-tag-item{display:flex;align-items:center;gap:3px;padding:2px 5px;background:rgba(59,130,246,0.2);border:1px solid rgba(59,130,246,0.5);border-radius:10px;font-size:11px;line-height:1;cursor:pointer;transition:all 0.15s ease}
|
||||
.co-tag-item:hover{background:rgba(59,130,246,0.3);border-color:rgba(59,130,246,0.7);transform:translateY(-1px)}
|
||||
.co-tag-remove{background:none;border:1px solid rgba(255,255,255,0.3);color:inherit;font-size:9px;width:14px;height:14px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;opacity:0.7;line-height:1;transition:all 0.15s ease}
|
||||
.co-tag-remove:hover{opacity:1;background:rgba(255,100,100,0.3);border-color:rgba(255,100,100,0.6);transform:scale(1.1)}
|
||||
.tag-chip.overlap .co-actions{display:flex;align-items:center;gap:8px;margin-left:8px;flex:0 0 auto}
|
||||
.tag-chip.overlap .constraint-btn{background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:4px;padding:2px 6px;font-size:11px;cursor:pointer}
|
||||
.tag-chip.overlap .co-chip-remove,.tag-chip.overlap .remove-group-btn{background:none;border:none;color:inherit;font-size:12px;width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;opacity:0.85}
|
||||
.tag-chip.overlap .co-chip-remove:hover,.tag-chip.overlap .remove-group-btn:hover{opacity:1;background:rgba(255,255,255,0.12)}
|
||||
|
||||
/* Final override: ensure unified tag controls use the semi-transparent textbox fill */
|
||||
body .ai-tag-fallback.unified {
|
||||
background-color: rgba(16,22,26,.3) !important;
|
||||
background-image: none !important;
|
||||
color: #f5f8fa !important;
|
||||
}
|
||||
386
plugins/AIOverhaul/plugin_setup.py
Normal file
386
plugins/AIOverhaul/plugin_setup.py
Normal file
@ -0,0 +1,386 @@
|
||||
"""Minimal plugin setup helper using only standard library facilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import gzip
|
||||
import zlib
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
CONFIG_QUERY = """
|
||||
query Configuration($pluginIds: [ID!]) {
|
||||
configuration {
|
||||
general {
|
||||
databasePath
|
||||
apiKey
|
||||
}
|
||||
# Keep plugins in the payload so callers can still inspect plugin entries if needed
|
||||
plugins(include: $pluginIds)
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# Shared fallback backend base so setup can still sync metadata when no override exists yet.
|
||||
DEFAULT_BACKEND_BASE_URL = "http://localhost:4153"
|
||||
|
||||
|
||||
def _normalize_backend_base(raw: Any) -> Optional[str]:
|
||||
if isinstance(raw, str):
|
||||
trimmed = raw.strip()
|
||||
if not trimmed:
|
||||
return ""
|
||||
return trimmed.rstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
def _format_base_url(raw: Optional[str]) -> Optional[str]:
|
||||
if not raw:
|
||||
return None
|
||||
cleaned = raw.strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
if cleaned.endswith('/'):
|
||||
cleaned = cleaned.rstrip('/')
|
||||
return cleaned or None
|
||||
|
||||
def _build_backend_setting_url(base: str, key: str) -> str:
|
||||
clean_base = _format_base_url(base) or ''
|
||||
if not clean_base:
|
||||
raise ValueError('backend base URL is required')
|
||||
return f"{clean_base}/api/v1/plugins/system/settings/{urllib.parse.quote(key, safe='')}"
|
||||
|
||||
|
||||
def _push_backend_setting(base: str, key: str, value: Any, timeout: float = 10.0) -> None:
|
||||
url = _build_backend_setting_url(base, key)
|
||||
payload = json.dumps({"value": value}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="PUT",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
# Drain response body to allow connection reuse; backend returns small JSON.
|
||||
response.read()
|
||||
|
||||
|
||||
def _build_logger():
|
||||
try:
|
||||
import stashapi.log as stash_log # type: ignore
|
||||
|
||||
return stash_log
|
||||
except Exception: # pragma: no cover - fallback when stashapi isn't available
|
||||
class _FallbackLog:
|
||||
def info(self, msg: Any) -> None:
|
||||
sys.stderr.write(f"[INFO] {msg}\n")
|
||||
|
||||
def warning(self, msg: Any) -> None:
|
||||
sys.stderr.write(f"[WARN] {msg}\n")
|
||||
|
||||
def error(self, msg: Any) -> None:
|
||||
sys.stderr.write(f"[ERROR] {msg}\n")
|
||||
|
||||
return _FallbackLog()
|
||||
|
||||
|
||||
log = _build_logger()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
raw_input = sys.stdin.read()
|
||||
result = {"output": "ok", "error": None}
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_input) if raw_input.strip() else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
log.error(f"Failed to decode input JSON: {exc}")
|
||||
result = {"output": None, "error": f"invalid JSON input: {exc}"}
|
||||
_emit_result(result)
|
||||
return
|
||||
|
||||
try:
|
||||
result = run(payload)
|
||||
except Exception as exc: # pragma: no cover - surfaced to caller
|
||||
log.error(f"Plugin setup failed: {exc}")
|
||||
result = {"output": None, "error": str(exc)}
|
||||
|
||||
# If run returned a dict-style result, use it; otherwise fall back to ok
|
||||
if isinstance(result, dict):
|
||||
_emit_result(result)
|
||||
else:
|
||||
_emit_result({"output": "ok", "error": None})
|
||||
|
||||
|
||||
def _emit_result(result: Dict[str, Any]) -> None:
|
||||
sys.stdout.write(json.dumps(result))
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def run(json_input: Dict[str, Any]) -> Dict[str, Any]:
|
||||
args = json_input.get("args") or {}
|
||||
mode = args.get("mode")
|
||||
log.info(f"Plugin setup triggered (mode={mode!r})")
|
||||
|
||||
if mode != "plugin_setup":
|
||||
log.info("No setup action requested; exiting early.")
|
||||
return {"output": None, "error": "no setup requested"}
|
||||
|
||||
return plugin_setup(json_input)
|
||||
|
||||
|
||||
def plugin_setup(json_input: Dict[str, Any]) -> Dict[str, Any]:
|
||||
connection = json_input.get("server_connection") or {}
|
||||
plugin_info = json_input.get("plugin") or {}
|
||||
plugin_id = plugin_info.get("id") or plugin_info.get("name")
|
||||
|
||||
target = _build_graphql_url(connection)
|
||||
headers = _build_headers(connection)
|
||||
verify_ssl = connection.get("VerifySSL", True)
|
||||
|
||||
log.info(f"Connecting to GraphQL endpoint: {target}")
|
||||
if plugin_id:
|
||||
log.info(f"Fetching configuration for plugin: {plugin_id}")
|
||||
variables: Optional[Dict[str, Any]] = {"pluginIds": [plugin_id]}
|
||||
else:
|
||||
log.info("Fetching configuration for all plugins (no plugin id supplied)")
|
||||
variables = {"pluginIds": []}
|
||||
# Request only the specific configuration fields we need (no introspection):
|
||||
# general.databasePath and general.apiKey. Keep plugins in the payload so
|
||||
# the caller can still inspect plugin entries if desired.
|
||||
try:
|
||||
full_query = CONFIG_QUERY
|
||||
response = _execute_graphql(target, full_query, variables, headers, verify_ssl)
|
||||
config = (response or {}).get("configuration")
|
||||
log.info(f"Received configuration: {json.dumps(config, default=str)[:1000]}")
|
||||
except Exception as exc: # pragma: no cover - runtime fallback
|
||||
log.warning(f"Configuration query failed: {exc}; falling back to plugins-only query")
|
||||
fallback_query = """
|
||||
query PluginSetupConfig($pluginIds: [ID!]) {
|
||||
configuration { plugins(include: $pluginIds) }
|
||||
}
|
||||
"""
|
||||
response = _execute_graphql(target, fallback_query, variables, headers, verify_ssl)
|
||||
config = ((response or {}).get("configuration") or {}).get("plugins")
|
||||
log.info(f"Current plugin configuration payload (fallback): {json.dumps(config, default=str)}")
|
||||
|
||||
# Resolve database path (absolute or relative to stash base dir) and verify existence
|
||||
database_path_raw = None
|
||||
api_key = None
|
||||
absolute_db_path = None
|
||||
db_exists = False
|
||||
plugin_entries: Dict[str, Any] = {}
|
||||
|
||||
if isinstance(config, dict):
|
||||
if 'general' in config or 'plugins' in config:
|
||||
general = config.get("general") or {}
|
||||
database_path_raw = general.get("databasePath")
|
||||
api_key = general.get("apiKey")
|
||||
raw_plugins = config.get("plugins")
|
||||
log.info(f"Resolved raw plugins entry: {raw_plugins!r}")
|
||||
if isinstance(raw_plugins, dict):
|
||||
plugin_entries = raw_plugins
|
||||
else:
|
||||
general = {}
|
||||
plugin_entries = config
|
||||
elif isinstance(config, list):
|
||||
general = {}
|
||||
else:
|
||||
general = {}
|
||||
|
||||
if database_path_raw:
|
||||
if os.path.isabs(database_path_raw):
|
||||
log.info(f"Database path {database_path_raw} is absolute")
|
||||
absolute_db_path = os.path.normpath(database_path_raw)
|
||||
else:
|
||||
stash_dir = connection.get("Dir") or ""
|
||||
log.info(f"Database path {database_path_raw} is relative to Stash directory {stash_dir}")
|
||||
absolute_db_path = os.path.normpath(os.path.join(stash_dir, database_path_raw))
|
||||
|
||||
db_exists = os.path.isabs(absolute_db_path) and os.path.exists(absolute_db_path)
|
||||
|
||||
plugin_entry: Optional[Dict[str, Any]] = None
|
||||
if isinstance(plugin_entries, dict) and plugin_entries:
|
||||
entry = plugin_entries.get("AIOverhaul")
|
||||
if isinstance(entry, dict):
|
||||
plugin_entry = entry
|
||||
|
||||
backend_base_override = None
|
||||
if isinstance(plugin_entry, dict):
|
||||
backend_base_override = plugin_entry.get("backend_base_url")
|
||||
|
||||
backend_base_override = _normalize_backend_base(backend_base_override)
|
||||
normalized_api_key = api_key
|
||||
if isinstance(normalized_api_key, str):
|
||||
trimmed = normalized_api_key.strip()
|
||||
if not trimmed:
|
||||
normalized_api_key = ''
|
||||
elif trimmed.upper() == 'REPLACE_WITH_API_KEY':
|
||||
normalized_api_key = None
|
||||
|
||||
backend_base_url = (
|
||||
_format_base_url(backend_base_override)
|
||||
or _format_base_url(os.getenv('AI_BACKEND_BASE_URL'))
|
||||
or DEFAULT_BACKEND_BASE_URL
|
||||
)
|
||||
stash_base_url = target
|
||||
if stash_base_url.endswith('/graphql'):
|
||||
stash_base_url = stash_base_url[:-len('/graphql')]
|
||||
stash_base_url = _format_base_url(stash_base_url)
|
||||
|
||||
if backend_base_url:
|
||||
log.info(f"Syncing Stash connection metadata to AI backend at {backend_base_url}")
|
||||
try:
|
||||
if stash_base_url:
|
||||
_push_backend_setting(backend_base_url, 'STASH_URL', stash_base_url)
|
||||
else:
|
||||
log.warning('Unable to derive Stash base URL; skipping STASH_URL sync')
|
||||
|
||||
if normalized_api_key is None:
|
||||
log.info('No Stash API key detected; clearing backend value')
|
||||
_push_backend_setting(backend_base_url, 'STASH_API_KEY', None)
|
||||
else:
|
||||
if str(normalized_api_key):
|
||||
log.info(f'Setting Stash API key in backend (length={len(str(normalized_api_key))})')
|
||||
else:
|
||||
log.info('Setting empty Stash API key in backend')
|
||||
_push_backend_setting(backend_base_url, 'STASH_API_KEY', normalized_api_key)
|
||||
|
||||
if db_exists and absolute_db_path:
|
||||
_push_backend_setting(backend_base_url, 'STASH_DB_PATH', absolute_db_path)
|
||||
else:
|
||||
log.info('No valid Stash database path detected; skipping STASH_DB_PATH sync')
|
||||
except Exception as exc:
|
||||
log.warning(f"Failed to sync configuration to AI backend {backend_base_url}: {exc}")
|
||||
else:
|
||||
log.warning('No backend base URL could be determined; skipping AI backend configuration sync')
|
||||
|
||||
result_payload = {
|
||||
"configuration": config,
|
||||
"databasePath": absolute_db_path,
|
||||
"databaseExists": db_exists,
|
||||
"apiKey": api_key,
|
||||
"backendBaseOverride": backend_base_override
|
||||
}
|
||||
log.info(f"Plugin setup completed successfully: {json.dumps(result_payload, default=str)}")
|
||||
return {"output": result_payload, "error": None}
|
||||
|
||||
|
||||
def _build_graphql_url(connection: Dict[str, Any]) -> str:
|
||||
host = connection.get("Host", "localhost")
|
||||
if host == "0.0.0.0" or host == "127.0.0.1":
|
||||
host = "localhost"
|
||||
|
||||
port = connection.get("Port", 9999)
|
||||
scheme = connection.get("Scheme", "http")
|
||||
base_path = connection.get("Path", "/graphql")
|
||||
if not base_path.startswith("/"):
|
||||
base_path = f"/{base_path}"
|
||||
|
||||
return f"{scheme}://{host}:{port}{base_path}"
|
||||
|
||||
|
||||
def _build_headers(connection: Dict[str, Any]) -> Dict[str, str]:
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"User-Agent": "AIOverhaulPluginSetup/1.0",
|
||||
}
|
||||
|
||||
api_key = connection.get("ApiKey")
|
||||
if api_key:
|
||||
headers["ApiKey"] = str(api_key)
|
||||
|
||||
cookie_value: Optional[str] = None
|
||||
session_cookie = connection.get("SessionCookie")
|
||||
if isinstance(session_cookie, dict):
|
||||
cookie_value = session_cookie.get("Value") or session_cookie.get("value")
|
||||
elif isinstance(session_cookie, str):
|
||||
cookie_value = session_cookie
|
||||
|
||||
if cookie_value:
|
||||
headers["Cookie"] = f"session={cookie_value}"
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
def _execute_graphql(
|
||||
url: str,
|
||||
query: str,
|
||||
variables: Optional[Dict[str, Any]],
|
||||
headers: Dict[str, str],
|
||||
verify_ssl: bool = True,
|
||||
timeout: float = 15.0,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"query": query}
|
||||
if variables is not None:
|
||||
payload["variables"] = variables
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||
|
||||
context = None
|
||||
if url.lower().startswith("https") and not verify_ssl:
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, context=context, timeout=timeout) as response:
|
||||
body = _read_response_body(response)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(f"HTTP error {exc.code} while calling GraphQL: {exc.reason}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"Failed to reach GraphQL endpoint: {exc.reason}") from exc
|
||||
|
||||
try:
|
||||
preview = body if len(body) < 500 else body[:500] + "…"
|
||||
log.info(f"Received GraphQL response: {preview}")
|
||||
payload = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Invalid JSON from GraphQL endpoint: {exc}") from exc
|
||||
|
||||
errors = payload.get("errors")
|
||||
if errors:
|
||||
raise RuntimeError(f"GraphQL returned errors: {errors}")
|
||||
|
||||
return payload.get("data", {})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _read_response_body(response: Any, default_charset: str = "utf-8") -> str:
|
||||
raw = response.read()
|
||||
encoding = (response.headers.get("Content-Encoding") or "").lower()
|
||||
|
||||
if "gzip" in encoding:
|
||||
try:
|
||||
raw = gzip.decompress(raw)
|
||||
except OSError:
|
||||
pass
|
||||
elif "deflate" in encoding:
|
||||
try:
|
||||
raw = zlib.decompress(raw)
|
||||
except zlib.error:
|
||||
try:
|
||||
raw = zlib.decompress(raw, -zlib.MAX_WBITS)
|
||||
except zlib.error:
|
||||
pass
|
||||
|
||||
charset = response.headers.get_content_charset() or default_charset
|
||||
try:
|
||||
return raw.decode(charset)
|
||||
except UnicodeDecodeError:
|
||||
return raw.decode(charset, errors="replace")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user