Files
DemonGatanjieu-Anomalous_Mo…/web/modules/ui_doctor.js
T

2187 lines
122 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { applyNodeMaterialValues } from './node_material_actions.js';
import { applyMaterialToSelectedNode } from './ui_material_application.js';
import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
import { escapeHtml } from './safe_dom.js';
import {
analyzeModelChainInsertion,
getModelChainInsertionCapabilities,
spliceModelChainNode,
} from './graph_splice.js';
import {
collectMainModelContextRequests,
formatModelTypeLabel,
getBaseModelFamily,
inferPickerModelType,
} from './model_picker.js';
import { requiresHashForModelRecovery } from './model_policies.js';
/**
* ui_doctor.js
* Extracted Doctor Panel & Assistant Panel methods.
*/
const t = (key, params) => translate(key, params);
function workflowHashRecord(nodeId, value) {
const hashes = app.graph?.extra?.anomalous_hashes;
if (!hashes || typeof value !== 'string') return null;
const normalized = value.replace(/\\/g, '/');
const windowsPath = value.replace(/\//g, '\\');
return hashes[`${nodeId}_${value}`]
|| hashes[`${nodeId}_${normalized}`]
|| hashes[`${nodeId}_${windowsPath}`]
|| hashes[value]
|| hashes[normalized]
|| hashes[windowsPath]
|| null;
}
function localHashRecord(value) {
if (!window.anomalous_hash_cache || typeof value !== 'string') return null;
const normalized = value.replace(/\\/g, '/');
const basename = normalized.split('/').pop();
return window.anomalous_hash_cache[value]
|| window.anomalous_hash_cache[normalized]
|| window.anomalous_hash_cache[basename]
|| null;
}
function hashFromRecord(record) {
const value = typeof record === 'string' ? record : record?.hash;
return String(value || '').trim().toUpperCase();
}
function hasFoundationIdentityMismatch(node, widget, value) {
if (!requiresHashForModelRecovery(node, widget)) return false;
const workflowHash = hashFromRecord(workflowHashRecord(node.id, value));
const localHash = hashFromRecord(localHashRecord(value));
return Boolean(workflowHash && localHash && workflowHash !== localHash);
}
export function initDoctorPanel() {
this.doctorPanelInitialized = true;
this.doctorPanel.innerHTML = '';
this.doctorPanel.style.padding = '0'; // Use full bleed
this.doctorPanel.style.boxSizing = 'border-box';
this.doctorPanel.style.overflow = 'hidden';
// Beautiful dark gradient header
const header = document.createElement('div');
header.style.cssText = 'padding: 24px 28px; background: linear-gradient(180deg, rgba(20,20,25,1) 0%, rgba(20,20,25,0) 100%); display:flex; flex-direction:column; gap:16px; flex-shrink:0; border-bottom: 1px solid rgba(255,255,255,0.05);';
const topRow = document.createElement('div');
topRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;';
const titleEl = document.createElement('div');
titleEl.style.cssText = 'display:flex;align-items:center;gap:12px;';
titleEl.innerHTML = `<span style="font-size:24px; filter: drop-shadow(0 0 8px rgba(0,255,204,0.3));">🩺</span><span style="font-size:18px;font-weight:600;color:#fff;font-family:Inter, sans-serif; letter-spacing: 0.5px;">${t('doctorTitle')}</span>`;
// Header control group on the right side (contains toggle & close button)
const controlGroup = document.createElement('div');
controlGroup.style.cssText = 'display:flex;align-items:center;gap:12px;';
// Auto Scan Toggle inside Doctor Panel
const autoScanToggle = document.createElement('div');
autoScanToggle.style.cssText = 'display:flex;align-items:center;gap:8px;background:rgba(255,255,255,0.05);padding:6px 12px;border-radius:6px;cursor:pointer;transition:all 0.2s;';
const renderAutoScanToggle = () => {
let isAutoEnabled = localStorage.getItem('anomalous_auto_scan_enabled') === 'true';
autoScanToggle.innerHTML = isAutoEnabled
? `<span style="font-size:16px;">🛎️</span><span style="font-size:12px;color:#f59e0b;font-weight:600;">${t('doctorAutoScanOn')}</span>`
: `<span style="font-size:16px;opacity:0.5;">🔕</span><span style="font-size:12px;color:#aaa;">${t('doctorAutoScanOff')}</span>`;
};
renderAutoScanToggle();
autoScanToggle.onmouseover = () => { autoScanToggle.style.background = 'rgba(255,255,255,0.1)'; };
autoScanToggle.onmouseout = () => { autoScanToggle.style.background = 'rgba(255,255,255,0.05)'; };
autoScanToggle.onclick = () => {
let isAutoEnabled = localStorage.getItem('anomalous_auto_scan_enabled') === 'true';
localStorage.setItem('anomalous_auto_scan_enabled', isAutoEnabled ? 'false' : 'true');
renderAutoScanToggle();
};
const refreshBtn = document.createElement('button');
refreshBtn.textContent = t('doctorRefresh');
refreshBtn.style.cssText = 'background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.15);color:#e5e7eb;font-size:12px;cursor:pointer;padding:6px 12px;border-radius:6px;transition:all 0.2s; font-weight:600;';
refreshBtn.title = t('doctorRefreshTitle');
refreshBtn.onmouseover = () => { refreshBtn.style.background = 'rgba(255,255,255,0.12)'; };
refreshBtn.onmouseout = () => { refreshBtn.style.background = 'rgba(255,255,255,0.06)'; };
refreshBtn.onclick = async () => {
refreshBtn.disabled = true;
refreshBtn.style.opacity = '0.5';
if (app.refreshComboInNodes) await app.refreshComboInNodes();
if (window.anomalous_reload_hashes) await window.anomalous_reload_hashes();
if (app.lastNodeErrors) app.lastNodeErrors = null;
if (typeof app.clearErrors === 'function') app.clearErrors();
if (app.graph) {
app.graph.setDirtyCanvas(true, true);
if (app.graph.change) app.graph.change();
}
try { window.dispatchEvent(new CustomEvent("graphChanged")); } catch(e){}
this.renderGlobalDashboard();
refreshBtn.disabled = false;
refreshBtn.style.opacity = '1';
};
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '✖';
closeBtn.style.cssText = 'background:transparent;border:none;color:rgba(255,255,255,0.4);font-size:18px;cursor:pointer;padding:4px 8px;border-radius:4px;transition:all 0.2s;';
closeBtn.onmouseover = () => { closeBtn.style.background = 'rgba(255,255,255,0.1)'; closeBtn.style.color = '#fff'; };
closeBtn.onmouseout = () => { closeBtn.style.background = 'transparent'; closeBtn.style.color = 'rgba(255,255,255,0.4)'; };
closeBtn.onclick = () => { this.doctorPanel.style.display = 'none'; if (this.grid) this.grid.style.display = 'grid'; };
controlGroup.appendChild(autoScanToggle);
controlGroup.appendChild(refreshBtn);
controlGroup.appendChild(closeBtn);
topRow.appendChild(titleEl);
topRow.appendChild(controlGroup);
header.appendChild(topRow);
// Stats row placeholder (populated by renderGlobalDashboard)
const statsRow = document.createElement('div');
statsRow.id = 'anomalous-doctor-stats-row';
statsRow.style.cssText = 'display:flex; gap:12px; align-items:center;';
header.appendChild(statsRow);
this.doctorPanel.appendChild(header);
// Node list container (takes up remaining space)
const nodeListContainer = document.createElement('div');
nodeListContainer.id = 'anomalous-doctor-node-list';
nodeListContainer.style.cssText = 'display:flex;flex-direction:column;gap:12px;overflow-y:auto;flex:1; padding: 20px 28px; background: rgba(0,0,0,0.2);';
this.doctorPanel.appendChild(nodeListContainer);
// Initial render
this.renderGlobalDashboard();
}
function getInsertionCapabilityMessage(capability) {
const messages = {
missing_graph_or_node: 'doctorGraphUnavailable',
missing_chain_inputs: 'doctorMissingChainInputs',
unconnected_chain_inputs: 'doctorUnconnectedChainInputs',
missing_chain_outputs: 'doctorMissingChainOutputs',
ambiguous_downstream_branches: 'doctorAmbiguousBranches',
invalid_downstream_link: 'doctorInvalidDownstream',
};
return t(messages[capability?.code] || 'doctorUnsupportedInsertion');
}
function getNativeWidgetValues(node, widget) {
const source = widget?.options?.values;
let values = source;
if (typeof source === 'function') {
try {
values = source(widget, node);
} catch (error) {
console.warn('[Anomalous] Failed to resolve native combo values:', error);
values = [];
}
}
return Array.isArray(values)
? [...new Set(values.filter(value => typeof value === 'string'))]
: [];
}
function findModelComboWidget(node) {
return (node?.widgets || []).find(widget => {
if (widget?.type !== 'combo') return false;
const values = getNativeWidgetValues(node, widget);
return values.some(value => /\.(safetensors|ckpt|pt|bin|pth|sft)$/i.test(value));
}) || null;
}
function setWidgetValue(node, widget, value) {
widget.value = value;
const widgetIndex = node?.widgets?.indexOf(widget) ?? -1;
if (widgetIndex >= 0) {
node.widgets_values = Array.isArray(node.widgets_values)
? node.widgets_values
: node.widgets.map(item => item?.value);
node.widgets_values[widgetIndex] = value;
}
}
export function openLoraInsertionPicker(anchorNode, direction) {
const analysis = analyzeModelChainInsertion(app.graph, anchorNode, direction);
if (!analysis.supported) {
alert(getInsertionCapabilityMessage(analysis));
return;
}
const insertedNode = typeof LiteGraph !== 'undefined' ? LiteGraph.createNode('LoraLoader') : null;
if (!insertedNode) {
alert(t('doctorCreateLoraFailed'));
return;
}
const modelWidget = findModelComboWidget(insertedNode);
if (!modelWidget || getNativeWidgetValues(insertedNode, modelWidget).length === 0) {
alert(t('doctorLoraNotReady'));
return;
}
this._openGalleryReplacer(insertedNode, modelWidget, {
mode: 'insert',
direction,
anchorNode,
analysis,
modelTypeLabel: 'LoRA',
});
}
export function diagnoseNode(node, forceRefresh = false) {
// This method serves the Node Assistant panel only
if (!this.assistantPanelInitialized) {
this.initAssistantPanel();
}
const placeholder = document.getElementById('anomalous-assistant-placeholder');
const nodeContent = document.getElementById('anomalous-assistant-node-content');
if (!placeholder || !nodeContent || !app.graph || !app.graph._nodes) return;
if (!node) {
placeholder.innerHTML = `<div style="font-size:48px;">🤖</div><div style="text-align:center;">${t('assistantSelectNode')}</div>`;
placeholder.style.display = 'flex';
nodeContent.style.display = 'none';
nodeContent.innerHTML = '';
return;
}
const modelWidgets = [];
if (node.widgets) {
for (const w of node.widgets) {
if (w.type === 'combo' && typeof w.value === 'string') {
if (w.value.match(/\.(safetensors|ckpt|pt|bin|pth|sft)$/i)) modelWidgets.push(w);
}
}
}
const insertionCapabilities = getModelChainInsertionCapabilities(app.graph, node);
const canInsert = insertionCapabilities.before.supported || insertionCapabilities.after.supported;
const hasModelOrInsertion = modelWidgets.length > 0 || canInsert;
placeholder.style.display = 'none';
nodeContent.style.display = 'flex';
nodeContent.innerHTML = '';
const titleBar = document.createElement('div');
titleBar.style.cssText = 'margin:14px 16px 0;padding:16px;border:1px solid rgba(255,255,255,0.1);border-radius:12px;background:rgba(255,255,255,0.04);display:flex;align-items:center;gap:12px;flex-shrink:0;box-shadow:0 8px 24px rgba(0,0,0,0.25);';
titleBar.innerHTML = `<span style="width:38px;height:38px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:20px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);">🤖</span><span style="display:flex;flex-direction:column;min-width:0;gap:3px;"><span style="font-size:10px;letter-spacing:0.11em;text-transform:uppercase;color:#9ca3af;">${t('assistantSelectedNode')}</span><span class="ast-title" style="font-weight:700;color:#f3f4f6;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></span></span><span class="ast-type" style="font-size:10px;color:#d1d5db;margin-left:auto;padding:4px 8px;border-radius:999px;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.06);max-width:38%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></span>`;
titleBar.querySelector('.ast-title').textContent = node.title || node.type || 'Node';
titleBar.querySelector('.ast-type').textContent = node.type || '';
const refreshBtn = document.createElement('button');
refreshBtn.title = t('refresh') || 'Refresh';
refreshBtn.innerHTML = '🔄';
refreshBtn.style.cssText = 'background:none; border:none; color:#c9d6ff; cursor:pointer; font-size:14px; padding:4px; margin-left:4px; border-radius:4px; transition:background 0.2s, transform 0.3s; display:flex; align-items:center; justify-content:center;';
refreshBtn.onmouseover = () => refreshBtn.style.background = 'rgba(255,255,255,0.1)';
refreshBtn.onmouseout = () => refreshBtn.style.background = 'none';
refreshBtn.onclick = () => {
refreshBtn.style.transform = 'rotate(180deg)';
setTimeout(() => this.diagnoseNode(node, true), 150);
};
titleBar.appendChild(refreshBtn);
nodeContent.appendChild(titleBar);
const quickActions = document.createElement('div');
quickActions.style.cssText = 'padding:14px 16px 4px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;flex-shrink:0;';
const actionsLabel = document.createElement('div');
actionsLabel.textContent = t('assistantQuickActions');
actionsLabel.style.cssText = 'grid-column:1/-1;color:#8b91a3;font-size:10px;font-weight:750;letter-spacing:0.1em;text-transform:uppercase;padding:0 2px 2px;';
quickActions.appendChild(actionsLabel);
const makeActionButton = ({ icon, label, hint, accent, onClick, capability = null, primary = false }) => {
const button = document.createElement('button');
const enabled = !capability || capability.supported;
button.disabled = !enabled;
const gridPlacement = primary ? 'grid-column:1/-1;' : '';
button.style.cssText = gridPlacement + (enabled
? `min-width:0;padding:${primary ? '13px 14px' : '11px 10px'};background:${accent};color:#fff;border:1px solid rgba(255,255,255,0.14);border-radius:11px;cursor:pointer;text-align:left;display:flex;align-items:center;gap:10px;transition:transform 0.15s,filter 0.15s,box-shadow 0.15s;box-shadow:0 8px 18px rgba(0,0,0,0.14);`
: 'min-width:0;padding:11px 10px;background:rgba(255,255,255,0.035);color:#656b78;border:1px solid rgba(255,255,255,0.055);border-radius:11px;cursor:not-allowed;text-align:left;display:flex;align-items:center;gap:9px;');
const iconEl = document.createElement('span');
iconEl.textContent = icon;
iconEl.style.cssText = `width:${primary ? '34px' : '28px'};height:${primary ? '34px' : '28px'};border-radius:9px;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,${enabled ? '0.14' : '0.04'});font-size:${primary ? '17px' : '14px'};flex-shrink:0;`;
const copy = document.createElement('span');
copy.style.cssText = 'min-width:0;display:flex;flex-direction:column;gap:2px;';
const title = document.createElement('span');
title.textContent = label;
title.style.cssText = `font-weight:750;font-size:${primary ? '13px' : '11px'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
const subtitle = document.createElement('span');
subtitle.textContent = enabled ? hint : getInsertionCapabilityMessage(capability);
subtitle.style.cssText = `font-size:9px;color:${enabled ? 'rgba(255,255,255,0.68)' : '#555b66'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
copy.append(title, subtitle);
button.append(iconEl, copy);
if (enabled) {
button.onmouseover = () => { button.style.filter = 'brightness(1.12)'; button.style.transform = 'translateY(-1px)'; };
button.onmouseout = () => { button.style.filter = 'brightness(1)'; button.style.transform = 'none'; };
button.onclick = onClick;
} else if (capability) {
button.title = getInsertionCapabilityMessage(capability);
}
quickActions.appendChild(button);
};
for (const widget of modelWidgets) {
const widgetLabel = modelWidgets.length > 1 && widget.name
? t('assistantChangeWidget', { name: widget.name })
: t('assistantChangeCurrent');
const pickerType = inferPickerModelType(node, widget);
makeActionButton({
icon: '⇄',
label: widgetLabel,
hint: t('assistantVisualPicker', { type: pickerType.label }),
accent: 'linear-gradient(135deg,rgba(245,124,0,0.96),rgba(255,82,82,0.82))',
onClick: () => this._openGalleryReplacer(node, widget),
primary: true,
});
}
makeActionButton({
icon: '←',
label: t('assistantInsertBefore'),
hint: t('assistantConnectInputs'),
accent: 'linear-gradient(135deg,rgba(25,118,210,0.9),rgba(80,110,230,0.82))',
onClick: () => this.openLoraInsertionPicker(node, 'before'),
capability: insertionCapabilities.before,
});
makeActionButton({
icon: '→',
label: t('assistantInsertAfter'),
hint: t('assistantConnectOutputs'),
accent: 'linear-gradient(135deg,rgba(0,137,123,0.92),rgba(67,160,71,0.82))',
onClick: () => this.openLoraInsertionPicker(node, 'after'),
capability: insertionCapabilities.after,
});
const tabsRow = document.createElement('div');
tabsRow.style.cssText = 'display:flex; padding: 10px 16px 0; gap: 8px; flex-shrink:0;';
const btnActions = document.createElement('button');
btnActions.textContent = t('assistantTabActions') || '🛠️ Quick Actions';
btnActions.style.cssText = 'flex:1; padding: 8px; border-radius: 8px; background: rgba(255,255,255,0.1); color: #fff; cursor: pointer; border: none; font-size: 11px; font-weight: bold; transition: background 0.2s;';
const btnPresets = document.createElement('button');
btnPresets.textContent = t('assistantTabPresets') || '📚 Parameter Presets';
btnPresets.style.cssText = 'flex:1; padding: 8px; border-radius: 8px; background: transparent; color: #aaa; cursor: pointer; border: none; font-size: 11px; font-weight: bold; transition: background 0.2s;';
tabsRow.append(btnActions, btnPresets);
nodeContent.appendChild(tabsRow);
const actionsContainer = document.createElement('div');
actionsContainer.style.cssText = 'display:flex; flex-direction:column; flex:1; overflow-y:auto; min-height:0;';
const presetsContainer = document.createElement('div');
presetsContainer.style.cssText = 'display:none; flex-direction:column; flex:1; overflow-y:auto; min-height:0;';
nodeContent.append(actionsContainer, presetsContainer);
btnActions.onclick = () => {
btnActions.style.background = 'rgba(255,255,255,0.1)';
btnActions.style.color = '#fff';
btnPresets.style.background = 'transparent';
btnPresets.style.color = '#aaa';
actionsContainer.style.display = 'flex';
presetsContainer.style.display = 'none';
};
btnPresets.onclick = () => {
btnPresets.style.background = 'rgba(255,255,255,0.1)';
btnPresets.style.color = '#fff';
btnActions.style.background = 'transparent';
btnActions.style.color = '#aaa';
presetsContainer.style.display = 'flex';
actionsContainer.style.display = 'none';
};
if (hasModelOrInsertion) {
actionsContainer.appendChild(quickActions);
for (const w of modelWidgets) {
this.renderAssistantModelCard(node, w, actionsContainer);
}
} else {
const noModelWarning = document.createElement('div');
noModelWarning.style.cssText = 'display:flex; flex-direction:column; align-items:center; justify-content:center; padding: 30px 20px;';
noModelWarning.innerHTML = `<div style="font-size:36px;margin-bottom:10px;">⚠️</div><div style="text-align:center;color:#aaa;font-size:12px;">${t('assistantNoModelParameter')}</div><div style="font-size:10px;color:#555;margin-top:6px;">${escapeHtml(node.type || '')}</div>`;
actionsContainer.appendChild(noModelWarning);
btnPresets.onclick(); // switch to presets by default if no model actions
}
renderParameterPresets.call(this, node, presetsContainer, forceRefresh);
}
export function renderGlobalDashboard() {
const content = document.getElementById('anomalous-doctor-node-list');
const statsRow = document.getElementById('anomalous-doctor-stats-row');
if (!content || !statsRow || !app.graph || !app.graph._nodes) return;
content.innerHTML = '';
statsRow.innerHTML = '';
if (this.doctorPanel) this.doctorPanel.currentDiagnosedNode = 'global';
let nodes = [];
if (app.graph && app.graph.computeExecutionOrder) {
nodes = app.graph.computeExecutionOrder(false, true);
} else if (app.graph && app.graph._nodes) {
nodes = app.graph._nodes;
}
let total = 0, healthy = 0, missing = 0, identityWarnings = 0;
let missingNodesData = [];
let has_native_fixes = false;
// Collect data
for (const node of nodes) {
if (!node.widgets) continue;
for (const w of node.widgets) {
if (w.type === 'combo' && typeof w.value === 'string' && w.value.match(/\.(safetensors|ckpt|pt|bin|pth|sft)$/i)) {
total++;
const val = w.value;
let isHealthy = false;
let exactMatch = null;
if (w.options && w.options.values && w.options.values.includes(val)) {
isHealthy = true;
} else if (w.options && w.options.values) {
const normVal = val.replace(/\\/g, '/');
exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normVal);
if (exactMatch) {
isHealthy = true;
has_native_fixes = true;
if (w.value !== exactMatch) {
w.value = exactMatch;
const wIdx = node.widgets.indexOf(w);
if (wIdx !== -1 && node.widgets_values) node.widgets_values[wIdx] = exactMatch;
if (w.callback) w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
app.graph.setDirtyCanvas(true, true);
}
delete node.color;
delete node.bgcolor;
node.has_errors = false;
if (app.lastNodeErrors && app.lastNodeErrors[node.id]) {
delete app.lastNodeErrors[node.id];
}
}
}
const identityMismatch = isHealthy && hasFoundationIdentityMismatch(node, w, val);
if (identityMismatch) identityWarnings++;
if (isHealthy) healthy++; else missing++;
missingNodesData.push({
node,
w,
val,
isHealthy,
exactMatch,
identityMismatch,
resolutionStatus: w.anomalous_resolution_status || '',
sizeCandidate: w.anomalous_size_candidate || null,
});
}
}
}
if (has_native_fixes) {
if (app.graph && app.graph.change) app.graph.change();
try { window.dispatchEvent(new CustomEvent("graphChanged")); } catch(e){}
if (typeof app.clearErrors === 'function') app.clearErrors();
}
// Render Stats Badges
const createBadge = (label, count, color, bg) => {
return `<div style="display:flex;align-items:center;gap:8px;padding:8px 16px;border-radius:20px;background:${bg};border:1px solid ${color}33;">
<span style="color:${color};font-size:13px;font-weight:600;">${label}</span>
<span style="color:#fff;font-size:14px;font-weight:bold;">${count}</span>
</div>`;
};
statsRow.innerHTML = `
${createBadge(t('doctorTotal'), total, '#aaa', 'rgba(255,255,255,0.05)')}
${createBadge(t('doctorHealthy'), healthy, '#28a745', 'rgba(40, 167, 69, 0.1)')}
${identityWarnings ? createBadge(t('doctorIdentityWarning'), identityWarnings, '#ffc107', 'rgba(255, 193, 7, 0.1)') : ''}
${createBadge(t('doctorMissing'), missing, '#ff6b6b', 'rgba(220, 53, 69, 0.1)')}
`;
if (total === 0) {
content.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:rgba(255,255,255,0.3);font-size:14px;">
<div style="font-size:48px;margin-bottom:16px;">👻</div>
${t('doctorNoWorkflowModels')}
</div>`;
return;
}
// Render List
for (const data of missingNodesData) {
const {
node,
w,
val,
isHealthy,
exactMatch,
identityMismatch,
resolutionStatus,
sizeCandidate,
} = data;
const hasIdentityConflict = resolutionStatus === 'identity_conflict';
const hasSizeCandidate = resolutionStatus === 'size_candidate' && sizeCandidate?.filename;
const item = document.createElement('div');
item.style.cssText = `display:flex; flex-direction:column; padding:16px 20px; background:rgba(255,255,255,0.02); border-radius:12px; border:1px solid rgba(255,255,255,0.04); transition:all 0.2s; position:relative; overflow:hidden; flex-shrink:0;`;
item.onmouseover = () => item.style.background = 'rgba(255,255,255,0.04)';
item.onmouseout = () => item.style.background = 'rgba(255,255,255,0.02)';
// Accent bar
const accent = document.createElement('div');
const accentColor = hasSizeCandidate
? '#ffc107'
: (hasIdentityConflict || !isHealthy ? '#ff6b6b' : (identityMismatch ? '#ffc107' : '#28a745'));
accent.style.cssText = `position:absolute; left:0; top:0; bottom:0; width:4px; background:${accentColor};`;
item.appendChild(accent);
const top = document.createElement('div');
top.style.cssText = 'display:flex; justify-content:space-between; align-items:flex-start; margin-left:8px;';
const left = document.createElement('div');
left.style.cssText = 'display:flex; flex-direction:column; gap:6px;';
const nodeTitle = document.createElement('div');
nodeTitle.innerHTML = `<span style="color:rgba(255,255,255,0.4);font-size:12px;"></span> <span style="color:#aaa;font-size:12px;font-weight:600;"></span>`;
nodeTitle.children[0].textContent = `#${node.id}`;
nodeTitle.children[1].textContent = node.title || node.type;
const fileText = document.createElement('div');
fileText.innerText = val.split(/[\\/]/).pop();
fileText.style.cssText = 'color:#fff; font-size:15px; font-weight:600; word-break:break-all; font-family:Inter, sans-serif;';
left.appendChild(nodeTitle);
left.appendChild(fileText);
const right = document.createElement('div');
right.style.cssText = 'display:flex; align-items:center; gap:12px;';
if (hasIdentityConflict) {
right.innerHTML = `<div style="color:#ff6b6b;font-size:13px;font-weight:bold;padding:6px 12px;background:rgba(220,53,69,0.1);border-radius:20px;">⛔ ${t('doctorIdentityConflict')}</div>`;
} else if (hasSizeCandidate) {
right.innerHTML = `<div style="text-align:right;"><div style="color:#ffc107;font-size:13px;font-weight:bold;">🟡 ${t('doctorSizeCandidate')}</div><div style="color:rgba(255,255,255,0.4);font-size:11px;margin-top:4px;">${escapeHtml(sizeCandidate.filename.split(/[\\/]/).pop())}</div></div>`;
} else if (identityMismatch) {
right.innerHTML = `<div style="color:#ffc107;font-size:13px;font-weight:bold;padding:6px 12px;background:rgba(255,193,7,0.1);border-radius:20px;">🟡 ${t('doctorIdentityChanged')}</div>`;
} else if (isHealthy && exactMatch && exactMatch !== val) {
right.innerHTML = `<div style="text-align:right;"><div style="color:#ffc107;font-size:13px;font-weight:bold;">🟡 ${t('doctorAutoRedirected')}</div><div style="color:rgba(255,255,255,0.4);font-size:11px;margin-top:4px;">${escapeHtml(exactMatch.split(/[\/]/).pop())}</div></div>`;
} else if (isHealthy) {
right.innerHTML = `<div style="color:#28a745;font-size:13px;font-weight:bold;padding:6px 12px;background:rgba(40,167,69,0.1);border-radius:20px;">🟢 ${t('doctorReady')}</div>`;
} else {
right.innerHTML = `<div style="color:#ff6b6b;font-size:13px;font-weight:bold;padding:6px 12px;background:rgba(220,53,69,0.1);border-radius:20px;">🔴 ${t('doctorMissingStatus')}</div>`;
}
top.appendChild(left);
top.appendChild(right);
item.appendChild(top);
const actionRow = document.createElement('div');
actionRow.style.cssText = 'display:flex; gap:10px; margin-top:16px; margin-left:8px;';
const civitaiBtn = document.createElement('button');
civitaiBtn.textContent = t('doctorCivitai');
civitaiBtn.style.cssText = 'padding:8px 16px; background:rgba(255,255,255,0.1); color:#fff; border:none; border-radius:6px; cursor:pointer; font-weight:600; font-size:12px; transition:background 0.2s;';
civitaiBtn.onmouseover = () => civitaiBtn.style.background = 'rgba(255,255,255,0.2)';
civitaiBtn.onmouseout = () => civitaiBtn.style.background = 'rgba(255,255,255,0.1)';
civitaiBtn.onclick = async () => {
let searchHash = null;
if (app.graph && app.graph.extra && app.graph.extra.anomalous_hashes) {
const normVal = val.replace(/\\/g, '/');
const winVal = val.replace(/\//g, '\\');
const hData = app.graph.extra.anomalous_hashes[`${node.id}_${val}`] ||
app.graph.extra.anomalous_hashes[`${node.id}_${normVal}`] ||
app.graph.extra.anomalous_hashes[`${node.id}_${winVal}`] ||
app.graph.extra.anomalous_hashes[val] ||
app.graph.extra.anomalous_hashes[normVal] ||
app.graph.extra.anomalous_hashes[winVal];
if (hData) searchHash = typeof hData === 'string' ? hData : hData.hash;
}
if (!searchHash && window.anomalous_hash_cache) {
const normVal = val.replace(/\\/g, '/');
const basename = val.split(/[/\\]/).pop();
const cData = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
if (cData) searchHash = typeof cData === 'string' ? cData : cData.hash;
}
if (searchHash) {
const prevText = civitaiBtn.textContent;
civitaiBtn.textContent = '⏳...';
try {
const res = await fetch(`https://civitai.com/api/v1/model-versions/by-hash/${encodeURIComponent(searchHash)}`);
if (res.ok) {
const data = await res.json();
if (data && data.modelId) {
const nsfwLevel = data.nsfwLevel || 1;
const isNsfw = (data.model && data.model.nsfw) || (nsfwLevel > 1);
const domain = isNsfw ? 'civitai.red' : 'civitai.com';
let targetUrl = `https://${domain}/models/${data.modelId}`;
if (data.id) {
targetUrl += `?modelVersionId=${data.id}`;
}
window.open(targetUrl, '_blank');
civitaiBtn.textContent = prevText;
return;
}
}
} catch (e) {
console.warn("[Anomalous Doctor] Failed to query Civitai by hash, fallback to query search:", e);
}
civitaiBtn.textContent = prevText;
}
const searchStr = val.split(/[/\\]/).pop().replace('.safetensors', '').replace('.ckpt', '').replace('.pt', '').replace('.sft', '');
const url = `https://civitai.com/search/models?sortBy=models_v9&query=${encodeURIComponent(searchStr)}`;
window.open(url, '_blank');
};
if (!isHealthy) {
const deepScanBtn = document.createElement('button');
deepScanBtn.textContent = t('doctorDeepHashScan');
deepScanBtn.style.cssText = 'padding:8px 16px; background:#e5e7eb; color:#111827; border:none; border-radius:6px; cursor:pointer; font-weight:600; font-size:12px; transition:background 0.2s;';
deepScanBtn.onmouseover = () => deepScanBtn.style.background = '#ffffff';
deepScanBtn.onmouseout = () => deepScanBtn.style.background = '#e5e7eb';
deepScanBtn.onclick = async () => {
deepScanBtn.textContent = t('doctorScanStarting');
deepScanBtn.disabled = true;
deepScanBtn.style.opacity = '0.7';
try {
const r = await fetch('/anomalous/scan_missing_models', { method: 'POST' });
if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`);
const rData = await r.json();
if (rData.status === 'error' && rData.message !== 'Scan already in progress') {
throw new Error(rData.message);
}
let pollActive = true;
const pollStatus = async () => {
if (!pollActive) return;
try {
const statusRes = await fetch('/anomalous/scan_missing_models_status');
if (!statusRes.ok) throw new Error(`HTTP ${statusRes.status}`);
const statusData = await statusRes.json();
if (statusData.scanning) {
let filename = statusData.filename || '';
if (filename.length > 20) filename = filename.substring(0, 10) + '...' + filename.substring(filename.length - 7);
deepScanBtn.textContent = t('doctorScanning', { current: statusData.current, total: statusData.total, filename });
setTimeout(pollStatus, 500);
} else {
if (statusData.error) {
alert(t('doctorScanError') + statusData.error);
deepScanBtn.textContent = t('doctorDeepHashScan');
deepScanBtn.disabled = false;
deepScanBtn.style.opacity = '1';
return;
}
deepScanBtn.textContent = t('doctorMatching');
if (window.anomalous_reload_hashes) {
await window.anomalous_reload_hashes();
}
if (window.anomalous_resolve_all_missing_nodes) {
await window.anomalous_resolve_all_missing_nodes(true, false);
}
let stillMissing = false;
const currentVal = w.value;
const currentNorm = typeof currentVal === 'string' ? currentVal.replace(/\\/g, '/') : '';
if (w.options && w.options.values) {
const match = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === currentNorm);
if (!match) stillMissing = true;
}
if (node.has_errors || node.color === "#FF3333" || node.bgcolor === "#FF3333") {
stillMissing = true;
}
const scanInfo = t('doctorDeepScanSummary', { total: statusData.total });
if (stillMissing) {
alert(t('doctorNoLocalMatch', { summary: scanInfo }));
} else {
alert(t('doctorScanSuccess', { summary: scanInfo }));
}
this.renderGlobalDashboard();
deepScanBtn.textContent = t('doctorDeepHashScan');
deepScanBtn.disabled = false;
deepScanBtn.style.opacity = '1';
}
} catch (err) {
alert(t('doctorPollError') + err.message);
deepScanBtn.textContent = t('doctorDeepHashScan');
deepScanBtn.disabled = false;
deepScanBtn.style.opacity = '1';
}
};
setTimeout(pollStatus, 500);
} catch(e) {
alert(t('doctorScanFailed') + e.message);
deepScanBtn.textContent = t('doctorDeepHashScan');
deepScanBtn.disabled = false;
deepScanBtn.style.opacity = '1';
}
};
const manualBtn = document.createElement('button');
manualBtn.textContent = t('doctorManualReplace');
manualBtn.style.cssText = 'padding:8px 16px; background:rgba(255,255,255,0.1); color:#fff; border:none; border-radius:6px; cursor:pointer; font-weight:600; font-size:12px; transition:background 0.2s;';
manualBtn.onmouseover = () => manualBtn.style.background = 'rgba(255,255,255,0.2)';
manualBtn.onmouseout = () => manualBtn.style.background = 'rgba(255,255,255,0.1)';
manualBtn.onclick = () => {
this._openGalleryReplacer(node, w);
};
actionRow.appendChild(deepScanBtn);
actionRow.appendChild(manualBtn);
const pathText = document.createElement('div');
pathText.textContent = t('doctorOriginalPath', { path: val });
pathText.style.cssText = 'margin-top:12px; margin-left:8px; color:rgba(255,255,255,0.3); font-size:11px; font-family:monospace; word-break:break-all;';
item.appendChild(pathText);
}
const viewHashBtn = document.createElement('button');
viewHashBtn.textContent = t('doctorViewHash');
viewHashBtn.style.cssText = 'padding:8px 16px; background:rgba(255,255,255,0.1); color:#fff; border:none; border-radius:6px; cursor:pointer; font-weight:600; font-size:12px; transition:background 0.2s;';
viewHashBtn.onmouseover = () => viewHashBtn.style.background = 'rgba(255,255,255,0.2)';
viewHashBtn.onmouseout = () => viewHashBtn.style.background = 'rgba(255,255,255,0.1)';
viewHashBtn.onclick = () => {
openHashDetailDialog(node, w, val);
};
actionRow.appendChild(viewHashBtn);
actionRow.appendChild(civitaiBtn);
item.appendChild(actionRow);
content.appendChild(item);
}
}
function openHashDetailDialog(node, widget, val) {
let workflowHash = null;
let workflowSize = null;
if (app.graph && app.graph.extra && app.graph.extra.anomalous_hashes) {
const normVal = val.replace(/\\/g, '/');
const winVal = val.replace(/\//g, '\\');
const hData = app.graph.extra.anomalous_hashes[`${node.id}_${val}`] ||
app.graph.extra.anomalous_hashes[`${node.id}_${normVal}`] ||
app.graph.extra.anomalous_hashes[`${node.id}_${winVal}`] ||
app.graph.extra.anomalous_hashes[val] ||
app.graph.extra.anomalous_hashes[normVal] ||
app.graph.extra.anomalous_hashes[winVal];
if (hData) {
workflowHash = typeof hData === 'string' ? hData : (hData.hash || null);
workflowSize = typeof hData === 'object' ? (hData.size || null) : null;
}
}
let localHash = null;
let localSize = null;
if (window.anomalous_hash_cache) {
const normVal = val.replace(/\\/g, '/');
const basename = val.split(/[/\\]/).pop();
const cData = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[normVal] || window.anomalous_hash_cache[basename];
if (cData) {
localHash = typeof cData === 'string' ? cData : (cData.hash || null);
localSize = typeof cData === 'object' ? (cData.size || null) : null;
}
}
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.75);backdrop-filter:blur(6px);z-index:9999999;display:flex;align-items:center;justify-content:center;font-family:Inter,-apple-system,sans-serif;';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const modal = document.createElement('div');
modal.style.cssText = 'background:#1a1c23;border:1px solid rgba(255,255,255,0.12);border-radius:14px;padding:24px;width:540px;max-width:92vw;max-height:85vh;overflow-y:auto;box-shadow:0 20px 50px rgba(0,0,0,0.6);display:flex;flex-direction:column;gap:18px;color:#fff;';
const headerRow = document.createElement('div');
headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid rgba(255,255,255,0.08);padding-bottom:14px;';
headerRow.innerHTML = `<div style="font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px;"><span>🔑</span><span>${escapeHtml(t('doctorHashModalTitle'))}</span></div>`;
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
closeBtn.style.cssText = 'background:none;border:none;color:#aaa;font-size:18px;cursor:pointer;padding:4px 8px;border-radius:6px;transition:color 0.2s;';
closeBtn.onmouseover = () => closeBtn.style.color = '#fff';
closeBtn.onmouseout = () => closeBtn.style.color = '#aaa';
closeBtn.onclick = () => overlay.remove();
headerRow.appendChild(closeBtn);
modal.appendChild(headerRow);
const metaSection = document.createElement('div');
metaSection.style.cssText = 'display:flex;flex-direction:column;gap:6px;background:rgba(255,255,255,0.03);padding:12px 14px;border-radius:8px;border:1px solid rgba(255,255,255,0.05);';
metaSection.innerHTML = `
<div style="font-size:11px;color:#e5e7eb;font-weight:600;">#${escapeHtml(String(node.id))} · ${escapeHtml(node.title || node.type || 'Node')} · ${escapeHtml(widget?.name || '')}</div>
<div style="font-size:13px;font-weight:600;word-break:break-all;color:#e8eaed;">${escapeHtml(val)}</div>
`;
modal.appendChild(metaSection);
const formatBytes = (bytes) => {
if (!bytes || isNaN(bytes)) return '';
const b = parseInt(bytes);
if (b > 1024 * 1024 * 1024) return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
return (b / (1024 * 1024)).toFixed(2) + ' MB';
};
const renderHashCard = (title, hash, size, badgeColor, isProvenance) => {
const card = document.createElement('div');
card.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:12px 14px;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:10px;';
const cardHeader = document.createElement('div');
cardHeader.style.cssText = 'display:flex;align-items:center;justify-content:space-between;font-size:12px;';
cardHeader.innerHTML = `<span style="font-weight:600;color:${badgeColor};">${escapeHtml(title)}</span>${size ? `<span style="color:#aaa;font-size:11px;">${formatBytes(size)} (${size} B)</span>` : ''}`;
card.appendChild(cardHeader);
if (hash) {
const hashBox = document.createElement('div');
hashBox.style.cssText = 'display:flex;align-items:center;gap:8px;background:#0d0e12;padding:8px 10px;border-radius:6px;border:1px solid rgba(255,255,255,0.08);';
const hashText = document.createElement('code');
hashText.style.cssText = 'font-size:11px;color:#f59e0b;word-break:break-all;flex:1;font-family:Consolas,monospace;letter-spacing:0.5px;';
hashText.textContent = hash;
const copyBtn = document.createElement('button');
copyBtn.textContent = '📋';
copyBtn.title = 'Copy';
copyBtn.style.cssText = 'background:rgba(255,255,255,0.08);border:none;color:#fff;cursor:pointer;padding:4px 8px;border-radius:4px;font-size:12px;transition:background 0.2s;flex-shrink:0;';
copyBtn.onmouseover = () => copyBtn.style.background = 'rgba(255,255,255,0.18)';
copyBtn.onmouseout = () => copyBtn.style.background = 'rgba(255,255,255,0.08)';
copyBtn.onclick = () => {
navigator.clipboard.writeText(hash);
copyBtn.textContent = '✅';
setTimeout(() => copyBtn.textContent = '📋', 1500);
};
hashBox.append(hashText, copyBtn);
card.appendChild(hashBox);
} else {
const emptyTip = document.createElement('div');
emptyTip.style.cssText = 'font-size:12px;color:#888;font-style:italic;padding:4px 0;';
emptyTip.textContent = isProvenance ? t('doctorNoHashInWorkflow') : t('doctorNoLocalHash');
card.appendChild(emptyTip);
}
return card;
};
modal.appendChild(renderHashCard(t('doctorWorkflowHash'), workflowHash, workflowSize, '#e5e7eb', true));
modal.appendChild(renderHashCard(t('doctorLocalHash'), localHash, localSize, '#81c995', false));
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;justify-content:flex-end;margin-top:4px;';
const okBtn = document.createElement('button');
okBtn.textContent = 'OK';
okBtn.style.cssText = 'padding:8px 24px;background:#e5e7eb;color:#111827;border:none;border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;';
okBtn.onclick = () => overlay.remove();
footer.appendChild(okBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
export function initAssistantPanel() {
this.assistantPanelInitialized = true;
this.assistantPanel.innerHTML = '';
this.assistantPanel.style.padding = '0';
this.assistantPanel.style.overflow = 'hidden';
if (!this._assistantPanelHooked) {
this._assistantPanelHooked = true;
const self = this;
const originalOnSelected = app.canvas.onNodeSelected;
app.canvas.onNodeSelected = function (node) {
if (originalOnSelected) originalOnSelected.apply(this, arguments);
if (self.assistantPanel && self.assistantPanel.style.display !== 'none') {
self.diagnoseNode(node);
}
};
const originalOnDeselected = app.canvas.onNodeDeselected;
app.canvas.onNodeDeselected = function (node) {
if (originalOnDeselected) originalOnDeselected.apply(this, arguments);
if (self.assistantPanel && self.assistantPanel.style.display !== 'none') {
const stillSelected = Object.values(app.canvas.selected_nodes || {});
if (stillSelected.length > 0) self.diagnoseNode(stillSelected[0]);
else self.diagnoseNode(null);
}
};
}
const placeholder = document.createElement('div');
placeholder.id = 'anomalous-assistant-placeholder';
placeholder.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;color:#666;font-size:15px;gap:12px;padding:40px;text-align:center;';
placeholder.innerHTML = `<div style="font-size:48px;">🤖</div><div>${t('assistantSelectNode')}</div>`;
this.assistantPanel.appendChild(placeholder);
const nodeContent = document.createElement('div');
nodeContent.id = 'anomalous-assistant-node-content';
nodeContent.style.cssText = 'display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden;';
this.assistantPanel.appendChild(nodeContent);
}
export function renderAssistantModelCard(node, w, container) {
const val = w.value;
const filename = val.split(/[\\/]/).pop();
const pickerType = inferPickerModelType(node, w);
const wrapper = document.createElement('div');
wrapper.style.cssText = 'margin:12px 16px 16px;padding:10px;border:1px solid rgba(255,255,255,0.075);border-radius:14px;background:linear-gradient(160deg,rgba(31,33,42,0.96),rgba(20,21,27,0.96));display:flex;flex-direction:column;gap:12px;box-shadow:0 16px 35px rgba(0,0,0,0.2);';
// Preview image
const previewBox = document.createElement('div');
previewBox.style.cssText = 'width:100%;aspect-ratio:1.65;max-height:260px;background:radial-gradient(circle at 50% 20%,#252a3b,#0b0c10 70%);border-radius:10px;overflow:hidden;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid rgba(255,255,255,0.05);';
previewBox.innerHTML = `<span style="color:#444;font-size:13px;">${t('doctorLoadingPreview')}</span>`;
wrapper.appendChild(previewBox);
// Name and path
const identityRow = document.createElement('div');
identityRow.style.cssText = 'display:flex;align-items:flex-start;gap:10px;padding:0 3px;';
const identityCopy = document.createElement('div');
identityCopy.style.cssText = 'display:flex;flex-direction:column;gap:4px;min-width:0;flex:1;';
const nameEl = document.createElement('div');
nameEl.style.cssText = 'color:#fff;font-weight:750;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
nameEl.title = filename;
nameEl.innerText = filename;
const pathEl = document.createElement('div');
pathEl.style.cssText = 'color:#646b7a;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
pathEl.title = val;
pathEl.innerText = val;
const modelTypeBadge = document.createElement('span');
modelTypeBadge.textContent = pickerType.label;
modelTypeBadge.style.cssText = 'padding:5px 8px;border-radius:999px;background:rgba(138,180,248,0.1);border:1px solid rgba(138,180,248,0.22);color:#a9c7ff;font-size:9px;font-weight:750;white-space:nowrap;';
identityCopy.append(nameEl, pathEl);
identityRow.append(identityCopy, modelTypeBadge);
wrapper.appendChild(identityRow);
// Metadata zone (populated async)
const metaZone = document.createElement('div');
metaZone.style.cssText = 'display:flex;flex-direction:column;gap:10px;';
wrapper.appendChild(metaZone);
// Secondary action; model replacement lives in the prominent node toolbar.
const actionRow = document.createElement('div');
actionRow.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;';
const profileBtn = document.createElement('button');
profileBtn.textContent = t('doctorProfile');
profileBtn.style.cssText = 'flex:1;padding:10px 12px;background:rgba(138,180,248,0.09);color:#a9c7ff;border:1px solid rgba(138,180,248,0.22);border-radius:9px;cursor:pointer;font-size:12px;font-weight:700;transition:filter 0.2s;';
profileBtn.onmouseover = () => profileBtn.style.filter = 'brightness(1.2)';
profileBtn.onmouseout = () => profileBtn.style.filter = 'brightness(1)';
actionRow.appendChild(profileBtn);
wrapper.appendChild(actionRow);
container.appendChild(wrapper);
// Async: load preview + metadata
fetch(`/anomalous/find_model?search=${encodeURIComponent(val.replace(/\\/g, '/'))}`)
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(d => {
// Preview
if (d.status === 'success' && d.model && d.model.preview_url) {
previewBox.innerHTML = '';
const pu = d.model.preview_url;
const isVid = /\.(mp4|webm)(?:$|\?|&|#)/i.test(pu);
if (isVid) {
const vid = document.createElement('video');
vid.src = pu; vid.muted = true; vid.loop = true; vid.autoplay = true; vid.playsInline = true;
vid.style.cssText = 'width:100%;height:100%;object-fit:contain;';
previewBox.appendChild(vid);
} else {
const img = document.createElement('img');
img.src = pu;
img.style.cssText = 'width:100%;height:100%;object-fit:contain;';
previewBox.appendChild(img);
}
} else {
previewBox.innerHTML = `<span style="color:#444;font-size:13px;">${t('doctorNoPreview')}</span>`;
}
// Profile button links to detail
if (d.status === 'success' && d.model) {
profileBtn.onclick = () => {
this.assistantPanel.style.display = 'none';
this.currentType = d.type;
this.currentPathIdx = d.path_idx;
this.currentSubfolder = d.subfolder;
this.historyStack = this.historyStack || [];
this.historyStack.push({ type: 'assistant' });
this.showDetail(d.model);
if (this.foldersData) this.renderSidebar();
};
}
// Metadata
if (d.status === 'success' && d.model && d.model.metadata) {
const meta = d.model.metadata;
// Type + base model badges
if (d.type) modelTypeBadge.textContent = formatModelTypeLabel(d.type, pickerType.label);
if (meta.baseModel) {
const badgeRow = document.createElement('div');
badgeRow.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;';
const b = document.createElement('span');
b.style.cssText = 'background:rgba(0,255,204,0.08);border:1px solid rgba(0,255,204,0.15);color:#77e6cf;padding:4px 8px;border-radius:999px;font-size:10px;';
b.textContent = `${t('doctorBase')} · ${meta.baseModel}`;
badgeRow.appendChild(b);
metaZone.appendChild(badgeRow);
}
// Trigger words
const triggers = meta.trainedWords || meta.trigger_words || meta.trained_words;
if (triggers && triggers.length > 0) {
const trigSection = document.createElement('div');
trigSection.style.cssText = 'background:rgba(255,255,255,0.04);border-radius:6px;padding:10px 12px;';
const trigTitle = document.createElement('div');
trigTitle.style.cssText = 'color:#aaa;font-size:11px;margin-bottom:8px;font-weight:bold;text-transform:uppercase;letter-spacing:0.5px;';
trigTitle.textContent = t('doctorTriggerWords');
const tagList = document.createElement('div');
tagList.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px;';
const words = Array.isArray(triggers) ? triggers : [triggers];
words.forEach(word => {
const tag = document.createElement('span');
tag.style.cssText = 'background:rgba(255,193,7,0.12);color:#ffc107;padding:3px 8px;border-radius:4px;font-size:12px;cursor:pointer;';
tag.innerText = word;
tag.title = t('doctorClickCopy');
tag.onclick = () => {
navigator.clipboard.writeText(word).then(() => { const o = tag.innerText; tag.innerText = '✅'; setTimeout(() => tag.innerText = o, 1000); });
};
tagList.appendChild(tag);
});
const copyAll = document.createElement('button');
copyAll.style.cssText = 'background:transparent;border:1px solid #444;color:#888;border-radius:4px;padding:3px 8px;font-size:11px;cursor:pointer;margin-top:4px;';
copyAll.textContent = t('doctorCopyAll');
copyAll.onclick = () => {
navigator.clipboard.writeText(words.join(', ')).then(() => { copyAll.textContent = '✅'; setTimeout(() => copyAll.textContent = t('doctorCopyAll'), 1500); });
};
trigSection.appendChild(trigTitle);
trigSection.appendChild(tagList);
trigSection.appendChild(copyAll);
metaZone.appendChild(trigSection);
}
// Custom notes (parchment)
const textNotes = meta.custom_notes || meta.notes;
if (textNotes) {
const notesCard = document.createElement('div');
notesCard.style.cssText = 'background:linear-gradient(135deg,#262522 0%,#202124 100%);border:1px solid #3c4043;border-left:4px solid #a38d53;border-radius:4px 8px 8px 4px;padding:12px 14px;';
const notesTitle = document.createElement('div');
notesTitle.style.cssText = 'color:#a38d53;font-size:11px;font-weight:bold;margin-bottom:6px;';
notesTitle.textContent = t('doctorNotes');
const notesText = document.createElement('div');
notesText.style.cssText = 'color:#d4c4a0;font-size:13px;line-height:1.6;white-space:pre-wrap;';
notesText.innerText = textNotes;
notesCard.appendChild(notesTitle);
notesCard.appendChild(notesText);
metaZone.appendChild(notesCard);
}
}
// History gallery — always load if we can resolve the filename
const resolvedFilename = (d.status === 'success' && d.model) ? (d.model.filename || filename) : filename;
this._loadAssistantHistory(resolvedFilename, metaZone, d.status === 'success' ? d.model : null);
}).catch(() => {
previewBox.innerHTML = `<span style="color:#444;font-size:13px;">${t('assistantPreviewFailed')}</span>`;
// Still try to load history gallery by filename
this._loadAssistantHistory(filename, metaZone, null);
});
}
export function _loadAssistantHistory(filename, container, model) {
fetch('/anomalous/model_images?model_name=' + encodeURIComponent(filename) + '&t=' + Date.now())
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(data => {
const images = data.images || [];
if (images.length === 0) return;
const section = document.createElement('div');
section.style.cssText = 'display:flex;flex-direction:column;gap:8px;';
// Section header with count + full gallery button
const sectionHeader = document.createElement('div');
sectionHeader.style.cssText = 'display:flex;align-items:center;justify-content:space-between;';
const sectionTitle = document.createElement('div');
sectionTitle.style.cssText = 'color:#aaa;font-size:11px;font-weight:bold;text-transform:uppercase;letter-spacing:0.5px;';
sectionTitle.textContent = t('assistantHistoryTitle', { count: images.length });
sectionHeader.appendChild(sectionTitle);
// "View all" button if model is available
if (model) {
const viewAllBtn = document.createElement('button');
viewAllBtn.textContent = t('assistantViewAll');
viewAllBtn.style.cssText = 'background:transparent;border:1px solid rgba(255,255,255,0.2);color:#e5e7eb;font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer;transition:all 0.2s;';
viewAllBtn.onmouseover = () => { viewAllBtn.style.background = 'rgba(255,255,255,0.1)'; };
viewAllBtn.onmouseout = () => { viewAllBtn.style.background = 'transparent'; };
viewAllBtn.onclick = () => this.showGeneratedGallery(model);
sectionHeader.appendChild(viewAllBtn);
}
section.appendChild(sectionHeader);
const grid = document.createElement('div');
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(88px,1fr));gap:6px;';
images.slice(0, 16).forEach(img => {
const card = document.createElement('div');
card.style.cssText = 'border-radius:6px;overflow:hidden;aspect-ratio:1;background:#111;cursor:pointer;transition:transform 0.15s,box-shadow 0.15s;';
card.onmouseover = () => {
card.style.transform = 'scale(1.05)'; card.style.boxShadow = '0 4px 12px rgba(0,0,0,0.5)';
const v = card.querySelector('video'); if (v) v.play().catch(()=>{});
};
card.onmouseout = () => {
card.style.transform = 'scale(1)'; card.style.boxShadow = 'none';
const v = card.querySelector('video'); if (v) v.pause();
};
const imgUrl = img.url || img;
const isVid = /\.(mp4|webm)(?:$|\?|&|#)/i.test(imgUrl);
if (isVid) {
const vidEl = document.createElement('video');
vidEl.src = imgUrl; vidEl.muted = true; vidEl.loop = true; vidEl.autoplay = false; vidEl.playsInline = true; vidEl.preload = 'metadata';
vidEl.style.cssText = 'width:100%;height:100%;object-fit:cover;';
card.appendChild(vidEl);
} else {
const imgEl = document.createElement('img');
imgEl.src = imgUrl;
imgEl.style.cssText = 'width:100%;height:100%;object-fit:cover;';
imgEl.loading = 'lazy';
card.appendChild(imgEl);
}
if (img.workflow) {
card.title = t('assistantRestoreWorkflow');
card.onclick = () => {
try {
const wf = typeof img.workflow === 'string' ? JSON.parse(img.workflow) : img.workflow;
if (app && app.loadGraphData) app.loadGraphData(wf);
} catch (e) { }
};
} else if (model) {
card.title = t('assistantViewGallery');
card.onclick = () => this.showGeneratedGallery(model);
}
grid.appendChild(card);
});
section.appendChild(grid);
container.appendChild(section);
}).catch(() => { });
}
export function _openGalleryReplacer(node, w, options = {}) {
const mode = options.mode === 'insert' ? 'insert' : 'replace';
const pickerType = inferPickerModelType(node, w, options);
const validPaths = getNativeWidgetValues(node, w);
if (!validPaths.length) {
alert(t('pickerNoCompatible'));
return;
}
const normalizePath = value => String(value || '').replace(/\\/g, '/');
const getName = value => normalizePath(value).split('/').pop() || normalizePath(value);
const getFolder = value => {
const normalized = normalizePath(value);
const splitAt = normalized.lastIndexOf('/');
return splitAt >= 0 ? normalized.slice(0, splitAt) : '';
};
const currentPath = mode === 'replace'
? validPaths.find(path => normalizePath(path) === normalizePath(w.value)) || null
: null;
const contextRequests = pickerType.isLora
? collectMainModelContextRequests(app.graph, options.anchorNode || node)
: [];
let selectedPath = currentPath;
let selectedFolder = '';
let previews = {};
let modelInfo = {};
let contextModels = {};
let selectedBaseFamily = '';
let renderGeneration = 0;
let applying = false;
const modal = document.createElement('div');
modal.style.cssText = 'position:fixed;inset:0;background:radial-gradient(circle at 15% 0%,rgba(53,73,118,0.34),transparent 35%),rgba(7,8,12,0.965);z-index:999999;display:flex;flex-direction:column;padding:22px 24px;box-sizing:border-box;color:#fff;font-family:Inter,Arial,sans-serif;';
const stopMedia = container => container?.querySelectorAll?.('video,audio').forEach(media => {
media.pause();
media.removeAttribute('src');
media.load?.();
});
const closeModal = () => {
renderGeneration += 1;
stopMedia(modal);
modal.remove();
};
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-shrink:0;';
const headerIcon = document.createElement('span');
headerIcon.textContent = mode === 'insert' ? '+' : '⇄';
headerIcon.style.cssText = 'width:40px;height:40px;display:flex;align-items:center;justify-content:center;border-radius:12px;background:linear-gradient(135deg,#4776e6,#8e54e9);font-size:21px;font-weight:800;box-shadow:0 8px 22px rgba(78,91,220,0.35);';
const headerCopy = document.createElement('div');
headerCopy.style.cssText = 'display:flex;flex-direction:column;gap:3px;';
const eyebrow = document.createElement('div');
eyebrow.textContent = t('pickerEyebrow');
eyebrow.style.cssText = 'font-size:10px;color:#8fa8da;letter-spacing:0.12em;text-transform:uppercase;font-weight:750;';
const title = document.createElement('h2');
title.style.cssText = 'margin:0;font-size:19px;line-height:1.15;';
title.textContent = mode === 'insert'
? (options.direction === 'before' ? t('pickerInsertBeforeTitle') : t('pickerInsertAfterTitle'))
: t('pickerChangeTitle');
const typeBadge = document.createElement('span');
typeBadge.textContent = pickerType.label;
typeBadge.style.cssText = 'padding:5px 10px;border-radius:20px;background:rgba(138,180,248,0.1);border:1px solid rgba(138,180,248,0.25);color:#a9c7ff;font-size:10px;font-weight:750;';
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
closeBtn.style.cssText = 'margin-left:auto;width:38px;height:38px;border-radius:10px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.07);color:#aaa;font-size:20px;cursor:pointer;';
closeBtn.onclick = closeModal;
headerCopy.append(eyebrow, title);
header.append(headerIcon, headerCopy, typeBadge, closeBtn);
modal.appendChild(header);
const compatibilityHint = document.createElement('div');
compatibilityHint.style.cssText = 'display:none;align-items:center;gap:8px;margin:0 0 12px;padding:9px 12px;border-radius:10px;background:rgba(0,200,160,0.075);border:1px solid rgba(0,220,180,0.13);color:#8edfd0;font-size:11px;flex-shrink:0;';
modal.appendChild(compatibilityHint);
const body = document.createElement('div');
body.style.cssText = 'display:grid;grid-template-columns:minmax(190px,250px) minmax(0,1fr);gap:14px;min-height:0;flex:1;';
const folderPanel = document.createElement('aside');
folderPanel.style.cssText = 'background:rgba(20,22,29,0.92);border:1px solid rgba(255,255,255,0.075);border-radius:13px;overflow:auto;padding:10px;box-shadow:0 14px 35px rgba(0,0,0,0.18);';
const folderTitle = document.createElement('div');
folderTitle.textContent = t('pickerFolders');
folderTitle.style.cssText = 'font-size:13px;font-weight:700;color:#ddd;padding:8px 10px 10px;';
const folderList = document.createElement('div');
folderList.style.cssText = 'display:flex;flex-direction:column;gap:3px;';
folderPanel.append(folderTitle, folderList);
const content = document.createElement('section');
content.style.cssText = 'display:flex;flex-direction:column;min-width:0;min-height:0;background:rgba(18,20,27,0.92);border:1px solid rgba(255,255,255,0.075);border-radius:13px;overflow:hidden;box-shadow:0 14px 35px rgba(0,0,0,0.18);';
const toolbar = document.createElement('div');
toolbar.style.cssText = 'display:flex;gap:10px;padding:12px;border-bottom:1px solid #333;flex-wrap:wrap;align-items:center;';
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.placeholder = t('pickerSearch');
searchInput.style.cssText = 'flex:1;min-width:220px;padding:10px 12px;border-radius:9px;border:1px solid rgba(255,255,255,0.1);background:#222631;color:#fff;font-size:13px;outline:none;';
const baseFilterSelect = document.createElement('select');
baseFilterSelect.style.cssText = `display:${pickerType.isLora ? 'block' : 'none'};padding:10px 12px;border-radius:9px;border:1px solid rgba(0,220,180,0.2);background:#1d292b;color:#9be8d9;font-size:12px;max-width:230px;`;
const allBaseOption = document.createElement('option');
allBaseOption.value = '';
allBaseOption.textContent = t('pickerMainAll');
baseFilterSelect.appendChild(allBaseOption);
const sortSelect = document.createElement('select');
sortSelect.style.cssText = 'padding:10px 12px;border-radius:9px;border:1px solid rgba(255,255,255,0.1);background:#222631;color:#fff;font-size:12px;';
[
['name-asc', t('pickerNameAsc')],
['name-desc', t('pickerNameDesc')],
['folder-asc', t('pickerByFolder')],
].forEach(([value, label]) => {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
sortSelect.appendChild(option);
});
const resultCount = document.createElement('span');
resultCount.style.cssText = 'color:#888;font-size:12px;white-space:nowrap;';
toolbar.append(searchInput, baseFilterSelect, sortSelect, resultCount);
content.appendChild(toolbar);
const loadingText = document.createElement('div');
loadingText.textContent = t('pickerLoadingCovers');
loadingText.style.cssText = 'color:#888;font-size:13px;padding:10px 14px 0;';
const gridScroll = document.createElement('div');
gridScroll.style.cssText = 'overflow:auto;min-height:0;flex:1;padding:14px;';
const grid = document.createElement('div');
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;align-content:start;';
gridScroll.appendChild(grid);
content.append(loadingText, gridScroll);
body.append(folderPanel, content);
modal.appendChild(body);
const footer = document.createElement('div');
footer.style.cssText = 'display:flex;align-items:center;gap:12px;margin-top:14px;flex-shrink:0;';
const selectionText = document.createElement('div');
selectionText.style.cssText = 'min-width:0;flex:1;color:#aaa;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
const cancelBtn = document.createElement('button');
cancelBtn.textContent = t('pickerCancel');
cancelBtn.style.cssText = 'padding:10px 18px;background:#333;color:#ddd;border:1px solid #555;border-radius:7px;cursor:pointer;';
cancelBtn.onclick = closeModal;
const confirmBtn = document.createElement('button');
confirmBtn.textContent = mode === 'insert'
? t('pickerInsertConfirm')
: t('pickerReplaceConfirm');
confirmBtn.style.cssText = 'padding:10px 20px;background:#1976d2;color:#fff;border:none;border-radius:7px;cursor:pointer;font-weight:700;';
footer.append(selectionText, cancelBtn, confirmBtn);
modal.appendChild(footer);
document.body.appendChild(modal);
let folderCounts = new Map();
const pathMatchesBaseFilter = path => {
if (!selectedBaseFamily) return true;
const baseModel = modelInfo[path]?.metadata?.baseModel;
return getBaseModelFamily(baseModel) === selectedBaseFamily;
};
const rebuildFolderCounts = () => {
const visibleByBase = validPaths.filter(pathMatchesBaseFilter);
folderCounts = new Map([['', visibleByBase.length]]);
visibleByBase.forEach(path => {
const parts = getFolder(path).split('/').filter(Boolean);
let accumulated = '';
parts.forEach(part => {
accumulated = accumulated ? `${accumulated}/${part}` : part;
folderCounts.set(accumulated, (folderCounts.get(accumulated) || 0) + 1);
});
});
};
rebuildFolderCounts();
const updateSelection = () => {
selectionText.textContent = selectedPath
? t('pickerSelected', { path: normalizePath(selectedPath) })
: t('pickerChooseModel');
confirmBtn.disabled = !selectedPath || applying;
confirmBtn.style.opacity = confirmBtn.disabled ? '0.45' : '1';
confirmBtn.style.cursor = confirmBtn.disabled ? 'not-allowed' : 'pointer';
};
let renderCards = () => {};
const configureBaseFilter = () => {
if (!pickerType.isLora) return;
const groups = new Map();
validPaths.forEach(path => {
const baseModel = modelInfo[path]?.metadata?.baseModel;
const family = getBaseModelFamily(baseModel);
if (!family) return;
const existing = groups.get(family) || { label: baseModel, count: 0 };
existing.count += 1;
if (String(baseModel).length < String(existing.label).length) existing.label = baseModel;
groups.set(family, existing);
});
baseFilterSelect.replaceChildren(allBaseOption);
[...groups.entries()]
.sort((a, b) => String(a[1].label).localeCompare(String(b[1].label), undefined, { numeric: true }))
.forEach(([family, group]) => {
const option = document.createElement('option');
option.value = family;
option.textContent = `${group.label} (${group.count})`;
baseFilterSelect.appendChild(option);
});
const mainBaseModel = Object.values(contextModels)
.map(item => item?.metadata?.baseModel)
.find(Boolean) || '';
const currentBaseModel = currentPath ? modelInfo[currentPath]?.metadata?.baseModel || '' : '';
const preferredBaseModel = mainBaseModel || currentBaseModel;
const preferredFamily = getBaseModelFamily(preferredBaseModel);
selectedBaseFamily = preferredFamily && groups.has(preferredFamily) ? preferredFamily : '';
baseFilterSelect.value = selectedBaseFamily;
if (selectedBaseFamily && selectedPath && !pathMatchesBaseFilter(selectedPath)) selectedPath = null;
rebuildFolderCounts();
compatibilityHint.style.display = 'flex';
if (mainBaseModel && selectedBaseFamily) {
compatibilityHint.textContent = t('pickerCompatibleMain', { name: mainBaseModel });
} else if (preferredBaseModel && selectedBaseFamily) {
compatibilityHint.textContent = t('pickerCompatibleTag', { name: preferredBaseModel });
} else if (mainBaseModel) {
compatibilityHint.textContent = t('pickerCompatibleNoTags', { name: mainBaseModel });
} else {
compatibilityHint.textContent = t('pickerCompatibleUnknown');
}
};
const renderFolders = () => {
folderList.replaceChildren();
const folders = ['', ...[...folderCounts.keys()].filter(Boolean).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))];
folders.forEach(folderPath => {
const button = document.createElement('button');
const depth = folderPath ? folderPath.split('/').length - 1 : 0;
const iconSvg = folderPath
? '<svg style="width:13px;height:13px;vertical-align:-2px;margin-right:4px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>'
: '<svg style="width:13px;height:13px;vertical-align:-2px;margin-right:4px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>';
button.innerHTML = `${iconSvg}${escapeHtml(label)} (${folderCounts.get(folderPath) || 0})`;
button.title = folderPath || label;
button.style.cssText = `text-align:left;padding:7px 8px 7px ${8 + depth * 14}px;border-radius:6px;border:none;cursor:pointer;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:${selectedFolder === folderPath ? '#fff' : '#aaa'};background:${selectedFolder === folderPath ? 'rgba(25,118,210,0.45)' : 'transparent'};`;
button.onclick = () => {
selectedFolder = folderPath;
renderFolders();
renderCards();
};
folderList.appendChild(button);
});
};
renderCards = () => {
const term = searchInput.value.trim().toLowerCase();
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
const paths = validPaths.filter(path => {
const normalized = normalizePath(path);
const folderPath = getFolder(normalized);
const inFolder = !selectedFolder || folderPath === selectedFolder || folderPath.startsWith(`${selectedFolder}/`);
return pathMatchesBaseFilter(path) && inFolder && (!term || normalized.toLowerCase().includes(term));
});
if (sortSelect.value === 'name-desc') paths.sort((a, b) => collator.compare(getName(b), getName(a)));
else if (sortSelect.value === 'folder-asc') paths.sort((a, b) => collator.compare(normalizePath(a), normalizePath(b)));
else paths.sort((a, b) => collator.compare(getName(a), getName(b)));
resultCount.textContent = t('pickerResults', { count: paths.length });
stopMedia(grid);
grid.replaceChildren();
const generation = ++renderGeneration;
let index = 0;
const renderChunk = () => {
if (generation !== renderGeneration || !modal.isConnected) return;
const fragment = document.createDocumentFragment();
const end = Math.min(index + 40, paths.length);
for (; index < end; index += 1) {
const path = paths[index];
const isSelected = selectedPath === path;
const isCurrent = currentPath === path;
const info = modelInfo[path] || {};
const baseModel = info.metadata?.baseModel || '';
const card = document.createElement('div');
card.tabIndex = 0;
card.setAttribute('role', 'button');
card.style.cssText = `position:relative;background:linear-gradient(160deg,#252935,#1b1d24);border-radius:11px;overflow:hidden;cursor:pointer;display:flex;flex-direction:column;border:1px solid ${isSelected ? '#6ea8ff' : isCurrent ? '#e9b949' : 'rgba(255,255,255,0.09)'};box-shadow:${isSelected ? '0 0 0 2px rgba(88,151,255,0.22),0 14px 28px rgba(0,0,0,0.28)' : '0 8px 20px rgba(0,0,0,0.16)'};transition:transform 0.12s,box-shadow 0.12s;min-width:0;`;
const previewBox = document.createElement('div');
previewBox.style.cssText = 'height:150px;background:radial-gradient(circle at 50% 15%,#2b3041,#0d0e13 72%);display:flex;align-items:center;justify-content:center;font-size:30px;position:relative;overflow:hidden;';
const previewUrl = info.preview_url || previews[path];
if (/\.(mp4|webm)(?:$|\?|&|#)/i.test(previewUrl || '')) {
const video = document.createElement('video');
video.src = previewUrl;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = 'metadata';
video.style.cssText = 'width:100%;height:100%;object-fit:cover;';
previewBox.appendChild(video);
} else if (previewUrl) {
const image = document.createElement('img');
image.src = previewUrl;
image.alt = '';
image.loading = 'lazy';
image.decoding = 'async';
image.style.cssText = 'width:100%;height:100%;object-fit:cover;';
previewBox.appendChild(image);
} else {
previewBox.innerHTML = '<span style="display:inline-block;opacity:0.25;"><svg style="width:28px;height:28px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></span>';
}
if (isCurrent) {
const badge = document.createElement('span');
badge.textContent = t('pickerCurrent');
badge.style.cssText = 'position:absolute;top:6px;left:6px;background:rgba(255,193,7,0.92);color:#111;padding:3px 6px;border-radius:4px;font-size:10px;font-weight:800;';
previewBox.appendChild(badge);
}
const badgeStack = document.createElement('div');
badgeStack.style.cssText = 'position:absolute;top:6px;right:6px;display:flex;flex-direction:column;align-items:flex-end;gap:4px;max-width:76%;';
const categoryBadge = document.createElement('span');
categoryBadge.textContent = formatModelTypeLabel(info.type, pickerType.label);
categoryBadge.style.cssText = 'padding:3px 6px;border-radius:5px;background:rgba(25,34,54,0.9);border:1px solid rgba(138,180,248,0.28);color:#b8d0ff;font-size:9px;font-weight:800;box-shadow:0 3px 8px rgba(0,0,0,0.22);';
badgeStack.appendChild(categoryBadge);
if (baseModel) {
const baseBadge = document.createElement('span');
baseBadge.textContent = baseModel;
baseBadge.title = `${t('pickerBaseModel')}: ${baseModel}`;
baseBadge.style.cssText = 'max-width:100%;padding:3px 6px;border-radius:5px;background:rgba(7,50,43,0.9);border:1px solid rgba(70,220,185,0.25);color:#8ce1cf;font-size:9px;font-weight:750;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 3px 8px rgba(0,0,0,0.22);';
badgeStack.appendChild(baseBadge);
}
previewBox.appendChild(badgeStack);
const name = document.createElement('div');
name.textContent = getName(path);
name.title = normalizePath(path);
name.style.cssText = 'padding:9px 9px 3px;font-size:12px;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:700;';
const folder = document.createElement('div');
folder.textContent = getFolder(path) || t('pickerRoot');
folder.title = getFolder(path);
folder.style.cssText = 'padding:0 9px 9px;font-size:9px;color:#747b8b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
card.append(previewBox, name, folder);
const choose = () => {
selectedPath = path;
updateSelection();
renderCards();
};
card.onclick = choose;
card.onkeydown = event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
choose();
}
};
card.onmouseenter = () => {
card.style.transform = 'translateY(-2px)';
card.querySelector('video')?.play?.().catch(() => {});
};
card.onmouseleave = () => {
card.style.transform = 'none';
card.querySelector('video')?.pause?.();
};
fragment.appendChild(card);
}
grid.appendChild(fragment);
if (index < paths.length) requestAnimationFrame(renderChunk);
};
if (paths.length) requestAnimationFrame(renderChunk);
else {
const empty = document.createElement('div');
empty.textContent = t('pickerNoMatches');
empty.style.cssText = 'color:#777;padding:30px;text-align:center;grid-column:1/-1;';
grid.appendChild(empty);
}
};
confirmBtn.onclick = () => {
if (!selectedPath || applying) return;
applying = true;
updateSelection();
const oldValue = w.value;
try {
if (mode === 'insert') {
setWidgetValue(node, w, selectedPath);
spliceModelChainNode({ graph: app.graph, anchorNode: options.anchorNode, insertedNode: node, direction: options.direction });
try {
if (typeof w.callback === 'function') w.callback(w.value, app.canvas, node, app.canvas?.graph_mouse, null);
} catch (error) {
console.warn('[Anomalous] LoRA widget callback failed:', error);
}
} else {
app.graph?.beforeChange?.(node);
try {
setWidgetValue(node, w, selectedPath);
if (typeof w.callback === 'function') w.callback(w.value, app.canvas, node, app.canvas?.graph_mouse, null);
app.graph?.afterChange?.(node);
} catch (error) {
setWidgetValue(node, w, oldValue);
app.graph?.afterChange?.(node);
throw error;
}
app.graph?.change?.();
app.graph?.setDirtyCanvas?.(true, true);
}
delete node.color;
delete node.bgcolor;
node.has_errors = false;
if (app.lastNodeErrors?.[node.id]) delete app.lastNodeErrors[node.id];
if (typeof app.clearErrors === 'function') app.clearErrors();
try { window.dispatchEvent(new CustomEvent('graphChanged')); } catch (error) {}
closeModal();
if (mode === 'insert' && app.canvas?.selectNode) app.canvas.selectNode(node);
else this.diagnoseNode(node);
} catch (error) {
setWidgetValue(node, w, oldValue);
applying = false;
updateSelection();
console.error('[Anomalous] Failed to apply model choice:', error);
alert(t('pickerOperationFailed') + error.message);
}
};
searchInput.oninput = renderCards;
baseFilterSelect.onchange = () => {
selectedBaseFamily = baseFilterSelect.value;
selectedFolder = '';
if (selectedPath && !pathMatchesBaseFilter(selectedPath)) selectedPath = null;
rebuildFolderCounts();
renderFolders();
updateSelection();
renderCards();
};
sortSelect.onchange = renderCards;
modal.onkeydown = event => { if (event.key === 'Escape') closeModal(); };
renderFolders();
updateSelection();
renderCards();
setTimeout(() => searchInput.focus(), 0);
const requestPayload = {
paths: validPaths,
context_requests: contextRequests,
};
if (pickerType.folderTypes.length) requestPayload.folder_types = pickerType.folderTypes;
fetch('/anomalous/resolve_paths_to_previews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestPayload),
}).then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}).then(data => {
if (!modal.isConnected) return;
previews = data.previews || {};
modelInfo = data.models || {};
contextModels = data.context_models || {};
loadingText.style.display = 'none';
configureBaseFilter();
renderFolders();
updateSelection();
renderCards();
}).catch(error => {
if (!modal.isConnected) return;
console.error('[Anomalous] Failed to load model previews:', error);
loadingText.textContent = t('pickerCoversFailed');
configureBaseFilter();
renderFolders();
});
}
export function runGlobalDoctorScan() {
const content = document.getElementById('anomalous-doctor-node-list');
const inst = document.getElementById('anomalous-doctor-instructions');
if (inst) inst.style.display = 'none';
if (content) content.innerHTML = '';
if (!content || !app.graph || !Array.isArray(app.graph._nodes)) return;
let totalNodes = 0;
let missingNodes = 0;
for (const node of app.graph._nodes) {
if (node.widgets) {
for (let w of node.widgets) {
const val = w.value;
if (typeof val === 'string' && val.match(/\.(safetensors|ckpt|pt|bin|pth|sft)$/i)) {
totalNodes++;
let isHealthy = false;
if (w.options && w.options.values && w.options.values.includes(val)) isHealthy = true;
if (!isHealthy) {
missingNodes++;
const nodeTitle = document.createElement('div');
nodeTitle.textContent = `${t('doctorNodeLabel')}: ${node.title || node.type}`;
nodeTitle.style.color = '#e5e7eb';
nodeTitle.style.fontWeight = 'bold';
nodeTitle.style.marginTop = '10px';
content.appendChild(nodeTitle);
content.appendChild(this.renderDoctorState(node, w));
}
}
}
}
}
if (missingNodes === 0) {
const healthyMessage = document.createElement('div');
healthyMessage.style.color = '#28a745';
healthyMessage.style.textAlign = 'center';
healthyMessage.style.padding = '20px';
healthyMessage.style.fontSize = '16px';
healthyMessage.textContent = t('doctorAllHealthy');
content.appendChild(healthyMessage);
}
}
export function applyLocalNodeParameters(targetNode, sourceWidgetValues) {
if (!Array.isArray(sourceWidgetValues)) throw new Error('materialNoCompatibleValues');
return applyNodeMaterialValues(app, targetNode, sourceWidgetValues.map((value, index) => ({ index, value })));
}
let activePopoverTimer = null;
let activeHideTimer = null;
let currentPopoverEl = null;
function extractNodeParamSummary(node, widgetValues) {
if (!Array.isArray(widgetValues)) return { isText: false, textPreview: '', fullText: '', pills: [], details: [] };
const widgetNames = (node?.widgets || []).map(w => w.name || '');
const stringVal = widgetValues.find(v => typeof v === 'string' && v.trim().length > 15);
const isText = Boolean(stringVal && (/cliptextencode|prompt/i.test(node?.type || '') || widgetValues.length === 1));
if (isText && stringVal) {
return {
isText: true,
textPreview: stringVal.length > 60 ? stringVal.slice(0, 60) + '...' : stringVal,
fullText: stringVal,
pills: [],
details: widgetValues.map((val, i) => ({
name: widgetNames[i] || `Widget #${i}`,
value: String(val)
}))
};
}
const pills = [];
const details = [];
widgetValues.forEach((val, i) => {
const name = (widgetNames[i] || `param_${i}`).toLowerCase();
const strVal = String(val);
details.push({ name: widgetNames[i] || `Param #${i}`, value: strVal });
if (pills.length < 5) {
if (name.includes('step')) {
pills.push({ label: `${strVal} steps`, type: 'steps' });
} else if (name.includes('cfg')) {
pills.push({ label: `CFG ${strVal}`, type: 'cfg' });
} else if (name.includes('sampler')) {
pills.push({ label: strVal, type: 'sampler' });
} else if (name.includes('scheduler')) {
pills.push({ label: strVal, type: 'scheduler' });
} else if (name.includes('denoise')) {
pills.push({ label: `denoise ${strVal}`, type: 'denoise' });
} else if (name.includes('width') || name.includes('height')) {
pills.push({ label: `${strVal}px`, type: 'size' });
} else if (typeof val === 'number') {
pills.push({ label: `${widgetNames[i] || 'val'}: ${strVal}`, type: 'num' });
} else if (typeof val === 'string' && val.length > 0 && val.length <= 20 && !val.includes('/')) {
pills.push({ label: strVal, type: 'str' });
}
}
});
if (pills.length === 0) {
details.slice(0, 3).forEach(d => {
const shortVal = d.value.length > 15 ? d.value.slice(0, 15) + '...' : d.value;
pills.push({ label: `${d.name}: ${shortVal}`, type: 'generic' });
});
}
return { isText: false, textPreview: '', fullText: '', pills, details };
}
function showParamPopover(anchorEl, { node, sourceTitle, schemeTitle, promptRoleTag, summary, onApply }) {
clearTimeout(activeHideTimer);
clearTimeout(activePopoverTimer);
activePopoverTimer = setTimeout(() => {
if (!anchorEl.isConnected) return;
if (!currentPopoverEl) {
currentPopoverEl = document.createElement('div');
currentPopoverEl.className = 'anomalous-param-popover';
document.body.appendChild(currentPopoverEl);
currentPopoverEl.addEventListener('mouseenter', () => {
clearTimeout(activeHideTimer);
});
currentPopoverEl.addEventListener('mouseleave', () => {
hideParamPopover(true);
});
}
currentPopoverEl.innerHTML = '';
const popHeader = document.createElement('div');
popHeader.style.cssText = 'display:flex;flex-direction:column;gap:4px;border-bottom:1px solid rgba(255,255,255,0.08);padding-bottom:10px;margin-bottom:10px;';
const srcTag = document.createElement('div');
srcTag.style.cssText = 'font-size:10px;color:#9ca3af;display:flex;align-items:center;gap:4px;text-transform:uppercase;letter-spacing:0.5px;';
srcTag.textContent = `${t('assistantSchemeSource') || '来源'}: ${sourceTitle}`;
popHeader.appendChild(srcTag);
const titleDiv = document.createElement('div');
titleDiv.style.cssText = 'font-size:14px;font-weight:700;color:#f3f4f6;display:flex;align-items:center;gap:6px;';
titleDiv.innerHTML = `${promptRoleTag || ''}<span>${escapeHtml(schemeTitle)}</span>`;
popHeader.appendChild(titleDiv);
currentPopoverEl.appendChild(popHeader);
if (summary.isText && summary.fullText) {
const copyRow = document.createElement('div');
copyRow.style.cssText = 'display:flex;justify-content:flex-end;margin-bottom:6px;';
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.style.cssText = 'background:transparent;border:1px solid rgba(255,255,255,0.15);color:#cbd5e1;border-radius:4px;padding:3px 8px;font-size:10px;cursor:pointer;';
copyBtn.textContent = '📋 复制文本';
copyBtn.onclick = () => {
navigator.clipboard.writeText(summary.fullText).then(() => {
copyBtn.textContent = '✅ 已复制';
setTimeout(() => copyBtn.textContent = '📋 复制文本', 1200);
});
};
copyRow.appendChild(copyBtn);
currentPopoverEl.appendChild(copyRow);
const textBox = document.createElement('div');
textBox.style.cssText = 'max-height:240px;overflow-y:auto;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.08);border-radius:8px;padding:10px;font-size:11.5px;line-height:1.6;color:#e2e8f0;white-space:pre-wrap;word-break:break-word;font-family:monospace;';
textBox.textContent = summary.fullText;
currentPopoverEl.appendChild(textBox);
} else if (summary.details.length > 0) {
const tableWrap = document.createElement('div');
tableWrap.style.cssText = 'max-height:260px;overflow-y:auto;display:flex;flex-direction:column;gap:5px;';
for (const d of summary.details) {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(255,255,255,0.03);padding:6px 10px;border-radius:6px;font-size:11.5px;';
const keySpan = document.createElement('span');
keySpan.style.cssText = 'color:#94a3b8;font-family:monospace;font-size:11px;';
keySpan.textContent = d.name;
const valSpan = document.createElement('span');
valSpan.style.cssText = 'color:#f1f5f9;font-weight:600;font-family:monospace;max-width:60%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
valSpan.textContent = d.value;
valSpan.title = d.value;
row.append(keySpan, valSpan);
tableWrap.appendChild(row);
}
currentPopoverEl.appendChild(tableWrap);
}
const popFooter = document.createElement('div');
popFooter.style.cssText = 'margin-top:12px;padding-top:10px;border-top:1px solid rgba(255,255,255,0.08);display:flex;justify-content:flex-end;';
const popApplyBtn = document.createElement('button');
popApplyBtn.type = 'button';
popApplyBtn.className = 'anomalous-scheme-apply-btn';
popApplyBtn.style.cssText = 'width:100%;padding:8px 14px;font-size:12px;font-weight:700;border-radius:7px;background:rgba(25,118,210,0.9);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;';
popApplyBtn.textContent = t('assistantApplyScheme') || '⚡ 应用方案到当前节点';
popApplyBtn.onclick = () => onApply();
popFooter.appendChild(popApplyBtn);
currentPopoverEl.appendChild(popFooter);
currentPopoverEl.style.display = 'flex';
const anchorRect = anchorEl.getBoundingClientRect();
const popWidth = 340;
let left = anchorRect.left - popWidth - 14;
if (left < 10) {
left = anchorRect.right + 14;
}
if (left + popWidth > window.innerWidth - 10) {
left = window.innerWidth - popWidth - 10;
}
let top = anchorRect.top - 8;
if (top + 420 > window.innerHeight) {
top = Math.max(16, window.innerHeight - 440);
}
currentPopoverEl.style.left = `${Math.max(10, left)}px`;
currentPopoverEl.style.top = `${Math.max(10, top)}px`;
}, 120);
}
function hideParamPopover(immediate = false) {
clearTimeout(activePopoverTimer);
if (immediate) {
if (currentPopoverEl) currentPopoverEl.style.display = 'none';
} else {
activeHideTimer = setTimeout(() => {
if (currentPopoverEl) currentPopoverEl.style.display = 'none';
}, 150);
}
}
function createSchemeCard({ node, sourceTitle, sourceBadge, schemeTitle, role, widgetValues, onApply }) {
const summary = extractNodeParamSummary(node, widgetValues);
const card = document.createElement('div');
card.className = 'anomalous-assistant-scheme-card';
card.style.cssText = 'background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;transition:all 0.2s cubic-bezier(0.16, 1, 0.3, 1);position:relative;cursor:default;';
card.onmouseover = () => {
card.style.borderColor = 'rgba(255,255,255,0.2)';
card.style.background = 'rgba(255,255,255,0.06)';
};
card.onmouseout = () => {
card.style.borderColor = 'rgba(255,255,255,0.08)';
card.style.background = 'rgba(255,255,255,0.04)';
};
const headerRow = document.createElement('div');
headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0;';
const titleWrap = document.createElement('div');
titleWrap.style.cssText = 'display:flex;align-items:center;gap:6px;min-width:0;flex:1;';
const promptRoleTag = {
positive: `[🟢 ${t('recipePromptRolePositive') || '正向'}] `,
negative: `[🔴 ${t('recipePromptRoleNegative') || '负向'}] `,
both: `[🟣 ${t('recipePromptRoleBoth') || '混合'}] `,
}[role] || (/cliptextencode/i.test(node?.type || '') ? `[⚪ ${t('recipePromptRoleUnknown') || '提示词'}] ` : '');
const titleEl = document.createElement('span');
titleEl.style.cssText = 'font-weight:700;font-size:12px;color:#f3f4f6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
titleEl.innerHTML = `${promptRoleTag}${escapeHtml(schemeTitle)}`;
titleWrap.appendChild(titleEl);
const badgeEl = document.createElement('span');
badgeEl.style.cssText = 'font-size:9px;color:#9ca3af;padding:2px 6px;border-radius:999px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);flex-shrink:0;max-width:35%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
badgeEl.textContent = sourceBadge || sourceTitle;
badgeEl.title = sourceTitle;
headerRow.append(titleWrap, badgeEl);
card.appendChild(headerRow);
const contentRow = document.createElement('div');
contentRow.style.cssText = 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0;';
if (summary.isText && summary.textPreview) {
const textSnippet = document.createElement('div');
textSnippet.style.cssText = 'font-size:11px;color:#cbd5e1;background:rgba(0,0,0,0.25);border:1px solid rgba(255,255,255,0.06);border-radius:6px;padding:6px 8px;width:100%;box-sizing:border-box;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace;';
textSnippet.textContent = summary.textPreview;
contentRow.appendChild(textSnippet);
} else if (summary.pills.length > 0) {
for (const pill of summary.pills) {
const pillSpan = document.createElement('span');
let pillBg = 'rgba(255,255,255,0.07)';
let pillColor = '#e2e8f0';
let pillBorder = 'rgba(255,255,255,0.1)';
if (pill.type === 'steps') { pillBg = 'rgba(59,130,246,0.15)'; pillColor = '#93c5fd'; pillBorder = 'rgba(59,130,246,0.3)'; }
else if (pill.type === 'cfg') { pillBg = 'rgba(245,158,11,0.15)'; pillColor = '#fcd34d'; pillBorder = 'rgba(245,158,11,0.3)'; }
else if (pill.type === 'sampler' || pill.type === 'scheduler') { pillBg = 'rgba(168,85,247,0.15)'; pillColor = '#d8b4fe'; pillBorder = 'rgba(168,85,247,0.3)'; }
else if (pill.type === 'denoise') { pillBg = 'rgba(16,185,129,0.15)'; pillColor = '#6ee7b7'; pillBorder = 'rgba(16,185,129,0.3)'; }
pillSpan.style.cssText = `font-size:10px;font-weight:600;padding:2px 7px;border-radius:5px;background:${pillBg};color:${pillColor};border:1px solid ${pillBorder};white-space:nowrap;`;
pillSpan.textContent = pill.label;
contentRow.appendChild(pillSpan);
}
}
card.appendChild(contentRow);
const actionRow = document.createElement('div');
actionRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:2px;';
const hintSpan = document.createElement('span');
hintSpan.style.cssText = 'font-size:10px;color:#64748b;display:flex;align-items:center;gap:4px;';
hintSpan.innerHTML = `<span style="font-size:11px;">🔍</span> <span>${t('assistantHoverToInspect') || '悬停预览详情'}</span>`;
actionRow.appendChild(hintSpan);
const applyBtn = document.createElement('button');
applyBtn.type = 'button';
applyBtn.className = 'anomalous-scheme-apply-btn';
applyBtn.style.cssText = 'padding:5px 12px;font-size:11px;font-weight:700;border-radius:6px;background:rgba(25,118,210,0.85);color:#fff;border:1px solid rgba(255,255,255,0.18);cursor:pointer;transition:all 0.15s;display:flex;align-items:center;gap:4px;box-shadow:0 2px 6px rgba(0,0,0,0.2);';
applyBtn.innerHTML = t('assistantApplyScheme') || '⚡ 应用方案';
applyBtn.onmouseover = (e) => { e.stopPropagation(); applyBtn.style.filter = 'brightness(1.15)'; };
applyBtn.onmouseout = (e) => { e.stopPropagation(); applyBtn.style.filter = 'none'; };
applyBtn.onclick = async (e) => {
e.stopPropagation();
applyBtn.disabled = true;
applyBtn.innerHTML = t('assistantApplying') || '⏳ 应用中...';
applyBtn.style.background = 'rgba(255,255,255,0.1)';
try {
await onApply();
applyBtn.innerHTML = t('assistantApplied') || '✅ 已应用';
applyBtn.style.background = 'rgba(16,185,129,0.7)';
} catch (err) {
console.error('[Anomalous] Apply scheme failed:', err);
applyBtn.innerHTML = '⚠️ 失败';
applyBtn.style.background = 'rgba(239,68,68,0.7)';
}
setTimeout(() => {
if (!applyBtn.isConnected) return;
applyBtn.disabled = false;
applyBtn.innerHTML = t('assistantApplyScheme') || '⚡ 应用方案';
applyBtn.style.background = 'rgba(25,118,210,0.85)';
}, 1500);
};
actionRow.appendChild(applyBtn);
card.appendChild(actionRow);
card.addEventListener('mouseenter', () => {
showParamPopover(card, {
node,
sourceTitle,
schemeTitle,
promptRoleTag,
summary,
onApply: () => applyBtn.click()
});
});
card.addEventListener('mouseleave', () => {
hideParamPopover(false);
});
return card;
}
function renderMaterialPresets(node, container, forceRefresh) {
const section = document.createElement('div');
section.className = 'anomalous-assistant-parameter-presets anomalous-assistant-material-presets';
section.style.cssText = 'margin:14px 16px; display:flex; flex-direction:column; gap:8px;';
const header = document.createElement('div');
header.style.cssText = 'color:#68cdb9;font-size:10px;font-weight:750;letter-spacing:0.1em;text-transform:uppercase;';
header.textContent = t('materialLibrary');
const browse = document.createElement('button');
browse.textContent = t('materialShowCompatible');
browse.className = 'anomalous-btn-ghost';
browse.onclick = () => window.anomalousBrowserInstance?.openMaterialLibrary();
header.appendChild(browse);
const loader = document.createElement('div');
loader.style.cssText = 'font-size:12px;color:#555;text-align:center;padding:10px;';
loader.textContent = t('loading');
section.append(header, loader);
container.appendChild(section);
const url = `/anomalous/materials/by_node_type?type=${encodeURIComponent(node.type)}${forceRefresh ? '&refresh=1' : ''}`;
fetch(url, { cache: 'no-store' }).then(response => {
if (!response.ok) throw new Error('material preset request failed');
return response.json();
}).then(payload => {
loader.remove();
const materials = Array.isArray(payload.materials) ? payload.materials : [];
if (!materials.length) {
const empty = document.createElement('div');
empty.style.cssText = 'font-size:11px;color:#666;text-align:center;padding:10px;background:rgba(0,0,0,.2);border-radius:8px;border:1px dashed rgba(255,255,255,.1);';
empty.textContent = t('materialNoNodePresets');
section.appendChild(empty);
return;
}
for (const material of materials) {
const group = document.createElement('div');
group.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid rgba(92,202,180,.2);border-radius:10px;background:rgba(92,202,180,.04);';
const name = document.createElement('strong');
name.innerHTML = `<svg style="width:13px;height:13px;margin-right:5px;vertical-align:-1px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>${escapeHtml(material.name || t('materialUntitled'))}`;
name.style.cssText = 'font-size:12px;color:#bcebe1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
group.appendChild(name);
for (const block of (Array.isArray(material.blocks) ? material.blocks : [])) {
const suffix = material.blocks.length > 1 ? ` #${block.occurrence}` : '';
const schemeTitle = `${block.title || block.type}${suffix}`;
const card = createSchemeCard({
node,
sourceTitle: material.name || t('materialLibrary'),
sourceBadge: t('materialLibrary'),
schemeTitle,
role: block.role,
widgetValues: block.widgets_values,
onApply: () => applyMaterialToSelectedNode(node, block, material.workflow_hashes, group)
});
group.appendChild(card);
}
section.appendChild(group);
}
}).catch(error => {
console.error('Failed to load material presets:', error);
loader.textContent = t('materialLoadError');
loader.style.color = '#ff7070';
});
}
export function renderParameterPresets(node, container, forceRefresh = false) {
if (!node || !node.type) return;
const betaNotice = document.createElement('div');
betaNotice.className = 'anomalous-beta-notice anomalous-assistant-beta-notice';
const betaBadge = document.createElement('strong');
betaBadge.className = 'anomalous-beta-badge';
betaBadge.dataset.anomalousI18nKey = 'betaFeature';
betaBadge.textContent = t('betaFeature');
const betaText = document.createElement('span');
betaText.dataset.anomalousI18nKey = 'assistantBetaNotice';
betaText.textContent = t('assistantBetaNotice');
betaNotice.append(betaBadge, betaText);
container.appendChild(betaNotice);
renderMaterialPresets(node, container, forceRefresh);
const presetSection = document.createElement('div');
presetSection.className = 'anomalous-assistant-parameter-presets';
presetSection.style.cssText = 'margin:14px 16px; display:flex; flex-direction:column; gap:8px;';
const header = document.createElement('div');
header.style.cssText = 'color:#8b91a3;font-size:10px;font-weight:750;letter-spacing:0.1em;text-transform:uppercase;';
header.textContent = t('recipeParameterNotebooks') || 'Parameter Notebooks';
presetSection.appendChild(header);
const loader = document.createElement('div');
loader.style.cssText = 'font-size:12px; color:#555; text-align:center; padding:10px;';
loader.textContent = t('loading') || 'Loading...';
presetSection.appendChild(loader);
container.appendChild(presetSection);
// Debounce fetching
if (this._presetFetchTimer) clearTimeout(this._presetFetchTimer);
this._presetFetchTimer = setTimeout(async () => {
try {
const fetchUrl = `/anomalous/parameters/by_node_type?type=${encodeURIComponent(node.type)}${forceRefresh ? '&refresh=1' : ''}`;
const res = await fetch(fetchUrl);
if (!res.ok) throw new Error('Network error');
const data = await res.json();
loader.style.display = 'none';
if (!Array.isArray(data.groups) || data.groups.length === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'font-size:11px; color:#666; text-align:center; padding:10px; background:rgba(0,0,0,0.2); border-radius:8px; border:1px dashed rgba(255,255,255,0.1);';
empty.textContent = t('assistantNoPresets') || 'No presets found for this node type.';
presetSection.appendChild(empty);
return;
}
const flatContainer = document.createElement('div');
flatContainer.style.cssText = 'display:flex; flex-direction:column; gap:6px; margin-top:8px;';
let presetCount = 0;
let renderedGroups = 0;
for (const group of data.groups) {
const displayName = String(group.recipe_name || group.recipe_filename || t('recipeUntitled'));
// Hide unbound and deleted recipes as per user request
if (group.recipe_filename === 'unbound' || displayName.endsWith('.json')) {
continue;
}
const recipeBox = document.createElement('div');
recipeBox.style.cssText = 'background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.05); border-radius:10px; overflow:hidden;';
const recipeHeader = document.createElement('div');
recipeHeader.style.cssText = 'padding:10px 12px; font-size:12px; font-weight:bold; color:#c9d6ff; cursor:pointer; display:flex; align-items:center; gap:8px; background:rgba(0,0,0,0.2);';
recipeHeader.innerHTML = `<span style="font-size:14px;">🍱</span> <span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(displayName)}</span> <span>▼</span>`;
const notebookList = document.createElement('div');
notebookList.style.cssText = 'display:flex; flex-direction:column; gap:6px; padding:8px;';
let hasNodes = false;
for (const nb of (Array.isArray(group.notebooks) ? group.notebooks : [])) {
for (const n of (Array.isArray(nb?.nodes) ? nb.nodes : [])) {
hasNodes = true;
presetCount++;
let summaryTitle = nb.name || t('recipeParameterUntitled') || 'Untitled';
if (nb.nodes.length > 1) {
summaryTitle += ` - ${n.title}`;
}
const card = createSchemeCard({
node,
sourceTitle: displayName,
sourceBadge: t('recipeParameterNotebooks') || '配方',
schemeTitle: summaryTitle,
role: n.role,
widgetValues: n.widgets_values,
onApply: () => applyLocalNodeParameters(node, n.widgets_values)
});
notebookList.appendChild(card);
}
}
if (hasNodes) {
recipeBox.appendChild(recipeHeader);
recipeBox.appendChild(notebookList);
flatContainer.appendChild(recipeBox);
if (renderedGroups > 0) {
notebookList.style.display = 'none';
recipeHeader.querySelector('span:last-child').textContent = '▶';
}
renderedGroups++;
recipeHeader.onclick = () => {
if (notebookList.style.display === 'none') {
notebookList.style.display = 'flex';
recipeHeader.querySelector('span:last-child').textContent = '▼';
} else {
notebookList.style.display = 'none';
recipeHeader.querySelector('span:last-child').textContent = '▶';
}
};
}
}
if (presetCount === 0) {
const empty = document.createElement('div');
empty.style.cssText = 'color:#aaa; font-style:italic; font-size:12px; padding:10px; text-align:center;';
empty.textContent = t('assistantNoPresets') || 'No presets found for this node type.';
presetSection.appendChild(empty);
} else {
presetSection.appendChild(flatContainer);
}
} catch (e) {
console.error("Failed to load parameter presets", e);
loader.textContent = 'Error loading presets.';
loader.style.color = '#ff5252';
}
}, 300);
}