feat(dashboard): wf_params facets, labels, workflow job panel

Quick Filter / Logic Filter (wf:<key>) / label overlay use item.wf_params. Sweep items get a
JOB button opening a read-only Workflow job panel with download/copy of the job's API prompt.
Standalone progress bar shows paused/cancelled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-11-04 00:55:11 -08:00
co-authored by Claude Fable 5.1
parent 968b0dfdfc
commit 13422104f4
10 changed files with 349 additions and 14 deletions
+1
View File
@@ -22,6 +22,7 @@ def get_html_template(title, manifest_data, node_id, standalone=False):
"logic_state.js", # Global variables
"logic_utils.js", # API & Helper functions
"logic_ratings.js", # Pairwise review: pure logic (normalisers, Bradley-Terry, pairing, scoreboard)
"logic_wfparams.js", # Workflow Sweep: wf_params helpers (pure; must load before logic_ui/logic_pipeline)
"logic_ui.js", # DOM Creation, Modals, JSON Bars
"logic_upscale_modal.js", # Upscale Modal (preset management, async upscale)
"logic_virtual.js", # The Virtual Scroll Engine & Viewport
+1 -1
View File
@@ -38,7 +38,7 @@ window.addEventListener('message', (event) => {
if (etaText) {
const phaseLabel = p.phase === 'upscaling' ? 'Upscaling' : (p.phase === 'sweep' ? 'Sweep job' : 'Job');
const finishPart = p.finish_time ? ` | ~${p.finish_time}` : '';
etaText.textContent = `${phaseLabel} ${p.current_job}/${p.total_jobs} (${p.progress_pct}%) | ETA: ${p.eta_str}${finishPart} | ${p.avg_duration}s/job${p.errors ? ` | ${p.errors} error${p.errors === 1 ? '' : 's'}` : ''}`;
etaText.textContent = `${phaseLabel} ${p.current_job}/${p.total_jobs} (${p.progress_pct}%) | ETA: ${p.eta_str}${finishPart} | ${p.avg_duration}s/job${p.errors ? ` | ${p.errors} error${p.errors === 1 ? '' : 's'}` : ''}${(p.state === 'paused' || p.state === 'cancelled') ? ` | ${p.state}` : ''}`;
}
if (etaProgress) {
etaProgress.style.width = `${p.progress_pct}%`;
+7
View File
@@ -25,6 +25,9 @@ function refreshIndices() {
* Returns null if the field is missing or not parseable as a number.
*/
function _extractFieldValue(item, field) {
if (typeof field === 'string' && field.startsWith('wf:')) {
return (typeof wfParamNumber === 'function') ? wfParamNumber(item, field.slice(3)) : null;
}
if (typeof field === 'string' && field.startsWith('metric:')) {
const mv = item.metrics ? item.metrics[field.slice(7)] : undefined;
return (typeof mv === 'number' && isFinite(mv)) ? mv : null;
@@ -95,6 +98,9 @@ function matchesLogicFilters(item) {
* - everything else: String exact match.
*/
function _quickFilterMatches(item, f) {
if (typeof f.type === 'string' && f.type.startsWith('wf:')) {
return (typeof wfParamMatches === 'function') && wfParamMatches(item, f.type.slice(3), f.value);
}
if (f.type === 'lora') {
const lora = String(item.lora || 'None');
if (lora === 'None') return f.value === 'None';
@@ -547,6 +553,7 @@ function executePipeline() {
const startTime = performance.now();
refreshSupersededFps();
if (typeof populateMetricFilterUI === 'function') populateMetricFilterUI();
if (typeof populateWfParamFilterUI === 'function') populateWfParamFilterUI();
const currentFilterKey = getFilterKey();
const filtersChanged = currentFilterKey !== lastFilterKey;
+2 -1
View File
@@ -142,7 +142,8 @@ var labelMode = labelMode || {
hiresPromptBehavior: false,
hiresPromptText: false,
rating: false, // Pairwise-review rating badge
metrics: false
metrics: false,
wfParams: false // Workflow Sweep: varied wf_params as tags
}
};
+113 -12
View File
@@ -318,6 +318,17 @@ function buildLabelOverlay(d) {
}
}
if (labelMode.fields.wfParams && d.wf_params && typeof d.wf_params === 'object' && typeof wfParamKeys === 'function') {
const wfKeys = wfParamKeys(activeData);
const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
for (const row of wfKeys) {
if (row.distinct <= 1) continue; // constant axes are noise
const v = wfParamValue(d, row.key);
if (v === null) continue;
tags.push(`<span class="label-tag label-wf" title="${esc(row.key)}: ${esc(v)}">${esc(wfShortLabel(row.key, wfKeys))}:${esc(wfDisplay(v))}</span>`);
}
}
if (tags.length === 0) return '';
return `<div class="label-overlay">${tags.join('')}</div>`;
}
@@ -343,7 +354,7 @@ function toggleLabelMode(enabled) {
function updateLabelFields() {
const fields = ['model', 'lora', 'prompt', 'sampler', 'scheduler', 'cfg', 'steps', 'seed', 'denoise', 'upscale',
'upscaleMode', 'upscaleModel', 'upscaleRatio', 'upscaleDenoise', 'upscaleResizeMethod', 'upscaleHiresSteps', 'upscaleTiling',
'hiresPromptBehavior', 'hiresPromptText', 'rating', 'metrics'];
'hiresPromptBehavior', 'hiresPromptText', 'rating', 'metrics', 'wfParams'];
fields.forEach(f => {
const cb = document.getElementById(`label-field-${f}`);
if (cb) labelMode.fields[f] = cb.checked;
@@ -411,7 +422,7 @@ function loadLabelPreferences() {
const fields = ['model', 'lora', 'prompt', 'sampler', 'scheduler', 'cfg', 'steps', 'seed', 'denoise', 'upscale',
'upscaleMode', 'upscaleModel', 'upscaleRatio', 'upscaleDenoise', 'upscaleResizeMethod', 'upscaleHiresSteps', 'upscaleTiling',
'hiresPromptBehavior', 'hiresPromptText', 'rating', 'metrics'];
'hiresPromptBehavior', 'hiresPromptText', 'rating', 'metrics', 'wfParams'];
fields.forEach(f => {
const cb = document.getElementById(`label-field-${f}`);
if (cb) cb.checked = labelMode.fields[f] || false;
@@ -1368,7 +1379,8 @@ function createCard(d) {
? `<video ondblclick="toggleFavorite(this)" data-src="${d.file}" muted loop playsinline preload="metadata" draggable="false"></video>`
: `<img ondblclick="toggleFavorite(this)" data-src="${d.file}" alt="Image ${d.id}" draggable="false">`;
// Workflow Sweep items: the Revise modal builds a standard config and would lose the workflow template
const reviseBtn = (isVideo || d.model_type === 'workflow') ? '' : `<button class="revise-btn" onclick="event.stopPropagation(); openM(${d.id})">REVISE</button>`;
// (they get a JOB button instead: openM shows the read-only Workflow job panel)
const reviseBtn = isVideo ? '' : `<button class="revise-btn" onclick="event.stopPropagation(); openM(${d.id})">${d.model_type === 'workflow' ? 'JOB' : 'REVISE'}</button>`;
const upscaleBtn = isVideo ? '' : `<button class="upscale-btn" onclick="openUpscaleModal(${d.id})" title="Upscale this image">\u2B06</button>`;
const videoBadge = isVideo ? '<div class="video-badge">\u25B6 VIDEO</div>' : '';
const previewBadge = d.preview
@@ -1423,12 +1435,49 @@ function createCard(d) {
return card;
}
// Workflow Sweep items show a read-only "Workflow job" panel instead of the revise form.
function _setModalWorkflowMode(d) {
const isWf = d.model_type === 'workflow';
const rev = document.getElementById('m-revise-controls');
const pnl = document.getElementById('m-workflow-panel');
if (!rev || !pnl) return;
rev.style.display = isWf ? 'none' : 'flex';
pnl.style.display = isWf ? 'flex' : 'none';
if (!isWf) return;
const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const metaEl = document.getElementById('m-wf-meta');
if (metaEl) {
const bits = [d.wf_source || 'workflow'];
if (d.wf_output && d.wf_output.node_id !== undefined) bits.push('output ' + d.wf_output.node_id + (d.wf_output.output_index ? ':' + d.wf_output.output_index : ''));
if (d.wf_sweep_id) bits.push('sweep ' + d.wf_sweep_id);
if (d.wf_job_index !== undefined && d.wf_job_index !== null) bits.push('job #' + (Number(d.wf_job_index) + 1));
metaEl.textContent = bits.join(' \u00B7 ');
}
const tbl = document.getElementById('m-wf-params');
if (tbl) {
const p = (d.wf_params && typeof d.wf_params === 'object') ? d.wf_params : {};
const keys = Object.keys(p).sort();
tbl.innerHTML = keys.length
? keys.map(k => `<tr><td style="padding:3px 8px 3px 0; color:#888; vertical-align:top;">${esc(k)}</td><td style="padding:3px 0; color:#fff; word-break:break-all;">${esc(p[k])}</td></tr>`).join('')
: '<tr><td style="color:#666;">No varied parameters recorded.</td></tr>';
}
const canFetch = !!d.wf_sweep_id && d.wf_job_index !== undefined && d.wf_job_index !== null;
['m-wf-download', 'm-wf-copy'].forEach(bid => {
const b = document.getElementById(bid);
if (!b) return;
b.disabled = !canFetch;
b.title = canFetch ? '' : 'This item was not produced by a recorded sweep (no sweep id).';
b.style.opacity = canFetch ? '' : '0.5';
});
}
// Open Revision Modal
function openM(id) {
window.currentModalId = id;
const d = activeData.find(x => x.id === id);
if (!d) return;
document.getElementById('m-img').src = d.file;
_setModalWorkflowMode(d);
const previewNote = document.getElementById('m-preview-note');
if (previewNote) {
if (d.preview) {
@@ -2003,6 +2052,37 @@ function addLogicFilter() {
console.log(`[Logic Filter] Added: ${field} ${op} ${value}`);
}
// --- Workflow params in the Logic Filter (numeric wf_params keys only; group is absent when no item has wf_params) ---
var _wfUiSig = null;
function populateWfParamFilterUI() {
if (typeof wfParamKeys !== 'function') return;
const keys = wfParamKeys(activeData).filter(k => k.numeric);
const sig = keys.map(k => k.key).join(',');
if (sig === _wfUiSig) return;
_wfUiSig = sig;
const sel = document.getElementById('logic-filter-field');
if (!sel) return;
const prev = sel.value;
const old = document.getElementById('logic-filter-wf-group');
if (old) old.remove();
if (keys.length) {
const group = document.createElement('optgroup');
group.id = 'logic-filter-wf-group';
group.label = 'Workflow params';
const all = keys.map(k => k.key);
keys.forEach(k => {
const o = document.createElement('option');
o.value = 'wf:' + k.key;
o.textContent = wfShortLabel(k.key, all);
o.title = k.key;
group.appendChild(o);
});
sel.appendChild(group);
}
sel.value = prev;
}
// --- Metric fields in the Logic Filter (populated from the data; hidden when no item has metrics) ---
var _metricUiSig = null;
@@ -2129,7 +2209,9 @@ function renderLogicFilters() {
tag.className = 'search-filter-tag';
const label = fieldLabels[filter.field] ||
(typeof filter.field === 'string' && filter.field.startsWith('metric:') ? metricLabel(filter.field.slice(7)) : filter.field);
(typeof filter.field === 'string' && filter.field.startsWith('metric:') ? metricLabel(filter.field.slice(7)) :
(typeof filter.field === 'string' && filter.field.startsWith('wf:') && typeof wfShortLabel === 'function'
? wfShortLabel(filter.field.slice(3), wfParamKeys(activeData)) : filter.field));
tag.innerHTML = `
<span class="filter-type" style="background: #1a4a2a; color: #4ecf7a;">${label}</span>
<span class="filter-term">${filter.op} ${filter.value}</span>
@@ -2180,11 +2262,29 @@ const _qfAllTypes = ['lora', 'lora_strength', 'cfg', 'steps', 'sampler', 'schedu
// Types shown in the expanded facet panel (omit prompt types for brevity)
const _qfFacetableTypes = ['sampler', 'scheduler', 'lora', 'lora_strength', 'model', 'denoise', 'cfg', 'steps', 'size', 'seed', 'upscaleMethod', 'mediaType', 'passType'];
// Workflow Sweep: one extra chip type per VARIED wf_params key ("wf:<key>"); the lists are dynamic.
function _qfTypes(base) {
if (typeof wfParamKeys !== 'function') return base;
return base.concat(wfParamKeys(typeof activeData !== 'undefined' ? activeData : []).filter(k => k.distinct > 1).map(k => 'wf:' + k.key));
}
function _qfLabel(type) {
if (_qfTypeLabels[type]) return _qfTypeLabels[type];
if (typeof type === 'string' && type.startsWith('wf:') && typeof wfShortLabel === 'function') {
return 'WF ' + wfShortLabel(type.slice(3), wfParamKeys(typeof activeData !== 'undefined' ? activeData : []));
}
return type;
}
/**
* Extract a facet value (or array of values) from an item for a given type.
* Returns null if the field is missing.
*/
function _qfExtractItemValue(item, type) {
if (typeof type === 'string' && type.startsWith('wf:')) {
const raw = wfParamValue(item, type.slice(3));
return raw === null ? null : String(raw);
}
if (type === 'lora') {
const lora = String(item.lora || 'None');
if (lora === 'None') return ['None'];
@@ -2251,12 +2351,13 @@ function _computeChipFacets(chip) {
const data = (typeof activeData !== 'undefined' ? activeData : []);
const subset = data.filter(item => _quickFilterMatches(item, chip));
const facets = {};
for (const type of _qfFacetableTypes) {
const facetTypes = _qfTypes(_qfFacetableTypes);
for (const type of facetTypes) {
if (type === chip.type) continue;
facets[type] = new Set();
}
for (const item of subset) {
for (const type of _qfFacetableTypes) {
for (const type of facetTypes) {
if (type === chip.type) continue;
const val = _qfExtractItemValue(item, type);
if (val === null || val === undefined) continue;
@@ -2328,8 +2429,8 @@ function _openQuickFilterTypeMenu(btnEl) {
return;
}
for (const type of _qfAllTypes) {
const label = _qfTypeLabels[type] || type;
for (const type of _qfTypes(_qfAllTypes)) {
const label = _qfLabel(type);
const div = document.createElement('div');
div.style.cssText = 'padding:7px 14px; cursor:pointer; font-size:12px; color:#ccc; white-space:nowrap;';
div.textContent = label;
@@ -2357,7 +2458,7 @@ function _openQuickFilterValueMenu(type) {
vm.innerHTML = '';
const values = _qfUniqueValuesForType(type);
const label = _qfTypeLabels[type] || type;
const label = _qfLabel(type);
if (values.length === 0) {
vm.innerHTML = `<div style="padding:8px 12px; color:#666; font-size:11px;">No values for ${label}</div>`;
@@ -2451,7 +2552,7 @@ function renderQuickFilters() {
}
quickFilters.forEach((chip, idx) => {
const label = _qfTypeLabels[chip.type] || chip.type;
const label = _qfLabel(chip.type);
const chipEl = document.createElement('div');
chipEl.style.cssText = 'border:1px solid #1e4a6e; border-radius:4px; margin-bottom:6px; overflow:hidden; background:#0d1f2d;';
@@ -2501,7 +2602,7 @@ function renderQuickFilters() {
body.appendChild(countEl);
let anyFacets = false;
for (const type of _qfFacetableTypes) {
for (const type of _qfTypes(_qfFacetableTypes)) {
if (type === chip.type) continue;
const vals = facets[type];
if (!vals || vals.size === 0) continue;
@@ -2514,7 +2615,7 @@ function renderQuickFilters() {
const rowLabel = document.createElement('span');
rowLabel.style.cssText = 'font-size:9px; color:#555; text-transform:uppercase; letter-spacing:0.05em; margin-right:6px;';
rowLabel.textContent = (_qfTypeLabels[type] || type) + ':';
rowLabel.textContent = _qfLabel(type) + ':';
row.appendChild(rowLabel);
for (const val of sortedVals) {
+62
View File
@@ -1071,3 +1071,65 @@ async function runFullPassForSurvivors() {
if (res && res.ok) { _uscgToast(res.message || 'Full pass queued.', true); return; }
_uscgToast((res && res.message ? res.message + ' ' : '') + manual, false);
}
// --- Workflow Sweep: download / copy the API prompt of one job (GET /config_tester/sweep/job_prompt) ---
async function _fetchWorkflowJobPrompt() {
const d = (typeof activeData !== 'undefined' ? activeData : []).find(x => x.id === window.currentModalId);
if (!d || d.wf_sweep_id === undefined || d.wf_sweep_id === null || d.wf_job_index === undefined || d.wf_job_index === null) {
_uscgToast('This item has no recorded sweep job.', false);
return null;
}
const session = document.getElementById('session-input')?.value || (typeof fullManifest !== 'undefined' && fullManifest && fullManifest.session_name) || '';
const q = 'session_name=' + encodeURIComponent(session) + '&sweep_id=' + encodeURIComponent(d.wf_sweep_id) + '&job_index=' + encodeURIComponent(d.wf_job_index);
try {
const r = await fetch('/config_tester/sweep/job_prompt?' + q);
const data = await r.json();
if (!r.ok) { _uscgToast(data.error || ('Could not load the job prompt (' + r.status + ')'), false); return null; }
return { session: session, job: d.wf_job_index, prompt: data.prompt };
} catch (e) {
_uscgToast('Could not load the job prompt: ' + e, false);
return null;
}
}
async function downloadWorkflowJobPrompt() {
const res = await _fetchWorkflowJobPrompt();
if (!res) return;
const text = JSON.stringify(res.prompt, null, 2);
try {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url;
a.download = (res.session || 'sweep') + '_job' + (Number(res.job) + 1) + '_api.json';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 4000);
_uscgToast('Job prompt downloaded (if nothing happened, use "Copy job prompt").', true);
} catch (e) {
_uscgToast('Download blocked: ' + e + ' (use "Copy job prompt")', false);
}
}
async function copyWorkflowJobPrompt() {
const res = await _fetchWorkflowJobPrompt();
if (!res) return;
const text = JSON.stringify(res.prompt, null, 2);
let ok = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); ok = true; }
} catch (e) { ok = false; }
if (!ok) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:0;';
document.body.appendChild(ta);
ta.select();
ok = document.execCommand('copy');
ta.remove();
} catch (e) { ok = false; }
}
_uscgToast(ok ? 'Job prompt copied to the clipboard.' : 'Copy failed: clipboard access is blocked here.', ok);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* WORKFLOW SWEEP PARAMS - PURE LOGIC (no DOM access)
* Helpers for item.wf_params ({"KSampler.cfg": 4.0, ...}; values are numbers OR strings), used by the
* Quick Filter, the Logic Filter ("wf:<key>" fields) and the label overlay.
* The dashboard inlines every logic_*.js into ONE <script>, so top-level names are prefixed wf*.
* Node tests load this file through the module.exports guard at the bottom (tests/js/wfparams.test.js).
*/
var _wfKeysCache = { arr: null, len: -1, keys: [] };
function _wfIsNumeric(v) {
if (typeof v === 'number') return isFinite(v);
if (typeof v === 'string') return v.trim() !== '' && isFinite(Number(v));
return false;
}
/** [{key, numeric, distinct}] sorted by key. `numeric` = EVERY present value is a finite number / numeric string. Cached per (array, length). */
function wfParamKeys(items) {
if (!items) return [];
if (_wfKeysCache.arr === items && _wfKeysCache.len === items.length) return _wfKeysCache.keys;
const info = {};
for (let i = 0; i < items.length; i++) {
const p = items[i] && items[i].wf_params;
if (!p || typeof p !== 'object') continue;
for (const k in p) {
const v = p[k];
if (v === null || v === undefined) continue;
const e = info[k] || (info[k] = { numeric: true, vals: new Set() });
if (!_wfIsNumeric(v)) e.numeric = false;
e.vals.add(typeof v + ':' + String(v));
}
}
const keys = Object.keys(info).sort().map(k => ({ key: k, numeric: info[k].numeric, distinct: info[k].vals.size }));
_wfKeysCache = { arr: items, len: items.length, keys: keys };
return keys;
}
/** Raw value or null. */
function wfParamValue(item, key) {
const p = item && item.wf_params;
if (!p || typeof p !== 'object') return null;
const v = p[key];
return (v === undefined || v === null) ? null : v;
}
/** Finite number or null. */
function wfParamNumber(item, key) {
const v = wfParamValue(item, key);
if (v === null || !_wfIsNumeric(v)) return null;
return Number(v);
}
/** Numeric tolerance 1e-6 when both sides are numeric, String equality otherwise. */
function wfParamMatches(item, key, value) {
const v = wfParamValue(item, key);
if (v === null) return false;
if (_wfIsNumeric(v) && _wfIsNumeric(value)) return Math.abs(Number(v) - Number(value)) < 1e-6;
return String(v) === String(value);
}
/** Text after the last '.', unless that short name collides with another key (then the full key). `keys` = strings or wfParamKeys() rows. */
function wfShortLabel(key, keys) {
const names = (keys || []).map(k => (k && typeof k === 'object') ? k.key : k);
const short = String(key).slice(String(key).lastIndexOf('.') + 1) || String(key);
const clash = names.some(n => n !== key && String(n).slice(String(n).lastIndexOf('.') + 1) === short);
return clash ? String(key) : short;
}
/** Numbers: 4 significant digits; strings cut to 24 chars. */
function wfDisplay(v) {
if (typeof v === 'number') return String(isFinite(v) ? parseFloat(v.toPrecision(4)) : v);
const s = String(v);
return s.length > 24 ? s.slice(0, 23) + '…' : s;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
wfParamKeys: wfParamKeys, wfParamValue: wfParamValue, wfParamNumber: wfParamNumber,
wfParamMatches: wfParamMatches, wfShortLabel: wfShortLabel, wfDisplay: wfDisplay
};
}
+1
View File
@@ -662,6 +662,7 @@ body {
.label-tag.label-seed { background: rgba(40, 40, 40, 0.6); }
.label-tag.label-denoise { background: rgba(0, 70, 60, 0.6); }
.label-tag.label-metric { background: rgba(30, 60, 100, 0.6); }
.label-tag.label-wf { background: rgba(70, 40, 110, 0.6); }
.label-tag.label-upscale { background: rgba(90, 30, 55, 0.6); }
.label-tag.label-hiresPrompt { background: rgba(70, 30, 90, 0.6); }
+16
View File
@@ -188,6 +188,10 @@
<input type="checkbox" id="label-field-metrics" onchange="updateLabelFields()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="label-field-metrics" style="font-size: 10px; color: #7ab0e0; cursor: pointer; user-select: none;">Metrics</label>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<input type="checkbox" id="label-field-wfParams" onchange="updateLabelFields()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="label-field-wfParams" style="font-size: 10px; color: #b48ae0; cursor: pointer; user-select: none;">Workflow params</label>
</div>
<div style="display: flex; align-items: center; gap: 4px;">
<input type="checkbox" id="label-field-upscale" onchange="updateLabelFields()" style="width: 12px; height: 12px; cursor: pointer;">
<label for="label-field-upscale" style="font-size: 10px; color: #e06090; cursor: pointer; user-select: none;">Upscale (all)</label>
@@ -860,6 +864,7 @@
<div class="modal-main">
<div class="preview"><img id="m-img" src=""><div id="m-preview-note" style="display: none; font-size: 11px; color: #cdb26a; padding: 4px 8px; text-align: center;"></div></div>
<div class="controls">
<div id="m-revise-controls" style="display:flex; flex-direction:column; gap:10px; flex:1; min-height:0;">
<h2 style="margin:0 0 10px 0; color:#fff;">Revise & Generate</h2>
<button class="modal-action-btn copy-comfy-btn" onclick="copyConfigsAsComfyNodes(window.currentModalId)"
style="background: #6366f1; margin-top: 10px;">
@@ -889,6 +894,17 @@
onclick="triggerGen(this)">GENERATE NEW</button>
<button class="action-btn" style="flex:1" onclick="closeM()">EXIT</button>
</div>
</div>
<div id="m-workflow-panel" style="display:none; flex-direction:column; gap:10px; flex:1; min-height:0;">
<h2 style="margin:0 0 6px 0; color:#fff;">Workflow job</h2>
<div id="m-wf-meta" style="font-size:11px; color:#888; word-break:break-all;"></div>
<table id="m-wf-params" style="width:100%; border-collapse:collapse; font-size:12px; color:#ccc;"></table>
<div style="margin-top:auto; display:flex; flex-direction:column; gap:8px;">
<button id="m-wf-download" class="modal-action-btn" style="background:#6366f1;" onclick="downloadWorkflowJobPrompt()">⬇ Download job prompt (API JSON)</button>
<button id="m-wf-copy" class="modal-action-btn" style="background:#333;" onclick="copyWorkflowJobPrompt()">📋 Copy job prompt</button>
<button class="action-btn" onclick="closeM()">EXIT</button>
</div>
</div>
</div>
</div>
<div class="reel-wrap">
+65
View File
@@ -0,0 +1,65 @@
const test = require('node:test');
const assert = require('node:assert');
const wf = require('../../resources/logic_wfparams.js');
const items = [
{ id: 1, wf_params: { 'KSampler.cfg': 4, 'KSampler.sampler_name': 'euler', 'KSampler.steps': '20', 'A.seed': 1, 'B.seed': 2 } },
{ id: 2, wf_params: { 'KSampler.cfg': 5.5, 'KSampler.sampler_name': 'dpmpp', 'KSampler.steps': 30, 'A.seed': 1, 'B.seed': 2 } },
{ id: 3 },
{ id: 4, wf_params: { 'KSampler.cfg': 5.5, 'KSampler.sampler_name': 'euler' } },
];
test('wfParamKeys: sorted, numeric detection with mixed values, distinct counts', () => {
const keys = wf.wfParamKeys(items);
assert.deepStrictEqual(keys.map(k => k.key), ['A.seed', 'B.seed', 'KSampler.cfg', 'KSampler.sampler_name', 'KSampler.steps']);
const by = Object.fromEntries(keys.map(k => [k.key, k]));
assert.strictEqual(by['KSampler.cfg'].numeric, true);
assert.strictEqual(by['KSampler.cfg'].distinct, 2);
assert.strictEqual(by['KSampler.sampler_name'].numeric, false);
assert.strictEqual(by['KSampler.steps'].numeric, true); // '20' and 30 both parse
assert.strictEqual(by['A.seed'].distinct, 1);
assert.deepStrictEqual(wf.wfParamKeys(null), []);
assert.deepStrictEqual(wf.wfParamKeys([{ id: 1 }]), []);
});
test('wfParamKeys: one non-numeric value makes the key non-numeric; cache refreshes on length change', () => {
const arr = [{ wf_params: { k: 1 } }];
assert.strictEqual(wf.wfParamKeys(arr)[0].numeric, true);
assert.strictEqual(wf.wfParamKeys(arr), wf.wfParamKeys(arr)); // cached
arr.push({ wf_params: { k: 'abc' } });
assert.strictEqual(wf.wfParamKeys(arr)[0].numeric, false);
});
test('wfParamValue / wfParamNumber', () => {
assert.strictEqual(wf.wfParamValue(items[0], 'KSampler.cfg'), 4);
assert.strictEqual(wf.wfParamValue(items[2], 'KSampler.cfg'), null);
assert.strictEqual(wf.wfParamValue(items[3], 'nope'), null);
assert.strictEqual(wf.wfParamNumber(items[0], 'KSampler.steps'), 20);
assert.strictEqual(wf.wfParamNumber(items[0], 'KSampler.sampler_name'), null);
assert.strictEqual(wf.wfParamNumber(items[2], 'KSampler.cfg'), null);
});
test('wfParamMatches: tolerance for numbers, string equality otherwise', () => {
assert.ok(wf.wfParamMatches(items[1], 'KSampler.cfg', '5.5'));
assert.ok(wf.wfParamMatches({ wf_params: { c: 0.1 + 0.2 } }, 'c', 0.3));
assert.ok(!wf.wfParamMatches(items[1], 'KSampler.cfg', 5.6));
assert.ok(wf.wfParamMatches(items[0], 'KSampler.sampler_name', 'euler'));
assert.ok(!wf.wfParamMatches(items[0], 'KSampler.sampler_name', 'Euler'));
assert.ok(!wf.wfParamMatches(items[2], 'KSampler.cfg', 4));
});
test('wfShortLabel: last segment unless it collides', () => {
const keys = wf.wfParamKeys(items);
assert.strictEqual(wf.wfShortLabel('KSampler.cfg', keys), 'cfg');
assert.strictEqual(wf.wfShortLabel('A.seed', keys), 'A.seed');
assert.strictEqual(wf.wfShortLabel('B.seed', ['A.seed', 'B.seed']), 'B.seed');
assert.strictEqual(wf.wfShortLabel('plain', ['plain']), 'plain');
});
test('wfDisplay: 4 significant digits, strings cut at 24', () => {
assert.strictEqual(wf.wfDisplay(4), '4');
assert.strictEqual(wf.wfDisplay(0.123456), '0.1235');
assert.strictEqual(wf.wfDisplay(1234567), '1235000');
assert.strictEqual(wf.wfDisplay('euler'), 'euler');
assert.strictEqual(wf.wfDisplay('x'.repeat(40)).length, 24);
});