mirror of
https://github.com/stashapp/CommunityScripts.git
synced 2026-02-05 04:45:09 -06:00
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
786 lines
42 KiB
JavaScript
786 lines
42 KiB
JavaScript
(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();
|
|
})();
|
|
})();
|
|
|