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;} + }}; +}