feat(workspace): inspect and send positive and negative prompts

This commit is contained in:
Vito Sansevero
2026-10-17 13:25:10 -07:00
parent 76e9b51079
commit aaf75abc15
19 changed files with 1760 additions and 5 deletions
+14
View File
@@ -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.
+32
View File
@@ -0,0 +1,32 @@
# 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,
embedded generation parameters, or an exact saved-library association. 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+73
View File
@@ -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:
+169
View File
@@ -0,0 +1,169 @@
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.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 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();}
});
+42 -2
View File
@@ -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")
+68
View File
@@ -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();}
});
+36
View File
@@ -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);
});
+121
View File
@@ -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:'<unsafe>new</unsafe>'}],composition:{prepend:'prefix',append:'suffix',before:null,after:null,prependConnected:true}}),apply:async()=>({targetId:'one',values:{positive:'<unsafe>new</unsafe>'}}),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:'<unsafe>new</unsafe>'},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==='<unsafe>new</unsafe>'));
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();
});
+581
View File
@@ -0,0 +1,581 @@
"""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_association_fallback_is_exact_and_uses_unique_context(self):
path = make_png(self.output_dir / "image.png")
self._link_image(path, "Saved positive")
_, result = await self.inspect()
self.assertEqual(
result["prompts"]["positive"],
self.side("Saved positive", "saved-association"),
)
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"],
self.side("Effective positive", "saved-association"),
)
self.assertEqual(
result["prompts"]["negative"],
self.side("Recorded negative", "saved-association"),
)
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"],
self.side("Saved large JPEG", "saved-association"),
)
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"])
+12
View File
@@ -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
}
}
+271
View File
@@ -0,0 +1,271 @@
"""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:
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:
context = saved.get("pair_context") if saved else None
if isinstance(context, dict) and all(
isinstance(context.get(f"{role}_text"), str)
for role in ("positive", "negative")
):
return {
role: _side("resolved", context[f"{role}_text"], "saved-association")
for role in ("positive", "negative")
}
positive = saved.get("text") if saved else None
return {
"positive": (
_side("resolved", positive, "saved-association")
if isinstance(positive, str)
else _side("absent")
),
"negative": _side("absent"),
}
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
+43
View File
@@ -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); }
}
+19 -3
View File
@@ -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';
@@ -315,6 +330,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.'));
+124
View File
@@ -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', 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()};
}
+155
View File
@@ -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;}
}};
}