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

530 lines
26 KiB
JavaScript

/** Node parameter scheme cards, previews, and application. */
import { app } from "../../../scripts/app.js";
import { translate } from "./locales.js";
import { escapeHtml } from "./safe_dom.js";
import { applyNodeMaterialValues } from "./node_material_actions.js";
import { applyMaterialToSelectedNode } from "./ui_material_application.js";
const t = (key, params) => translate(key, params);
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);
}