From 4e669f8cba1026d1ad3e043d8c1b86f05b7de606 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 18 Oct 2026 04:00:31 -0700 Subject: [PATCH] feat(inspector): derive conservative generation facts --- tests/browser/full-inspector.spec.mjs | 28 +++ tests/js/api-client.test.js | 25 +++ tests/js/comfy-metadata.test.js | 153 ++++++++++++- tests/js/prompt-inspector.test.js | 10 +- tests/js/workspace-inspection-state.test.js | 228 +++++++++++++++++++ web/js/api-client.js | 7 +- web/js/comfy-metadata.js | 169 +++++++++++++- web/js/workspace/inspection-state.mjs | 231 ++++++++++++++++++++ web/js/workspace/prompt-blocks.mjs | 116 ++++++++++ web/js/workspace/prompt-inspector.mjs | 132 +---------- web/workspace.html | 1 + 11 files changed, 961 insertions(+), 139 deletions(-) create mode 100644 tests/js/workspace-inspection-state.test.js create mode 100644 web/js/workspace/inspection-state.mjs create mode 100644 web/js/workspace/prompt-blocks.mjs diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index aae56cd..1c1b77e 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -1,5 +1,33 @@ import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +test('inspection facts survive actual API to browser transport', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { full_inspector: true } })).toBeOK(); + await page.goto('/prompt_manager/workspace'); + const result = await page.evaluate(async () => { + if (typeof window.ComfyMetadata?.extractGenerationFacts !== 'function') return { missingFactsExport: true }; + const { inspectPath, toInspectionRecord } = await import('/prompt_manager/js/workspace/inspection-state.mjs'); + const { createApiClient } = window.ApiClient; + const api = createApiClient(); + const collection = await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40'); + const records = {}; + for (const name of ['complete', 'ambiguous', 'workflow-only', 'control-heavy']) { + const image = collection.images.find(entry => entry.root_index === 0 && entry.path === `inspector/${name}.png`); + const envelope = await api.get(inspectPath(image)); + records[name] = toInspectionRecord(envelope, image, window.ComfyMetadata.extractGenerationFacts); + } + return records; + }); + expect(result.missingFactsExport).toBeUndefined(); + 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); + expect(Object.values(result.ambiguous.generation).every(fact => fact.status === 'ambiguous' && fact.value === 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'); + expect(Object.values(result['control-heavy'].generation).every(fact => fact.status === 'unresolved' && fact.value === null && fact.reason === 'projection-limited')).toBe(true); + await expect(await request.post('/__test__/reset')).toBeOK(); +}); + test('inspection wire preserves exact rooted envelope', async ({ request }) => { for (const data of [ { full_inspector: 'true' }, { full_inspector: 1 }, diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js index 9a6a362..5595111 100644 --- a/tests/js/api-client.test.js +++ b/tests/js/api-client.test.js @@ -113,6 +113,31 @@ test("a 2xx body with success:false throws ApiError", async () => { await assert.rejects(pending, (err) => err instanceof ApiError && err.status === 200 && err.message === "Nope"); }); +test("ApiError preserves only a bounded machine code without changing status or message", async () => { + assert.equal(new ApiError(409, "unchanged").code, null); + for (const [code, expected] of [ + ["source-changed", "source-changed"], ["a".repeat(64), "a".repeat(64)], + [undefined, null], [null, null], [4, null], [{ code: "x" }, null], + ["", null], ["Source-changed", null], ["source_changed", null], + ["source-changed\n", null], ["a".repeat(65), null], + ]) { + for (const status of [409, 200]) { + const api = createApiClient(async () => jsonResponse(status, { + success: false, error: "unchanged", code, private: "never expose", + })); + await assert.rejects(api.get("/x"), error => { + assert.ok(error instanceof ApiError); + assert.equal(error.status, status); + assert.equal(error.message, "unchanged"); + assert.equal(error.code, expected); + assert.equal(error.private, undefined); + assert.equal(error.body, undefined); + return true; + }); + } + } +}); + test("a 2xx body that is not JSON throws ApiError", async () => { const fetch = fakeFetch(); const api = createApiClient(fetch); diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js index 12f23c0..0b66e4f 100644 --- a/tests/js/comfy-metadata.test.js +++ b/tests/js/comfy-metadata.test.js @@ -1,7 +1,158 @@ // Run with: node --test tests/js/ const test = require("node:test"); const assert = require("node:assert/strict"); -const { extractGenerationParams } = require("../../web/js/comfy-metadata.js"); +const { extractGenerationParams, extractGenerationFacts } = require("../../web/js/comfy-metadata.js"); + +const parsedFactGraph = value => ({ status: "parsed", value }); +const absentFactGraph = () => ({ status: "absent", value: null }); +const factFields = ["seed", "steps", "cfg", "sampler", "scheduler", "model"]; +function factsFor(graph, extra = {}) { + assert.equal(typeof extractGenerationFacts, "function", "the conservative facts contract is exported"); + return extractGenerationFacts({ prompt_graph: parsedFactGraph(graph), workflow_graph: absentFactGraph(), metadataStatus: "available", ...extra }); +} +function factGraph(inputs = {}) { + return { + s: { class_type: "KSampler", inputs: { seed: "9007199254740993", steps: 28, cfg: "6.5000000000000000001", sampler_name: "dpmpp_2m", scheduler: "karras", model: ["model", 0], ...inputs } }, + model: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "model.safetensors" } }, + }; +} + +test("facts keep unsafe seed and decimal strings exact", () => { + const facts = factsFor(factGraph()); + assert.deepEqual(Object.keys(facts), factFields); + assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "dpmpp_2m", "karras", "model.safetensors"]); + for (const fact of Object.values(facts)) assert.deepEqual(fact, { + status: "known", value: fact.value, source: "api-graph", evidence: "configuration", reason: null, sampler_id: "s", + }); + const zero = factsFor(factGraph({ seed: 0, cfg: "0", steps: "1" })); + assert.equal(zero.seed.value, "0"); assert.equal(zero.cfg.value, "0"); + for (const inputs of [{ seed: 9007199254740992 }, { seed: "1.5" }, { steps: "0" }, { cfg: "NaN" }, { cfg: {} }, { cfg: true }]) { + const key = Object.keys(inputs)[0]; + assert.equal(factsFor(factGraph(inputs))[key].status, "unresolved"); + } +}); + +test("facts do not choose equal sampler branches or unrelated model", () => { + const graph = factGraph(); graph.other = structuredClone(graph.s); + const ambiguous = factsFor(graph); + assert.ok(Object.values(ambiguous).every(fact => fact.status === "ambiguous" && fact.value === null)); + const selected = factsFor(graph, { resolution: { state: "resolved", sampler_id: "other" } }); + assert.equal(selected.seed.sampler_id, "other"); + const unrelated = factsFor(factGraph({ model: ["missing", 0] })); + assert.equal(unrelated.model.status, "unresolved"); + assert.equal(unrelated.model.value, null); + assert.equal(factsFor({ only: graph.model }).seed.status, "unresolved"); +}); + +test("facts respect named scalar outputs and custom scheduler paths", () => { + const graph = factGraph({ seed: ["primitive", 0], steps: ["settings", 2], cfg: ["settings", 3], sampler_name: ["settings", 0], scheduler: ["settings", 1], model: ["lora", 0] }); + graph.primitive = { class_type: "PrimitiveInt", inputs: { value: "0" } }; + graph.settings = { class_type: "SamplerCombo", inputs: { sampler_name: "euler", scheduler: "normal", steps: "50", cfg: "0" } }; + graph.lora = { class_type: "LoraLoader", inputs: { lora_name: "style", model: ["model", 0] } }; + assert.deepEqual(factFields.map(field => factsFor(graph)[field].value), ["0", "50", "0", "euler", "normal", "model.safetensors"]); + graph.settings = { class_type: "UnknownSettings", inputs: { first: 20, second: 30 } }; + assert.equal(factsFor(graph).steps.status, "unresolved"); + const custom = { + s: { class_type: "SamplerCustomAdvanced", inputs: { guider: ["g", 0], noise: ["n", 0], sampler: ["k", 0], sigmas: ["sigmas", 0] } }, + g: { class_type: "CFGGuider", inputs: { model: ["m", 0], cfg: "4.0000000000000001" } }, + n: { class_type: "RandomNoise", inputs: { noise_seed: "9007199254740993" } }, + k: { class_type: "KSamplerSelect", inputs: { sampler_name: "euler" } }, + sigmas: { class_type: "BasicScheduler", inputs: { steps: 22, scheduler: "karras" } }, + m: { class_type: "UNETLoader", inputs: { unet_name: "flux.safetensors" } }, + }; + assert.deepEqual(factFields.map(field => factsFor(custom)[field].value), ["9007199254740993", "22", "4.0000000000000001", "euler", "karras", "flux.safetensors"]); + custom.s = { class_type: "SamplerCustom", inputs: { model: ["m", 0], cfg: 0, noise_seed: 0, sampler: ["k", 0], sigmas: ["sigmas", 0] } }; + assert.deepEqual([factsFor(custom).seed.value, factsFor(custom).steps.value, factsFor(custom).cfg.value], ["0", "22", "0"]); + custom.sigmas.inputs = { steps: 22 }; + assert.equal(factsFor(custom).scheduler.status, "not-recorded"); + assert.equal(factsFor(custom).sampler.value, "euler"); +}); + +test("workflow-only facts are configuration without creating sendable roles", () => { + const workflow = { nodes: [ + { id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"] }, + { id: 2, type: "SetNode", widgets_values: ["model"], inputs: [{ name: "MODEL", link: 1 }] }, + { id: 3, type: "GetNode", widgets_values: ["model"] }, + { id: 4, type: "KSamplerAdvanced", widgets_values: ["enable", "9007199254740993", "fixed", 28, "6.5000000000000000001", "euler", "normal", 0, 28, "disable"], inputs: [{ name: "model", link: 2 }] }, + ], links: [[1, 1, 0, 2, 0, "MODEL"], [2, 3, 0, 4, 0, "MODEL"]] }; + const facts = factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) }); + assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "euler", "normal", "flux.safetensors"]); + assert.ok(Object.values(facts).every(fact => fact.source === "workflow" && fact.evidence === "configuration")); + assert.equal(facts.positive, undefined); + workflow.nodes.push({ ...workflow.nodes[3] }); + assert.ok(Object.values(factsFor(null, { prompt_graph: absentFactGraph(), workflow_graph: parsedFactGraph(workflow) })).every(fact => fact.status === "unresolved")); +}); + +test("limited projection stays unresolved", () => { + for (const extra of [ + { prompt_graph: { status: "limited", value: null } }, + { prompt_graph: absentFactGraph(), workflow_graph: { status: "limited", value: null } }, + { prompt_graph: absentFactGraph(), metadataStatus: "limited", parameters: null }, + ]) { + const facts = factsFor(null, extra); + assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null && fact.reason === "projection-limited")); + } + const unreadable = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "unreadable" }); + assert.equal(unreadable.seed.reason, "metadata-unreadable"); + const absent = factsFor(null, { prompt_graph: absentFactGraph(), metadataStatus: "absent" }); + assert.equal(absent.seed.status, "not-recorded"); +}); + +test("facts preserve exact video-projected decimals without reading reserialized raw", () => { + const graph = factGraph(); + const facts = factsFor(graph, { recordFormats: { prompt: "reserialized-json" }, raw: { prompt: '{"s":{"inputs":{"seed":9007199254740992,"cfg":6.5}}}' } }); + assert.equal(facts.seed.value, "9007199254740993"); + assert.equal(facts.cfg.value, "6.5000000000000000001"); + assert.equal(factsFor(factGraph({ cfg: "1E+1000000000" })).cfg.value, "1E+1000000000"); +}); + +test("nontext parameters remain unresolved", () => { + const parameters = "A portrait\nNegative prompt: blur\nSteps: 28, Sampler: Euler, Schedule type: Karras, CFG scale: 6.5000000000000000001, Seed: 9007199254740993, Model: model"; + const base = { prompt_graph: absentFactGraph(), parameters }; + for (const format of [undefined, "reserialized-json", "invalid"]) { + const facts = factsFor(null, { ...base, recordFormats: format ? { parameters: format } : {} }); + assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null)); + } + const facts = factsFor(null, { ...base, recordFormats: { parameters: "original-text" } }); + assert.deepEqual(factFields.map(field => facts[field].value), ["9007199254740993", "28", "6.5000000000000000001", "Euler", "Karras", "model"]); + assert.ok(Object.values(facts).every(fact => fact.source === "embedded-parameters" && fact.evidence === "embedded-parameters")); + for (const invalid of [parameters + "\nSteps: 5, Seed: 2", parameters + ", Seed: 1", parameters + "\ntrailing", parameters.replace("Seed: 9007199254740993", "Seed: -1"), parameters.replace("Karras", "invented"), parameters.replace("Negative prompt: blur", "Negative prompt: blur\nNegative prompt: again")]) { + assert.ok(Object.values(factsFor(null, { ...base, parameters: invalid, recordFormats: { parameters: "original-text" } })).some(fact => fact.status === "unresolved")); + } +}); + +test("malformed or unsupported API evidence blocks plausible workflow and parameter fallback", () => { + const workflow = { nodes: [{ id: 1, type: "KSampler", widgets_values: [42, "fixed", 20, 7, "euler", "normal"] }], links: [] }; + const extra = { workflow_graph: parsedFactGraph(workflow), parameters: "portrait\nSteps: 20, Sampler: Euler, CFG scale: 7, Seed: 42", recordFormats: { parameters: "original-text" } }; + for (const prompt_graph of [{ status: "malformed", value: null }, parsedFactGraph({ s: { class_type: "UnknownSampler", inputs: { seed: 99 } } }), parsedFactGraph({ s: { class_type: "KSampler", inputs: [] } })]) { + const facts = factsFor(null, { ...extra, prompt_graph }); + assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null)); + assert.ok(Object.values(facts).every(fact => fact.source !== "workflow" && fact.source !== "embedded-parameters")); + } +}); + +test("conservative facts bound cycles fan-out visits and link depth", () => { + const graph = factGraph({ seed: ["loop", 0], model: ["loop", 0] }); + graph.loop = { class_type: "Unknown", inputs: { seed: ["loop", 0], model: ["loop", 0], a: ["loop", 0], b: ["loop", 0] } }; + assert.equal(factsFor(graph).seed.status, "unresolved"); + assert.equal(factsFor(graph).model.status, "unresolved"); + const chain = depth => { + const value = factGraph({ seed: ["p0", 0] }); + for (let index = 0; index < depth; index++) value[`p${index}`] = { class_type: "PrimitiveInt", inputs: { value: index === depth - 1 ? "42" : [`p${index + 1}`, 0] } }; + return value; + }; + assert.equal(factsFor(chain(64)).seed.value, "42"); + assert.equal(factsFor(chain(65)).seed.status, "unresolved"); + const custom = chain(64); + custom.s = { class_type: "SamplerCustomAdvanced", inputs: { noise: ["noise", 0] } }; + custom.noise = { class_type: "RandomNoise", inputs: { noise_seed: ["p0", 0] } }; + assert.equal(factsFor(custom).seed.status, "unresolved", "the provider itself consumes one of the 64 allowed links"); + const large = factGraph(); + for (let index = 0; index < 498; index++) large[`unused${index}`] = { class_type: "PrimitiveInt", inputs: { value: index } }; + assert.equal(factsFor(large).seed.value, "9007199254740993"); + large.extra = { class_type: "PrimitiveInt", inputs: { value: 1 } }; + assert.equal(factsFor(large).seed.status, "unresolved"); +}); // Same shape as the workflow attached to #75: PromptManager nodes are saved as // CLIPTextEncode with extra inputs, and the checkpoint loader is pysssss'. diff --git a/tests/js/prompt-inspector.test.js b/tests/js/prompt-inspector.test.js index d662877..72095bc 100644 --- a/tests/js/prompt-inspector.test.js +++ b/tests/js/prompt-inspector.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { inspectPath, promptRoles, createInspectionLoader } from '../../web/js/workspace/prompt-inspector.mjs'; +import { inspectPath, promptRoles, createInspectionLoader, mountPromptBlocks } from '../../web/js/workspace/prompt-inspector.mjs'; test('inspection keeps exact physical root and encodes every path component', () => { assert.equal(inspectPath({root_index:2,path:'a b/x#%.png'}), '/prompt_manager/images/inspect/a%20b/x%23%25.png?root=2'); @@ -13,6 +13,14 @@ test('only confirmed strings can be sent, including explicit empty negatives', ( } assert.deepEqual(promptRoles(null), {}); }); +test('already aborted prompt blocks touch no container DOM or active handlers', () => { + const parent = new AbortController(); parent.abort(); + const container = new Proxy({}, { get() { assert.fail('aborted mount accessed the container'); } }); + const parentScope = { signal: parent.signal }; + const view = mountPromptBlocks(container, { parentScope, sender: { open() { assert.fail('aborted action'); } } }); + view.update({ positive: { status: 'resolved', text: 'late' } }); + view.setState({ loading: true }); view.dispose(); +}); test('superseded requests and disposed loaders cannot replace current metadata', async () => { const pending = [], changes = []; const loader = createInspectionLoader({get:(url,{signal})=>new Promise(resolve=>pending.push({url,signal,resolve}))}, value=>changes.push(value)); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js new file mode 100644 index 0000000..57ef0cd --- /dev/null +++ b/tests/js/workspace-inspection-state.test.js @@ -0,0 +1,228 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { extractGenerationFacts } = require('../../web/js/comfy-metadata.js'); +const { ApiError } = require('../../web/js/api-client.js'); +const module = await import('../../web/js/workspace/inspection-state.mjs').catch(error => { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + return {}; +}); +const scopeA = 'a'.repeat(64), scopeB = 'b'.repeat(64); +const refreshMessage = 'The source changed. Refresh the image collection before inspecting this file.'; +const fields = ['seed', 'steps', 'cfg', 'sampler', 'scheduler', 'model']; +function contract(name) { assert.equal(typeof module[name], 'function', `${name} contract is exported`); return module[name]; } +function image(root_scope = scopeA) { + return { root_index: 0, path: 'folder/a #%.png', filename: 'a #%.png', url: `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0${root_scope === undefined ? '' : `&root_scope=${root_scope}`}`, ...(root_scope === undefined ? {} : { root_scope }) }; +} +function envelope(entry = image()) { + return { + success: true, inspection_version: 1, root_scope: entry.root_scope ?? scopeA, + image: { root_index: entry.root_index, path: entry.path, filename: entry.filename }, + file: { observation: 'current-file', source_label: 'same label', media_type: 'image', extension: '.png', format: 'PNG', size: 123, width: 720, height: 480, modified_time: 1234 }, + prompts: { positive: { status: 'resolved', text: 'exact\npositive', source: 'execution' }, negative: { status: 'ambiguous', text: null, source: null } }, + metadata: { + status: 'available', omitted_reason: null, raw: { prompt: '{"rounded":"raw copy"}' }, record_formats: { prompt: 'reserialized-json' }, + prompt_graph: { status: 'parsed', value: { s: { class_type: 'KSampler', inputs: { seed: '9007199254740993', steps: 28, cfg: '6.5000000000000000001', sampler_name: 'euler', scheduler: 'normal', model: ['model', 0] } }, model: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'model.safetensors' } } } }, + workflow_graph: { status: 'absent', value: null }, parameters: null, + }, + resolution: { state: 'ambiguous', sampler_id: null, reason: 'unknown negative' }, + library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], metadata_scope: 'pair' }, + }; +} +function pendingApi() { + const requests = []; + const api = { get: (url, { signal }) => new Promise((resolve, reject) => requests.push({ url, signal, resolve, reject })) }; + return { api, requests }; +} + +test('record preserves exact projections and unknown roles without execution fact promotion', () => { + const entry = image(), data = envelope(entry); + const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); + assert.deepEqual(record.identity, { kind: 'rooted-file', key: '0:folder/a #%.png', root_index: 0, path: entry.path, root_scope: scopeA }); + assert.equal(record.generation.seed.value, '9007199254740993'); + assert.equal(record.generation.cfg.value, '6.5000000000000000001'); + assert.equal(record.generation.seed.evidence, 'configuration'); + assert.equal(record.prompts.negative.status, 'ambiguous'); + assert.equal(record.prompts.negative.text, null); + assert.equal(record.historyTarget, '#/library/7'); + assert.deepEqual(record.download, { url: entry.url, filename: entry.filename }); + assert.equal(record.file.source_label, 'same label'); + for (const status of ['none', 'ambiguous', 'unavailable', 'limited']) { + const result = envelope(entry); result.library.status = status; + assert.equal(module.toInspectionRecord(result, entry, extractGenerationFacts).historyTarget, null); + } +}); + +test('record format validation never guesses original text', () => { + const toRecord = contract('toInspectionRecord'), entry = image(); + for (const mutate of [ + data => delete data.metadata.record_formats, + data => { data.metadata.record_formats = { prompt: 'guess' }; }, + data => { data.metadata.record_formats.extra = 'original-text'; }, + data => { data.metadata.record_formats.prompt = 'invalid'; }, + data => { data.metadata.raw.workflow = '{}'; }, + data => { data.metadata.raw.prompt = {}; }, + data => { data.metadata.raw.extra = 'secret'; }, + data => { data.metadata.parameters = 'manufactured'; }, + data => { data.metadata.prompt_graph = { status: 'absent', value: null }; }, + data => { data.metadata.record_formats = {}; data.metadata.raw = {}; }, + data => { data.metadata.status = 'limited'; }, + ]) { + const data = envelope(entry); mutate(data); + assert.throws(() => toRecord(data, entry, extractGenerationFacts), TypeError); + } + const data = envelope(entry); + data.metadata.record_formats.workflow = 'invalid'; + data.metadata.workflow_graph = { status: 'malformed', value: null }; + assert.equal(toRecord(data, entry, extractGenerationFacts).metadata.record_formats.workflow, 'invalid'); + data.metadata.raw = {}; data.metadata.status = 'limited'; data.metadata.omitted_reason = 'response-size-limit'; + data.metadata.prompt_graph = { status: 'limited', value: null }; + const limited = toRecord(data, entry, extractGenerationFacts); + assert.deepEqual(limited.metadata.record_formats, { prompt: 'reserialized-json', workflow: 'invalid' }); + assert.equal(limited.generation.seed.status, 'unresolved'); + assert.equal(limited.generation.seed.reason, 'projection-limited'); + let seen; + toRecord(envelope(entry), entry, input => { seen = input; return extractGenerationFacts(input); }); + assert.deepEqual(seen.recordFormats, { prompt: 'reserialized-json' }); + assert.equal(seen.raw, undefined, 'facts never receive raw graphs for reparsing'); +}); + +test('record rejects root path media and invalid versioned fields with retryable TypeError', () => { + const toRecord = contract('toInspectionRecord'); + for (const mutate of [ + data => { data.image.root_index = 1; }, data => { data.image.path = 'other.png'; }, + data => { data.inspection_version = 2; }, data => { data.file.width = 0; }, + data => { delete data.success; }, + data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; }, + data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; }, + data => { data.resolution.sampler_id = {}; }, + ]) { + const data = envelope(); mutate(data); + assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired); + } + for (const url of ['https://attacker.invalid/prompt_manager/images/serve/folder/a%20%23%25.png?root=0', 'data:text/plain,unsafe', '/other/a.png', '/prompt_manager/images/serve/other.png?root=0', `/prompt_manager/images/serve/folder/a%20%23%25.png?root=1&root_scope=${scopeA}`, `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0&root_scope=${scopeB}`, `/prompt_manager/images/serve/folder/a%20%23%25.png?root=0&root=1&root_scope=${scopeA}`]) { + assert.throws(() => toRecord(envelope(), { ...image(), url }, extractGenerationFacts), TypeError); + } +}); + +test('record preserves proxy originals and rejects opaque or normalized path escapes', () => { + const toRecord = contract('toInspectionRecord'); + const previousLocation = globalThis.location, previousApi = globalThis.ApiClient; + globalThis.location = { href: 'https://workspace.example/nested/proxy/prompt_manager/workspace', pathname: '/nested/proxy/prompt_manager/workspace' }; + globalThis.ApiClient = require('../../web/js/api-client.js'); + try { + assert.equal(toRecord(envelope(), image(), extractGenerationFacts).download.url, `/nested/proxy${image().url}`); + const absolute = { ...image(), url: `https://workspace.example:443/nested/proxy${image().url}` }; + assert.equal(toRecord(envelope(), absolute, extractGenerationFacts).download.url, absolute.url); + for (const url of [`https://workspace.example.attacker.invalid/nested/proxy${image().url}`, `/nested/proxy/prompt_manager/images/serve/folder/../a%20%23%25.png?root=0&root_scope=${scopeA}`, `//attacker.invalid${image().url}`]) assert.throws(() => toRecord(envelope(), { ...image(), url }, extractGenerationFacts), TypeError); + } finally { globalThis.location = previousLocation; globalThis.ApiClient = previousApi; } +}); + +test('inspectPath does not coerce or strip malformed scopes', () => { + const inspectPath = contract('inspectPath'); + assert.equal(inspectPath(image()), `/prompt_manager/images/inspect/folder/a%20%23%25.png?root=0&root_scope=${scopeA}`); + assert.equal(inspectPath({ root_index: 2, path: 'a b/x#%.png' }), '/prompt_manager/images/inspect/a%20b/x%23%25.png?root=2'); + for (const root_scope of [null, true, 1, {}, 'a'.repeat(63), 'A'.repeat(64), scopeA + '\n']) { + assert.throws(() => inspectPath({ ...image(), root_scope }), TypeError); + } + for (const entry of [{ root_index: '0', path: 'a.png' }, { root_index: 0, path: '../a.png' }, { root_index: 0, path: 'a//b.png' }, { root_index: 0, path: '/a.png' }, { root_index: 0, path: 'C:file.png' }]) assert.throws(() => inspectPath(entry), TypeError); +}); + +test('controller shares current request across subscribers', async () => { + const { api, requests } = pendingApi(), updates = [[], []]; + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + const off = controller.subscribe(state => updates[0].push(state)); + controller.subscribe(state => updates[1].push(state)); + assert.equal(updates[0][0].record, null); + const first = controller.select(image()), second = controller.select({ ...image() }); + assert.equal(requests.length, 1); + assert.equal(controller.state.loading, true); + requests[0].resolve(envelope()); await Promise.all([first, second]); + assert.equal(controller.state.record.generation.seed.value, '9007199254740993'); + assert.equal(updates[0].at(-1), updates[1].at(-1)); + await controller.select(image()); assert.equal(requests.length, 1); + off(); const count = updates[0].length; + const retry = controller.retry(); assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined); + assert.equal(requests.length, 2); assert.ok(requests[1].url.endsWith(`root_scope=${scopeA}`)); + requests[1].resolve(envelope()); await retry; + assert.equal(updates[0].length, count); + controller.clear(); assert.equal(controller.state.record, null); assert.equal(controller.state.loading, false); + const reselect = controller.select(image()); assert.equal(requests.length, 3); + requests[2].resolve(envelope()); await reselect; controller.dispose(); +}); + +test('root scope echo and source-changed require refresh', async () => { + const SourceChanged = contract('InspectionSourceChangedError'), toRecord = contract('toInspectionRecord'); + for (const root_scope of [undefined, scopeB]) { + const data = envelope(); + if (root_scope === undefined) delete data.root_scope; else data.root_scope = root_scope; + assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof SourceChanged && error.refreshRequired && error.message === refreshMessage); + const { api, requests } = pendingApi(); + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + const load = controller.select(image()); requests[0].resolve(data); await load; + assert.deepEqual([controller.state.error, controller.state.refreshRequired, controller.state.record, controller.state.prompts, controller.state.message], [true, true, null, undefined, refreshMessage]); + await controller.retry(); assert.equal(requests.length, 1); controller.dispose(); + } + for (const [status, code, refresh] of [[409, 'source-changed', true], [409, null, false], [409, 'file-changed', false], [500, 'source-changed', false]]) { + const { api, requests } = pendingApi(); + const controller = module.createInspectionController({ api, extractFacts: extractGenerationFacts }); + const load = controller.select(image()); requests[0].reject(new ApiError(status, refreshMessage, code)); await load; + assert.equal(controller.state.refreshRequired, refresh); + const retry = controller.retry(); assert.equal(requests.length, refresh ? 1 : 2); + if (!refresh) { requests[1].resolve(envelope()); await retry; assert.equal(controller.state.error, false); } + controller.dispose(); + } +}); + +test('same image key with changed scope supersedes pending actions', async () => { + const { api, requests } = pendingApi(); + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + const first = controller.select(image()); + requests[0].resolve(envelope()); await first; + const changed = image(scopeB), next = controller.select(changed); + assert.equal(controller.state.record, null); assert.equal(controller.state.prompts, undefined); + assert.equal(requests.length, 2); + requests[1].resolve(envelope(changed)); await next; + assert.equal(controller.state.record.identity.root_scope, scopeB); + assert.equal(controller.state.record.file.source_label, 'same label'); + controller.dispose(); +}); + +test('unscoped role-only callers stay compatible', async () => { + const toRecord = contract('toInspectionRecord'), entry = { root_index: 0, path: 'old.png' }; + const data = { prompts: { positive: { status: 'resolved', text: 'old', source: 'graph-inferred' }, negative: { status: 'unresolved', text: null, source: null } } }; + const record = toRecord(data, entry, extractGenerationFacts); + assert.equal(record.identity.root_scope, null); assert.equal(record.identity.key, '0:old.png'); + for (const key of ['file', 'metadata', 'library', 'download', 'historyTarget']) assert.equal(record[key], null); + for (const field of fields) assert.deepEqual(record.generation[field], { status: 'unresolved', value: null, source: null, evidence: null, reason: null, sampler_id: null }); + assert.deepEqual(record.prompts, data.prompts); + const changes = []; + const loader = contract('createInspectionLoader')({ get: async () => data }, value => changes.push(value)); + await loader.load(entry); + assert.deepEqual(changes.at(-1).prompts, data.prompts); assert.equal(changes.at(-1).record.identity.key, '0:old.png'); + loader.dispose(); + assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error.refreshRequired === true); + const scoped = toRecord({ ...data, root_scope: scopeA }, image(), extractGenerationFacts); + assert.equal(scoped.metadata, null); assert.equal(scoped.download.url, image().url); +}); + +test('disposed or superseded responses never publish', async () => { + const { api, requests } = pendingApi(), updates = []; + const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts }); + controller.subscribe(state => updates.push(state)); + const first = controller.select(image()), newer = image(scopeB), second = controller.select(newer); + assert.equal(requests[0].signal.aborted, true); + requests[1].resolve(envelope(newer)); await second; + const count = updates.length; + requests[0].reject(new ApiError(409, refreshMessage, 'source-changed')); await first; + assert.equal(updates.length, count); assert.equal(controller.state.record.identity.root_scope, scopeB); + const third = controller.retry(); controller.clear(); + assert.equal(requests[2].signal.aborted, true); + requests[2].resolve(envelope(newer)); await third; + assert.equal(controller.state.record, null); + const final = controller.select(image()); controller.dispose(); const disposedCount = updates.length; + requests[3].resolve(envelope()); await final; + await controller.select(newer); await controller.retry(); controller.clear(); + assert.equal(updates.length, disposedCount); assert.equal(requests.length, 4); +}); diff --git a/web/js/api-client.js b/web/js/api-client.js index fe8b887..e4d4aa4 100644 --- a/web/js/api-client.js +++ b/web/js/api-client.js @@ -15,10 +15,11 @@ "use strict"; class ApiError extends Error { - constructor(status, message) { + constructor(status, message, code = null) { super(message); this.name = "ApiError"; this.status = status; + this.code = typeof code === "string" && /^[a-z0-9-]{1,64}$/.test(code) ? code : null; } } @@ -62,11 +63,11 @@ const serverMessage = data && typeof data === "object" ? data.error || data.message : undefined; if (!response.ok) { - throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`); + throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code); } if (!parsed) throw new ApiError(response.status, "Invalid JSON response"); if (data && typeof data === "object" && data.success === false) { - throw new ApiError(response.status, serverMessage || "Request failed"); + throw new ApiError(response.status, serverMessage || "Request failed", data.code); } return data; } diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index 234f66a..d2a8e32 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -59,7 +59,7 @@ * their workflow widget text back. Otherwise the API graph is what actually ran * (expanded dynamic prompts, linked text) and always wins. */ - function createContext(graph, workflow) { + function createContext(graph, workflow, conservative = false, budget = { remaining: MAX_VISITS }) { const widgets = new Map(); for (const n of workflowNodes(workflow)) { const text = widgetsOf(n)[0]; @@ -82,7 +82,7 @@ ids.forEach((id) => widgetText.set(id, widgets.get(id))); } } - return { graph, widgetText }; + return { graph, widgetText, conservative, budget }; } /** @@ -92,8 +92,9 @@ */ function enter(ctx, link, seen) { if (!isLink(link) || seen.size >= MAX_VISITS) return []; + if (ctx.conservative && (!Number.isSafeInteger(link[1]) || link[1] < 0 || seen.size >= 64 || ctx.budget.remaining-- <= 0)) return []; const id = String(link[0]); - const node = ctx.graph[id]; + const node = !ctx.conservative || Object.hasOwn(ctx.graph, id) ? ctx.graph[id] : undefined; const key = `${id}:${link[1]}`; if (!node || !node.inputs || seen.has(key)) return []; seen.add(key); @@ -112,6 +113,10 @@ if (name && Object.prototype.hasOwnProperty.call(node.inputs, name)) { return resolveScalar(ctx, node.inputs[name], name, seen); } + if (ctx.conservative) { + return value[1] === 0 && /^(Primitive(Int|Float|String|Boolean|Node)|PrimitiveStringMultiline)$/.test(node.class_type) + && Object.hasOwn(node.inputs, "value") ? resolveScalar(ctx, node.inputs.value, name, seen) : undefined; + } for (const v of Object.values(node.inputs)) { const resolved = resolveScalar(ctx, v, name, seen); if (resolved !== undefined && typeof resolved !== "object") return resolved; @@ -185,6 +190,13 @@ function traceModelName(ctx, link, seen = new Set()) { const [, node] = enter(ctx, link, seen); if (!node) return undefined; + if (ctx.conservative) { + const names = MODEL_NAME_KEYS.filter(key => Object.hasOwn(node.inputs, key)); + if (names.length) { + const values = names.map(key => resolveScalar(ctx, node.inputs[key], key, new Set(seen))); + return values.every(value => typeof value === "string" && value.trim() && value === values[0]) ? values[0] : undefined; + } + } const key = MODEL_NAME_KEYS.find((k) => typeof node.inputs[k] === "string"); return key ? node.inputs[key] : traceModelName(ctx, node.inputs.model, seen); } @@ -283,7 +295,7 @@ * without a `prompt` chunk are traced the same way. Set/Get pairs, Reroutes and * bypassed nodes are resolved to their real source; muted nodes are dropped. */ - function workflowToGraph(workflow) { + function workflowToGraph(workflow, conservative = false, budget = { remaining: MAX_VISITS }) { const nodes = workflowNodes(workflow).filter((n) => n && n.id !== undefined && n.id !== null); const byId = new Map(nodes.map((n) => [String(n.id), n])); const links = new Map(); @@ -299,7 +311,8 @@ const source = (linkId, hops = 0) => { const link = links.get(linkId); const node = link && byId.get(String(link.from)); - if (!node || node.mode === MODE_MUTED || hops > MAX_VISITS) return undefined; + if (!node || node.mode === MODE_MUTED || hops > MAX_VISITS || + (conservative && (hops >= 64 || budget.remaining-- <= 0))) return undefined; const next = (id) => source(id, hops + 1); if (node.type === "Reroute") return next(node.inputs && node.inputs[0] && node.inputs[0].link); if (node.type === "GetNode") return next(setters.get(widgetsOf(node)[0])); @@ -318,7 +331,7 @@ const inputs = namedWidgets(n); for (const i of n.inputs || []) { const src = i && i.link !== null && i.link !== undefined ? source(i.link) : undefined; - if (src) inputs[i.name] = src; + if (src || (conservative && i && i.link !== null && i.link !== undefined)) inputs[i.name] = src; } graph[String(n.id)] = { class_type: n.type, inputs }; } @@ -344,7 +357,149 @@ return merged; } - const api = { extractGenerationParams, PLACEHOLDERS }; + const FACT_FIELDS = ["seed", "steps", "cfg", "sampler", "scheduler", "model"]; + const GENERATION_SAMPLERS = new Set(["KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"]); + const object = value => value !== null && typeof value === "object" && !Array.isArray(value); + + function fact(status, source = null, samplerId = null, value = null, reason = null) { + return { status, value, source, evidence: source === "embedded-parameters" ? source : source ? "configuration" : null, reason, sampler_id: samplerId }; + } + + function factSet(status, source = null, reason = null) { + return Object.fromEntries(FACT_FIELDS.map(name => [name, fact(status, source, null, null, reason)])); + } + + function exactFactValue(value, field) { + if (typeof value === "number") { + // The projection carries decimals as strings; a float here cannot prove exactness. + if (!Number.isFinite(value) || !Number.isSafeInteger(value)) return null; + value = String(value); + } + if (typeof value !== "string" || !value.trim()) return null; + if (field === "seed" || field === "steps") { + return /^\d+$/.test(value) && !/[\r\n]/.test(value) && (field === "seed" || /[1-9]/.test(value)) ? value : null; + } + if (field === "cfg") return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value) && value.trim() === value ? value : null; + return value === "Unknown" ? null : value; + } + + function scalarFact(ctx, inputs, key, field, source, samplerId, seen = new Set()) { + if (!Object.hasOwn(inputs, key)) return fact("not-recorded", source, samplerId); + const value = exactFactValue(resolveScalar(ctx, inputs[key], key, seen), field); + return fact(value === null ? "unresolved" : "known", source, samplerId, value); + } + + function providerFact(ctx, link, keys, field, source, samplerId) { + const seen = new Set(); + const [, node] = enter(ctx, link, seen); + if (!node) return fact("unresolved", source, samplerId); + const key = keys.find(name => Object.hasOwn(node.inputs, name)); + return key === undefined ? fact("not-recorded", source, samplerId) : scalarFact(ctx, node.inputs, key, field, source, samplerId, seen); + } + + function graphFacts(graph, source, resolution, budget) { + if (!object(graph)) return factSet("unresolved", source); + const entries = Object.entries(graph); + if (entries.length > MAX_VISITS) return factSet("unresolved", source, "projection-limited"); + if (entries.some(([, node]) => !object(node) || !object(node.inputs) || typeof node.class_type !== "string")) return factSet("unresolved", source); + const candidates = entries.filter(([, node]) => GENERATION_SAMPLERS.has(node.class_type)); + const selected = candidates.find(([id]) => typeof resolution?.sampler_id === "string" && id === resolution.sampler_id); + if (!selected && candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source); + const [samplerId, sampler] = selected || candidates[0]; + const ctx = createContext(graph, null, true, budget); + const inputs = sampler.inputs; + const advanced = sampler.class_type === "SamplerCustomAdvanced"; + const custom = advanced || sampler.class_type === "SamplerCustom"; + let settings = inputs; + const settingsPath = new Set(); + if (advanced) { + const [, guider] = enter(ctx, inputs.guider, settingsPath); + settings = guider && ["CFGGuider", "BasicGuider"].includes(guider.class_type) ? guider.inputs : null; + } + const fromSettings = (key, field) => settings ? scalarFact(ctx, settings, key, field, source, samplerId, new Set(settingsPath)) : fact("unresolved", source, samplerId); + const seedKey = Object.hasOwn(inputs, "seed") ? "seed" : "noise_seed"; + const facts = { + seed: advanced ? providerFact(ctx, inputs.noise, ["noise_seed", "seed"], "seed", source, samplerId) : scalarFact(ctx, inputs, seedKey, "seed", source, samplerId), + steps: custom ? providerFact(ctx, inputs.sigmas, ["steps"], "steps", source, samplerId) : scalarFact(ctx, inputs, "steps", "steps", source, samplerId), + cfg: fromSettings("cfg", "cfg"), + sampler: custom ? providerFact(ctx, inputs.sampler, ["sampler_name"], "sampler", source, samplerId) : scalarFact(ctx, inputs, "sampler_name", "sampler", source, samplerId), + scheduler: Object.hasOwn(inputs, "scheduler") || !custom ? scalarFact(ctx, inputs, "scheduler", "scheduler", source, samplerId) : providerFact(ctx, inputs.sigmas, ["scheduler"], "scheduler", source, samplerId), + model: fact(settings && !Object.hasOwn(settings, "model") ? "not-recorded" : "unresolved", source, samplerId), + }; + if (settings && Object.hasOwn(settings, "model")) { + const model = exactFactValue(traceModelName(ctx, settings.model, new Set(settingsPath)), "model"); + facts.model = fact(model === null ? "unresolved" : "known", source, samplerId, model); + } + return facts; + } + + function validFactWorkflow(workflow) { + if (!object(workflow) || !Array.isArray(workflow.nodes) || (workflow.links !== undefined && !Array.isArray(workflow.links))) return false; + const ids = new Set(), links = new Set(), setters = new Set(); + for (const node of workflow.nodes) { + if (!object(node) || !["string", "number"].includes(typeof node.id) || typeof node.type !== "string" || + (node.inputs !== undefined && (!Array.isArray(node.inputs) || node.inputs.some(input => !object(input) || typeof input.name !== "string"))) || ids.has(String(node.id))) return false; + ids.add(String(node.id)); + if (node.type === "SetNode") { + const name = widgetsOf(node)[0]; + if (setters.has(name)) return false; + setters.add(name); + } + } + for (const link of workflow.links || []) { + const id = Array.isArray(link) ? link[0] : link?.id; + if (id === undefined || links.has(id)) return false; + links.add(id); + } + return true; + } + + function parameterFacts(parameters) { + const source = "embedded-parameters"; + const matches = [...parameters.matchAll(/^Steps:\s*\d+\s*,[^\n]*$/gm)]; + if (matches.length !== 1 || parameters.slice(matches[0].index + matches[0][0].length).trim()) return factSet("unresolved", source); + const body = parameters.slice(0, matches[0].index).replace(/[\r\n]+$/, ""); + if (!body.split("\nNegative prompt:")[0].trim() || body.split("\nNegative prompt:").length > 2) return factSet("unresolved", source); + const settings = new Map(); + for (const part of matches[0][0].split(",")) { + const match = part.match(/^\s*([^:]+):\s*(.*?)\s*$/); + if (!match) return factSet("unresolved", source); + const [, key, value] = match; + settings.set(key, settings.has(key) ? null : value); + } + const keys = { seed: "Seed", steps: "Steps", cfg: "CFG scale", sampler: "Sampler", scheduler: "Schedule type", model: "Model" }; + return Object.fromEntries(FACT_FIELDS.map(field => { + const key = keys[field]; + if (!settings.has(key)) return [field, fact("not-recorded", source)]; + let value = exactFactValue(settings.get(key), field); + if (field === "scheduler" && value !== null && !/^(automatic|normal|karras|exponential|sgm[ _]uniform|simple|ddim[ _]uniform|beta|uniform|linear[ _]quadratic|kl[ _]optimal)$/i.test(value)) value = null; + return [field, fact(value === null ? "unresolved" : "known", source, null, value)]; + })); + } + + /** Consume only the exact, bounded inspection projection; never reparse raw copies. */ + function extractGenerationFacts({ prompt_graph, workflow_graph, parameters, recordFormats = {}, metadataStatus, resolution } = {}) { + for (const [record, source] of [[prompt_graph, "api-graph"], [workflow_graph, "workflow"]]) { + if (!record || record.status === "absent") continue; + if (record.status === "limited") return factSet("unresolved", source, "projection-limited"); + if (record.status !== "parsed") return factSet("unresolved", source); + const budget = { remaining: MAX_VISITS }; + if (source === "workflow") { + if (record.value?.nodes?.length > MAX_VISITS) return factSet("unresolved", source, "projection-limited"); + if (!validFactWorkflow(record.value)) return factSet("unresolved", source); + return graphFacts(workflowToGraph(record.value, true, budget), source, null, budget); + } + return graphFacts(record.value, source, resolution, budget); + } + if (metadataStatus === "limited") return factSet("unresolved", null, "projection-limited"); + if (metadataStatus === "unreadable") return factSet("unresolved", null, "metadata-unreadable"); + if (parameters !== undefined && parameters !== null || Object.hasOwn(recordFormats, "parameters")) { + return recordFormats.parameters === "original-text" && typeof parameters === "string" ? parameterFacts(parameters) : factSet("unresolved"); + } + return factSet(metadataStatus === "available" || metadataStatus === "absent" ? "not-recorded" : "unresolved"); + } + + const api = { extractGenerationParams, extractGenerationFacts, PLACEHOLDERS }; if (typeof module !== "undefined" && module.exports) module.exports = api; else root.ComfyMetadata = api; })(typeof window !== "undefined" ? window : globalThis); diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs new file mode 100644 index 0000000..af8a3b1 --- /dev/null +++ b/web/js/workspace/inspection-state.mjs @@ -0,0 +1,231 @@ +import { imageKey } from './images-state.mjs'; +import { promptRoles } from './prompt-blocks.mjs'; + +const SOURCE_CHANGED = 'The source changed. Refresh the image collection before inspecting this file.'; +const RECORD_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; +const FACT_FIELDS = ['seed', 'steps', 'cfg', 'sampler', 'scheduler', 'model']; +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const validScope = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value) && value.length === 64; + +export class InspectionSourceChangedError extends Error { + constructor() { + super(SOURCE_CHANGED); + this.name = 'InspectionSourceChangedError'; + this.refreshRequired = true; + } +} + +function descriptorScope(image) { + if (!Object.hasOwn(image, 'root_scope')) return null; + if (!validScope(image.root_scope)) throw new TypeError('Invalid inspection source scope'); + return image.root_scope; +} + +function validateIdentity(image) { + if (!object(image) || !Number.isSafeInteger(image.root_index) || image.root_index < 0 || + typeof image.path !== 'string' || !image.path || image.path.includes('\\') || /^[a-z]:/i.test(image.path) || /[\x00-\x1f\x7f]/.test(image.path) || + image.path.split('/').some(part => !part || part === '.' || part === '..')) throw new TypeError('Invalid inspection identity'); + return descriptorScope(image); +} + +export function inspectPath(image) { + const scope = validateIdentity(image); + const query = new URLSearchParams({ root: String(image.root_index) }); + if (scope !== null) query.set('root_scope', scope); + return `/prompt_manager/images/inspect/${image.path.split('/').map(encodeURIComponent).join('/')}?${query}`; +} + +function downloadFor(image, required) { + try { + if (typeof image.url !== 'string' || !image.url) throw new TypeError('Missing original media URL'); + const location = globalThis.location || globalThis.window?.location; + const reference = new URL(location?.href || 'http://localhost/'); + const resolveUrl = globalThis.window?.ApiClient?.resolveUrl || globalThis.ApiClient?.resolveUrl || (url => url); + const value = resolveUrl(image.url); + const url = new URL(value, reference); + const sameOrigin = 'Origin' in globalThis ? + globalThis.Origin.from(url).isSameOrigin(globalThis.Origin.from(reference)) : + url.origin !== 'null' && url.origin === reference.origin; + const expected = new URL(resolveUrl(`/prompt_manager/images/serve/${image.path.split('/').map(encodeURIComponent).join('/')}`), reference); + const scope = descriptorScope(image); + if (!sameOrigin || !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || + url.pathname !== expected.pathname || url.searchParams.getAll('root').length !== 1 || url.searchParams.get('root') !== String(image.root_index) || + url.searchParams.getAll('root_scope').length > 1 || (scope !== null && url.searchParams.get('root_scope') !== scope) || + (url.searchParams.has('root_scope') && !validScope(url.searchParams.get('root_scope')))) throw new TypeError('Invalid original media identity'); + return { url: value, filename: image.path.split('/').at(-1) }; + } catch (error) { + if (required) throw new TypeError('Invalid original media identity', { cause: error }); + return null; + } +} + +function promptsFor(value) { + if (!object(value)) throw new TypeError('Invalid inspection prompts'); + const confirmed = promptRoles(value); + for (const role of ['positive', 'negative']) { + const record = value[role]; + if (record === undefined) continue; + if (!object(record) || typeof record.status !== 'string' || + (record.text !== undefined && record.text !== null && typeof record.text !== 'string') || + (record.status === 'resolved' && !Object.hasOwn(confirmed, role))) throw new TypeError('Invalid inspection role'); + } + return value; +} + +function fileFor(value) { + if (!object(value) || value.observation !== 'current-file' || typeof value.source_label !== 'string' || + !['image', 'video'].includes(value.media_type) || typeof value.extension !== 'string' || + (value.format !== null && typeof value.format !== 'string')) throw new TypeError('Invalid inspection file'); + const file = { observation: value.observation, source_label: value.source_label, media_type: value.media_type, extension: value.extension, format: value.format }; + for (const key of ['size', 'width', 'height', 'modified_time']) { + const item = value[key] ?? null; + if (item !== null && (typeof item !== 'number' || !Number.isFinite(item) || + (['width', 'height'].includes(key) ? !Number.isSafeInteger(item) || item <= 0 : item < 0))) throw new TypeError('Invalid inspection file observation'); + file[key] = item; + } + return file; +} + +function graphFor(value) { + if (!object(value) || !['parsed', 'absent', 'malformed', 'limited'].includes(value.status) || + (value.status === 'parsed' ? !object(value.value) : value.value !== null)) throw new TypeError('Invalid inspection graph'); + return { status: value.status, value: value.value }; +} + +function metadataFor(value) { + if (!object(value) || !['available', 'absent', 'unreadable', 'limited'].includes(value.status) || + ![null, 'response-size-limit'].includes(value.omitted_reason) || !object(value.raw) || !object(value.record_formats) || + (value.parameters !== null && typeof value.parameters !== 'string')) throw new TypeError('Invalid inspection metadata'); + const raw = {}, formats = {}; + for (const [key, format] of Object.entries(value.record_formats)) { + if (!RECORD_KEYS.includes(key) || !['original-text', 'reserialized-json', 'invalid'].includes(format)) throw new TypeError('Invalid inspection record format'); + formats[key] = format; + } + for (const [key, text] of Object.entries(value.raw)) { + if (!RECORD_KEYS.includes(key) || typeof text !== 'string' || !Object.hasOwn(formats, key) || formats[key] === 'invalid') throw new TypeError('Invalid inspection raw record'); + raw[key] = text; + } + if (value.parameters !== (raw.parameters ?? null)) throw new TypeError('Invalid inspection parameters projection'); + const prompt = graphFor(value.prompt_graph), workflow = graphFor(value.workflow_graph); + for (const [key, graph] of [['prompt', prompt], ['workflow', workflow]]) { + const hasRecord = Object.hasOwn(formats, key); + if ((formats[key] === 'invalid' && graph.status !== 'malformed') || + hasRecord !== (graph.status !== 'absent')) throw new TypeError('Invalid graph representation provenance'); + } + if ((value.status === 'limited') !== (value.omitted_reason === 'response-size-limit')) throw new TypeError('Invalid metadata omission status'); + if (['absent', 'unreadable'].includes(value.status) && (Object.keys(raw).length || Object.keys(formats).length || prompt.status !== 'absent' || workflow.status !== 'absent')) throw new TypeError('Invalid unavailable metadata'); + return { status: value.status, omitted_reason: value.omitted_reason, raw, record_formats: formats, prompt_graph: prompt, workflow_graph: workflow, parameters: value.parameters }; +} + +function libraryFor(value) { + if (!object(value) || !['linked', 'none', 'ambiguous', 'unavailable', 'limited'].includes(value.status) || value.scope !== 'saved-library' || + !Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') || ![null, 'pair', 'positive'].includes(value.metadata_scope)) throw new TypeError('Invalid inspection library'); + const library = { status: value.status, scope: value.scope, tags: [...value.tags], metadata_scope: value.metadata_scope }; + for (const key of ['positive_id', 'pair_id', 'title', 'category']) { + const item = value[key] ?? null; + if (item !== null && (key.endsWith('_id') ? !Number.isSafeInteger(item) || item <= 0 : typeof item !== 'string')) throw new TypeError('Invalid inspection library field'); + library[key] = item; + } + return library; +} + +function unavailableFacts() { + return Object.fromEntries(FACT_FIELDS.map(key => [key, { status: 'unresolved', value: null, source: null, evidence: null, reason: null, sampler_id: null }])); +} + +export function toInspectionRecord(result, image, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts) { + const scope = validateIdentity(image); + if (scope !== null && (!object(result) || result.root_scope !== scope)) throw new InspectionSourceChangedError(); + if (!object(result) || result.success === false) throw new TypeError('Invalid inspection response'); + const legacy = result.inspection_version === undefined; + if (!legacy && result.inspection_version !== 1) throw new TypeError('Unsupported inspection version'); + if (!legacy && result.success !== true) throw new TypeError('Invalid inspection success status'); + if ((!legacy || result.image !== undefined) && (!object(result.image) || result.image.root_index !== image.root_index || result.image.path !== image.path || result.image.filename !== image.path.split('/').at(-1))) throw new TypeError('Mismatched inspection identity'); + const record = { + identity: { kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope }, + file: null, prompts: promptsFor(result.prompts), library: null, metadata: null, + generation: unavailableFacts(), historyTarget: null, download: downloadFor(image, !legacy || scope !== null), + }; + if (legacy) return record; + const resolution = result.resolution; + if (!object(resolution) || !['resolved', 'absent', 'ambiguous', 'unresolved'].includes(resolution.state) || + (resolution.sampler_id !== null && (typeof resolution.sampler_id !== 'string' || !resolution.sampler_id)) || typeof resolution.reason !== 'string') throw new TypeError('Invalid inspection resolution'); + record.file = fileFor(result.file); + record.metadata = metadataFor(result.metadata); + record.library = libraryFor(result.library); + 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 }); + if (record.library.status === 'linked' && record.library.positive_id !== null) record.historyTarget = `#/library/${record.library.positive_id}`; + return record; +} + +export function createInspectionLoader(api, onChange, { extractFacts } = {}) { + let request = null, disposed = false; + return { + async load(image) { + if (disposed) return; + request?.abort(); + const current = request = new AbortController(); + onChange({ loading: true }); + try { + const result = await api.get(inspectPath(image), { signal: current.signal }); + if (disposed || request !== current) return; + const record = toInspectionRecord(result, image, extractFacts); + onChange({ prompts: record.prompts, record }); + } catch (error) { + if (disposed || request !== current) return; + if (error?.status === 409 && error.code === 'source-changed') error = new InspectionSourceChangedError(); + const refreshRequired = error instanceof InspectionSourceChangedError; + onChange({ error: true, refreshRequired, message: refreshRequired ? SOURCE_CHANGED : 'Could not read details from this file.' }); + } + }, + dispose() { disposed = true; request?.abort(); request = null; }, + }; +} + +const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false }); + +export function createInspectionController({ api, extractFacts }) { + let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false; + const listeners = new Set(); + function publish(update) { + if (disposed) return; + state = Object.freeze({ ...emptyState(), ...update }); + for (const listener of listeners) listener(state); + } + const makeLoader = () => createInspectionLoader(api, publish, { extractFacts }); + let loader = makeLoader(); + function read(image) { + pending = loader.load(image); + return pending; + } + return { + get state() { return state; }, + select(image) { + if (disposed) return; + const scope = validateIdentity(image), key = imageKey(image); + if (current?.key === key && current.scope === scope) return pending; + current = { key, scope, image: { ...image } }; + return read(current.image); + }, + retry() { + if (disposed || !current || state.refreshRequired) return; + return read(current.image); + }, + clear() { + if (disposed) return; + loader.dispose(); loader = makeLoader(); current = null; pending = null; + publish({}); + }, + subscribe(listener) { + if (disposed) return () => {}; + listeners.add(listener); listener(state); + return () => listeners.delete(listener); + }, + dispose() { + if (disposed) return; + disposed = true; loader.dispose(); listeners.clear(); current = null; pending = null; state = Object.freeze(emptyState()); + }, + }; +} diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs new file mode 100644 index 0000000..b752394 --- /dev/null +++ b/web/js/workspace/prompt-blocks.mjs @@ -0,0 +1,116 @@ +import { createScope, createButton } from '../shared/toolkit/core.js'; +import { createSendButton } from './prompt-sender.mjs'; +import { createWorkspaceIcon } from './icons.mjs'; + +export function promptRoles(prompts) { + return Object.fromEntries(['positive', 'negative'].filter(role => prompts?.[role]?.status === 'resolved' && typeof prompts[role].text === 'string').map(role => [role, prompts[role].text])); +} + +const sources = { + library: 'Saved library prompt', + execution: 'Recorded at execution', + 'graph-inferred': 'Inferred from the saved workflow', + 'embedded-parameters': 'Embedded generation parameters', + 'saved-association': 'Saved library association', +}; +const statuses = { + absent: 'Not recorded in this file.', + ambiguous: 'Multiple possible prompts. Source could not be determined.', + unresolved: 'Prompt could not be resolved.', +}; +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +function childScope(parentScope) { + const scope = createScope(); + const dispose = () => scope.dispose(); + parentScope.signal.addEventListener('abort', dispose, {once:true}); + scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); + if (parentScope.signal.aborted) scope.dispose(); + return scope; +} + +export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry}) { + if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}}; + const scope = childScope(parentScope); + container.classList.add('prompt-inspector'); + scope.listen(container, 'click', async event => { + const send = event.target.closest('[data-send-role]'); + if (send) { + const role = send.dataset.sendRole; + sender.open(role === 'pair' ? roles : {[role]:roles[role]}, send); + } + const copy = event.target.closest('[data-copy-role]'); + if (copy) { + const role = copy.dataset.copyRole; + const status = copy.closest('section').querySelector('.prompt-copy-status'); + try { + await navigator.clipboard.writeText(roles[role]); + if (!scope.signal.aborted) status.textContent = 'Copied.'; + } catch { + if (!scope.signal.aborted) status.textContent = 'Copy unavailable. Select the prompt text to copy it.'; + } + } + if (event.target.closest('[data-retry-prompts]')) onRetry?.(); + }); + let roles = {}; + function render(state) { + if (scope.signal.aborted) return; + roles = {}; + container.replaceChildren(); + if (state.loading || state.error) { + const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted'); + status.setAttribute('role', state.error ? 'alert' : 'status'); + container.append(status); + if (state.error && onRetry) { + const retry = createButton('Retry prompt details'); + retry.dataset.retryPrompts = ''; + container.append(retry); + } + return; + } + roles = Object.fromEntries(Object.entries(promptRoles(state.prompts)).filter(([role]) => visibleRoles.includes(role))); + for (const role of visibleRoles) { + const title = `${role[0].toUpperCase() + role.slice(1)} prompt`; + const record = state.prompts?.[role]; + const block = el('section', undefined, `prompt-block prompt-${role}`); + block.setAttribute('aria-label', title); + const heading = el('div', undefined, 'prompt-block-heading'); + heading.append(el('h3', title)); + block.append(heading); + if (Object.hasOwn(roles, role)) { + block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted')); + if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty')); + else { + const text = el('pre', roles[role], 'prompt-text'); + text.tabIndex = 0; + block.append(text); + } + const actions = el('div', undefined, 'prompt-actions'); + const send = createSendButton(`Send ${role} prompt`); + send.dataset.sendRole = role; + const copy = createButton('', 'ca-icon-button'); + copy.setAttribute('aria-label', `Copy ${role} prompt`); + copy.title = `Copy ${role} prompt`; + copy.append(createWorkspaceIcon('copy')); + copy.dataset.copyRole = role; + actions.append(copy, send); + heading.append(actions); + const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status'); + block.append(status); + } else block.append(el('p', typeof record?.message === 'string' ? record.message : statuses[record?.status] || statuses.unresolved, 'muted')); + container.append(block); + } + if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { + const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair'; + container.append(pair); + } + } + render({prompts}); + return {update:prompts => render({prompts}), setState:render, dispose:() => scope.dispose()}; +} + diff --git a/web/js/workspace/prompt-inspector.mjs b/web/js/workspace/prompt-inspector.mjs index 0fe3a84..875a482 100644 --- a/web/js/workspace/prompt-inspector.mjs +++ b/web/js/workspace/prompt-inspector.mjs @@ -1,52 +1,9 @@ -import { createScope, createButton } from '../shared/toolkit/core.js'; -import { createSendButton } from './prompt-sender.mjs'; -import { createWorkspaceIcon } from './icons.mjs'; +import { createScope } from '../shared/toolkit/core.js'; +import { mountPromptBlocks } from './prompt-blocks.mjs'; +import { createInspectionLoader } from './inspection-state.mjs'; -export function inspectPath(image) { - return `/prompt_manager/images/inspect/${image.path.split('/').map(encodeURIComponent).join('/')}?root=${image.root_index}`; -} - -export function promptRoles(prompts) { - return Object.fromEntries(['positive', 'negative'].filter(role => prompts?.[role]?.status === 'resolved' && typeof prompts[role].text === 'string').map(role => [role, prompts[role].text])); -} - -export function createInspectionLoader(api, onChange) { - let request, disposed = false; - return { - async load(image) { - if (disposed) return; - request?.abort(); - const current = request = new AbortController(); - onChange({ loading: true }); - try { - const result = await api.get(inspectPath(image), {signal:current.signal}); - if (!disposed && request === current) onChange({prompts:result.prompts}); - } catch { - if (!disposed && request === current) onChange({error:true}); - } - }, - dispose() { disposed = true; request?.abort(); }, - }; -} - -const sources = { - library: 'Saved library prompt', - execution: 'Recorded at execution', - 'graph-inferred': 'Inferred from the saved workflow', - 'embedded-parameters': 'Embedded generation parameters', - 'saved-association': 'Saved library association', -}; -const statuses = { - absent: 'Not recorded in this file.', - ambiguous: 'Multiple possible prompts. Source could not be determined.', - unresolved: 'Prompt could not be resolved.', -}; -function el(tag, text, className) { - const node = document.createElement(tag); - if (text !== undefined) node.textContent = text; - if (className) node.className = className; - return node; -} +export { promptRoles, mountPromptBlocks } from './prompt-blocks.mjs'; +export { inspectPath, createInspectionLoader } from './inspection-state.mjs'; function childScope(parentScope) { const scope = createScope(); @@ -57,85 +14,6 @@ function childScope(parentScope) { return scope; } -export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry}) { - const scope = childScope(parentScope); - container.classList.add('prompt-inspector'); - scope.listen(container, 'click', async event => { - const send = event.target.closest('[data-send-role]'); - if (send) { - const role = send.dataset.sendRole; - sender.open(role === 'pair' ? roles : {[role]:roles[role]}, send); - } - const copy = event.target.closest('[data-copy-role]'); - if (copy) { - const role = copy.dataset.copyRole; - const status = copy.closest('section').querySelector('.prompt-copy-status'); - try { - await navigator.clipboard.writeText(roles[role]); - if (!scope.signal.aborted) status.textContent = 'Copied.'; - } catch { - if (!scope.signal.aborted) status.textContent = 'Copy unavailable. Select the prompt text to copy it.'; - } - } - if (event.target.closest('[data-retry-prompts]')) onRetry?.(); - }); - let roles = {}; - function render(state) { - if (scope.signal.aborted) return; - roles = {}; - container.replaceChildren(); - if (state.loading || state.error) { - const status = el('p', state.loading ? 'Loading prompts…' : 'Could not read prompts from this file.', 'muted'); - status.setAttribute('role', state.error ? 'alert' : 'status'); - container.append(status); - if (state.error && onRetry) { - const retry = createButton('Retry prompt details'); - retry.dataset.retryPrompts = ''; - container.append(retry); - } - return; - } - roles = Object.fromEntries(Object.entries(promptRoles(state.prompts)).filter(([role]) => visibleRoles.includes(role))); - for (const role of visibleRoles) { - const title = `${role[0].toUpperCase() + role.slice(1)} prompt`; - const record = state.prompts?.[role]; - const block = el('section', undefined, `prompt-block prompt-${role}`); - block.setAttribute('aria-label', title); - const heading = el('div', undefined, 'prompt-block-heading'); - heading.append(el('h3', title)); - block.append(heading); - if (Object.hasOwn(roles, role)) { - block.append(el('p', sources[record.source] || 'Source not recorded', 'prompt-source muted')); - if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty')); - else { - const text = el('pre', roles[role], 'prompt-text'); - text.tabIndex = 0; - block.append(text); - } - const actions = el('div', undefined, 'prompt-actions'); - const send = createSendButton(`Send ${role} prompt`); - send.dataset.sendRole = role; - const copy = createButton('', 'ca-icon-button'); - copy.setAttribute('aria-label', `Copy ${role} prompt`); - copy.title = `Copy ${role} prompt`; - copy.append(createWorkspaceIcon('copy')); - copy.dataset.copyRole = role; - actions.append(copy, send); - heading.append(actions); - const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status'); - block.append(status); - } else block.append(el('p', typeof record?.message === 'string' ? record.message : statuses[record?.status] || statuses.unresolved, 'muted')); - container.append(block); - } - if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { - const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair'; - container.append(pair); - } - } - render({prompts}); - return {update:prompts => render({prompts}), setState:render, dispose:() => scope.dispose()}; -} - export function mountPromptInspector(container, {api, image, sender, parentScope}) { const scope = childScope(parentScope); let loader; diff --git a/web/workspace.html b/web/workspace.html index 0057c5a..a4754d7 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -11,6 +11,7 @@ +