/** 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 || ''}${escapeHtml(schemeTitle)}`; 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 = `🔍 ${t('assistantHoverToInspect') || '悬停预览详情'}`; 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 = `${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 = `🍱 ${escapeHtml(displayName)} ▼`; 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); }