Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion plugins/AIOverhaul/AIOverhaul.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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.2
version: 0.9.3
url: https://discourse.stashapp.cc/t/aioverhaul/4847
ui:
javascript:
Expand Down Expand Up @@ -30,6 +30,13 @@ ui:
- ws://127.0.0.1:4153
- https://127.0.0.1:4153
# Add additional urls here for the stash-ai-server if your browser is not on the same host
script-src:
- 'self'
- http://localhost:4153
- https://localhost:4153
- 'unsafe-inline'
- 'unsafe-eval'
# Allow plugin JavaScript files to be loaded from the backend server
interface: raw
exec:
- python
Expand Down
216 changes: 207 additions & 9 deletions plugins/AIOverhaul/PluginSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -1621,10 +1621,142 @@ const PluginSettings = () => {
React.createElement("button", { style: smallBtn, onClick: handleConfigure }, openConfig === p.name ? 'Close' : 'Configure'))));
}))));
}
// Component to handle dynamic loading of custom field renderer scripts
function CustomFieldLoader({ fieldType, pluginName, field, backendBase, savePluginSetting, loadPluginSettings, setError, renderDefaultInput }) {
var _a;
const React = ((_a = window.PluginApi) === null || _a === void 0 ? void 0 : _a.React) || window.React;
const [renderer, setRenderer] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [failed, setFailed] = React.useState(false);
React.useEffect(() => {
const pluginSpecificName = `${pluginName}_${fieldType}_Renderer`;
const genericName = `${fieldType}_Renderer`;
const legacyName = fieldType === 'tag_list_editor' ? 'SkierAITaggingTagListEditor' : null;
// Check if renderer is already available
const checkRenderer = () => {
const found = window[pluginSpecificName] ||
window[genericName] ||
(legacyName ? window[legacyName] : null);
if (found && typeof found === 'function') {
setRenderer(() => found);
setLoading(false);
return true;
}
return false;
};
if (checkRenderer())
return;
// Try to load the script from the backend server
// Normalize backendBase to ensure it doesn't end with a slash
const normalizedBackendBase = backendBase.replace(/\/+$/, '');
const possiblePaths = [
`${normalizedBackendBase}/plugins/${pluginName}/${fieldType}.js`,
`${normalizedBackendBase}/dist/plugins/${pluginName}/${fieldType}.js`,
];
// Also try camelCase version
const typeParts = fieldType.split('_');
if (typeParts.length > 1) {
const camelCase = typeParts[0] + typeParts.slice(1).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
possiblePaths.push(`${normalizedBackendBase}/plugins/${pluginName}/${camelCase}.js`);
possiblePaths.push(`${normalizedBackendBase}/dist/plugins/${pluginName}/${camelCase}.js`);
}
let attemptIndex = 0;
const tryLoad = () => {
if (attemptIndex >= possiblePaths.length) {
setLoading(false);
setFailed(true);
if (window.AIDebug) {
console.warn('[PluginSettings.CustomFieldLoader] Failed to load renderer for', fieldType, 'tried:', possiblePaths);
}
return;
}
const path = possiblePaths[attemptIndex];
// Use fetch + eval instead of script tag to work around CSP script-src-elem restrictions
// This uses script-src (which has unsafe-eval) instead of script-src-elem
fetch(path)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
})
.then(scriptText => {
console.log('[PluginSettings.CustomFieldLoader] Fetched script:', path);
try {
// Eval the script - this uses script-src (with unsafe-eval) instead of script-src-elem
// Create a new function context to avoid polluting global scope
const scriptFunction = new Function(scriptText);
scriptFunction();
// Wait a bit for the script to register, then check again
setTimeout(() => {
if (checkRenderer()) {
return;
}
// Script loaded but renderer not found, try next path
attemptIndex++;
tryLoad();
}, 200);
}
catch (evalError) {
console.error('[PluginSettings.CustomFieldLoader] Error evaluating script:', path, evalError);
attemptIndex++;
tryLoad();
}
})
.catch(error => {
console.warn('[PluginSettings.CustomFieldLoader] Failed to fetch script:', path, error);
attemptIndex++;
tryLoad();
});
};
tryLoad();
// Also poll for renderer in case it loads asynchronously (max 10 seconds)
let pollCount = 0;
const pollInterval = setInterval(() => {
pollCount++;
if (checkRenderer() || pollCount > 20) {
clearInterval(pollInterval);
if (pollCount > 20 && !renderer) {
setLoading(false);
setFailed(true);
}
}
}, 500);
return () => clearInterval(pollInterval);
}, [fieldType, pluginName]);
if (renderer) {
return React.createElement(renderer, {
field: field,
pluginName: pluginName,
backendBase: backendBase,
savePluginSetting: savePluginSetting,
loadPluginSettings: loadPluginSettings,
setError: setError
});
}
if (loading) {
return React.createElement('div', { style: { padding: 8, fontSize: 11, color: '#888', fontStyle: 'italic' } }, `Loading ${fieldType} editor...`);
}
// Failed to load - use default input if provided, otherwise show error message
if (failed && renderDefaultInput) {
return renderDefaultInput();
}
if (failed) {
return React.createElement('div', { style: { padding: 8, fontSize: 11, color: '#f85149' } }, `Failed to load ${fieldType} editor. Using default input.`);
}
return null;
}
function FieldRenderer({ f, pluginName }) {
const t = f.type || 'string';
const label = f.label || f.key;
const savedValue = f.value === undefined ? f.default : f.value;
// Define styles and computed values early so they're available to callbacks
const changed = savedValue !== undefined && savedValue !== null && f.default !== undefined && savedValue !== f.default;
const inputStyle = { padding: 6, background: '#111', color: '#eee', border: '1px solid #333', minWidth: 120 };
const wrap = { position: 'relative', padding: '4px 4px 6px', border: '1px solid #2a2a2a', borderRadius: 4, background: '#101010' };
const resetStyle = { position: 'absolute', top: 2, right: 4, fontSize: 9, padding: '1px 4px', cursor: 'pointer' };
const labelTitle = f && f.description ? String(f.description) : undefined;
const labelEl = React.createElement('span', { title: labelTitle }, React.createElement(React.Fragment, null, label, changed ? React.createElement('span', { style: { color: '#ffa657', fontSize: 10 } }, ' •') : null));
if (t === 'path_map') {
const containerStyle = {
position: 'relative',
Expand All @@ -1643,15 +1775,81 @@ const PluginSettings = () => {
changedMap && React.createElement("span", { style: { color: '#ffa657', fontSize: 10 } }, "\u2022")),
React.createElement(PathMapEditor, { value: savedValue, defaultValue: f.default, onChange: async (next) => { await savePluginSetting(pluginName, f.key, next); }, onReset: async () => { await savePluginSetting(pluginName, f.key, null); }, variant: "plugin" })));
}
const changed = savedValue !== undefined && savedValue !== null && f.default !== undefined && savedValue !== f.default;
const inputStyle = { padding: 6, background: '#111', color: '#eee', border: '1px solid #333', minWidth: 120 };
const wrap = { position: 'relative', padding: '4px 4px 6px', border: '1px solid #2a2a2a', borderRadius: 4, background: '#101010' };
const resetStyle = { position: 'absolute', top: 2, right: 4, fontSize: 9, padding: '1px 4px', cursor: 'pointer' };
const labelTitle = f && f.description ? String(f.description) : undefined;
const labelEl = React.createElement("span", { title: labelTitle },
label,
" ",
changed && React.createElement("span", { style: { color: '#ffa657', fontSize: 10 } }, "\u2022"));
// Check for custom field renderers registered by plugins
// Supports both plugin-specific (pluginName_type_Renderer) and generic (type_Renderer) naming
if (t && typeof t === 'string' && t !== 'string' && t !== 'boolean' && t !== 'number' && t !== 'select' && t !== 'path_map') {
const pluginSpecificName = `${pluginName}_${t}_Renderer`;
const genericName = `${t}_Renderer`;
const customRenderer = window[pluginSpecificName] || window[genericName];
const renderer = customRenderer;
// Debug logging
if (window.AIDebug) {
console.log('[PluginSettings.FieldRenderer] Custom field type detected:', {
type: t,
pluginName: pluginName,
pluginSpecificName: pluginSpecificName,
genericName: genericName,
hasPluginSpecific: !!window[pluginSpecificName],
hasGeneric: !!window[genericName],
renderer: renderer ? typeof renderer : 'null'
});
}
if (renderer && typeof renderer === 'function') {
if (window.AIDebug) {
console.log('[PluginSettings.FieldRenderer] Using custom renderer for', t);
}
return React.createElement(renderer, {
field: f,
pluginName: pluginName,
backendBase: backendBase,
savePluginSetting: savePluginSetting,
loadPluginSettings: loadPluginSettings,
setError: setError
});
}
else {
// Renderer not found - use CustomFieldLoader to dynamically load it
// CustomFieldLoader will handle fallback to default input if renderer not found
return React.createElement(CustomFieldLoader, {
fieldType: t,
pluginName: pluginName,
field: f,
backendBase: backendBase,
savePluginSetting: savePluginSetting,
loadPluginSettings: loadPluginSettings,
setError: setError,
// Pass the default input rendering logic as fallback
renderDefaultInput: () => {
// This will be called if renderer not found - render default text input
const display = savedValue === undefined || savedValue === null ? '' : String(savedValue);
const inputKey = `${pluginName}:${f.key}:${display}`;
const handleBlur = async (event) => {
var _a;
const next = (_a = event.target.value) !== null && _a !== void 0 ? _a : '';
if (next === display)
return;
await savePluginSetting(pluginName, f.key, next);
};
const handleKeyDown = (event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.target.blur();
}
};
const handleReset = async () => {
await savePluginSetting(pluginName, f.key, null);
};
return React.createElement('div', { style: wrap }, React.createElement('label', { style: { fontSize: 12 } }, React.createElement(React.Fragment, null, labelEl, React.createElement('br'), React.createElement('input', {
key: inputKey,
style: inputStyle,
defaultValue: display,
onBlur: handleBlur,
onKeyDown: handleKeyDown
}))), changed ? React.createElement('button', { style: resetStyle, onClick: handleReset }, 'Reset') : null);
}
});
}
}
if (t === 'boolean') {
return (React.createElement("div", { style: wrap },
React.createElement("label", { style: { fontSize: 12, display: 'flex', alignItems: 'center', gap: 8 } },
Expand Down
15 changes: 9 additions & 6 deletions plugins/AIOverhaul/SimilarScenes.js
Original file line number Diff line number Diff line change
Expand Up @@ -619,11 +619,13 @@
}, [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;
var _a, _b, _c, _d, _e;
const filepath = ((_b = (_a = scene.files) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.path) || '';
const filename = filepath ? filepath.replace(/\\/g, '/').split('/').pop() || '' : '';
const title = scene.title || filename || `Scene ${scene.id}`;
const studio = ((_c = scene.studio) === null || _c === void 0 ? void 0 : _c.name) || '';
const performers = ((_d = scene.performers) === null || _d === void 0 ? void 0 : _d.map(p => p.name).join(', ')) || '';
const screenshot = (_e = scene.paths) === null || _e === void 0 ? void 0 : _e.screenshot;
const date = scene.date || scene.created_at || '';
return React.createElement('li', {
key: scene.id,
Expand All @@ -647,10 +649,11 @@
className: 'queue-scene-details'
}, [
React.createElement('span', { key: 'title', className: 'queue-scene-title' }, title),
filepath ? React.createElement('span', { key: 'filepath', className: 'queue-scene-filepath', title: filepath, style: { fontSize: '0.75em', color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '300px', display: 'block' } }, filepath) : null,
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)
])
].filter(Boolean))
])));
}, [handleSceneClick]);
// Render recommender selector when recommenders are available
Expand Down
Loading