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:
co-authored by
Claude Fable 5.1
parent
968b0dfdfc
commit
13422104f4
@@ -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
|
||||
|
||||
@@ -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}%`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user