diff --git a/TESTING.md b/TESTING.md
index 9f40f7f..dc1f5c9 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -183,3 +183,17 @@ The compatibility runner reserves loopback ports 8193–8195; `PM_COMPAT_PORT`
selects another consecutive unprivileged range. Never run automated tests against
the maintainer's running ComfyUI. This JavaScript host extension needs a full
browser refresh after deployment; its first slice adds no gallery send controls.
+
+### Workspace prompt inspection and sending
+
+`tests/test_api_prompt_inspector.py` covers rooted physical-file identity,
+execution/graph/parameter evidence, saved associations, ambiguity, empty negatives,
+read-only behavior, bounded PNG/video metadata and corrupt inputs. Run it with
+`python -m unittest discover -s tests -p test_api_prompt_inspector.py` in a disposable
+checkout. `tests/js/prompt-inspector.test.js` and `prompt-sender.test.js` cover
+request races, explicit destination selection, preview tickets and uncertain
+acknowledgements. Browser workflows exercise the same controls in Images and
+Viewer, including nested modal focus, mobile layouts and per-role sending.
+`tests/comfyui/prompt-sender.spec.mjs` sends through the actual workspace UI into
+real Classic and Nodes 2.0 widgets and verifies workflow serialization without
+loading a workflow or queueing generation.
diff --git a/docs/prompt-inspector.md b/docs/prompt-inspector.md
new file mode 100644
index 0000000..539fd39
--- /dev/null
+++ b/docs/prompt-inspector.md
@@ -0,0 +1,34 @@
+# Prompt inspection and sending
+
+The opt-in workspace at `/prompt_manager/workspace#/images` shows full positive
+and negative prompts in both Image details and the full-image preview. Existing
+gallery and metadata tools remain available during the application rebuild.
+
+Each prompt shows its evidence source: recorded execution, workflow inference,
+or embedded generation parameters. Path-only saved-library associations are
+marked unverified and cannot be sent: a filename alone cannot prove that an image
+has not been replaced. Missing, ambiguous, and unreadable prompts remain visible
+as distinct states. An explicitly
+empty negative is a resolved value and can clear a destination's negative field.
+
+Use the blue paper-plane action beside a prompt, or **Send prompt pair**. Keep a
+ComfyUI tab open under the same origin and deployment prefix. Choose a window and
+compatible Prompt Manager node, select **Preview changes**, review the complete
+before/after text and preserved prepend/append composition, then **Apply to
+ComfyUI**. The dialog reports success only after a matching acknowledgement.
+It does not load workflows or queue generation. If confirmation is lost, inspect
+ComfyUI and preview again before another attempt. Closing the dialog cannot undo
+an update already sent.
+
+## Read-only API
+
+`GET /prompt_manager/images/inspect/{relative_path}?root={root_index}` requires
+an explicit configured root, including when two roots contain the same filename.
+The response contains `image` identity and `prompts.positive` / `prompts.negative`.
+Each role has `status` (`resolved`, `absent`, `unresolved`, or `ambiguous`), `text`
+(string only when resolved, otherwise null), and `source`. Inspection does not
+index files, alter saved prompts, run scans, or decode image pixels. PNG text and
+video probe output are bounded; unsupported or malformed evidence remains
+unresolved rather than becoming sendable placeholder text.
+
+Python route changes require restarting ComfyUI. Browser assets require a refresh.
diff --git a/docs/screenshots/204/inspector-dark.png b/docs/screenshots/204/inspector-dark.png
new file mode 100644
index 0000000..a69c5c8
Binary files /dev/null and b/docs/screenshots/204/inspector-dark.png differ
diff --git a/docs/screenshots/204/inspector-light.png b/docs/screenshots/204/inspector-light.png
new file mode 100644
index 0000000..8c4ca71
Binary files /dev/null and b/docs/screenshots/204/inspector-light.png differ
diff --git a/docs/screenshots/204/inspector-mobile.png b/docs/screenshots/204/inspector-mobile.png
new file mode 100644
index 0000000..67ef1e5
Binary files /dev/null and b/docs/screenshots/204/inspector-mobile.png differ
diff --git a/docs/screenshots/204/sender-preview.png b/docs/screenshots/204/sender-preview.png
new file mode 100644
index 0000000..497df5f
Binary files /dev/null and b/docs/screenshots/204/sender-preview.png differ
diff --git a/docs/screenshots/204/viewer-prompts.png b/docs/screenshots/204/viewer-prompts.png
new file mode 100644
index 0000000..d8a4502
Binary files /dev/null and b/docs/screenshots/204/viewer-prompts.png differ
diff --git a/py/api/images.py b/py/api/images.py
index 51f97b7..8f42289 100644
--- a/py/api/images.py
+++ b/py/api/images.py
@@ -22,8 +22,18 @@ from .prompts import safe_error_message as _safe_error
try:
from ...utils.parallel import map_parallel
+ from ...utils.prompt_inspector import (
+ MetadataReadError,
+ inspect_prompt_roles,
+ read_inspection_metadata,
+ )
except ImportError:
from utils.parallel import map_parallel
+ from utils.prompt_inspector import (
+ MetadataReadError,
+ inspect_prompt_roles,
+ read_inspection_metadata,
+ )
# Only these file types are ever served by the image routes, regardless of
# what sits inside an allowed directory (defence in depth for the gallery).
@@ -448,6 +458,10 @@ class ImageRoutesMixin:
async def get_image_prompt_route(request):
return await self.get_image_prompt(request)
+ @routes.get("/prompt_manager/images/inspect/{image_path:.*}")
+ async def inspect_image_route(request):
+ return await self.inspect_image(request)
+
@routes.delete("/prompt_manager/images/{image_id}")
async def delete_image_route(request):
return await self.delete_image(request)
@@ -1628,6 +1642,65 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
+ async def inspect_image(self, request):
+ """Read prompt evidence for one explicitly rooted physical media file."""
+ roots = list(self._get_all_output_dirs())
+ try:
+ index = _workspace_root(request.query, roots)
+ if index is None:
+ raise ValueError("Gallery root is required")
+ except ValueError as error:
+ return bad_request(str(error))
+ relative = request.match_info.get("image_path", "")
+ if (
+ _has_traversal(relative)
+ or "\0" in relative
+ or re.match(r"^[a-zA-Z]:", relative)
+ ):
+ return _forbidden()
+ path = Path(roots[index]) / relative
+ denied = _media_access_error(path, [roots[index]])
+ if denied is not None:
+ return denied
+ if not path.is_file():
+ return web.json_response(
+ {"success": False, "error": "Image file not found"}, status=404
+ )
+ try:
+
+ def inspect() -> dict:
+ try:
+ metadata = read_inspection_metadata(path)
+ except MetadataReadError:
+ return {
+ role: {"status": "unresolved", "text": None, "source": None}
+ for role in ("positive", "negative")
+ }
+ saved = (
+ self.db.get_image_prompt_info(str(path), False)
+ if not metadata
+ else None
+ )
+ return inspect_prompt_roles(metadata, saved)
+
+ prompts = await self._run_in_executor(inspect)
+ return web.json_response(
+ {
+ "success": True,
+ "image": {
+ "root_index": index,
+ "path": Path(relative).as_posix(),
+ "filename": path.name,
+ },
+ "prompts": prompts,
+ }
+ )
+ except Exception:
+ self.logger.exception("Prompt inspection failed")
+ return web.json_response(
+ {"success": False, "error": "Prompt inspection failed"}, status=500
+ )
+
async def get_image_prompt(self, request):
"""Get prompt information for a specific image path."""
try:
diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs
new file mode 100644
index 0000000..8d072b6
--- /dev/null
+++ b/tests/browser/prompt-inspector.spec.mjs
@@ -0,0 +1,205 @@
+import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
+import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
+
+const workspace='/prompt_manager/workspace#/images';
+const details=page=>page.getByRole('region',{name:'Image details',exact:true});
+const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true});
+const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Open /}).click();
+const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
+
+test.beforeEach(async({page,request,browserName})=>{
+ await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{prompt_inspector:true}})).toBeOK();
+ page.promptErrors=[];page.on('pageerror',error=>page.promptErrors.push(error.message));
+});
+test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.promptErrors).toEqual([]);});
+
+async function fakeHost(context,page,{rejectApply=false}={}) {
+ const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
+ await host.evaluate(async rejectApply=>{
+ const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
+ window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[];
+ const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']};
+ window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
+ listTargets:()=>[target],
+ preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after})),composition:{prepend:'trigger',append:'detail',prependConnected:false,appendConnected:false,before:`trigger ${window.hostValues.positive} detail`,after:`trigger ${roles.positive??window.hostValues.positive} detail`}},snapshot:roles}),
+ apply:roles=>{if(rejectApply)throw new Error('Workflow changed. Preview again.');Object.assign(window.hostValues,roles);window.applied.push(roles);return{targetId:target.id,values:roles};},
+ }});
+ },rejectApply);
+ return host;
+}
+async function chooseDestination(page) {
+ const sender=dialog(page);await expect(sender).toBeVisible();
+ const host=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
+ await expect(host.locator('option')).toContainText(['Test ComfyUI']);
+ await host.selectOption({label:'Test ComfyUI'});
+ await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
+ return sender;
+}
+
+test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{
+ await page.goto(workspace);await select(page,'0:shared/same.png');
+ await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark');
+ await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
+ await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
+ await expect(role(details(page),'Negative')).toContainText('low quality');await expect(details(page)).not.toContainText('Cat portrait');
+});
+
+test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{
+ await page.goto(workspace);await select(page,'0:empty-negative.png');
+ await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
+ await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toBeEnabled();
+ await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
+ await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt$/})).toHaveCount(0);
+});
+
+test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{
+ let fail=true;let release;const held=new Promise(resolve=>{release=resolve;});
+ await page.route('**/images/inspect/**',async route=>{
+ const url=new URL(route.request().url());
+ if(url.searchParams.get('root')==='0'&&url.pathname.endsWith('same.png')){
+ if(fail){return route.fulfill({status:503,json:{success:false,error:'Temporarily unavailable'}});}
+ await held;
+ }
+ await route.continue().catch(()=>{});
+ });
+ await page.goto(workspace);await select(page,'0:shared/same.png');
+ await expect(details(page)).toContainText('Could not read prompts from this file.');
+ fail=false;await details(page).getByRole('button',{name:'Retry prompt details',exact:true}).click();
+ await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
+ release();await expect(role(details(page),'Positive')).toContainText('Dog landscape');
+});
+
+test('sender requires explicit destination, previews full changes and acknowledges only the applied pair',async({page,context},testInfo)=>{
+ const host=await fakeHost(context,page);
+ try {
+ await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
+ const sender=dialog(page);await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
+ const chosen=await chooseDestination(page);await chosen.getByRole('button',{name:'Preview changes',exact:true}).click();
+ for(const text of ['Existing positive','Existing negative','Cat portrait','blurry, watermark','trigger Cat portrait detail'])await expect(chosen).toContainText(text);
+ expect(await host.evaluate(()=>window.applied)).toEqual([]);
+ await page.screenshot({path:testInfo.outputPath('sender-preview.png')});
+ await chosen.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(chosen).toContainText('Sent to ComfyUI.');
+ expect(await host.evaluate(()=>window.applied)).toEqual([{positive:'Cat portrait',negative:'blurry, watermark'}]);
+ await chosen.getByRole('button',{name:'Close',exact:true}).click();
+ await page.evaluate(()=>scrollTo(0,0));
+ await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
+ await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption('light');
+ await page.screenshot({path:testInfo.outputPath('inspector-light.png'),fullPage:true});
+ await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
+ await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
+ await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
+ await page.getByRole('button',{name:'Close preview',exact:true}).click();
+ await page.setViewportSize({width:390,height:844});await details(page).scrollIntoViewIfNeeded();
+ await page.screenshot({path:testInfo.outputPath('inspector-mobile.png')});
+ } finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
+});
+
+test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
+ const host=await fakeHost(context,page,{rejectApply:true});
+ try {
+ await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
+ const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
+ await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
+ await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
+ expect(await host.evaluate(()=>window.hostValues.positive)).toBe('Existing positive');
+ } finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
+});
+
+test('nested viewer sender Escape restores focus without closing the image and navigation disposes both overlays',async({page})=>{
+ await page.goto(workspace);await select(page,'0:shared/same.png');
+ await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
+ const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible();
+ const send=viewer.getByRole('button',{name:'Send positive prompt',exact:true});await send.click();await expect(dialog(page)).toBeVisible();
+ await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused();
+ await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0);
+});
+
+test('empty negative send clears only the negative destination and preserves its positive',async({page,context})=>{
+ const host=await fakeHost(context,page);
+ try {
+ await page.goto(workspace);await select(page,'0:empty-negative.png');
+ await details(page).getByRole('button',{name:'Send negative prompt',exact:true}).click();
+ const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
+ await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
+ expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''});
+ } finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
+});
+
+test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
+ await page.goto(workspace);await select(page,'0:shared/same.png');
+ await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
+ const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
+ const host=await fakeHost(context,page);
+ try {
+ await sender.getByRole('button',{name:'Find ComfyUI windows',exact:true}).click();
+ await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
+ await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
+ expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Cat portrait',negative:'Existing negative'});
+ } finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
+});
+
+test('mobile inspector exposes full long prompts, truthful uncertainty and copy failure feedback',async({page})=>{
+ const text=`A long detailed prompt ${'with readable full composition, '.repeat(150)}END OF FULL PROMPT`;
+ await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}}));
+ await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}}));
+ await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
+ const positive=role(details(page),'Positive');await expect(positive.locator('pre')).toHaveText(text);
+ await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
+ await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);
+ await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(positive).toContainText('Copy unavailable. Select the prompt text to copy it.');
+ expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
+ await positive.getByRole('button',{name:'Send positive prompt',exact:true}).click();
+ await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
+ expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
+});
+
+test('filtering away a selected image aborts its pending metadata request',async({page})=>{
+ await page.addInitScript(()=>{
+ const fetch=window.fetch;window.inspectionAborts=0;
+ window.fetch=function(input,options){
+ if(String(input).includes('/images/inspect/')&&options?.signal)options.signal.addEventListener('abort',()=>window.inspectionAborts++,{once:true});
+ return fetch.call(this,input,options);
+ };
+ });
+ let started;const requested=new Promise(resolve=>{started=resolve;});let release;
+ const hold=new Promise(resolve=>{release=resolve;});
+ await page.route('**/images/inspect/**',async route=>{started();await hold;await route.continue().catch(()=>{});});
+ try {
+ await page.goto(workspace);await select(page,'0:shared/same.png');await requested;
+ const baseline=await page.evaluate(()=>window.inspectionAborts);
+ await page.getByRole('searchbox',{name:'Search filenames',exact:true}).fill('no-such-file');
+ await expect(page.getByText('No matching images or media. Try changing the filters.',{exact:true})).toBeVisible();
+ await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline);
+ } finally {release();}
+});
+
+test('responsive gallery defers observed size writes and still fits the new viewport',async({page})=>{
+ await page.addInitScript(()=>{
+ const Native=window.ResizeObserver;window.inlineResizeWrites=0;
+ window.ResizeObserver=class extends Native {
+ constructor(callback){super((entries,observer)=>{
+ const sizes=entries.map(entry=>entry.target.style.height);
+ callback(entries,observer);
+ entries.forEach((entry,index)=>{
+ if(entry.target.classList.contains('image-grid')&&entry.target.style.height!==sizes[index])window.inlineResizeWrites++;
+ });
+ });}
+ };
+ });
+ await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
+ await expect(page.locator('.image-card')).toHaveCount(4);
+ await page.setViewportSize({width:390,height:844});
+ await expect.poll(()=>page.locator('.image-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
+ await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeGreaterThan(900);
+ expect(await page.evaluate(()=>window.inlineResizeWrites)).toBe(0);
+});
+
+test('unverified saved associations explain uncertainty and cannot be sent',async({page})=>{
+ await page.route('**/images/inspect/uncertain.png?root=0',route=>route.fulfill({json:{success:true,prompts:{
+ positive:{status:'unresolved',text:null,source:'saved-association',message:'A saved library association exists, but it cannot be verified against this file.'},
+ negative:{status:'absent',text:null,source:null},
+ }}}));
+ await page.goto(workspace);await select(page,'0:uncertain.png');
+ await expect(role(details(page),'Positive')).toContainText('cannot be verified against this file');
+ await expect(details(page).getByRole('button',{name:/^Send /})).toHaveCount(0);
+});
diff --git a/tests/browser/server.py b/tests/browser/server.py
index f1e36d0..1d3cd4d 100644
--- a/tests/browser/server.py
+++ b/tests/browser/server.py
@@ -78,7 +78,11 @@ def create_app(root: Path) -> web.Application:
api = PromptManagerAPI()
api.add_routes(routes)
- def seed(extra_images: int = 0, include_video: bool = False) -> None:
+ def seed(
+ extra_images: int = 0,
+ include_video: bool = False,
+ prompt_inspector: bool = False,
+ ) -> None:
with api.db.model.get_connection() as conn:
for table in (
"image_prompt_pairs",
@@ -107,6 +111,25 @@ def create_app(root: Path) -> web.Application:
)
with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
image.save(image_path, pnginfo=info)
+ if prompt_inspector:
+ graph = {
+ "1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}},
+ "2": {
+ "class_type": "CLIPTextEncode",
+ "inputs": {
+ "text": "blurry, watermark" if index == 0 else "low quality"
+ },
+ },
+ "3": {
+ "class_type": "KSampler",
+ "inputs": {"positive": ["1", 0], "negative": ["2", 0]},
+ },
+ "4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
+ }
+ info = PngInfo()
+ info.add_text("prompt", json.dumps(graph))
+ with Image.new("RGB", (64, 64), (index * 255, 64, 128)) as image:
+ image.save(image_path, pnginfo=info)
stamp = time.time() - index * 10
os.utime(image_path, (stamp, stamp))
pid = api.db.save_prompt(
@@ -120,6 +143,21 @@ def create_app(root: Path) -> web.Application:
extra.parent.mkdir(exist_ok=True)
shutil.copyfile(image_path, extra)
api.db.link_image_to_prompt(pid, str(extra))
+ if prompt_inspector:
+ for name, parameters in (
+ (
+ "empty-negative.png",
+ "Explicit empty negative\nNegative prompt: \n"
+ "Steps: 20, Sampler: Euler",
+ ),
+ ("uncertain.png", None),
+ ):
+ info = PngInfo()
+ if parameters:
+ info.add_text("parameters", parameters)
+ with Image.new("RGB", (64, 64), (100, 80, 120)) as image:
+ image.save(root / "output" / name, pnginfo=info)
+ os.utime(root / "output" / name, (time.time() - 100, time.time() - 100))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
if include_video:
# Seed after thumbnail generation to exercise a real unthumbnailed video.
@@ -135,14 +173,16 @@ def create_app(root: Path) -> web.Application:
data = await request.json() if request.can_read_body else {}
extra = data.get("extra_images", 0)
include_video = data.get("include_video", False)
+ prompt_inspector = data.get("prompt_inspector", False)
if (
isinstance(extra, bool)
or not isinstance(extra, int)
or not 0 <= extra <= 200
or not isinstance(include_video, bool)
+ or not isinstance(prompt_inspector, bool)
):
raise web.HTTPBadRequest()
- await api._run_in_executor(seed, extra, include_video)
+ await api._run_in_executor(seed, extra, include_video, prompt_inspector)
return web.json_response({"success": True})
@routes.post("/__test__/add-nested-image")
diff --git a/tests/comfyui/prompt-sender.spec.mjs b/tests/comfyui/prompt-sender.spec.mjs
new file mode 100644
index 0000000..a8beeeb
--- /dev/null
+++ b/tests/comfyui/prompt-sender.spec.mjs
@@ -0,0 +1,68 @@
+import {execFileSync} from 'node:child_process';
+import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
+import {installHostHooks} from './host-fixture.mjs';
+
+installHostHooks(test,['PromptManagerPair']);
+
+test('native image inspector previews and sends a pair into real visible widgets without loading or queueing',async({page,context,request},testInfo)=>{
+ const positive=`Inspector host positive ${testInfo.project.name}`;const negative='host negative, watermark';
+ const image=execFileSync(process.env.PM_TEST_PYTHON||'python',['-c',[
+ 'import io,sys',
+ 'from PIL import Image',
+ 'from PIL.PngImagePlugin import PngInfo',
+ 'info=PngInfo()',
+ 'info.add_text("parameters",sys.argv[1]+"\\nNegative prompt: "+sys.argv[2]+"\\nSteps: 20, Sampler: Euler")',
+ 'buffer=io.BytesIO()',
+ 'Image.new("RGB",(64,64),(30,80,140)).save(buffer,format="PNG",pnginfo=info)',
+ 'sys.stdout.buffer.write(buffer.getvalue())',
+ ].join('\n'),positive,negative]);
+ const filename=`sender-${testInfo.project.name}.png`;
+ const upload=await request.post('/upload/image',{multipart:{image:{name:filename,mimeType:'image/png',buffer:image},type:'output',subfolder:'prompt-sender',overwrite:'true'}});
+ expect(upload.ok()).toBe(true);
+ const id=await page.evaluate(async()=>{
+ const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
+ node.setSize([700,1000]);app.canvas.ds.scale=.8;app.canvas.centerOnNode(node);
+ return node.id;
+ });
+ const nodeRoot=page.locator('.pm-pair');
+ await nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true}).fill('Before positive');
+ await nodeRoot.getByRole('textbox',{name:'Negative prompt',exact:true}).fill('Before negative');
+ await nodeRoot.getByText('Composition · prepend / append',{exact:true}).click();
+ await nodeRoot.getByRole('textbox',{name:'Prepend',exact:true}).fill('LoRA trigger');
+ await nodeRoot.getByRole('textbox',{name:'Append',exact:true}).fill('fine detail');
+ await page.evaluate(async()=>{
+ const {app}=await import('/scripts/app.js');window.senderEffects={queue:0,load:0};
+ for(const [method,key]of [['queuePrompt','queue'],['loadGraphData','load']]){
+ const original=app[method];app[method]=function(...args){window.senderEffects[key]++;return original.apply(this,args);};
+ }
+ });
+ const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
+ try {
+ await workspace.goto('/prompt_manager/workspace#/images');
+ await workspace.getByRole('button',{name:'Refresh',exact:true}).click();
+ const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})});
+ await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
+ const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
+ await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
+ await inspector.getByRole('button',{name:'Send prompt pair',exact:true}).click();
+ const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
+ const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
+ await expect(hosts.locator('option')).toHaveCount(2);
+ await hosts.selectOption({index:1});
+ await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({index:1});
+ await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
+ for(const text of ['Before positive','Before negative',positive,negative,`LoRA trigger ${positive} fine detail`])await expect(sender).toContainText(text);
+ await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
+ await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
+ await expect(sender).toContainText('Sent to ComfyUI.');
+ await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue(positive);
+ await expect(nodeRoot.getByRole('textbox',{name:'Negative prompt',exact:true})).toHaveValue(negative);
+ const state=await page.evaluate(async id=>{
+ const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);const serialized=node.serialize();
+ return{inputs:(await app.graphToPrompt()).output[String(id)].inputs,
+ serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.senderEffects};
+ },id);
+ for(const name of ['inputs','serialized'])expect(state[name]).toMatchObject({text:positive,negative_text:negative,prepend_text:'LoRA trigger',append_text:'fine detail'});
+ expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
+ } finally {await workspace.close();}
+});
diff --git a/tests/js/prompt-inspector.test.js b/tests/js/prompt-inspector.test.js
new file mode 100644
index 0000000..d662877
--- /dev/null
+++ b/tests/js/prompt-inspector.test.js
@@ -0,0 +1,36 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { inspectPath, promptRoles, createInspectionLoader } 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');
+});
+test('only confirmed strings can be sent, including explicit empty negatives', () => {
+ const record = {positive:{status:'resolved',text:'full\npositive'},negative:{status:'resolved',text:''}};
+ assert.deepEqual(promptRoles(record), {positive:'full\npositive',negative:''});
+ for (const status of ['absent','ambiguous','unresolved','other']) {
+ assert.deepEqual(promptRoles({positive:{status,text:'placeholder'},negative:{status:'resolved',text:null}}), {});
+ }
+ assert.deepEqual(promptRoles(null), {});
+});
+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));
+ const first=loader.load({root_index:0,path:'one.png'});
+ const second=loader.load({root_index:1,path:'one.png'});
+ assert.equal(pending[0].signal.aborted,true);
+ pending[1].resolve({prompts:{positive:{status:'resolved',text:'latest'}}}); await second;
+ pending[0].resolve({prompts:{positive:{status:'resolved',text:'old'}}}); await first;
+ assert.equal(changes.at(-1).prompts.positive.text,'latest');
+ const third=loader.load({root_index:0,path:'third.png'}); loader.dispose();
+ assert.equal(pending[2].signal.aborted,true);
+ pending[2].resolve({prompts:{positive:{status:'resolved',text:'disposed'}}}); await third;
+ assert.equal(changes.at(-1).loading,true);
+});
+test('failed reads report a retryable error and later reads recover', async () => {
+ let fail=true; const changes=[];
+ const loader=createInspectionLoader({get:async()=>{if(fail)throw Error('bad');return {prompts:{}};}},value=>changes.push(value));
+ await loader.load({root_index:0,path:'one.png'}); assert.equal(changes.at(-1).error,true);
+ fail=false; await loader.load({root_index:0,path:'one.png'}); assert.deepEqual(changes.at(-1).prompts,{});
+ loader.dispose(); await loader.load({root_index:0,path:'one.png'}); assert.equal(changes.at(-1).loading,undefined);
+});
diff --git a/tests/js/prompt-sender.test.js b/tests/js/prompt-sender.test.js
new file mode 100644
index 0000000..70d3395
--- /dev/null
+++ b/tests/js/prompt-sender.test.js
@@ -0,0 +1,121 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const source = import('../../web/js/workspace/prompt-sender.mjs');
+const hosts = [{hostId:'host',label:'Workflow',targets:[{id:'one',label:'Positive',roles:['positive']},{id:'pair',label:'Pair',roles:['positive','negative']}]}];
+const tick = () => new Promise(resolve => setImmediate(resolve));
+function setup(createSendSession, overrides = {}, roles = {positive:'new'}) {
+ const calls = [];
+ const client = {discover:async () => hosts, preview:async (...args) => {calls.push(['preview',...args]);return {ticket:'ticket',target:{id:args[1]},fields:[{role:'positive',before:'old',after:'new'}],composition:{before:'prefix old',after:'prefix new'}};},apply:async (...args) => {calls.push(['apply',...args]);return {targetId:'one',values:{positive:'new'}};},dispose:() => calls.push(['dispose']),...overrides};
+ const states = []; const session = createSendSession({client,roles,onChange:state=>states.push(state)});
+ return {client,calls,states,session};
+}
+test('sender requires explicit compatible host and target, preview, then acknowledged apply', async () => {
+ const {createSendSession} = await source; const s=setup(createSendSession);
+ await s.session.discover(); assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.targetId,'');
+ await s.session.preview(); assert.equal(s.calls.length,0);
+ s.session.selectHost('host');assert.equal(s.session.state.targetId,''); s.session.selectTarget('one');
+ await s.session.apply();assert.equal(s.calls.length,0);
+ await s.session.preview();assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview.composition.after,'prefix new');
+ await s.session.apply();assert.equal(s.session.state.phase,'success');assert.equal(s.calls.filter(call=>call[0]==='apply').length,1);
+ await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,1); s.session.dispose();
+});
+test('pair roles filter incompatible nodes and do not silently choose the sole destination', async()=>{
+ const {createSendSession}=await source;const s=setup(createSendSession,{}, {positive:'',negative:'bad'});
+ await s.session.discover();assert.deepEqual(s.session.state.hosts[0].targets.map(target=>target.id),['pair']);
+ s.session.selectHost('host');s.session.selectTarget('one');assert.equal(s.session.state.targetId,'');
+ s.session.selectTarget('pair');assert.equal(s.session.state.targetId,'pair');s.session.dispose();
+});
+test('late previews are ignored after target changes; aborted and disposed work cannot update state',async()=>{
+ const {createSendSession}=await source;let resolve;const s=setup(createSendSession,{preview:()=>new Promise(done=>resolve=done)});
+ await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');const pending=s.session.preview();
+ s.session.selectTarget('pair');resolve({ticket:'old'});await pending;assert.equal(s.session.state.preview,null);
+ s.session.selectTarget('one');const pending2=s.session.preview();s.session.dispose();const count=s.states.length;resolve({ticket:'old'});await pending2;assert.equal(s.states.length,count);
+});
+test('unknown apply outcome never retries a ticket and requires a new preview',async()=>{
+ const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>{throw Error('timed out');}});
+ await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();
+ assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/may have applied/i);assert.equal(s.session.state.preview,null);
+ await s.session.apply();assert.equal(s.session.state.phase,'error');await s.session.preview();assert.equal(s.session.state.phase,'ready');s.session.dispose();
+});
+test('only matching explicit acknowledgements count as success',async()=>{
+ const {createSendSession}=await source;
+ for(const ack of [undefined,{targetId:'other',values:{positive:'new'}},{targetId:'one',values:{positive:'wrong'}}]){
+ const s=setup(createSendSession,{apply:async()=>ack});await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');s.session.dispose();
+ }
+});
+test('discovery, empty results and stale preview errors remain actionable',async()=>{
+ const {createSendSession}=await source;const s=setup(createSendSession,{discover:async()=>[]});await s.session.discover();assert.match(s.session.state.message,/No compatible/i);s.session.dispose();
+ const failed=setup(createSendSession,{discover:async()=>{throw Error('unavailable');}});await failed.session.discover();assert.match(failed.session.state.message,/unavailable/);failed.session.dispose();
+ const stale=setup(createSendSession,{preview:async()=>{throw Error('Workflow changed. Preview again.');}});await stale.session.discover();stale.session.selectHost('host');stale.session.selectTarget('one');await stale.session.preview();assert.match(stale.session.state.message,/Workflow changed/);stale.session.dispose();
+});
+test('rediscovery invalidates selections and old discovery cannot replace new results',async()=>{
+ const {createSendSession}=await source;let resolve;let count=0;const s=setup(createSendSession,{discover:()=>++count===1?new Promise(done=>resolve=done):Promise.resolve(hosts)});
+ const first=s.session.discover();await s.session.discover();resolve([]);await first;assert.equal(s.session.state.hosts.length,1);
+ s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.discover();assert.equal(s.session.state.hostId,'');assert.equal(s.session.state.preview,null);s.session.dispose();
+});
+test('pending apply explains closing cannot undo the sent update',async()=>{
+ const {createSendSession}=await source;let done;const s=setup(createSendSession,{apply:()=>new Promise(resolve=>done=resolve)});
+ await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();const pending=s.session.apply();
+ assert.match(s.session.state.message,/Closing this dialog does not undo/i);s.session.dispose();done({targetId:'one',values:{positive:'new'}});await pending;
+});
+function fakeDOM() {
+ class Node extends EventTarget {
+ constructor(tag){super();this.tagName=tag;this.children=[];this.attributes={};this.dataset={};this.value='';this.textContent='';this.isConnected=true;this.open=false;}
+ append(...children){this.children.push(...children);}
+ add(option){this.append(option);}
+ replaceChildren(...children){this.children=children;}
+ setAttribute(key,value){this.attributes[key]=value;}
+ showModal(){this.open=true;}
+ close(){this.open=false;this.dispatchEvent(new Event('close'));}
+ remove(){this.isConnected=false;}
+ focus(){this.focused=true;}
+ }
+ const previous={document:global.document,window:global.window,Option:global.Option};
+ const body=new Node('body');
+ global.document={body,createElement:tag=>new Node(tag),createElementNS:(namespace,tag)=>new Node(tag)};
+ global.Option=class extends Node {constructor(text,value){super('option');this.textContent=text;this.value=value;}};
+ global.window={location:new URL('https://example.test/prefix/prompt_manager/workspace'),ApiClient:{resolveUrl:()=>'/prefix/prompt_manager/'}};
+ const find=(root,predicate)=>predicate(root)?root:root.children.map(child=>find(child,predicate)).find(Boolean);
+ return {body,Node,find,restore(){Object.assign(global,previous);}};
+}
+test('native sender dialog uses prefixed namespace, full safe text, explicit choices and owned cleanup',async()=>{
+ const {createPromptSender,createSendButton}=await source;
+ const {createScope}=await import('../../web/js/shared/toolkit/core.js');
+ const dom=fakeDOM();const scope=createScope();const trigger=new dom.Node('button');let options,disposed=0;
+ const client={discover:async()=>hosts,preview:async()=>({ticket:'t',target:{id:'one'},fields:[{role:'positive',before:'',after:'new'}],composition:{prepend:'prefix',append:'suffix',before:null,after:null,prependConnected:true}}),apply:async()=>({targetId:'one',values:{positive:'new'}}),dispose:()=>disposed++};
+ try {
+ const button=createSendButton('Send positive prompt');assert.equal(button.attributes['aria-label'],'Send positive prompt');assert.equal(button.children[0].attributes['aria-hidden'],'true');
+ const sender=createPromptSender({scope,clientFactory:value=>{options=value;return client;}});sender.open({positive:'new'},trigger);await tick();
+ assert.deepEqual(options,{namespace:'/prefix/',origin:'https://example.test'});
+ const dialog=dom.body.children.at(-1);assert.equal(dialog.open,true);assert.equal(dialog.attributes['aria-label'],'Send to ComfyUI');
+ const host=dom.find(dialog,node=>node.id==='prompt-sender-host');const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
+ assert.equal(host.value,'');assert.equal(target.value,'');host.value='host';host.dispatchEvent(new Event('change'));target.value='one';target.dispatchEvent(new Event('change'));
+ dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));await tick();
+ assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='(empty)'));assert.ok(dom.find(dialog,node=>node.tagName==='code' && node.textContent==='new'));
+ const apply=dom.find(dialog,node=>node.textContent==='Apply to ComfyUI');assert.equal(apply.className,'ca-primary');assert.equal(apply.disabled,false);apply.dispatchEvent(new Event('click'));await tick();
+ assert.ok(dom.find(dialog,node=>node.textContent==='Sent to ComfyUI.'));scope.dispose();assert.equal(dialog.isConnected,false);assert.equal(trigger.focused,true);assert.equal(disposed,1);
+ sender.open({positive:'new'},trigger);assert.equal(dom.body.children.length,1);
+ } finally {scope.dispose();dom.restore();}
+});
+test('unsupported client or deployment errors are visible and close stays available',async()=>{
+ const {createPromptSender}=await source;const {createScope}=await import('../../web/js/shared/toolkit/core.js');const dom=fakeDOM();
+ try {
+ for(const baseUrl of ['https://other.test/prompt_manager/','/prefix/not-prompt-manager/','/prefix/prompt_manager/']) {
+ const scope=createScope();const sender=createPromptSender({scope,baseUrl,clientFactory:()=>{throw Error('BroadcastChannel unavailable');}});
+ sender.open({positive:'new'});const dialog=dom.body.children.at(-1);const alert=dom.find(dialog,node=>node.attributes.role==='alert');assert.match(alert.textContent,/unavailable|Invalid/);
+ dom.find(dialog,node=>node.textContent==='Close').dispatchEvent(new Event('click'));assert.equal(dialog.isConnected,false);scope.dispose();
+ }
+ } finally {dom.restore();}
+});
+test('approval requires an exact destination and complete unambiguous matching preview',async()=>{
+ const {createSendSession}=await source;
+ const valid={ticket:'ticket',target:{id:'one'},fields:[{role:'positive',before:'old',after:'new'}]};
+ for(const preview of [null,{}, {...valid,ticket:''},{...valid,target:{id:'other'}},{...valid,fields:[]},{...valid,fields:new Array(1)},{...valid,fields:[null]},{...valid,fields:[...valid.fields,...valid.fields]},{...valid,fields:[{role:'negative',before:'old',after:'new'}]},{...valid,fields:[{role:'positive',before:null,after:'new'}]},{...valid,fields:[{role:'positive',before:'old',after:'unexpected'}]}]) {
+ const s=setup(createSendSession,{preview:async()=>preview});await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();
+ assert.equal(s.session.state.phase,'error');assert.equal(s.session.state.preview,null);await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,0);s.session.dispose();
+ }
+});
+test('acknowledgement cannot claim an extra role beyond approved changes',async()=>{
+ const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>({targetId:'one',values:{positive:'new',negative:'also changed'}})});
+ await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/unexpected acknowledgement/);s.session.dispose();
+});
diff --git a/tests/test_api_prompt_inspector.py b/tests/test_api_prompt_inspector.py
new file mode 100644
index 0000000..9932197
--- /dev/null
+++ b/tests/test_api_prompt_inspector.py
@@ -0,0 +1,643 @@
+"""Physical-file prompt inspection is bounded, conservative and read-only."""
+
+import json
+from unittest.mock import patch
+
+from PIL import Image, PngImagePlugin
+
+try:
+ from test_api_images import ImageAPITestCase, make_png
+except ImportError:
+ from tests.test_api_images import ImageAPITestCase, make_png
+
+from utils.pair_execution import EXECUTION_KEY, record_pair_execution
+from database.pair_ingestion import ingest_resolved_pair
+from utils.prompt_pair_resolver import resolve_prompt_pair
+
+
+def graph(positive="portrait", negative="blurry"):
+ return {
+ "1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
+ "2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
+ "3": {
+ "class_type": "KSampler",
+ "inputs": {"positive": ["1", 0], "negative": ["2", 0]},
+ },
+ }
+
+
+def png(path, **metadata):
+ info = PngImagePlugin.PngInfo()
+ for key, value in metadata.items():
+ info.add_text(key, value if isinstance(value, str) else json.dumps(value))
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with Image.new("RGB", (4, 4)) as image:
+ image.save(path, pnginfo=info)
+ return path
+
+
+class TestPromptInspector(ImageAPITestCase):
+ async def inspect(self, path="image.png", root="0"):
+ params = {} if root is None else {"root": root}
+ response = await self.client.get(
+ f"/prompt_manager/images/inspect/{path}", params=params
+ )
+ return response, await response.json()
+
+ def side(self, text, source="graph-inferred"):
+ return {"status": "resolved", "text": text, "source": source}
+
+ async def test_unindexed_graph_pair_is_read_without_indexing_or_decoding(self):
+ path = png(self.output_dir / "image.png", prompt=graph(negative=""))
+ before = path.read_bytes()
+ with self.api.db.model.get_connection() as connection:
+ rows = connection.execute("SELECT total_changes()").fetchone()[0]
+ with patch.object(Image.Image, "load", side_effect=AssertionError("decode")):
+ response, result = await self.inspect()
+ self.assertEqual(response.status, 200)
+ self.assertEqual(
+ result["image"],
+ {"root_index": 0, "path": "image.png", "filename": "image.png"},
+ )
+ self.assertEqual(result["prompts"]["positive"], self.side("portrait"))
+ self.assertEqual(result["prompts"]["negative"], self.side(""))
+ self.assertEqual(path.read_bytes(), before)
+ with self.api.db.model.get_connection() as connection:
+ self.assertEqual(
+ connection.execute("SELECT total_changes()").fetchone()[0], rows
+ )
+ self.assertEqual(
+ connection.execute("SELECT COUNT(*) FROM prompts").fetchone()[0], 0
+ )
+ self.assertNotIn(str(self.comfy_dir), json.dumps(result))
+
+ async def test_root_identity_collisions_are_not_filename_fallbacks(self):
+ other = self.comfy_dir / "input"
+ other.mkdir()
+ first = png(self.output_dir / "image.png", prompt=graph("output"))
+ png(other / "image.png", prompt=graph("input"))
+ self._link_image(first, "Saved output")
+ self.api._get_all_output_dirs = lambda: [self.output_dir, other]
+ _, result = await self.inspect(root="1")
+ self.assertEqual(result["image"]["root_index"], 1)
+ self.assertEqual(result["prompts"]["positive"]["text"], "input")
+ make_png(other / "plain.png")
+ _, result = await self.inspect("plain.png", root="1")
+ self.assertEqual(result["prompts"]["positive"]["status"], "absent")
+
+ async def test_requires_valid_root_safe_relative_path_existing_media(self):
+ make_png(self.output_dir / "image.png")
+ for root in (None, "-1", "1", "x", "0.0"):
+ response, result = await self.inspect(root=root)
+ self.assertEqual(response.status, 400, root)
+ self.assertNotIn(str(self.comfy_dir), json.dumps(result))
+ for path in (
+ "%2Fetc%2Fpasswd.png",
+ "..%2Fsecret.png",
+ "%5Csecret.png",
+ "bad.txt",
+ "null%00.png",
+ "C:%5Csecret.png",
+ ):
+ response, result = await self.inspect(path)
+ self.assertEqual(response.status, 403, path)
+ response, result = await self.inspect("missing.png")
+ self.assertEqual(response.status, 404)
+ outside = png(self.comfy_dir / "outside.png", prompt=graph("private"))
+ self._make_symlink(outside, self.output_dir / "escape.png")
+ response, result = await self.inspect("escape.png")
+ self.assertEqual(response.status, 403)
+ self.assertNotIn("private", json.dumps(result))
+
+ async def test_missing_negative_is_distinct_from_explicit_empty(self):
+ source = graph()
+ del source["3"]["inputs"]["negative"]
+ png(self.output_dir / "image.png", prompt=source)
+ _, result = await self.inspect()
+ self.assertEqual(result["prompts"]["positive"], self.side("portrait"))
+ self.assertEqual(result["prompts"]["negative"]["status"], "absent")
+ self.assertIsNone(result["prompts"]["negative"]["text"])
+
+ async def test_graph_ambiguity_and_unknown_semantics_do_not_use_saved_fallback(
+ self,
+ ):
+ path = self.output_dir / "image.png"
+ self._link_image(png(path), "Legacy saved")
+ for expected in ("ambiguous", "unresolved"):
+ source = graph()
+ if expected == "ambiguous":
+ source["4"] = source["3"]
+ else:
+ source["1"]["class_type"] = "UnknownEncoder"
+ png(
+ path,
+ prompt=source,
+ parameters=(
+ "Fallback\nNegative prompt: noise\n" "Steps: 20, Sampler: Euler"
+ ),
+ )
+ _, result = await self.inspect()
+ for side in result["prompts"].values():
+ self.assertEqual(side["status"], expected)
+ self.assertIsNone(side["text"])
+
+ async def test_parameters_and_partial_resolution_are_conservative(self):
+ path = self.output_dir / "image.png"
+ png(path, parameters="A mountain\nNegative prompt: \nSteps: 20, Sampler: Euler")
+ _, result = await self.inspect()
+ self.assertEqual(
+ result["prompts"]["positive"],
+ self.side("A mountain", "embedded-parameters"),
+ )
+ self.assertEqual(
+ result["prompts"]["negative"], self.side("", "embedded-parameters")
+ )
+ source = graph()
+ source["2"]["class_type"] = "UnknownEncoder"
+ png(path, prompt=source)
+ _, result = await self.inspect()
+ self.assertEqual(result["prompts"]["positive"], self.side("portrait"))
+ self.assertEqual(result["prompts"]["negative"]["status"], "unresolved")
+
+ async def test_execution_evidence_wins_over_graph_and_saved_recipe(self):
+ source = {
+ "1": {
+ "class_type": "PromptManagerPair",
+ "inputs": {"text": "portrait", "negative_text": "blurry"},
+ },
+ "3": {
+ "class_type": "KSampler",
+ "inputs": {"positive": ["1", 0], "negative": ["1", 1]},
+ },
+ }
+ evidence = {}
+ record_pair_execution(
+ evidence,
+ "1",
+ text="portrait",
+ negative_text="blurry",
+ positive_text="portrait LoRA trigger",
+ negative_effective_text="blurry",
+ )
+ path = png(self.output_dir / "image.png", prompt=source, **evidence)
+ self._link_image(path, "Saved recipe")
+ _, result = await self.inspect()
+ self.assertEqual(
+ result["prompts"]["positive"],
+ self.side("portrait LoRA trigger", "execution"),
+ )
+ self.assertEqual(
+ result["prompts"]["negative"], self.side("blurry", "execution")
+ )
+ png(path, prompt=source, **{EXECUTION_KEY: "broken"})
+ _, result = await self.inspect()
+ self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
+
+ async def test_saved_associations_remain_unverified_without_file_evidence(self):
+ path = make_png(self.output_dir / "image.png")
+ self._link_image(path, "Saved positive")
+ _, result = await self.inspect()
+ self.assertEqual(
+ result["prompts"]["positive"],
+ {
+ "status": "unresolved",
+ "text": None,
+ "source": "saved-association",
+ "message": (
+ "A saved library association exists, but it cannot be "
+ "verified against this file."
+ ),
+ },
+ )
+ self.assertEqual(result["prompts"]["negative"]["status"], "absent")
+ other = make_png(self.output_dir / "paired.png")
+ metadata = {"prompt": graph("Effective positive", "Recorded negative")}
+ resolution = resolve_prompt_pair(metadata)
+ ingest_resolved_pair(self.api.db, str(other), metadata, resolution)
+ _, result = await self.inspect("paired.png")
+ self.assertEqual(
+ result["prompts"]["positive"],
+ {
+ "status": "unresolved",
+ "text": None,
+ "source": "saved-association",
+ "message": (
+ "A saved library association exists, but it cannot be "
+ "verified against this file."
+ ),
+ },
+ )
+ self.assertEqual(
+ result["prompts"]["negative"],
+ {
+ "status": "unresolved",
+ "text": None,
+ "source": "saved-association",
+ "message": (
+ "A saved library association exists, but it cannot be "
+ "verified against this file."
+ ),
+ },
+ )
+
+ async def test_corrupt_malformed_and_no_metadata_do_not_invent_text(self):
+ path = self.output_dir / "image.png"
+ for content, expected in (
+ (b"broken", "unresolved"),
+ (None, "absent"),
+ ("bad-json", "unresolved"),
+ ):
+ if isinstance(content, bytes):
+ path.write_bytes(content)
+ else:
+ png(path, **({"prompt": content} if content else {}))
+ _, result = await self.inspect()
+ self.assertEqual(result["prompts"]["positive"]["status"], expected)
+ self.assertIsNone(result["prompts"]["positive"]["text"])
+
+ async def test_compressed_international_and_post_pixel_metadata(self):
+ import struct
+ import zlib
+
+ path = self.output_dir / "image.png"
+ for kind in (
+ "compressed",
+ "international",
+ "compressed-international",
+ "post-idat",
+ ):
+ info = PngImagePlugin.PngInfo()
+ value = json.dumps(graph("猫"), ensure_ascii=False)
+ if kind == "compressed":
+ info.add_text("prompt", json.dumps(graph()), zip=True)
+ elif kind in ("international", "compressed-international"):
+ info.add_itxt(
+ "prompt",
+ value,
+ lang="en",
+ tkey="Prompt",
+ zip=kind.startswith("compressed"),
+ )
+ else:
+ payload = b"prompt\0" + json.dumps(graph()).encode()
+ info.add_text("unrelated", "header text")
+ with Image.new("RGB", (4, 4)) as image:
+ image.save(path, pnginfo=info)
+ if kind == "post-idat":
+ content = path.read_bytes()
+ chunk = (
+ struct.pack(">I", len(payload))
+ + b"tEXt"
+ + payload
+ + struct.pack(">I", zlib.crc32(b"tEXt" + payload))
+ )
+ path.write_bytes(content[:-12] + chunk + content[-12:])
+ with patch.object(
+ Image.Image, "load", side_effect=AssertionError("pixels")
+ ):
+ _, result = await self.inspect()
+ self.assertEqual(
+ result["prompts"]["positive"]["text"],
+ "猫" if "international" in kind else "portrait",
+ )
+
+ async def test_corrupt_truncated_duplicate_and_expanding_metadata_fail_closed(self):
+ import struct
+ import zlib
+ from utils.prompt_inspector import MAX_METADATA_BYTES
+
+ path = self.output_dir / "image.png"
+ base = png(path, prompt=graph()).read_bytes()
+ cases = [base[:-1], base[:30]]
+ index = base.index(b"tEXt")
+ broken = bytearray(base)
+ broken[index + 8] ^= 1
+ cases.append(bytes(broken))
+ for kind, payload in (
+ (b"tEXt", b"prompt\0{}"),
+ (b"zTXt", b"prompt\0\0" + zlib.compress(b"x" * (MAX_METADATA_BYTES + 1))),
+ (b"tEXt", b"prompt\0" + b"x" * (MAX_METADATA_BYTES + 1)),
+ (b"tEXt", b"bad-key-without-separator"),
+ (b"zTXt", b"prompt\0\1bad-compression"),
+ (b"iTXt", b"prompt\0\2\0\0\0text"),
+ (b"iTXt", b"prompt\0\1\1\0\0text"),
+ (b"iTXt", b"prompt\0\0\0\0\0\xff"),
+ ):
+ chunk = (
+ struct.pack(">I", len(payload))
+ + kind
+ + payload
+ + struct.pack(">I", zlib.crc32(kind + payload))
+ )
+ cases.append(base[:-12] + chunk + base[-12:])
+ for index, content in enumerate(cases):
+ path.write_bytes(content)
+ _, result = await self.inspect()
+ self.assertEqual(
+ result["prompts"]["positive"]["status"], "unresolved", index
+ )
+ self.assertIsNone(result["prompts"]["positive"]["text"])
+
+ async def test_jpeg_header_only_no_metadata_and_read_failure_are_safe(self):
+ path = self.output_dir / "image.jpg"
+ with Image.new("RGB", (4, 4)) as image:
+ image.save(path)
+ with patch.object(Image.Image, "load", side_effect=AssertionError("pixels")):
+ response, result = await self.inspect("image.jpg")
+ self.assertEqual(response.status, 200)
+ self.assertEqual(result["prompts"]["positive"]["status"], "absent")
+ with patch(
+ "py.api.images.read_inspection_metadata",
+ side_effect=RuntimeError(str(self.comfy_dir)),
+ ):
+ response, result = await self.inspect("image.jpg")
+ self.assertEqual(response.status, 500)
+ self.assertNotIn(str(self.comfy_dir), json.dumps(result))
+
+ async def test_invalid_json_depth_and_saved_filename_collision_do_not_escape(self):
+ path = png(self.output_dir / "image.png", prompt="[" * 2000)
+ _, result = await self.inspect()
+ self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
+ self._link_image(path, "Saved first")
+ other = self.comfy_dir / "input"
+ other.mkdir()
+ make_png(other / "image.png")
+ self.api._get_all_output_dirs = lambda: [self.output_dir, other]
+ with patch.object(
+ self.api.db,
+ "get_image_prompt_info",
+ wraps=self.api.db.get_image_prompt_info,
+ ) as lookup:
+ _, result = await self.inspect(root="1")
+ lookup.assert_called_once_with(str(other / "image.png"), False)
+ self.assertEqual(result["prompts"]["positive"]["status"], "absent")
+
+ async def test_video_inspection_uses_bounded_container_evidence(self):
+ import io
+ from unittest.mock import MagicMock
+ from utils.prompt_inspector import MAX_METADATA_BYTES
+
+ (self.output_dir / "clip.mp4").write_bytes(b"video fixture")
+ metadata = {"prompt": graph("Video portrait", "")}
+ payload = json.dumps(
+ {"format": {"tags": {"comment": json.dumps(metadata)}}}
+ ).encode()
+ process = MagicMock()
+ process.stdout = io.BytesIO(payload)
+ process.poll.return_value = 0
+ process.returncode = 0
+ with (
+ patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"),
+ patch(
+ "utils.prompt_inspector.subprocess.Popen", return_value=process
+ ) as spawn,
+ ):
+ response, result = await self.inspect("clip.mp4")
+ self.assertEqual(response.status, 200)
+ self.assertEqual(result["prompts"]["positive"], self.side("Video portrait"))
+ self.assertEqual(result["prompts"]["negative"], self.side(""))
+ self.assertTrue(process.stdout.closed)
+ process.wait.assert_called()
+ self.assertNotIn("shell", spawn.call_args.kwargs)
+ self.assertIn(str(self.output_dir / "clip.mp4"), spawn.call_args.args[0])
+ process.stdout = io.BytesIO(b"x" * (MAX_METADATA_BYTES + 1))
+ with (
+ patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"),
+ patch("utils.prompt_inspector.subprocess.Popen", return_value=process),
+ ):
+ _, result = await self.inspect("clip.mp4")
+ self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
+ self.assertTrue(process.stdout.closed)
+
+ async def test_video_missing_probe_and_timeout_preserve_uncertainty(self):
+ import io
+ from unittest.mock import MagicMock
+
+ (self.output_dir / "clip.webm").write_bytes(b"fixture")
+ with patch("utils.prompt_inspector.ffprobe_path", return_value=None):
+ _, result = await self.inspect("clip.webm")
+ self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
+ process = MagicMock()
+ process.stdout = io.BytesIO(b"")
+ process.poll.return_value = None
+ process.returncode = -9
+ with (
+ patch("utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"),
+ patch("utils.prompt_inspector.subprocess.Popen", return_value=process),
+ ):
+ _, result = await self.inspect("clip.webm")
+ self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
+ process.kill.assert_called_once()
+ process.wait.assert_called()
+ self.assertTrue(process.stdout.closed)
+
+ async def test_video_description_execution_evidence_and_invalid_envelope(self):
+ import io
+ from unittest.mock import MagicMock
+
+ path = self.output_dir / "clip.mp4"
+ path.write_bytes(b"fixture")
+ source = {
+ "1": {
+ "class_type": "PromptManagerPair",
+ "inputs": {"text": "portrait", "negative_text": "bad"},
+ },
+ "3": {
+ "class_type": "KSampler",
+ "inputs": {"positive": ["1", 0], "negative": ["1", 1]},
+ },
+ }
+ evidence = {}
+ record_pair_execution(
+ evidence,
+ "1",
+ text="portrait",
+ negative_text="bad",
+ positive_text="portrait trigger",
+ negative_effective_text="bad",
+ )
+ for value in (
+ {
+ "format": {
+ "tags": {
+ "comment": "not-json",
+ "description": json.dumps({"prompt": source, **evidence}),
+ }
+ }
+ },
+ [],
+ {"format": {"tags": []}},
+ {"format": {"tags": {}}},
+ {
+ "format": {
+ "tags": {
+ "comment": json.dumps(
+ {"prompt": source, EXECUTION_KEY: "bad-json"}
+ )
+ }
+ }
+ },
+ ):
+ process = MagicMock()
+ process.stdout = io.BytesIO(json.dumps(value).encode())
+ process.poll.return_value = 0
+ process.returncode = 0
+ with (
+ patch(
+ "utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"
+ ),
+ patch("utils.prompt_inspector.subprocess.Popen", return_value=process),
+ ):
+ _, result = await self.inspect("clip.mp4")
+ positive = result["prompts"]["positive"]
+ if (
+ isinstance(value, list)
+ or not isinstance(value["format"]["tags"], dict)
+ or "bad-json" in json.dumps(value)
+ ):
+ self.assertEqual(positive["status"], "unresolved")
+ elif not value["format"]["tags"]:
+ self.assertEqual(positive["status"], "absent")
+ else:
+ self.assertEqual(positive, self.side("portrait trigger", "execution"))
+
+ async def test_large_sparse_pixels_are_skipped_and_chunk_count_is_bounded(self):
+ import struct
+ import zlib
+ from utils.prompt_inspector import read_inspection_metadata, MetadataReadError
+
+ path = self.output_dir / "large.png"
+ header = struct.pack(">IIBBBBB", 1024, 1024, 8, 2, 0, 0, 0)
+
+ def chunk(kind, data):
+ return (
+ struct.pack(">I", len(data))
+ + kind
+ + data
+ + struct.pack(">I", zlib.crc32(kind + data))
+ )
+
+ with path.open("wb") as stream:
+ stream.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header))
+ stream.write(struct.pack(">I4s", 64 * 1024 * 1024, b"IDAT"))
+ stream.seek(64 * 1024 * 1024, 1)
+ stream.write(b"\0\0\0\0")
+ stream.write(chunk(b"tEXt", b"prompt\0" + json.dumps(graph()).encode()))
+ stream.write(chunk(b"IEND", b""))
+ original_open = type(path).open
+ reads = []
+
+ def tracked_open(item, *args, **kwargs):
+ stream = original_open(item, *args, **kwargs)
+ original_read = stream.read
+
+ def read(size=-1):
+ value = original_read(size)
+ reads.append(len(value))
+ return value
+
+ stream.read = read
+ return stream
+
+ with patch.object(type(path), "open", tracked_open):
+ metadata = read_inspection_metadata(path)
+ self.assertEqual(json.loads(metadata["prompt"]), graph())
+ self.assertLess(sum(reads), 4096)
+ with patch("utils.prompt_inspector.MAX_PNG_CHUNKS", 1):
+ with self.assertRaises(MetadataReadError):
+ read_inspection_metadata(path)
+
+ async def test_large_jpeg_uses_bounded_header_without_rejecting_valid_file(self):
+ from utils.prompt_inspector import MAX_HEADER_BYTES
+
+ path = self.output_dir / "large.jpg"
+ with Image.new("RGB", (4, 4)) as image:
+ image.save(path)
+ with path.open("ab") as stream:
+ stream.write(b"x" * (MAX_HEADER_BYTES + 1))
+ self._link_image(path, "Saved large JPEG")
+ with patch.object(Image.Image, "load", side_effect=AssertionError("pixels")):
+ _, result = await self.inspect("large.jpg")
+ self.assertEqual(
+ result["prompts"]["positive"],
+ {
+ "status": "unresolved",
+ "text": None,
+ "source": "saved-association",
+ "message": (
+ "A saved library association exists, but it cannot be "
+ "verified against this file."
+ ),
+ },
+ )
+
+ async def test_invalid_or_execution_only_video_evidence_never_uses_saved_fallback(
+ self,
+ ):
+ import io
+ from unittest.mock import MagicMock
+
+ path = self.output_dir / "clip.mp4"
+ path.write_bytes(b"fixture")
+ self._link_image(path, "Saved recipe must not replace unknown evidence")
+ for envelope in (
+ {"prompt": None},
+ {"parameters": None},
+ {EXECUTION_KEY: {"version": 1, "nodes": {}}},
+ ):
+ process = MagicMock()
+ process.stdout = io.BytesIO(
+ json.dumps(
+ {
+ "format": {
+ "tags": {
+ "comment": json.dumps(envelope),
+ "description": json.dumps(
+ {"prompt": graph("Other comment")}
+ ),
+ }
+ }
+ }
+ ).encode()
+ )
+ process.poll.return_value = 0
+ process.returncode = 0
+ with (
+ patch(
+ "utils.prompt_inspector.ffprobe_path", return_value="/tool/ffprobe"
+ ),
+ patch("utils.prompt_inspector.subprocess.Popen", return_value=process),
+ ):
+ _, result = await self.inspect("clip.mp4")
+ self.assertEqual(result["prompts"]["positive"]["status"], "unresolved")
+ self.assertIsNone(result["prompts"]["positive"]["text"])
+
+ async def test_same_path_same_size_replacement_does_not_resolve_stale_saved_prompts(
+ self,
+ ):
+ import io
+
+ metadata = {"prompt": graph("Original portrait", "Original negative")}
+ path = png(self.output_dir / "image.png", **metadata)
+ ingest_resolved_pair(
+ self.api.db, str(path), metadata, resolve_prompt_pair(metadata)
+ )
+ _, original = await self.inspect()
+ self.assertEqual(
+ original["prompts"]["positive"], self.side("Original portrait")
+ )
+ size = path.stat().st_size
+ replacement = io.BytesIO()
+ with Image.new("RGB", (4, 4), (0, 0, 255)) as image:
+ image.save(replacement, "PNG")
+ content = replacement.getvalue()
+ self.assertLessEqual(len(content), size)
+ path.write_bytes(content.ljust(size, b"\0"))
+ self.assertEqual(path.stat().st_size, size)
+ _, replaced = await self.inspect()
+ for record in replaced["prompts"].values():
+ self.assertEqual(record["status"], "unresolved")
+ self.assertIsNone(record["text"])
+ self.assertEqual(record["source"], "saved-association")
+ self.assertIn("cannot be verified", record["message"])
+ self.assertNotIn("Original portrait", json.dumps(replaced))
diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json
index 7c7a753..ba3c41b 100644
--- a/tools/vendor/frontend-coverage-baseline.json
+++ b/tools/vendor/frontend-coverage-baseline.json
@@ -190,5 +190,17 @@
"branches": 93,
"functions": 98,
"statements": 98
+ },
+ "workspace/prompt-inspector.mjs": {
+ "lines": 97,
+ "branches": 92,
+ "functions": 98,
+ "statements": 97
+ },
+ "workspace/prompt-sender.mjs": {
+ "lines": 98,
+ "branches": 89,
+ "functions": 98,
+ "statements": 98
}
}
diff --git a/utils/prompt_inspector.py b/utils/prompt_inspector.py
new file mode 100644
index 0000000..03d6c10
--- /dev/null
+++ b/utils/prompt_inspector.py
@@ -0,0 +1,267 @@
+"""Read prompt evidence without inference, indexing, or decoding image pixels."""
+
+import io
+import json
+import struct
+import subprocess # nosec B404
+import threading
+import zlib
+from pathlib import Path
+from typing import Any
+
+from PIL import Image
+
+from .pair_execution import EXECUTION_KEY
+from .prompt_pair_resolver import resolve_prompt_pair
+from .video_metadata import ffprobe_path, is_video_path, metadata_from_tags
+
+MAX_METADATA_BYTES = 2 * 1024 * 1024
+MAX_HEADER_BYTES = 8 * 1024 * 1024
+MAX_PNG_CHUNKS = 10_000
+_METADATA_KEYS = frozenset({"prompt", "parameters", EXECUTION_KEY})
+_SIGNATURE = b"\x89PNG\r\n\x1a\n"
+
+
+class MetadataReadError(ValueError):
+ """Prompt evidence is unreadable, incomplete, or exceeds inspection limits."""
+
+
+def _inflate(data: bytes) -> bytes:
+ inflater = zlib.decompressobj()
+ result = inflater.decompress(data, MAX_METADATA_BYTES + 1)
+ if len(result) > MAX_METADATA_BYTES or not inflater.eof or inflater.unused_data:
+ raise MetadataReadError("Compressed metadata is invalid or exceeds limits")
+ return result
+
+
+def _png_text(kind: bytes, data: bytes) -> tuple[str, str]:
+ keyword, separator, body = data.partition(b"\0")
+ if not separator or not 1 <= len(keyword) <= 79:
+ raise MetadataReadError("Malformed PNG text metadata")
+ key = keyword.decode("latin-1")
+ encoding = "latin-1"
+ if kind == b"zTXt":
+ if not body or body[0] != 0:
+ raise MetadataReadError("Unsupported PNG text compression")
+ body = _inflate(body[1:])
+ elif kind == b"iTXt":
+ if len(body) < 2 or body[0] not in (0, 1):
+ raise MetadataReadError("Malformed international PNG metadata")
+ flag, method = body[:2]
+ fields = body[2:].split(b"\0", 2)
+ if len(fields) != 3 or (flag and method != 0):
+ raise MetadataReadError("Malformed international PNG metadata")
+ fields[0].decode("ascii")
+ fields[1].decode("utf-8")
+ body = _inflate(fields[2]) if flag else fields[2]
+ encoding = "utf-8"
+ if b"\0" in body:
+ raise MetadataReadError("Malformed PNG text metadata")
+ return key, body.decode(encoding)
+
+
+def _png_metadata(stream: Any, size: int) -> dict:
+ """CRC-check text and structural chunks, seeking past compressed pixels.
+
+ PNG text chunks may follow IDAT. Pillow's ``.text`` loads pixels to find
+ them; this bounded reader instead follows the W3C PNG chunk layout.
+ IDAT checksums/pixel validity are deliberately outside metadata inspection.
+ """
+ found, compressed_size, expanded_size = {}, 0, 0
+ saw_header = saw_pixels = False
+ for index in range(MAX_PNG_CHUNKS):
+ header = stream.read(8)
+ if len(header) != 8:
+ raise MetadataReadError("Incomplete PNG metadata")
+ length, kind = struct.unpack(">I4s", header)
+ if length > 0x7FFFFFFF or stream.tell() + length + 4 > size:
+ raise MetadataReadError("Incomplete PNG chunk")
+ if index == 0 and (kind != b"IHDR" or length != 13):
+ raise MetadataReadError("Missing PNG header")
+ if kind in (b"tEXt", b"zTXt", b"iTXt", b"IHDR", b"IEND"):
+ compressed_size += length
+ if compressed_size > MAX_METADATA_BYTES:
+ raise MetadataReadError("Metadata exceeds inspection limits")
+ data, checksum = stream.read(length), stream.read(4)
+ if zlib.crc32(kind + data) != int.from_bytes(checksum, "big"):
+ raise MetadataReadError("PNG metadata checksum mismatch")
+ if kind == b"IHDR":
+ if saw_header or not all(struct.unpack(">II", data[:8])):
+ raise MetadataReadError("Malformed PNG header")
+ saw_header = True
+ elif kind == b"IEND":
+ if length or not saw_pixels:
+ raise MetadataReadError("Incomplete PNG image")
+ return found
+ else:
+ key, value = _png_text(kind, data)
+ expanded_size += len(value.encode("utf-8"))
+ if expanded_size > MAX_METADATA_BYTES:
+ raise MetadataReadError("Metadata exceeds inspection limits")
+ if key in _METADATA_KEYS:
+ if key in found:
+ raise MetadataReadError("Duplicate prompt metadata")
+ found[key] = value
+ else:
+ saw_pixels |= kind == b"IDAT"
+ stream.seek(length + 4, 1)
+ raise MetadataReadError("PNG chunk count exceeds inspection limits")
+
+
+def _video_metadata(path: Path) -> dict:
+ probe = ffprobe_path()
+ if not probe:
+ raise MetadataReadError("Video metadata reader is unavailable")
+ command = [
+ probe,
+ "-v",
+ "quiet",
+ "-show_entries",
+ "format_tags=comment,description",
+ "-of",
+ "json",
+ str(path),
+ ]
+ process = subprocess.Popen( # nosec B603
+ command,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
+ )
+
+ def stop() -> None:
+ if process.poll() is None:
+ try:
+ process.kill()
+ except OSError:
+ # Timeout and final cleanup can race with the process exiting.
+ pass
+
+ timer = threading.Timer(3, stop)
+ timer.daemon = True
+ timer.start()
+ try:
+ raw = process.stdout.read(MAX_METADATA_BYTES + 1)
+ if len(raw) > MAX_METADATA_BYTES:
+ raise MetadataReadError("Video metadata exceeds inspection limits")
+ process.wait()
+ if process.returncode:
+ raise MetadataReadError("Video metadata could not be read")
+ finally:
+ timer.cancel()
+ stop()
+ process.wait()
+ process.stdout.close()
+ probed = json.loads(raw)
+ if not isinstance(probed, dict) or not isinstance(probed.get("format"), dict):
+ raise MetadataReadError("Malformed video metadata")
+ tags = probed["format"].get("tags", {})
+ if not isinstance(tags, dict):
+ raise MetadataReadError("Malformed video metadata")
+ # The scan normalizer drops null fields and unknown keys. Inspection must
+ # retain explicit prompt/execution markers so malformed evidence cannot
+ # silently fall back to an unrelated saved recipe or another comment.
+ for key in ("comment", "description"):
+ raw = tags.get(key)
+ if not isinstance(raw, str):
+ continue
+ try:
+ envelope = json.loads(raw)
+ except ValueError:
+ continue
+ if not isinstance(envelope, dict) or not _METADATA_KEYS.intersection(envelope):
+ continue
+ metadata = metadata_from_tags({key: raw})
+ for field in _METADATA_KEYS.intersection(envelope):
+ value = envelope[field]
+ metadata[field] = (
+ value if value is None or isinstance(value, str) else json.dumps(value)
+ )
+ return metadata
+ return {}
+
+
+def read_inspection_metadata(path: Path) -> dict:
+ """Return bounded, header-only metadata or raise a response-safe error."""
+ try:
+ if is_video_path(path):
+ metadata = _video_metadata(path)
+ else:
+ with path.open("rb") as stream:
+ signature = stream.read(8)
+ if signature == _SIGNATURE:
+ return _png_metadata(stream, path.stat().st_size)
+ stream.seek(0)
+ header = stream.read(MAX_HEADER_BYTES)
+ with Image.open(io.BytesIO(header)) as image:
+ metadata = dict(image.info)
+ result, size = {}, 0
+ for key in _METADATA_KEYS:
+ if key not in metadata:
+ continue
+ value = metadata[key]
+ if not isinstance(value, str):
+ raise MetadataReadError("Malformed prompt metadata")
+ size += len(value.encode("utf-8"))
+ if size > MAX_METADATA_BYTES:
+ raise MetadataReadError("Metadata exceeds inspection limits")
+ result[key] = value
+ return result
+ except MetadataReadError:
+ raise
+ except (
+ OSError,
+ ValueError,
+ EOFError,
+ SyntaxError,
+ RecursionError,
+ struct.error,
+ zlib.error,
+ ):
+ raise MetadataReadError("Media metadata could not be read") from None
+
+
+def _side(status: str, text: str | None = None, source: str | None = None) -> dict:
+ return {"status": status, "text": text, "source": source}
+
+
+def inspect_prompt_roles(metadata: dict, saved: dict | None = None) -> dict:
+ """Keep uncertainty visible; a saved association never replaces file evidence."""
+ if not metadata:
+ result = {role: _side("absent") for role in ("positive", "negative")}
+ if saved:
+ context = saved.get("pair_context")
+ paired = isinstance(saved.get("pair"), dict) or isinstance(context, dict)
+ for role in ("positive", "negative") if paired else ("positive",):
+ result[role] = {
+ **_side("unresolved", source="saved-association"),
+ "message": (
+ "A saved library association exists, but it cannot be "
+ "verified against this file."
+ ),
+ }
+ return result
+ canonical = dict(metadata)
+ if "prompt" not in canonical and "parameters" not in canonical:
+ return {role: _side("unresolved") for role in ("positive", "negative")}
+ if isinstance(canonical.get("prompt"), str):
+ try:
+ canonical["prompt"] = json.loads(canonical["prompt"])
+ except (ValueError, RecursionError):
+ return {role: _side("unresolved") for role in ("positive", "negative")}
+ resolution = resolve_prompt_pair(canonical)
+ state = resolution["state"]
+ result = {}
+ for role in ("positive", "negative"):
+ side = resolution[role]
+ if (
+ state != "ambiguous"
+ and isinstance(side, dict)
+ and (role == "negative" or side["text"].strip())
+ ):
+ result[role] = _side(
+ "resolved", side["text"], side["provenance"]["text_source"]
+ )
+ else:
+ result[role] = _side(state if state != "resolved" else "unresolved")
+ return result
diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css
index 4881459..cf225b7 100644
--- a/web/js/workspace/images.css
+++ b/web/js/workspace/images.css
@@ -57,3 +57,46 @@ main.images-main { max-inline-size: none; padding: 28px; }
}
.image-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; }
.options-status { font-size: 12px; padding-inline: 4px; }
+
+.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; }
+.prompt-block { border: 1px solid var(--ca-line); border-inline-start: 3px solid var(--prompt-role); border-radius: 7px; padding: 14px; margin-block-end: 14px; min-inline-size: 0; }
+.prompt-positive { --prompt-role: #6bd6a4; }
+[data-ca-theme="light"] .prompt-positive { --prompt-role: #14784f; }
+.prompt-negative { --prompt-role: #ff7f7f; }
+[data-ca-theme="light"] .prompt-negative { --prompt-role: #b51d2d; }
+.prompt-block h3 { font-size: 12px; margin: 0 0 8px; color: var(--prompt-role); }
+.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 11px; }
+.prompt-text { font: inherit; font-size: 12px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 12px; }
+.prompt-text:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
+.prompt-actions { display: flex; flex-wrap: wrap; gap: 8px; }
+.prompt-actions button { font-size: 11px; }
+.prompt-copy-status { font-size: 11px; margin-block-end: 0; }
+.prompt-copy-status:empty { display: none; }
+.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(270px, 340px); }
+.image-preview-details { padding: 0 18px 18px; max-block-size: 75dvh; overflow: auto; min-inline-size: 0; }
+.image-preview-details .prompt-inspector { margin-block-start: 0; }
+.image-preview > header { border-block-end: 1px solid var(--ca-line); }
+.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); }
+.prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; }
+.prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; }
+.prompt-sender::backdrop { background: #000b; }
+.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
+.prompt-sender-header h2 { font-size: 20px; margin: 0; }
+.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; }
+.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; }
+.prompt-sender select { inline-size: 100%; min-block-size: 38px; }
+.prompt-sender-status { font-size: 13px; line-height: 1.6; }
+.prompt-sender-status[role="alert"] { color: var(--ca-error-text); }
+.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; }
+.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; }
+.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; }
+.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; }
+.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; }
+.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; }
+@media (max-width: 780px) {
+ .image-preview-body { grid-template-columns: minmax(0, 1fr); }
+ .image-preview-stage { block-size: 52dvh; }
+ .image-preview-details { max-block-size: none; }
+ .image-preview { max-block-size: calc(100dvh - 24px); overflow: auto; }
+ .prompt-sender-options { grid-template-columns: minmax(0, 1fr); }
+}
diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs
index bdf3d2e..1566cf8 100644
--- a/web/js/workspace/images.mjs
+++ b/web/js/workspace/images.mjs
@@ -1,6 +1,8 @@
import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js';
import { createImageStream, imageKey } from './images-state.mjs';
import { layoutImages, visibleImages } from './image-layout.mjs';
+import { mountPromptInspector } from './prompt-inspector.mjs';
+import { createPromptSender } from './prompt-sender.mjs';
function el(tag, text, className) {
const element = document.createElement(tag);
@@ -19,6 +21,9 @@ export function mountImages(main, api, parentScope) {
const scope = createScope();
parentScope.cleanup(() => scope.dispose());
const { resolveUrl } = window.ApiClient;
+ const sender = createPromptSender({scope});
+ let detailInspector, previewInspector;
+ scope.cleanup(() => { detailInspector?.dispose(); previewInspector?.dispose(); });
const visit = lastVisit;
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer, viewer;
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, optionsRequest;
@@ -107,11 +112,17 @@ export function mountImages(main, api, parentScope) {
const close = createButton('Close preview');
dialogHeader.append(previewTitle, close);
const stage = el('div', undefined, 'image-preview-stage');
- dialog.append(dialogHeader, stage);
+ const previewBody = el('div', undefined, 'image-preview-body');
+ const previewDetails = el('aside', undefined, 'image-preview-details');
+ previewDetails.setAttribute('aria-label', 'Preview prompt details');
+ previewBody.append(stage, previewDetails);
+ dialog.append(dialogHeader, previewBody);
main.append(dialog);
scope.listen(close, 'click', () => dialog.close());
scope.listen(dialog, 'close', () => { destroyPreview(); scheduleLoad(); });
function destroyPreview() {
+ previewInspector?.dispose(); previewInspector = null;
+ previewDetails.replaceChildren();
viewer?.destroy(); viewer = null;
for (const video of stage.querySelectorAll('video')) releaseVideo(video);
stage.replaceChildren();
@@ -130,6 +141,7 @@ export function mountImages(main, api, parentScope) {
video.pause(); video.removeAttribute('src'); video.load();
}
function inspect(image) {
+ detailInspector?.dispose();
selection = imageKey(image);
for (const video of details.querySelectorAll('video')) releaseVideo(video);
const thumbnail = mediaElement(image);
@@ -145,8 +157,10 @@ export function mountImages(main, api, parentScope) {
preview.dataset.previewKey = selection;
const download = el('a', 'Download original', 'workspace-link');
download.href = resolveUrl(image.url); download.download = image.filename;
- const legacy = el('p', 'Open Current gallery for prompt details and tagging.', 'muted');
- details.replaceChildren(el('p', 'SELECTED IMAGE', 'eyebrow'), el('h2', image.filename), thumbnail, metadata, preview, download, legacy);
+ const prompts = el('div');
+ const legacy = el('p', 'Open Current gallery for tagging.', 'muted');
+ details.replaceChildren(el('p', 'SELECTED IMAGE', 'eyebrow'), el('h2', image.filename), thumbnail, preview, download, prompts, metadata, legacy);
+ detailInspector = mountPromptInspector(prompts, {api, image, sender, parentScope:scope});
for (const [key, card] of rendered) {
card.classList.toggle('selected', key === selection);
card.querySelector('.image-open').setAttribute('aria-pressed', String(key === selection));
@@ -161,6 +175,7 @@ export function mountImages(main, api, parentScope) {
if (selection !== imageKey(image)) inspect(image);
destroyPreview();
previewTitle.textContent = image.filename;
+ previewInspector = mountPromptInspector(previewDetails, {api, image, sender, parentScope:scope});
if (!dialog.open) dialog.showModal();
if (image.is_video) {
const video = el('video'); video.controls = true; video.src = resolveUrl(image.url); video.preload = 'metadata';
@@ -272,9 +287,19 @@ export function mountImages(main, api, parentScope) {
grid.style.height = `${geometry.height}px`;
renderVisible();
}
- const resize = new ResizeObserver(updateLayout); resize.observe(grid);
+ let resizeFrame = 0, observedWidth;
+ const resize = new ResizeObserver(entries => {
+ const width = entries[0].contentRect.width;
+ if (width === observedWidth) return;
+ observedWidth = width;
+ if (!resizeFrame) resizeFrame = requestAnimationFrame(() => {
+ resizeFrame = 0;
+ updateLayout();
+ });
+ });
+ resize.observe(grid);
scope.listen(window, 'scroll', scheduleVisible);
- scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
+ scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
function renderState(next) {
const changed = state?.items !== next.items;
if (changed) {
@@ -315,6 +340,7 @@ export function mountImages(main, api, parentScope) {
observer.observe(continuation);
}
function reset() {
+ detailInspector?.dispose(); detailInspector = null;
selection = null;
for (const video of details.querySelectorAll('video')) releaseVideo(video);
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
diff --git a/web/js/workspace/prompt-inspector.mjs b/web/js/workspace/prompt-inspector.mjs
new file mode 100644
index 0000000..bcd9bc8
--- /dev/null
+++ b/web/js/workspace/prompt-inspector.mjs
@@ -0,0 +1,124 @@
+import { createScope, createButton } from '../shared/toolkit/core.js';
+import { createSendButton } from './prompt-sender.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 = {
+ 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 function mountPromptInspector(container, {api, image, sender, parentScope}) {
+ const scope = createScope();
+ const dispose = () => scope.dispose();
+ parentScope.signal.addEventListener('abort', dispose, {once:true});
+ scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
+ container.classList.add('prompt-inspector');
+ const loader = createInspectionLoader(api, render);
+ scope.cleanup(() => loader.dispose());
+ 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]')) loader.load(image);
+ });
+ let roles = {};
+ function render(state) {
+ 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) {
+ const retry = createButton('Retry prompt details');
+ retry.dataset.retryPrompts = '';
+ container.append(retry);
+ }
+ return;
+ }
+ roles = promptRoles(state.prompts);
+ for (const role of ['positive', 'negative']) {
+ 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);
+ block.append(el('h3', title));
+ 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(`Copy ${role} prompt`);
+ copy.dataset.copyRole = role;
+ actions.append(send, copy);
+ const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
+ block.append(actions, 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);
+ }
+ }
+ loader.load(image);
+ return {dispose:() => scope.dispose()};
+}
diff --git a/web/js/workspace/prompt-sender.mjs b/web/js/workspace/prompt-sender.mjs
new file mode 100644
index 0000000..8d33a00
--- /dev/null
+++ b/web/js/workspace/prompt-sender.mjs
@@ -0,0 +1,155 @@
+import {createBridgeClient, validateRoles} from '../shared/toolkit/prompt-bridge.js';
+import {createButton, createField, createScope, createSelect} from '../shared/toolkit/core.js';
+
+export function createSendSession({client, roles:rawRoles, onChange}) {
+ const roles = Object.freeze(validateRoles(rawRoles));
+ let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,message:''};
+ let operation, generation = 0, disposed = false;
+ const emit = patch => { if (!disposed) { state = Object.freeze({...state,...patch}); onChange(state); } };
+ function invalidate() { generation++; operation?.abort(); operation = null; }
+ function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,message:''}); return {signal:operation.signal,version:generation}; }
+ const current = request => !disposed && generation === request.version;
+ const selected = () => state.hosts.find(host=>host.hostId===state.hostId)?.targets.find(target=>target.id===state.targetId);
+ return {
+ get state() { return state; },
+ async discover() {
+ if (disposed) return;
+ const request = begin('discovering'); emit({hosts:[],hostId:'',targetId:'',message:'Looking for ComfyUI windows…'});
+ try {
+ const found = await client.discover({signal:request.signal});
+ if (!current(request)) return;
+ const hosts = found.map(host=>({...host,targets:host.targets.filter(target=>typeof target.id==='string' && Array.isArray(target.roles) && Object.keys(roles).every(role=>target.roles.includes(role)))})).filter(host=>host.targets.length);
+ emit({phase:'select',hosts,message:hosts.length ? 'Choose a ComfyUI window and destination node.' : 'No compatible ComfyUI windows found. Open a workflow with a compatible Prompt Manager node, then find windows again.'});
+ } catch (error) { if (current(request)) emit({phase:'error',message:error.message}); }
+ },
+ selectHost(hostId) { if (disposed) return; invalidate(); emit({phase:'select',hostId:state.hosts.some(host=>host.hostId===hostId)?hostId:'',targetId:'',preview:null,message:'Choose a destination node, then preview changes.'}); },
+ selectTarget(targetId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===state.hostId); emit({phase:'select',targetId:host?.targets.some(target=>target.id===targetId)?targetId:'',preview:null,message:'Preview changes before applying.'}); },
+ async preview() {
+ if (disposed || !selected() || ['previewing','applying'].includes(state.phase)) return;
+ const request=begin('previewing');
+ try {
+ const preview=await client.preview(state.hostId,state.targetId,roles,{signal:request.signal});
+ if (!current(request)) return;
+ const fields=preview?.fields;
+ if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==state.targetId || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
+ emit({phase:'ready',preview,message:'Review the complete changes, then apply.'});
+ } catch(error) { if(current(request)) emit({phase:'error',preview:null,message:error.message}); }
+ },
+ async apply() {
+ if(disposed || state.phase!=='ready' || !state.preview) return;
+ const {ticket}=state.preview; const targetId=state.targetId; const hostId=state.hostId;
+ const request=begin('applying');
+ emit({message:'Waiting for ComfyUI confirmation. Closing this dialog does not undo a sent update.'});
+ try {
+ const acknowledgement=await client.apply(hostId,ticket,{signal:request.signal});
+ if(!current(request)) return;
+ if(acknowledgement?.targetId!==targetId || !acknowledgement.values || Object.keys(acknowledgement.values).length!==Object.keys(roles).length || Object.keys(roles).some(role=>!Object.hasOwn(acknowledgement.values,role) || acknowledgement.values[role]!==roles[role])) throw Error('ComfyUI returned an unexpected acknowledgement.');
+ emit({phase:'success',message:'Sent to ComfyUI.'});
+ } catch(error) { if(current(request)) emit({phase:'error',preview:null,message:`No confirmation received. The update may have applied. Check ComfyUI, then preview again. ${error.message}`}); }
+ },
+ dispose() { if(disposed) return; disposed=true; invalidate(); client.dispose(); },
+ };
+}
+
+function element(tag,text,className) {
+ const node=document.createElement(tag);
+ if(text!==undefined) node.textContent=text;
+ if(className) node.className=className;
+ return node;
+}
+
+export function createSendButton(label='Send to ComfyUI') {
+ const button=createButton('', 'prompt-send-button');
+ button.setAttribute('aria-label',label);
+ const icon=document.createElementNS('http://www.w3.org/2000/svg','svg');
+ icon.setAttribute('viewBox','0 0 24 24');icon.setAttribute('aria-hidden','true');icon.setAttribute('fill','none');icon.setAttribute('stroke','currentColor');icon.setAttribute('stroke-width','1.8');
+ const path=document.createElementNS('http://www.w3.org/2000/svg','path');
+ path.setAttribute('d','M21 3 9.5 14.5M21 3l-7 18-4.5-6.5L3 10z');icon.append(path);
+ button.append(icon,element('span',label));
+ return button;
+}
+
+export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/prompt_manager/'),clientFactory=createBridgeClient}) {
+ let active;
+ const close = () => active?.dispose();
+ scope.cleanup(close);
+ return {open(roles,trigger) {
+ if(scope.signal.aborted) return;
+ close();
+ const local=createScope();
+ const dialog=element('dialog',undefined,'prompt-sender');
+ dialog.setAttribute('aria-label','Send to ComfyUI');
+ const header=element('header',undefined,'prompt-sender-header');
+ const title=element('h2','Send to ComfyUI');const dismiss=createButton('Close');
+ header.append(title,dismiss);
+ const description=element('p','Choose where to send this prompt. Nothing changes until you approve the preview.');
+ const status=element('p','', 'prompt-sender-status');status.setAttribute('role','status');status.setAttribute('aria-live','polite');
+ const host=createSelect([['','Choose a ComfyUI window']], '');
+ const target=createSelect([['','Choose a destination node']], '');
+ const controls=element('div',undefined,'prompt-sender-options');
+ controls.append(createField('ComfyUI window',host,'prompt-sender-host'),createField('Destination node',target,'prompt-sender-target'));
+ const preview=element('section',undefined,'prompt-sender-preview');preview.setAttribute('aria-label','Prompt changes');
+ const find=createButton('Find ComfyUI windows');const inspect=createButton('Preview changes');const apply=createButton('Apply to ComfyUI','ca-primary');
+ inspect.disabled=true;apply.disabled=true;
+ const actions=element('footer',undefined,'prompt-sender-actions');actions.append(find,inspect,apply);
+ dialog.append(header,description,controls,status,preview,actions);document.body.append(dialog);
+ let session,closed=false;
+ function dispose() {
+ if(closed) return;closed=true;local.dispose();session?.dispose();
+ if(dialog.open) dialog.close();dialog.remove();
+ if(active?.dialog===dialog) active=null;
+ if(trigger?.isConnected) trigger.focus();
+ }
+ active={dialog,dispose};
+ local.listen(dismiss,'click',dispose);local.listen(dialog,'close',dispose);
+ local.listen(host,'change',()=>session?.selectHost(host.value));
+ local.listen(target,'change',()=>session?.selectTarget(target.value));
+ local.listen(find,'click',()=>session?.discover());
+ local.listen(inspect,'click',()=>session?.preview());
+ local.listen(apply,'click',()=>session?.apply());
+ function options(select,placeholder,entries,value) {
+ select.replaceChildren(...[["",placeholder],...entries].map(([value,label])=>{const option=element('option',label);option.value=value;return option;}));select.value=value;
+ }
+ function textBlock(parent,label,value) {
+ parent.append(element('h4',label));const text=element('pre');text.tabIndex=0;text.append(element('code',value === '' ? '(empty)' : value));parent.append(text);
+ }
+ function render(state) {
+ if(closed) return;
+ const busy=['discovering','previewing','applying'].includes(state.phase);
+ status.textContent=state.message;status.dataset.phase=state.phase;
+ status.setAttribute('role',state.phase==='error'?'alert':'status');status.setAttribute('aria-live',state.phase==='error'?'assertive':'polite');
+ dialog.setAttribute('aria-busy',String(busy));
+ options(host,'Choose a ComfyUI window',state.hosts.map(item=>[item.hostId,item.label]),state.hostId);
+ const selected=state.hosts.find(item=>item.hostId===state.hostId);
+ options(target,'Choose a destination node',(selected?.targets || []).map(item=>[item.id,item.label]),state.targetId);
+ host.disabled=busy;target.disabled=busy || !selected;
+ find.disabled=busy;inspect.disabled=busy || !state.targetId;apply.disabled=state.phase!=='ready';
+ preview.replaceChildren();
+ if(state.preview) {
+ preview.append(element('h3','Review changes'));
+ for(const field of state.preview.fields || []) {
+ const section=element('section',undefined,`prompt-sender-field prompt-role-${field.role}`);
+ section.append(element('h3',field.role==='positive'?'Positive prompt':'Negative prompt'));
+ textBlock(section,'Before',field.before);textBlock(section,'After',field.after);preview.append(section);
+ }
+ const composition=state.preview.composition;
+ if(composition) {
+ const section=element('section',undefined,'prompt-sender-composition');section.append(element('h3','Positive composition'));
+ textBlock(section,'Prepend (preserved)',composition.prepend || '');textBlock(section,'Append (preserved)',composition.append || '');
+ textBlock(section,'Combined before',composition.before ?? 'Resolved at execution from connected inputs.');
+ textBlock(section,'Combined after',composition.after ?? 'Resolved at execution from connected inputs.');
+ if(composition.prependConnected || composition.appendConnected) section.append(element('p','Connected prepend and append inputs are preserved and resolved when the workflow runs.'));
+ preview.append(section);
+ }
+ }
+ }
+ dialog.showModal();
+ try {
+ const url=new URL(baseUrl,window.location.href);
+ if(url.origin!==window.location.origin || !url.pathname.endsWith('/prompt_manager/')) throw Error('Invalid PromptManager deployment origin or path');
+ const client=clientFactory({namespace:url.pathname.slice(0,-'prompt_manager/'.length),origin:window.location.origin});
+ try { session=createSendSession({client,roles,onChange:render}); } catch(error) {client.dispose();throw error;}
+ session.discover();
+ } catch(error) {status.textContent=`Prompt sending is unavailable. ${error.message}`;status.dataset.phase='error';status.setAttribute('role','alert');status.setAttribute('aria-live','assertive');find.disabled=true;host.disabled=true;target.disabled=true;}
+ }};
+}