Add AI Overhaul plugin (#645)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
skier233 2025-12-19 14:30:13 -08:00 committed by GitHub
parent c3de80e75e
commit 54ff5ca251
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 11252 additions and 0 deletions

View 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;
})();

View 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');
})();
})();

View 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

View 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 { }
})();

View 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;
})();
})();

File diff suppressed because it is too large Load Diff

View 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()
};
})();

File diff suppressed because it is too large Load Diff

View 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

View 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();
})();
})();

File diff suppressed because it is too large Load Diff

View 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();
})();
})();

View 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();
})();
})();

View 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;
})();

View 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);
}
})();
})();
})();

View 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;
}

View 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); }
}

View 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;
}

View 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()