From 5b3ddb887d715cd6990af0c5023d6dcffe3f711f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 19 Oct 2026 18:20:34 -0700 Subject: [PATCH] fix(inspect): show the prompts and settings the legacy parser finds The workspace inspector took its prompts from the new server resolver, which refused workflows where several samplers share one prompt (base pass, upscale pass, FaceDetailer), and showed "Multiple possible prompts" for files the legacy gallery and admin pages read correctly. The inspector now runs the same extractGenerationParams those pages use and shows its positive, negative, seed, steps, CFG, sampler and model whenever it finds them. Prompts recorded at execution or saved in the Library are never replaced; when the legacy parser finds nothing, the server result stays. Read-only check over a 38,602-file output folder: prompts shown went from 1,798 to 7,542 files, and every one of the 7,236 positives and 9,421 negatives the legacy parser finds is shown identically. --- tests/browser/full-inspector.spec.mjs | 4 +- tests/js/workspace-inspection-state.test.js | 54 +++++++++++++++++++++ web/js/workspace/inspection-state.mjs | 31 ++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 5909afd..421e291 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -26,9 +26,9 @@ test('inspection facts survive actual API to browser transport', async ({ page, const complete = result.complete; expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']); expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true); - // Ambiguous branches merge per field: the differing seed is ambiguous, the shared fields known. + // Differing branches show what the legacy parser shows: the first sampler's seed. expect(Object.values(result.ambiguous.generation).map(fact => [fact.status, fact.value])).toEqual([ - ['ambiguous', null], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]); + ['known', '9007199254740993'], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]); expect(Object.values(result.ambiguous.generation).every(fact => fact.sampler_id === null)).toBe(true); expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' }); expect(result['workflow-only'].prompts.positive.source).toBe('saved-association'); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index fcb8e4d..bb884be 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -464,3 +464,57 @@ test('rooted controller keeps legacy descriptors with ignored noncloneable field let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);}); requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose(); }); + +// The maintainer's standard pipeline: one PromptManager pair feeds a base pass, an +// upscale pass and a FaceDetailer, saved by a custom saver. Text and seeds are invented. +function detailerPipelineGraph() { + return { + 1: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'Base/example_model.safetensors' } }, + 5: { class_type: 'KSampler', inputs: { seed: 111, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 1.0, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['20', 0] } }, + 6: { class_type: 'VAEDecode', inputs: { samples: ['5', 0], vae: ['1', 2] } }, + 7: { class_type: 'PreviewImage', inputs: { images: ['6', 0] } }, + 8: { class_type: 'UpscaleModelLoader', inputs: { model_name: 'upscaler.pth' } }, + 9: { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['8', 0], image: ['6', 0] } }, + 10: { class_type: 'ImageScaleBy', inputs: { upscale_method: 'lanczos', scale_by: 0.75, image: ['9', 0] } }, + 11: { class_type: 'VAEEncode', inputs: { pixels: ['10', 0], vae: ['1', 2] } }, + 12: { class_type: 'KSampler', inputs: { seed: 222, steps: 6, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.45, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['11', 0] } }, + 13: { class_type: 'VAEDecode', inputs: { samples: ['12', 0], vae: ['1', 2] } }, + 17: { class_type: 'FaceDetailer', inputs: { seed: 333, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.4, image: ['13', 0], model: ['47', 0], clip: ['47', 1], vae: ['1', 2], positive: ['25', 0], negative: ['26', 0] } }, + 20: { class_type: 'EmptyLatentBatch', inputs: { width: 640, height: 1536, batch_size: 1 } }, + 25: { class_type: 'PromptManager', inputs: { text: 'a lighthouse at dusk, oil painting', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } }, + 26: { class_type: 'PromptManager', inputs: { text: 'blurry, watermark', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } }, + 47: { class_type: 'Lora Loader (LoraManager)', inputs: { text: '', model: ['1', 0], clip: ['1', 1] } }, + 48: { class_type: 'KikoSaveImage', inputs: { filename_prefix: ['49', 0], format: 'PNG', images: ['17', 0] } }, + 49: { class_type: 'KikoProjectFilePath', inputs: { project_name: 'example', filename: 'image' } }, + }; +} + +test('workflow prompts and settings match the legacy parser when the server cannot decide', t => { + const parser = require('../../web/js/comfy-metadata.js'), { extractGenerationParams } = parser; + globalThis.ComfyMetadata = parser; + t.after(() => { delete globalThis.ComfyMetadata; }); + const entry = image(), data = envelope(entry); + data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } }; + data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() }; + data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' }; + const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); + const legacy = extractGenerationParams({ prompt: detailerPipelineGraph() }); + assert.deepEqual(record.prompts.positive, { status: 'resolved', text: legacy.positivePrompt, source: 'graph-inferred' }); + assert.deepEqual(record.prompts.negative, { status: 'resolved', text: legacy.negativePrompt, source: 'graph-inferred' }); + assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting'); + assert.equal(record.prompts.negative.text, 'blurry, watermark'); + for (const [field, key] of [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']]) { + assert.equal(record.generation[field].status, 'known', field); + assert.equal(record.generation[field].value, String(legacy[key]), field); + } +}); + +test('recorded and saved prompts are never replaced by the workflow parse', t => { + globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js'); + t.after(() => { delete globalThis.ComfyMetadata; }); + const entry = image(), data = envelope(entry); + data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() }; + const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); + assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' }); + assert.equal(record.prompts.negative.text, 'blurry, watermark'); +}); diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 9b7fb6b..638cd8a 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -163,6 +163,36 @@ function libraryFor(value) { return library; } +// The legacy workflow parse (comfy-metadata.js, as of #170) also feeds the gallery and +// admin pages; it fills any prompt or setting the server could not decide, so this page +// never shows less than they do. Recorded and saved prompts are never replaced. +const KEPT_PROMPT_SOURCES = ['execution', 'library', 'saved-association']; +const LEGACY_FACTS = [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']]; + +function comfyMetadata() { + return globalThis.ComfyMetadata || globalThis.window?.ComfyMetadata; +} + +function withWorkflowParams(record, parser = comfyMetadata()) { + const graph = record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null; + const workflow = record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null; + if (typeof parser?.extractGenerationParams !== 'function' || (!graph && !workflow)) return; + const params = parser.extractGenerationParams({ prompt: graph, workflow }); + const found = key => typeof params[key] === 'string' || typeof params[key] === 'number' ? + String(params[key]) !== '' && params[key] !== parser.PLACEHOLDERS?.[key] : false; + const prompts = { ...record.prompts }; + for (const [role, key] of [['positive', 'positivePrompt'], ['negative', 'negativePrompt']]) { + const current = prompts[role]; + if (!found(key) || (current?.status === 'resolved' && KEPT_PROMPT_SOURCES.includes(current.source))) continue; + prompts[role] = { status: 'resolved', text: params[key], source: 'graph-inferred' }; + } + record.prompts = prompts; + for (const [field, key] of LEGACY_FACTS) { + if (!found(key)) continue; + record.generation[field] = { status: 'known', value: String(params[key]), source: graph ? 'api-graph' : 'workflow', evidence: 'configuration', reason: null, sampler_id: null }; + } +} + function unavailableFacts() { return Object.fromEntries(FACT_FIELDS.map(key => [key, { status: 'unresolved', value: null, source: null, evidence: null, reason: null, sampler_id: null }])); } @@ -194,6 +224,7 @@ export function projectInspectionPayload(result, identity, download, extractFact if (typeof extractFacts !== 'function') throw new TypeError('Inspection facts parser unavailable'); record.generation = extractFacts({ prompt_graph: record.metadata.prompt_graph, workflow_graph: record.metadata.workflow_graph, parameters: record.metadata.parameters, recordFormats: record.metadata.record_formats, metadataStatus: record.metadata.status, resolution }); + withWorkflowParams(record); if (record.library.status === 'linked' && record.library.positive_id !== null) record.historyTarget = `#/library/${record.library.positive_id}`; return record; }