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 @@
+