From 7ffd1bc0e74d8bfceec986588aa0befd2cd92bbf Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 18 Oct 2026 04:52:10 -0700 Subject: [PATCH] feat(inspector): share complete file details and exports --- tests/browser/full-inspector.spec.mjs | 113 +++++++++++ tests/browser/prompt-inspector.spec.mjs | 4 +- tests/browser/workspace-library.spec.mjs | 2 + tests/js/workspace-file-inspector.test.js | 50 +++++ web/js/workspace/file-inspector.mjs | 230 ++++++++++++++++++++++ web/js/workspace/images.css | 2 +- web/js/workspace/images.mjs | 44 ++--- web/js/workspace/inspector.css | 38 ++++ web/js/workspace/prompt-blocks.mjs | 4 +- web/js/workspace/prompt-inspector.mjs | 24 +-- web/workspace.html | 1 + 11 files changed, 460 insertions(+), 52 deletions(-) create mode 100644 tests/js/workspace-file-inspector.test.js create mode 100644 web/js/workspace/file-inspector.mjs create mode 100644 web/js/workspace/inspector.css diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 1c1b77e..09f66cf 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -1,5 +1,9 @@ +import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +test.beforeEach(async({page,browserName})=>{await startCoverage(page,browserName);page.inspectorErrors=[];page.on('pageerror',error=>page.inspectorErrors.push(error.message));}); +test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.inspectorErrors).toEqual([]);}); + test('inspection facts survive actual API to browser transport', async ({ page, request }) => { await expect(await request.post('/__test__/reset', { data: { full_inspector: true } })).toBeOK(); await page.goto('/prompt_manager/workspace'); @@ -76,3 +80,112 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => { expect(collection.images.some(image => image.path.endsWith('clip.webm'))).toBe(true); await expect(await request.post('/__test__/reset')).toBeOK(); }); + +const inspector=page=>page.locator('.image-details'); +const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click(); +async function fullFixture(page,request) { + await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK(); + await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png'); +} +async function downloadText(page,action) { + const [download]=await Promise.all([page.waitForEvent('download'),action.click()]); + const fs=await import('node:fs/promises');return {name:download.suggestedFilename(),text:await fs.readFile(await download.path(),'utf8')}; +} + +test('full inspector shows six facts and scoped provenance',async({page,request})=>{ + await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page); + await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png'); + await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence'); + expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310); + expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px'); + await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); + await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); + await expect(panel).toContainText('Recipe tags');await expect(panel).toContainText('Recipe category'); + await expect(panel.locator('.file-vocabulary button')).toHaveCount(0); + await panel.getByText('File & provenance',{exact:true}).click(); + await expect(panel).toContainText('Current file at this source; historical bytes are not verified.');await expect(panel).toContainText('Saved association; not verified against current file'); + await expect(panel).toContainText('Configuration evidence');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/); + await expect(panel).not.toContainText(/Run:|Original file unchanged|Execution recorded/); + await selectFile(page,'empty.png');await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible(); + await expect(panel.getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveCount(0); + await selectFile(page,'explicit-empty.png');await expect(panel).toContainText('Empty negative prompt');await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(1); +}); + +test('full inspector preserves long role text and exact sends',async({page,request,context})=>{ + const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview'); + await host.evaluate(async()=>{const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');const target={id:'pair',label:'Exact pair',type:'PromptManagerPair',roles:['positive','negative']};window.sent=[];window.bridge=createBridgeHost({origin:location.origin,namespace:'/',label:'Exact host',adapter:{listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:'',after}))},snapshot:roles}),apply:roles=>{window.sent.push(roles);return{targetId:target.id,values:roles};}}});}); + try { + await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000); + await panel.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']); + await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'}); + 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.sent)).toEqual([{positive:text,negative:'blurred edges, watermark'}]); + } finally {await host.evaluate(()=>window.bridge.dispose());await host.close();} +}); + +test('full inspector downloads actual bounded evidence',async({page,request})=>{ + await fullFixture(page,request);const panel=inspector(page);await panel.getByText('Workflow & raw metadata',{exact:true}).click(); + const list=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector')).json();const image=list.images.find(image=>image.root_index===0&&image.path==='inspector/complete.png'); + const result=await(await request.get(`/prompt_manager/images/inspect/inspector/complete.png?root=0&root_scope=${image.root_scope}`)).json(); + await expect(panel).toContainText('Original embedded text');const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));expect(metadata.name).toBe('complete.metadata.json');expect(JSON.parse(metadata.text).metadata.raw).toEqual(result.metadata.raw);expect(JSON.parse(metadata.text).identity.root_scope).toBe(image.root_scope); + const workflow=await downloadText(page,panel.getByRole('button',{name:'Download workflow JSON',exact:true}));expect(workflow.name).toBe('complete.workflow.json');expect(workflow.text).toBe(result.metadata.raw.workflow); + await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',image.url); + await selectFile(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true'); + await selectFile(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.'); + let downloads=0;page.on('download',()=>downloads++);for(const name of ['Download metadata JSON','Download workflow JSON']) {const button=panel.getByRole('button',{name,exact:true});await expect(button).toHaveAttribute('aria-disabled','true');await button.focus();await page.keyboard.press('Enter');}expect(downloads).toBe(0); + await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=0&root_scope=[a-f0-9]{64}/); +}); + +test('full inspector rejects stale shared action state',async({page,request})=>{ + let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();}); + await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads; + await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close preview',exact:true}).click(); + stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.'); + await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0); + stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision'); + const urls=await page.evaluate(()=>performance.getEntriesByType('resource').map(item=>item.name).filter(name=>name.includes('/images/inspect/')));expect(urls.every(value=>new URL(value).searchParams.has('root_scope'))).toBe(true); +}); + +test('full inspector keeps long literal content reachable on mobile',async({page,request})=>{ + await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw Error('Denied');}}})); + const literal=''+'verylongmodelpath/'.repeat(30); + let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();}); + await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});}); + await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-positive pre')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(panel).toContainText('Copy unavailable. Select the prompt text to copy it.'); + await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);await expect(panel.locator('.generation-facts dd').last()).toHaveText(literal); + for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.getByRole('button',{name:'Send prompt pair',exact:true});await footer.focus();await expect(footer).toBeFocused();const summary=panel.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(panel).toContainText('historical bytes are not verified');} + expect(await page.evaluate(()=>window.executed)).toBeUndefined();expect(external).toBe(0); +}); + +test('embedded gallery uses the same full inspector',async({page,request})=>{ + await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id; + await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('.image-preview .file-inspector')).toHaveCount(1); +}); + +test('full inspector labels container representations and releases action URLs',async({page,request})=>{ + await fullFixture(page,request); + await page.evaluate(async()=>{ + const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs'); + const {toInspectionRecord,inspectPath}=await import('/prompt_manager/js/workspace/inspection-state.mjs'); + const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js'); + const api=window.ApiClient.createApiClient();const list=await api.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector');const image=list.images.find(item=>item.root_index===0&&item.path==='inspector/complete.png'); + const record=toInspectionRecord(await api.get(inspectPath(image)),image);record.file.media_type='video';record.metadata.record_formats.workflow='reserialized-json';record.metadata.record_formats.comfyassets_prompt_pairs='invalid'; + window.renderFixture={record,scope:createScope(),created:[],revoked:[],timers:0}; + const create=URL.createObjectURL.bind(URL),revoke=URL.revokeObjectURL.bind(URL),timeout=window.setTimeout.bind(window); + URL.createObjectURL=blob=>{const url=create(blob);window.renderFixture.created.push(url);return url;};URL.revokeObjectURL=url=>{window.renderFixture.revoked.push(url);revoke(url);}; + window.setTimeout=(callback,delay,...args)=>{if(delay===1000)window.renderFixture.timers++;return timeout(callback,delay,...args);}; + const container=document.createElement('section');container.id='render-fixture';document.body.append(container); + window.renderFixture.view=mountFileInspector(container,{parentScope:window.renderFixture.scope});window.renderFixture.view.setState({record}); + }); + const panel=page.locator('#render-fixture');await panel.getByText('Workflow & raw metadata',{exact:true}).click(); + await expect(panel).toContainText('Reserialized container JSON; numeric spelling/precision may differ');await expect(panel).toContainText('Invalid container record; text is unavailable'); + await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');await expect(panel).toContainText('Original workflow JSON text is unavailable for this container record. Download metadata or the original file.'); + const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));const value=JSON.parse(metadata.text);expect(value.metadata.record_formats.workflow).toBe('reserialized-json');expect(value.metadata.raw.workflow).toContain('9007199254740993');expect(value.metadata.raw.comfyassets_prompt_pairs).toBeUndefined(); + await page.evaluate(()=>window.renderFixture.view.setState({error:true,refreshRequired:true,message:'The source changed. Refresh the image collection before inspecting this file.'})); + await expect(panel).toContainText('Reopen this file from a refreshed image collection.');await expect(panel.locator('button,a[download]')).toHaveCount(0); + expect(await page.evaluate(()=>({created:window.renderFixture.created,revoked:window.renderFixture.revoked,timers:window.renderFixture.timers}))).toMatchObject({timers:1}); + await expect.poll(()=>page.evaluate(()=>window.renderFixture.created.length===window.renderFixture.revoked.length)).toBe(true); + await page.evaluate(()=>{window.renderFixture.view.dispose();window.renderFixture.view.dispose();window.renderFixture.scope.dispose();});await expect(panel).toBeEmpty(); + expect(await page.evaluate(()=>window.renderFixture.revoked)).toEqual(await page.evaluate(()=>window.renderFixture.created)); +}); diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index 49cb015..a38fb79 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -141,7 +141,7 @@ test('no open ComfyUI window is actionable and rediscovery finds a newly opened 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.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),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.'); @@ -195,7 +195,7 @@ test('responsive gallery defers observed size writes and still fits the new view }); 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:{ + await page.route('**/images/inspect/uncertain.png?root=0*',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'uncertain.png',filename:'uncertain.png'},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}, }}})); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index a6a530d..2b29e30 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -195,6 +195,8 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p await sender.getByRole('button',{name:'Close',exact:true}).click(); await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click(); const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait'); + await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); + await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']); await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0); await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();await expect(page.locator('.viewer-container')).toHaveCount(0); diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js new file mode 100644 index 0000000..8843406 --- /dev/null +++ b/tests/js/workspace-file-inspector.test.js @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const modulePath='../../web/js/workspace/file-inspector.mjs'; +const load=()=>import(modulePath); +const rawWorkflow='{ "nodes": [], "seed": 9007199254740993, "cfg": 1.0000000000000000001 }'; +function record() { + return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{metadata_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal '},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal '}}; +} + +test('metadata export retains literal raw and scoped evidence',async()=>{ + const {inspectionDownloads}=await load(),source=record(),download=inspectionDownloads(source).metadata; + assert.equal(download.available,true);assert.equal(download.mime,'application/json');assert.equal(download.filename,'a.metadata.json'); + const value=JSON.parse(download.bodyText);assert.equal(value.inspection_version,1); + for(const key of ['identity','file','prompts','generation','library'])assert.deepEqual(value[key],source[key]); + assert.deepEqual(value.metadata.raw,source.metadata.raw);assert.deepEqual(value.metadata.record_formats,source.metadata.record_formats); + assert.equal(value.metadata.prompt_graph,undefined);assert.equal(value.metadata.workflow_graph,undefined); +}); +test('workflow export never reserializes unsafe numeric literals',async()=>{ + const {inspectionDownloads}=await load();assert.equal(inspectionDownloads(record()).workflow.bodyText,rawWorkflow); + const source=record();source.metadata.workflow_graph.status='malformed';assert.equal(inspectionDownloads(source).workflow.available,false); +}); +test('omitted raw forbids partial export',async()=>{ + const {inspectionDownloads}=await load(),source=record();source.metadata.status='limited';source.metadata.omitted_reason='response-size-limit';source.metadata.raw={}; + for(const value of Object.values(inspectionDownloads(source))) {assert.equal(value.available,false);assert.equal(value.bodyText,null);assert.equal(value.reason,'Raw metadata exceeds the response limit. Download the original file to inspect it externally.');} + assert.ok(source.download.url); +}); +test('download names cannot introduce path segments',async()=>{ + const {inspectionDownloads}=await load(),source=record();source.download.filename='../../evil\\nested\u0000.png'; + for(const value of Object.values(inspectionDownloads(source)))assert.doesNotMatch(value.filename,/[\\/\x00-\x1f]/); +}); +test('already disposed inspector creates no DOM URLs or handlers',async()=>{ + const {mountFileInspector,mountPromptInspector}=await load(),parent=new AbortController();parent.abort(); + const forbidden=new Proxy({}, {get(){assert.fail('disposed mount touched DOM/API');}}); + for(const mount of [mountFileInspector,mountPromptInspector]) { + const view=mount(forbidden,{parentScope:{signal:parent.signal},sender:forbidden,api:forbidden,image:forbidden}); + view.setState?.({record:record()});view.dispose();view.dispose(); + } +}); +test('reserialized container workflow never exports as original',async()=>{ + const {inspectionDownloads}=await load(),source=record();source.metadata.record_formats.workflow='reserialized-json'; + const workflow=inspectionDownloads(source).workflow;assert.equal(workflow.available,false);assert.equal(workflow.bodyText,null); + assert.equal(workflow.reason,'Original workflow JSON text is unavailable for this container record. Download metadata or the original file.'); +}); +test('metadata export preserves representation formats and omits private carrier',async()=>{ + const {inspectionDownloads}=await load(),source=record();source.metadata.record_formats.workflow='reserialized-json';source.metadata.raw.private_carrier='SECRET';source.metadata.carrier='SECRET'; + const download=inspectionDownloads(source).metadata,value=JSON.parse(download.bodyText); + assert.equal(value.metadata?.record_formats?.workflow,'reserialized-json');assert.equal(value.metadata.raw.workflow,rawWorkflow);assert.ok(!download.bodyText.includes('SECRET')); + assert.equal(value.metadata.representations.workflow,'Reserialized container JSON; numeric spelling/precision may differ'); +}); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs new file mode 100644 index 0000000..7128b4b --- /dev/null +++ b/web/js/workspace/file-inspector.mjs @@ -0,0 +1,230 @@ +import { createScope, createButton } from '../shared/toolkit/core.js'; +import { mountPromptBlocks, promptRoles } from './prompt-blocks.mjs'; +import { createInspectionController } from './inspection-state.mjs'; +import { createSendButton } from './prompt-sender.mjs'; +import { createWorkspaceIcon } from './icons.mjs'; + +const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; +const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.'; +const CONTAINER_WORKFLOW = 'Original workflow JSON text is unavailable for this container record. Download metadata or the original file.'; +const REPRESENTATIONS = { + 'original-text': 'Original embedded text', + 'reserialized-json': 'Reserialized container JSON; numeric spelling/precision may differ', + invalid: 'Invalid container record; text is unavailable', +}; +const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']]; +const FACT_SOURCES = {'api-graph': 'Saved API graph', workflow: 'Saved workflow', 'embedded-parameters': 'Embedded generation parameters'}; + +function safeFilename(value) { + return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file'; +} + +/** Export only the bounded public evidence, never normalized graphs or container carriers. */ +export function inspectionDownloads(record) { + const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1)); + const stem = name.replace(/\.[^.]+$/, '') || 'file'; + const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'}); + const metadata = record?.metadata; + const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.'; + const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)}; + if (!metadata || metadata.status === 'limited') return result; + const raw = {}, record_formats = {}, representations = {}; + for (const key of RAW_KEYS) { + if (Object.hasOwn(REPRESENTATIONS, metadata.record_formats?.[key])) { + record_formats[key] = metadata.record_formats[key]; + representations[key] = REPRESENTATIONS[record_formats[key]]; + if (record_formats[key] !== 'invalid' && typeof metadata.raw?.[key] === 'string') raw[key] = metadata.raw[key]; + } + } + const envelope = {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts, + generation:record.generation, library:record.library, + metadata:{status:metadata.status, omitted_reason:metadata.omitted_reason, raw, record_formats, representations, + scope:'Supported embedded records only; one selected container carrier for video. Other metadata and private carrier text are excluded.'}}; + result.metadata = {...result.metadata, available:true, reason:null, bodyText:JSON.stringify(envelope, null, 2)}; + if (record_formats.workflow === 'reserialized-json') result.workflow.reason = CONTAINER_WORKFLOW; + else if (record_formats.workflow === 'original-text' && metadata.workflow_graph?.status === 'parsed' && typeof raw.workflow === 'string') { + result.workflow = {...result.workflow, available:true, reason:null, bodyText:raw.workflow}; + } else result.workflow.reason = 'A valid original workflow JSON record is not available in this file.'; + return result; +} + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} +function childScope(parent) { + const scope = createScope(), dispose = () => scope.dispose(); + parent.signal.addEventListener('abort', dispose, {once:true}); + scope.cleanup(() => parent.signal.removeEventListener('abort', dispose)); + if (parent.signal.aborted) scope.dispose(); + return scope; +} +function disclosure(label) { + const node = el('details'); node.append(el('summary', label)); return node; +} +function definitionList(entries, className) { + const list = el('dl', undefined, className); + for (const [label, value] of entries) { + const row = el('div'); row.append(el('dt', label), el('dd', value ?? 'Not available')); list.append(row); + } + return list; +} +function factText(fact) { + if (fact?.status === 'known' && typeof fact.value === 'string') return fact.value; + if (fact?.status === 'ambiguous') return 'Ambiguous'; + if (fact?.status === 'absent') return 'Not recorded'; + return fact?.reason === 'projection-limited' ? 'Unavailable within inspection limits' : 'Unresolved'; +} +function formatBytes(value) { + return value === null || value === undefined ? null : value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`; +} + +export function mountFileInspector(container, {sender, parentScope, inline = false, onPreview, onRetry, onRefresh, onInteraction = () => {}}) { + if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; + const scope = childScope(parentScope); + let contentScope; + container.classList.add('file-inspector'); + scope.listen(container, 'pointerdown', onInteraction); + scope.listen(container, 'keydown', onInteraction); + scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');}); + + function setState(state) { + if (scope.signal.aborted) return; + contentScope?.dispose(); contentScope = childScope(scope); + container.replaceChildren(); + if (state.loading || state.error) { + const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'file-status muted'); + status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status); + if (state.error && state.refreshRequired) { + if (onRefresh) { + const refresh = createButton('Refresh image collection'); + contentScope.listen(refresh, 'click', onRefresh); container.append(refresh); + } else container.append(el('p', 'Reopen this file from a refreshed image collection.', 'muted')); + } else if (state.error && onRetry) { + const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); container.append(retry); + } + return; + } + const record = state.record; + if (!record) return; + const {file, library, metadata} = record; + const filename = record.identity.path.split('/').at(-1); + if (inline && record.download) { + const cover = createButton('', 'file-cover'); cover.setAttribute('aria-label', 'Open full preview'); + const video = file?.media_type === 'video'; + const media = el(video ? 'video' : 'img'); media.src = record.download.url; + if (video) { + media.muted = true; media.playsInline = true; media.preload = 'metadata'; + contentScope.cleanup(() => {media.pause(); media.removeAttribute('src'); media.load();}); + } else {media.alt = filename; media.decoding = 'async'; contentScope.cleanup(() => media.removeAttribute('src'));} + cover.append(media); if (onPreview) contentScope.listen(cover, 'click', onPreview); + else {cover.setAttribute('aria-disabled', 'true'); cover.title = 'Full preview is unavailable in this view.';} + container.append(cover); + } + const inner = el('div', undefined, 'file-inspector-inner'); container.append(inner); + const header = el('div', undefined, 'file-title'); + header.append(el('h2', library?.status === 'linked' && library.title ? library.title : filename)); + const extension = file?.extension || filename.split('.').at(-1); + header.append(el('span', file?.format || `${extension.toUpperCase()} extension`, 'file-format file-chip')); + inner.append(header, el('p', filename, 'file-name muted')); + const chips = el('div', undefined, 'file-chips'); + const sources = new Set(Object.values(record.prompts).filter(role => role?.status === 'resolved').map(role => role.source)); + const evidence = sources.has('execution') ? 'Execution prompt evidence' : sources.has('graph-inferred') ? 'Workflow prompt evidence' : sources.has('embedded-parameters') ? 'Embedded parameter evidence' : null; + if (evidence) chips.append(el('span', evidence, 'file-chip file-evidence')); + if (library?.status === 'linked' && library.category) { + const label = library.metadata_scope === 'pair' ? 'Recipe category' : 'Positive prompt category'; + chips.append(el('span', `${label}: ${library.category}`, 'file-chip')); + } + if (chips.childNodes.length) inner.append(chips); + const prompts = el('div'); inner.append(prompts); + const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false}); + blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose); + if (library?.status === 'linked') { + const vocabulary = el('div', undefined, 'file-vocabulary'); + vocabulary.append(el('p', library.metadata_scope === 'pair' ? 'Recipe tags' : 'Positive prompt tags', 'muted')); + const tags = el('div', undefined, 'file-chips'); + for (const tag of library.tags) tags.append(el('span', tag, 'file-chip')); + vocabulary.append(library.tags.length ? tags : el('p', 'No saved tags', 'muted')); inner.append(vocabulary); + } else if (!library) inner.append(el('p', 'Saved vocabulary is unavailable for this inspection response.', 'muted')); + const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), 'generation-facts'); + inner.append(facts); + const provenance = disclosure('File & provenance'); + provenance.append(el('p', 'Current file at this source; historical bytes are not verified.', 'muted')); + if (file) provenance.append(definitionList([ + ['Source', file.source_label], ['Path', record.identity.path], ['Size', formatBytes(file.size)], + ['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null], + ['Format', file.format || `${file.extension.toUpperCase()} extension`], ['Media type', file.media_type], + ['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null], + ], 'file-facts')); + else provenance.append(el('p', 'Current file observations are unavailable for this inspection response.', 'muted')); + for (const [key, label] of FACTS) { + const fact = record.generation[key]; + if (fact?.evidence) provenance.append(el('p', `${label}: ${fact.evidence === 'configuration' ? 'Configuration evidence' : 'Embedded parameter evidence'} · ${FACT_SOURCES[fact.source] || 'Source not recorded'}`, 'muted')); + } + provenance.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'muted')); + if (record.download) { + const original = el('a', 'Download original', 'workspace-link'); original.href = record.download.url; + original.download = safeFilename(record.download.filename); provenance.append(original); + } + inner.append(provenance); + const raw = disclosure('Workflow & raw metadata'); + raw.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'muted')); + if (!metadata) raw.append(el('p', 'Full metadata is unavailable for this inspection response.', 'muted')); + else { + if (metadata.status !== 'available') raw.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'muted')); + for (const key of RAW_KEYS) { + const format = metadata.record_formats[key]; if (!format) continue; + raw.append(el('p', key, 'raw-record-name'), el('p', REPRESENTATIONS[format], 'muted')); + if (Object.hasOwn(metadata.raw, key)) { + const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); raw.append(text); + } + } + } + const downloads = inspectionDownloads(record); + for (const [key, label] of [['metadata', 'Download metadata JSON'], ['workflow', 'Download workflow JSON']]) { + const item = downloads[key], action = createButton(label); + const group = el('div', undefined, 'file-download'); group.append(action); + if (!item.available) { + action.setAttribute('aria-disabled', 'true'); + const explanation = el('p', item.reason, 'muted'); + // IDs need only be unique within the current document, including its two inspectors. + explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); group.append(explanation); + } + contentScope.listen(action, 'click', () => { + if (!item.available || contentScope.signal.aborted) return; + const url = URL.createObjectURL(new Blob([item.bodyText], {type:item.mime})); + let timer, released = false; + const release = () => {if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url);}; + contentScope.cleanup(release); + const anchor = el('a'); anchor.href = url; anchor.download = item.filename; + container.append(anchor); anchor.click(); anchor.remove(); timer = setTimeout(release, 1000); + }); + raw.append(group); + } + inner.append(raw); + const footer = el('footer', undefined, 'file-inspector-footer'), roles = promptRoles(record.prompts); + if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { + const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair'; + contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair); + } + if (record.historyTarget) { + const history = el('a', 'Prompt history', 'workspace-link'); history.href = record.historyTarget; + history.prepend(createWorkspaceIcon('branch')); footer.append(history); + } + if (footer.childNodes.length) container.append(footer); + } + return {setState, dispose:() => scope.dispose()}; +} +let downloadReasonId = 0; + +/** Compatible standalone controller; Images owns a single shared controller instead. */ +export function mountPromptInspector(container, {api, image, sender, parentScope}) { + if (parentScope.signal.aborted) return {dispose() {}}; + const scope = childScope(parentScope), controller = createInspectionController({api}); + const view = mountFileInspector(container, {sender, parentScope:scope, onRetry:() => controller.retry()}); + scope.cleanup(controller.subscribe(view.setState)); scope.cleanup(() => controller.dispose()); scope.cleanup(view.dispose); + controller.select(image); + return {dispose:() => scope.dispose()}; +} diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index 89001c7..fa22692 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -92,7 +92,7 @@ .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 { padding: 0; 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); } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 8e38d40..b44ca49 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -1,7 +1,8 @@ import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js'; import { createImageStream, imageKey } from './images-state.mjs'; import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layout.mjs'; -import { mountPromptInspector } from './prompt-inspector.mjs'; +import { mountFileInspector } from './file-inspector.mjs'; +import { createInspectionController } from './inspection-state.mjs'; import { createPromptSender } from './prompt-sender.mjs'; import { createWorkspaceIcon } from './icons.mjs'; @@ -27,6 +28,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const { resolveUrl } = window.ApiClient; const sender = createPromptSender({scope}); let detailInspector, previewInspector; + const inspection = createInspectionController({api}); + scope.cleanup(inspection.subscribe(next => {detailInspector?.setState(next); previewInspector?.setState(next);})); + scope.cleanup(() => inspection.dispose()); scope.cleanup(() => { detailInspector?.dispose(); previewInspector?.dispose(); }); const visit = embedded ? null : lastVisit; let density = visit?.density || 'comfortable'; @@ -167,39 +171,23 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage detailInspector?.dispose(); selection = imageKey(image); for (const video of details.querySelectorAll('video')) releaseVideo(video); - const thumbnail = mediaElement(image); - const metadata = el('dl', undefined, 'image-facts'); - const fields = [['Source', image.root_dir], ['Path', image.path], ['Size', formatBytes(image.size)], - ['Dimensions', image.width && image.height ? `${image.width} × ${image.height}` : 'Not available'], - ['Modified', new Date(image.modified_time * 1000).toLocaleString()]]; - for (const [label, value] of fields) { - const row = el('div'); row.append(el('dt', label), el('dd', value)); metadata.append(row); - } - const preview = createButton('Open full preview', 'ca-primary'); - // The view delegates these controls so changing selection retains no old handlers. - preview.dataset.previewKey = selection; - const download = el('a', 'Download original', 'workspace-link'); - download.href = resolveUrl(image.url); download.download = image.filename; - const prompts = el('div'); - const legacy = el('p', 'Open Current gallery for tagging.', 'muted'); - legacy.hidden = embedded; - details.replaceChildren(thumbnail, el('h2', image.filename), preview, download, prompts, metadata, legacy); - detailInspector = mountPromptInspector(prompts, {api, image, sender, parentScope:scope}); + detailInspector = mountFileInspector(details, {sender, parentScope:scope, inline:true, + onPreview:() => previewImage(image), onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection}); + detailInspector.setState(inspection.state); + inspection.select(image); for (const [key, card] of rendered) { card.classList.toggle('selected', key === selection); card.querySelector('.image-open').setAttribute('aria-pressed', String(key === selection)); } } - scope.listen(details, 'click', event => { - const key = event.target.closest('[data-preview-key]')?.dataset.previewKey; - if (key) previewImage(state.items.find(image => imageKey(image) === key)); - }); function previewImage(image) { if (!image) return; if (selection !== imageKey(image)) inspect(image); destroyPreview(); previewTitle.textContent = image.filename; - previewInspector = mountPromptInspector(previewDetails, {api, image, sender, parentScope:scope}); + previewInspector = mountFileInspector(previewDetails, {sender, parentScope:scope, + onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection}); + previewInspector.setState(inspection.state); if (!dialog.open) dialog.showModal(); if (image.is_video) { const video = el('video'); video.controls = true; video.src = resolveUrl(image.url); video.preload = 'metadata'; @@ -399,12 +387,20 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage observer.observe(continuation); } function reset() { + if (dialog.open) dialog.close(); + destroyPreview(); + inspection.clear(); 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.')); stream.reset(filters); } + async function refreshInspectionCollection() { + if (scope.signal.aborted) return; + reset(); + await loadOptions(); + } async function loadOptions() { optionsRequest?.abort(); const request = new AbortController(); optionsRequest = request; diff --git a/web/js/workspace/inspector.css b/web/js/workspace/inspector.css new file mode 100644 index 0000000..959b464 --- /dev/null +++ b/web/js/workspace/inspector.css @@ -0,0 +1,38 @@ +.image-details.file-inspector { padding: 0; } +.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; } +.file-inspector-inner { padding: 17px; } +.ca-tools .file-cover { display: block; padding: 0; border: 0; border-radius: 6px 6px 0 0; inline-size: 100%; block-size: 150px; background: var(--ca-field); } +.file-cover :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; } +.file-title { display: flex; align-items: start; justify-content: space-between; gap: 8px; } +.file-inspector .file-title h2 { font-size: 14px; line-height: 1.5; margin: 0; } +.file-name { font-size: 12px; margin-block: 12px; } +.file-chips { display: flex; flex-wrap: wrap; gap: 5px; } +.file-chip { display: inline-block; padding: 3px 7px; border: 1px solid var(--ca-line); border-radius: 4px; background: var(--ca-field); font-size: 10px; } +.file-evidence { color: var(--ca-focus); } +.file-format { flex-shrink: 0; max-inline-size: 50%; } +.file-inspector .prompt-inspector { margin-block-start: 15px; } +.file-inspector .prompt-block { padding-block: 15px 0; margin-block-start: 15px; } +.file-inspector .prompt-text { margin-block: 8px 0; } +.file-inspector .prompt-negative { margin-block-end: 12px; } +.file-vocabulary > p { margin-block: 0 6px; font-size: 10px; } +.file-vocabulary .file-chip { font-size: 12px; } +.generation-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-block: 16px; font-size: 11px; } +.generation-facts dt, .file-facts dt { color: var(--ca-muted); font-size: 10px; } +.generation-facts dd, .file-facts dd { margin: 3px 0 0; } +.file-inspector details { border-block-start: 1px solid var(--ca-line); margin-block-start: 16px; padding-block-start: 13px; font-size: 12px; } +.file-inspector summary { cursor: pointer; min-block-size: 24px; } +.file-inspector :where(summary, button, a):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; } +.file-inspector details p { font-size: 10px; line-height: 1.65; } +.file-facts { font-size: 11px; } +.file-facts > div { margin-block: 10px; } +.file-inspector details pre { white-space: pre-wrap; overflow-wrap: anywhere; max-block-size: 350px; overflow: auto; padding: 10px; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 4px; font-size: 11px; line-height: 1.6; } +.raw-record-name { font-weight: 600; } +.file-download { margin-block: 12px; } +.ca-tools .file-download button { font-size: 11px; min-block-size: 30px; } +.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; } +.file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; } +.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; } +.file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; } +.file-inspector-footer .workspace-icon { inline-size: 14px; block-size: 14px; } +.file-status { padding: 17px; } +@media (max-width: 700px) { .ca-tools .file-cover { block-size: 200px; } } diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index b752394..66450fa 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -34,7 +34,7 @@ function childScope(parentScope) { return scope; } -export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry}) { +export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true}) { if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}}; const scope = childScope(parentScope); container.classList.add('prompt-inspector'); @@ -105,7 +105,7 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role } 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')) { + if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { const pair = createSendButton('Send prompt pair'); pair.dataset.sendRole = 'pair'; container.append(pair); } diff --git a/web/js/workspace/prompt-inspector.mjs b/web/js/workspace/prompt-inspector.mjs index 875a482..432f342 100644 --- a/web/js/workspace/prompt-inspector.mjs +++ b/web/js/workspace/prompt-inspector.mjs @@ -1,25 +1,3 @@ -import { createScope } from '../shared/toolkit/core.js'; -import { mountPromptBlocks } from './prompt-blocks.mjs'; -import { createInspectionLoader } from './inspection-state.mjs'; - export { promptRoles, mountPromptBlocks } from './prompt-blocks.mjs'; export { inspectPath, createInspectionLoader } from './inspection-state.mjs'; - -function childScope(parentScope) { - const scope = createScope(); - const dispose = () => scope.dispose(); - parentScope.signal.addEventListener('abort', dispose, {once:true}); - scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); - if (parentScope.signal.aborted) scope.dispose(); - return scope; -} - -export function mountPromptInspector(container, {api, image, sender, parentScope}) { - const scope = childScope(parentScope); - let loader; - const blocks = mountPromptBlocks(container, {sender, parentScope:scope, onRetry:() => loader.load(image)}); - loader = createInspectionLoader(api, blocks.setState); - scope.cleanup(() => loader.dispose()); - if (!scope.signal.aborted) loader.load(image); - return {dispose:() => scope.dispose()}; -} +export { mountPromptInspector } from './file-inspector.mjs'; diff --git a/web/workspace.html b/web/workspace.html index a4754d7..620aaf5 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -10,6 +10,7 @@ +