diff --git a/html_generator.py b/html_generator.py
index 2cbc147..ac7b7d2 100644
--- a/html_generator.py
+++ b/html_generator.py
@@ -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
diff --git a/resources/logic_events.js b/resources/logic_events.js
index ea35b12..0a2f8bb 100644
--- a/resources/logic_events.js
+++ b/resources/logic_events.js
@@ -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}%`;
diff --git a/resources/logic_pipeline.js b/resources/logic_pipeline.js
index 7967cc9..ec1c13d 100644
--- a/resources/logic_pipeline.js
+++ b/resources/logic_pipeline.js
@@ -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;
diff --git a/resources/logic_state.js b/resources/logic_state.js
index 3542052..6236e0c 100644
--- a/resources/logic_state.js
+++ b/resources/logic_state.js
@@ -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
}
};
diff --git a/resources/logic_ui.js b/resources/logic_ui.js
index 2d172f8..aa28dec 100644
--- a/resources/logic_ui.js
+++ b/resources/logic_ui.js
@@ -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, '"');
+ 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(`${esc(wfShortLabel(row.key, wfKeys))}:${esc(wfDisplay(v))}`);
+ }
+ }
+
if (tags.length === 0) return '';
return `
${tags.join('')}
`;
}
@@ -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) {
? ``
: `
`;
// Workflow Sweep items: the Revise modal builds a standard config and would lose the workflow template
- const reviseBtn = (isVideo || d.model_type === 'workflow') ? '' : ``;
+ // (they get a JOB button instead: openM shows the read-only Workflow job panel)
+ const reviseBtn = isVideo ? '' : ``;
const upscaleBtn = isVideo ? '' : ``;
const videoBadge = isVideo ? '\u25B6 VIDEO
' : '';
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, '"');
+ 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 => `| ${esc(k)} | ${esc(p[k])} |
`).join('')
+ : '| No varied parameters recorded. |
';
+ }
+ 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 = `
${label}
${filter.op} ${filter.value}
@@ -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:"); 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 = `No values for ${label}
`;
@@ -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) {
diff --git a/resources/logic_utils.js b/resources/logic_utils.js
index 857349a..04ffefb 100644
--- a/resources/logic_utils.js
+++ b/resources/logic_utils.js
@@ -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);
+}
diff --git a/resources/logic_wfparams.js b/resources/logic_wfparams.js
new file mode 100644
index 0000000..9982efe
--- /dev/null
+++ b/resources/logic_wfparams.js
@@ -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:" fields) and the label overlay.
+ * The dashboard inlines every logic_*.js into ONE