feat(library): native recipe editor UI, pair archive browsing and export (#235)

* feat(library): manage native editor drafts and recovery

* fix(library): fence pair archive retries against restored storage

* feat(library): add native recipe editor dialogs

* fix(library): read archive counts through the editor controller

* feat(library): reconcile editor saves and archived pairs

Wire the concept's heading actions (Library Export and + New prompt, Images New prompt, history ← Prompt library, Edit metadata and pair Send to ComfyUI) and the Negative variants + Add negative and Show archived controls. Saves navigate or reconcile in place through createLibraryDetail.accept, invalidate cached shelf state by write generation, browse and restore archived pairs, and offer an explicit Library reload when storage was restored.

* fix(library): refresh archived pairs and align editor outcomes

* test(library): enforce approved native editor presentation

* test(library): verify editor host and coverage compatibility

Adds the rejected connected-target host assertion, pins the stock host listing at 80 cases, documents the native editor, and keeps Save/Archive focus after a failed write.

* docs(testing): correct host case totals and cover archive focus

* fix(library): fence editor reloads after restore and settle outcome copy
This commit is contained in:
Vito
2026-10-19 01:46:33 -07:00
committed by GitHub
parent e5da4e1d9d
commit 8bcde4fbe4
25 changed files with 3237 additions and 80 deletions
+66 -2
View File
@@ -258,7 +258,8 @@ loading a workflow or queueing generation.
`npm run --prefix tools/vendor test:executions` runs 46 real execution cases
(23 each in Classic and Nodes 2.0, retaining all 42 prior execution cases),
alongside the existing 78 stock host cases and six pack compatibility cases. Run
alongside the existing 80 stock host cases (the original 78 plus the native
editor host case) and six pack compatibility cases. Run
these suites serially because they share report cleanup and owned host resources. Use the same pinned disposable `PM_COMFY_ROOT`,
`PM_TEST_PYTHON` and Playwright browser installation as `test:comfyui`:
@@ -310,7 +311,8 @@ both ways and workflow reload. PM usage counts retain their existing per-job
behavior. References identify reported locators only: file identity is unverified,
new generation and context membership are unknown, and temp references are
transient. The stock suite excludes both execution files; discovery retains its
78 cases and the six pack cases unchanged.
80 cases (the original 78 plus the native editor host case) and the six pack
cases unchanged.
For a meaningful historical failure, create a separate disposable detached
checkout at `fdba6fd0126a8a40cbd6f3e50ed4c5de8991efd4` and pass its path to
@@ -501,3 +503,65 @@ every previous metric floor and each new file's 80% line requirement pass; rerun
the coverage gate using the committed baseline. Python retains its 85% combined
statement/branch gate. Deploying the history Python changes requires a maintainer
ComfyUI restart; automated acceptance never restarts the personal server.
## Native recipe editor (#234)
The Library page edits recipes in one native modal (`dialog.library-editor`)
instead of sending users to the legacy dashboard. It is frontend only: it consumes
the Library/pair routes, epoch tokens, null-clears and conflict codes defined by
the backend change (#233) and does not add or change a route, so this section does
not repeat them. The editor reads fresh, `no-store` state before it opens.
The four dialog modes match the approved concept's shell: **New prompt**,
**Edit recipe metadata**, **Create text revision** and **Add a negative variant**.
Pair **Archive…** and **Restore** are one reviewed action with **Show archived**
in the saved-variants list; archiving a pair never touches its positive prompt, its
siblings, history or images. **Export** downloads the full v1 recipe export. A
failed Save keeps focus inside the dialog and keeps every entry.
Recovery is exercised against real failure responses, not mocks of the controller:
- a **conflict** response keeps the draft and explains the newer saved state; a
**storage-changed** response writes nothing, and only New prompt re-reads in
place: every other dialog offers only **Reload Library**, because an old numeric
owner is never resolved after a restore;
- an **unknown outcome** (no response) keeps the draft and offers an explicit
**Retry** that resends the identical request once, which the server
deduplicates; an Edit metadata retry instead reloads the latest saved values for
review and resends nothing;
- a **confirmed receipt** whose follow-up refresh fails offers a read-only
refresh retry and never re-sends the write.
The concept comparison (`library-editor-canonical-comparison.mjs`) inventories the
concept dialog and the production dialog in the same browser: geometry, CSS tokens,
colors and a control inventory (labels, field types, options, actions and their
danger/primary classes). Only the documented differences (the prototype's Save
label and demo note) are mapped. The mutation check proves the comparison fails when
a token, label or control is changed, so it cannot pass vacuously.
PNG baselines are intentionally absent: `toHaveScreenshot` is not used for the
editor. Paired concept/production captures and the artifact-only states
(`library-editor-visual.spec.mjs`) are attached as test artifacts for maintainer
acceptance; a baseline is added only after the maintainer accepts them.
Focused commands:
```sh
PM_TEST_PORT=8766 npm run --prefix tools/vendor test:workflows -- \
workspace-library-editor.spec.mjs library-editor-visual.spec.mjs \
--project chromium --project touch --project firefox --project webkit
npm run --prefix tools/vendor test:comfyui # library-sender.spec.mjs host regression
```
`native editor preview sends exact saved roles across renderer transitions` opens
the real workspace, creates a prompt with an explicitly empty negative, and sends the
selected pair through the acknowledged host bridge in Classic and Nodes 2.0, across
both switches and a workflow reload. It asserts the actual widgets and serialized
workflow, no queue or workflow load, unchanged history and pair metadata, and that
a connected destination rejects the preview without changing either role. It needs
the pinned disposable ComfyUI, so it runs in CI's "real ComfyUI compatibility" job.
Deploying this change needs no Python change and no ComfyUI restart: the diff for
the editor UI touches only HTML, CSS and JavaScript (plus test fixtures). A browser
refresh is enough. New editor modules have additive coverage floors in
`tools/vendor/frontend-coverage-baseline.json`; none were lowered.
@@ -0,0 +1,44 @@
// Concept-vs-production control inventory for the native editor dialog. inventoryOf runs in the page.
export function inventoryOf(dialog) {
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
const header=dialog.querySelector('.dialog-header'),title=header.querySelector('h2'),close=header.querySelector('button');
const actions=dialog.querySelector('.dialog-actions'),twoCol=dialog.querySelector('.two-col');
return {
dialog:{...css(dialog,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopLeftRadius','borderTopWidth','borderTopStyle','maxHeight','overflowY','backgroundColor','color','borderTopColor','fontFamily','fontSize','lineHeight'),
width:dialog.getBoundingClientRect().width,modal:dialog.matches(':modal'),backdrop:getComputedStyle(dialog,'::backdrop').backgroundColor,
labelledBy:document.getElementById(dialog.getAttribute('aria-labelledby'))===title},
header:css(header,'display','justifyContent','alignItems','columnGap','marginBottom'),
title:{text:title.textContent,...css(title,'fontSize','fontWeight','marginTop','marginBottom','lineHeight')},
close:{label:close.getAttribute('aria-label'),text:close.textContent.trim(),focused:document.activeElement===close,...css(close,'fontSize','minWidth','minHeight')},
fields:[...dialog.querySelectorAll('.field')].map(field=>{
const label=field.querySelector('label'),control=field.querySelector('input,textarea,select');
return {label:label.textContent,labelled:label.htmlFor===control.id,tag:control.tagName.toLowerCase(),readOnly:Boolean(control.readOnly),required:control.required,
placeholder:control.getAttribute('placeholder'),listed:Boolean(control.list),commaOptions:control.list?[...control.list.options].every(option=>option.value.endsWith(',')):null,
options:control.tagName==='SELECT'?[...control.options].map(option=>[option.value,option.textContent]):null,
field:css(field,'display','rowGap','fontSize','marginTop','marginBottom'),control:{...css(control,'fontSize','minHeight','resize','backgroundColor','color','borderTopColor','borderTopLeftRadius','paddingLeft'),height:control.getBoundingClientRect().height}};
}),
twoCol:twoCol?{columns:getComputedStyle(twoCol).gridTemplateColumns.split(' ').length,...css(twoCol,'display','columnGap')}:null,
small:[...dialog.querySelectorAll('p.small')].map(node=>({text:node.textContent,...css(node,'fontSize','color')})),
actions:{...css(actions,'display','justifyContent','columnGap','flexWrap','marginTop'),
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),type:node.type,danger:node.classList.contains('danger'),primary:node.classList.contains('ca-primary'),
...css(node,'minHeight','fontSize','color','borderTopColor','backgroundColor'),height:node.getBoundingClientRect().height}))},
demo:Boolean(dialog.querySelector('.demo-note')),
};
}
// Documented differences only: prototype Save label, prototype demo note, and data-dependent values.
export function mapConcept(reference) {
return {...reference,demo:false,actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
}
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
function flatten(value,path,out) {
if(Array.isArray(value)) {out.set(`${path}.length`,value.length);value.forEach((item,index)=>flatten(item,`${path}.${index}`,out));}
else if(value&&typeof value==='object')for(const [key,item] of Object.entries(value))flatten(item,path?`${path}.${key}`:key,out);
else out.set(path,value);
return out;
}
export function diffInventory(reference,actual) {
const left=flatten(reference,'',new Map()),right=flatten(actual,'',new Map());
return [...new Set([...left.keys(),...right.keys()])].filter(path=>left.get(path)!==right.get(path))
.map(path=>({path,reference:left.get(path),actual:right.get(path)}));
}
@@ -0,0 +1,49 @@
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
// Artifact-only captures of the editor states that have no concept counterpart (archive confirmation, Show archived,
// busy and error). The four concept-referenced modes are captured as concept/production pairs by
// workspace-library-editor.spec.mjs. There are deliberately no pixel assertions or baselines: the maintainer accepts
// these captures visually before any baseline is added.
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const workspace='/prompt_manager/workspace';
const editor=page=>page.locator('dialog.library-editor');
const variants=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
const note=page=>editor(page).locator('.warning-note');
test.beforeEach(async({page,request})=>{
const reset=await request.post('/__test__/library-editor/reset',{data:{}});await expect(reset).toBeOK();page.fixture=await reset.json();
});
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`editor state captures for maintainer acceptance ${width} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});const f=page.fixture;
await page.goto(`${workspace}#/library/${f.positive_id}`);await expect(variants(page)).toBeVisible();
await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
await expect(editor(page).getByRole('button',{name:'Save',exact:true})).toBeEnabled();
await editor(page).getByRole('button',{name:'Archive…',exact:true}).click();
await expect(editor(page).getByRole('button',{name:'Archive',exact:true})).toBeEnabled();await shot('editor-archive');
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route(`**/prompt_manager/pairs/${f.pair_id}`,async route=>{if(route.request().method()==='PATCH')await held;await route.continue();});
await editor(page).getByRole('button',{name:'Archive',exact:true}).click();
await expect(note(page)).toBeVisible();await shot('editor-busy');
release();await expect(editor(page)).toHaveCount(0);await page.unroute(`**/prompt_manager/pairs/${f.pair_id}`);
await variants(page).getByRole('button',{name:/^Show archived/}).click();
await expect(variants(page).locator('.library-variant-archived').first()).toBeVisible();await shot('editor-archived-library');
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();
await expect(editor(page).getByRole('button',{name:'Save',exact:true})).toBeEnabled();
await page.route('**/prompt_manager/library',route=>route.request().method()==='POST'
?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Title is too long.","code":null}'}):route.continue(),{times:1});
await editor(page).getByLabel('Title',{exact:true}).fill(`Long title ${'W'.repeat(160)} with a very long tail`);
await editor(page).getByLabel('Positive prompt',{exact:true}).fill('A quiet harbor at dawn');
await editor(page).getByLabel('Notes',{exact:true}).fill(Array.from({length:40},(_,index)=>`Long note line ${index}`).join('\n'));
await editor(page).getByRole('button',{name:'Save',exact:true}).click();
await expect(note(page)).toHaveText('Not saved: Title is too long. Your entries are kept.');
await shot('editor-long-error');
});
+5
View File
@@ -21,6 +21,9 @@ from tests.browser.workspace_history_fixture import (
register_workspace_history_commands,
seed_workspace_history,
)
from tests.browser.workspace_library_editor_fixture import (
register_library_editor_commands,
)
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
CANONICAL_ASSETS = {
@@ -100,6 +103,7 @@ def create_app(root: Path) -> web.Application:
api = PromptManagerAPI()
api.add_routes(routes)
history_commands = register_workspace_history_commands(routes, api, root)
library_editor_commands = register_library_editor_commands(routes, api, root)
async def canonical_asset(request: web.Request) -> web.Response:
filename, content_type = CANONICAL_ASSETS[request.path]
@@ -274,6 +278,7 @@ def create_app(root: Path) -> web.Application:
@routes.post("/__test__/reset")
async def reset(request: web.Request) -> web.Response:
nonlocal history_fixture
library_editor_commands.reset()
data = await request.json() if request.can_read_body else {}
if not isinstance(data, dict):
raise web.HTTPBadRequest()
+21 -3
View File
@@ -333,7 +333,7 @@ test('route reentry restores older timeline range and both scroll axes after 61
await marker(page,oldRun).click();await expect(dialog(page)).toContainText(oldRun);await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await page.keyboard.press('Escape');
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=731;node.scrollTop=95;node.dispatchEvent(new Event('scroll'));});
const scroll=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(scroll.left).toBeGreaterThan(0);expect(scroll.top).toBeGreaterThan(0);
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'});
await page.getByRole('button',{name:'← Prompt library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'});
let windowReads=0;page.on('request',request=>{if(/\/library\/\d+\/history$/.test(new URL(request.url()).pathname))windowReads++;});
let release,entered;const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;});
await page.route(new RegExp(`/history/runs/${oldRun}\\?`),async route=>{const response=await route.fetch();entered();await held;await route.fulfill({response}).catch(()=>{});});
@@ -366,7 +366,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
await marker(page,oldRun).click();await expect(dialog(page)).toContainText(oldRun);await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await page.keyboard.press('Escape');
const timeline=recorded(page).locator('.history-timeline');await timeline.evaluate(node=>{node.scrollLeft=731;node.scrollTop=95;node.dispatchEvent(new Event('scroll'));});
const before=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));await reviewCapture(page,testInfo,`range-before-${width}-${theme}`,timeline);
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'});
await page.getByRole('button',{name:'← Prompt library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);await command(request,{action:'append-history-runs'});
await page.evaluate(id=>{location.hash=`#/library/${id}`;},range.positive_id);await expect(recorded(page)).toContainText('80 loaded positions');await expect(marker(page,oldRun)).toHaveAttribute('aria-disabled','false');await expect(marker(page,oldRun)).toHaveAttribute('aria-pressed','true');
const after=await timeline.evaluate(node=>({left:node.scrollLeft,top:node.scrollTop}));expect(Math.abs(after.left-before.left)).toBeLessThanOrEqual(1);expect(Math.abs(after.top-before.top)).toBeLessThanOrEqual(1);
expect(await recorded(page).locator('[data-history-run]').evaluateAll(nodes=>nodes.map(node=>node.dataset.historyRun))).toEqual(ids);
@@ -421,7 +421,7 @@ test('more than 1000 real runs retain sparse columns, focused identity and pixel
await expect.poll(()=>recorded(page).locator('[data-history-run]').count()).toBeLessThanOrEqual(bound);
expect(await recorded(page).locator('[data-run-id]').count()).toBeLessThanOrEqual(bound);
expect(await page.evaluate(()=>window.retainedHistoryButton.isConnected&&document.activeElement===window.retainedHistoryButton)).toBe(true);
await page.getByRole('link',{name:'Back to library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);
await page.getByRole('button',{name:'← Prompt library',exact:true}).evaluate(node=>node.click());await expect(recorded(page)).toHaveCount(0);
});
test('390px and 200 percent layout keeps scrolling inside timeline and coarse targets remain 44px',async({page})=>{
@@ -560,6 +560,24 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified
await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeVisible();await expect(choices.last()).toBeDisabled();
});
test('an authoritative save after a stale revalidation makes the saved view current again',async({page,request})=>{
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
await open(page,f.detail_id);await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeVisible();await expect(choices.first()).toBeDisabled();
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const editor=page.locator('dialog.library-editor');
await expect(editor.getByRole('button',{name:'Save',exact:true})).toBeEnabled();await editor.getByLabel('Notes',{exact:true}).fill('Saved after stale revalidation');
await editor.getByRole('button',{name:'Save',exact:true}).click();await expect(editor).toHaveCount(0);
await expect(page.locator('.library-parent-status')).toBeHidden();await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeHidden();
await expect(choices.first()).toBeEnabled();await expect(page.locator('.library-variant-dot[aria-pressed="true"]')).toHaveCount(1);
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
const names=await choices.evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);
});
async function retainSavedSelection(page,request,fixture) {
await open(page,fixture.detail_id);
const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json();
@@ -0,0 +1,786 @@
import {writeFile} from 'node:fs/promises';
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
// Dialog-level contracts mount the editor through a page import; the user flows below use the real entry actions.
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
const workspace='/prompt_manager/workspace';
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes'];
const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']];
const editorDialog=page=>page.locator('dialog.library-editor');
test.beforeEach(async({page,request,browserName})=>{
await startCoverage(page,browserName);
const reset=await request.post('/__test__/library-editor/reset',{data:{}});await expect(reset).toBeOK();page.fixture=await reset.json();
page.editorErrors=[];page.on('pageerror',error=>page.editorErrors.push(error.message));
page.writes=[];page.on('request',item=>{if(item.method()!=='GET')page.writes.push({method:item.method(),url:new URL(item.url()).pathname,body:item.postDataJSON()});});
});
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.editorErrors).toEqual([]);});
async function mountEditor(page,theme) {
await page.goto(`${workspace}#/overview`);await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
if(theme)await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme);
await page.evaluate(async()=>{
const trigger=document.createElement('button');trigger.type='button';trigger.id='editor-trigger';trigger.textContent='Open editor';document.body.append(trigger);
window.editorEvents={committed:[],reconciled:[]};
try {
const {createLibraryEditor,showWorkspaceToast}=await import('/prompt_manager/js/workspace/library-editor.mjs');
const {createScope}=await import('/prompt_manager/js/shared/toolkit/core.js');
const scope=createScope();
const editor=createLibraryEditor({api:window.ApiClient.createApiClient(),parentScope:scope,
onCommitted:commit=>window.editorEvents.committed.push(commit),onReconciled:result=>window.editorEvents.reconciled.push(result)});
window.editorHarness={scope,editor,showWorkspaceToast};
} catch(error) {window.editorHarness={missing:String(error)};}
});
}
async function openEditor(page,options) {
await page.evaluate(options=>{const trigger=document.getElementById('editor-trigger');trigger.focus();window.editorHarness.editor?.open({...options,trigger});},options);
const dialog=editorDialog(page);await expect(dialog).toBeVisible();return dialog;
}
const field=(dialog,label)=>dialog.getByLabel(label,{exact:true});
const button=(dialog,name)=>dialog.getByRole('button',{name,exact:true});
const note=dialog=>dialog.locator('.warning-note');
async function ready(dialog) {await expect(button(dialog,'Save')).toBeEnabled();}
async function actionNames(dialog) {return dialog.locator('.dialog-actions button').evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));}
const detail=async(request,id,archive='all')=>(await(await request.get(`/prompt_manager/library/${id}?archive=${archive}`)).json());
const pairRead=async(request,id)=>(await(await request.get(`/prompt_manager/pairs/${id}`)).json()).pair;
test('edit recipe metadata shows the concept fields with the selected saved negative read-only',async({page})=>{
const f=page.fixture;await mountEditor(page);
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});
await expect(dialog.getByRole('heading',{name:'Edit recipe metadata',exact:true})).toBeVisible();
expect(await dialog.evaluate(node=>node.matches(':modal'))).toBe(true);
await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog);
expect(await dialog.locator('.field label').evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(LABELS);
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);
await expect(field(dialog,'Positive prompt')).toHaveValue(f.positive_text);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
for(const label of ['Positive prompt','Negative prompt (optional)'])await expect(field(dialog,label)).toHaveJSProperty('readOnly',true);
expect(await dialog.locator('textarea[readonly]').evaluateAll(nodes=>nodes.map(node=>node.tagName))).toEqual(['TEXTAREA','TEXTAREA']);
await expect(dialog.getByRole('button',{name:/copy|send/i})).toHaveCount(0);
await expect(dialog.getByRole('region')).toHaveCount(0);
await expect(field(dialog,'Category')).toHaveValue('Portraits');
await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light');
await expect(field(dialog,'Rating')).toHaveValue('4');
await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes);
expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS);
expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']);
expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']);
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
await expect(dialog.locator('.demo-note')).toHaveCount(0);
expect(await actionNames(dialog)).toEqual(ACTIONS.metadata);
await expect(button(dialog,'Archive…')).toHaveClass(/\bdanger\b/);await expect(button(dialog,'Save')).toHaveClass(/\bca-primary\b/);
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
});
test('editor modes persist through reload with selected saved negative',async({page,request})=>{
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill('');
await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0');
await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click();
await expect(dialog).toHaveCount(0);
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]);
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'});
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
const saved=(await detail(request,f.positive_id)).positive;
expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
await page.reload();await mountEditor(page);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue('');
await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
await button(dialog,'Create text revision').click();
await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible();
await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true);
await expect(button(dialog,'Close dialog')).toBeFocused();await ready(dialog);
await expect(field(dialog,'Positive prompt')).toHaveJSProperty('readOnly',false);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
expect(await actionNames(dialog)).toEqual(ACTIONS.revision);
await field(dialog,'Positive prompt').fill('Editor portrait, revised dusk light');await field(dialog,'Category').fill('');
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library');
expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative,
parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}});
expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']);
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id);
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'});
dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
expect(await actionNames(dialog)).toEqual(ACTIONS.new);
for(const label of ['Title','Positive prompt'])await expect(field(dialog,label)).toHaveJSProperty('required',true);
const before=page.writes.length;await button(dialog,'Save').click();
await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true);
expect(page.writes).toHaveLength(before);
await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn');
await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'',
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}});
dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id});
await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog);
expect(await dialog.locator('.field label').evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Negative prompt']);
await expect(dialog.locator('p.small')).toHaveText('Leave empty for an explicitly empty negative. The positive stays unchanged.');
expect(await actionNames(dialog)).toEqual(ACTIONS.negative);
await field(dialog,'Negative prompt').fill('grain, dust');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
expect(page.writes.at(-1)).toMatchObject({method:'POST',url:`/prompt_manager/library/${f.positive_id}/variants`,body:{negative_text:'grain, dust'}});
const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}});
await expect(page.locator('#editor-trigger')).toBeFocused();
});
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.standalone_id,pairId:null});await ready(dialog);
const archive=button(dialog,'Archive…');await expect(archive).toBeDisabled();
await expect(archive).toHaveAccessibleDescription('Archive needs a selected saved negative variant.');
await expect(note(dialog)).toHaveText('Archive needs a selected saved negative variant.');
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive…').click();
await expect(dialog.getByRole('heading',{name:'Archive negative variant',exact:true})).toBeVisible();
await expect(button(dialog,'Close dialog')).toBeFocused();
const block=dialog.getByRole('region',{name:'Negative prompt',exact:true});
await expect(block).toHaveClass(/\bprompt-negative\b/);await expect(block.locator('pre.prompt-text')).toHaveText(f.selected_negative);
await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await expect(dialog).toContainText('You can restore it from Show archived in Negative variants.');
expect(await actionNames(dialog)).toEqual(['Cancel','Archive']);await expect(button(dialog,'Archive')).toHaveClass(/\bdanger\b/);
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
expect((await pairRead(request,f.pair_id)).archived).toBe(false);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:true}}]);
expect((await pairRead(request,f.pair_id)).archived).toBe(true);
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.at(-1)?.commit.kind)).toBe('archive-pair');
});
test('a failed archive keeps focus on the Archive confirmation inside the dialog',async({page})=>{
const f=page.fixture;await mountEditor(page);
let failed=false;
await page.route(`**/prompt_manager/pairs/${f.pair_id}`,route=>{
if(route.request().method()!=='PATCH'||failed)return route.continue();
failed=true;return route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Cannot archive.","code":null}'});
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toContainText('Cannot archive.');expect(page.writes).toHaveLength(1);
await expect(button(dialog,'Archive')).toBeFocused();
expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true);
});
test('an archive conflict offers an explicit Reload that re-reads and never replays the archive',async({page,request})=>{
const f=page.fixture;await mountEditor(page);
let conflicted=false;
await page.route(`**/prompt_manager/pairs/${f.pair_id}`,route=>{
if(route.request().method()!=='PATCH'||conflicted)return route.continue();
conflicted=true;return route.fulfill({status:409,contentType:'application/json',body:'{"success":false,"error":"changed","code":"metadata-conflict"}'});
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('This negative variant changed since it was loaded. Reload it and review it before archiving.');
await expect(button(dialog,'Archive')).toBeDisabled();expect(page.writes).toHaveLength(1);
expect(await actionNames(dialog)).toEqual(['Reload','Cancel','Archive']);
await button(dialog,'Reload').click();
await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review them, then archive.');
await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText(f.selected_negative);
expect(page.writes).toHaveLength(1);expect((await pairRead(request,f.pair_id)).archived).toBe(false);
await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/pairs/${f.pair_id}`,`PATCH /prompt_manager/pairs/${f.pair_id}`]);
expect((await pairRead(request,f.pair_id)).archived).toBe(true);
});
test('cancel and busy submit have honest write counts',async({page})=>{
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(0);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Notes').fill('changed then cancelled');await button(dialog,'Close dialog').click();
await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();expect(page.writes).toHaveLength(0);
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{if(route.request().method()==='PATCH')await held;await route.continue();});
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Notes').fill('busy save');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Saving… The dialog stays open until the server answers.');
for(const name of ['Save','Cancel','Close dialog','Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled();
await field(dialog,'Title').press('Enter');await page.keyboard.press('Escape');
await expect(dialog).toBeVisible();expect(page.writes).toHaveLength(1);
release();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(1);
await expect(page.locator('#editor-trigger')).toBeFocused();
});
test('recovery states render inline and never replay a write',async({page,request})=>{
const f=page.fixture;await mountEditor(page);
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.negative_only_id,pairId:null});
await expect(note(dialog)).toHaveText('The latest saved values could not be loaded. Nothing was saved.');
await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeVisible();
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
const current=await detail(request,f.positive_id,'active');
await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{notes:'Changed elsewhere'}}})).toBeOK();
await field(dialog,'Title').fill('Conflicting title');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('This prompt changed since it was loaded. Reload it and review your entries before saving.');
await expect(button(dialog,'Save')).toBeDisabled();await button(dialog,'Reload').click();
await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.');
await expect(field(dialog,'Title')).toHaveValue('Conflicting title');await expect(field(dialog,'Notes')).toHaveValue('Changed elsewhere');
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
expect(page.writes.filter(write=>write.method==='PATCH')).toHaveLength(2);
expect((await detail(request,f.positive_id)).positive).toMatchObject({title:'Conflicting title',notes:'Changed elsewhere'});
page.writes.length=0;
dialog=await openEditor(page,{mode:'new'});await ready(dialog);
const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK();
const restore=await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:await snapshot.body()}}});
await expect(restore).toBeOK();page.writes.length=0;
await field(dialog,'Title').fill('After restore');await field(dialog,'Positive prompt').fill('Restored storage prompt');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
await expect(button(dialog,'Save')).toBeDisabled();expect(page.writes).toHaveLength(1);
await button(dialog,'Reload Library state').click();
await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.');
await expect(field(dialog,'Title')).toHaveValue('After restore');expect(page.writes).toHaveLength(1);
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
page.writes.length=0;let dropped=false;
await page.route('**/prompt_manager/library',async route=>{
if(route.request().method()==='POST'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;}
await route.continue();
});
dialog=await openEditor(page,{mode:'new'});await ready(dialog);
await field(dialog,'Title').fill('Lost response');await field(dialog,'Positive prompt').fill('Lost response prompt');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry sends the same save once.');
await expect(button(dialog,'Save')).toBeDisabled();
await page.unroute('**/prompt_manager/library');
await page.route('**/prompt_manager/library/*?variant_limit=40',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1});
await button(dialog,'Retry').click();
await expect(note(dialog)).toHaveText('Saved; Library refresh failed.');
expect(await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt)).toMatchObject({positive_created:false,pair_created:false});
expect(page.writes.map(write=>write.body.positive_text)).toEqual(['Lost response prompt','Lost response prompt']);
await button(dialog,'Retry refresh').click();await expect(dialog).toHaveCount(0);expect(page.writes).toHaveLength(2);
const rows=(await(await request.get('/prompt_manager/library?q=Lost%20response')).json()).items.filter(item=>item.text==='Lost response prompt');
expect(rows).toHaveLength(1);
});
test('edge states keep drafts, explain disabled archive and clean up on dispose',async({page,request})=>{
const f=page.fixture;
await page.route('**/prompt_manager/tags',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'}));
await mountEditor(page);
let dialog=await openEditor(page,{mode:'new',draft:{title:'Drafted title',notes:'Drafted notes'}});await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('Drafted title');await expect(field(dialog,'Notes')).toHaveValue('Drafted notes');
expect(await field(dialog,'Tags, separated by commas').evaluate(node=>node.list.options.length)).toBe(0);
await field(dialog,'Title').fill(' ');await field(dialog,'Positive prompt').fill('Spaces only title');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Title is required');expect(page.writes).toHaveLength(0);
await page.route('**/prompt_manager/library',route=>route.request().method()==='POST'
?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Title is too long.","code":null}'}):route.continue(),{times:1});
await field(dialog,'Title').fill('Rejected title');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Not saved: Title is too long. Your entries are kept.');
await expect(button(dialog,'Save')).toBeEnabled();await expect(field(dialog,'Title')).toHaveValue('Rejected title');
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.negative_only_id,pairId:null});
await expect(button(dialog,'Retry')).toBeVisible();await button(dialog,'Retry').click();
await expect(note(dialog)).toHaveText('The latest saved values could not be loaded. Nothing was saved.');await button(dialog,'Cancel').click();
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.archived_pair_id});await ready(dialog);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.archived_negative);
await expect(button(dialog,'Archive…')).toBeDisabled();await expect(note(dialog)).toHaveText('This negative variant is already archived.');
await expect(button(dialog,'Archive…')).toHaveAccessibleDescription('This negative variant is already archived.');
await button(dialog,'Cancel').click();
let failed=true;
await page.route(`**/prompt_manager/pairs/${f.empty_pair_id}`,route=>{if(!failed&&route.request().method()==='GET'){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'});}return route.continue();});
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.empty_pair_id});await ready(dialog);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue('');
failed=false;await button(dialog,'Archive…').click();
await expect(note(dialog)).toHaveText('Linked images and recorded runs could not be read. Nothing was archived.');
await expect(button(dialog,'Archive')).toBeDisabled();
await expect(dialog.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
await button(dialog,'Retry').click();await expect(dialog).toContainText('0 images and 0 recorded runs stay linked.');
await expect(button(dialog,'Archive')).toBeEnabled();expect(page.writes).toHaveLength(1);// only the rejected create
await page.evaluate(()=>window.editorHarness.scope.dispose());
await expect(dialog).toHaveCount(0);await expect(page.locator('#editor-trigger')).toBeFocused();
await page.evaluate(()=>window.editorHarness.editor.open({mode:'new'}));await expect(editorDialog(page)).toHaveCount(0);
expect(page.writes).toHaveLength(1);expect((await pairRead(request,f.empty_pair_id)).archived).toBe(false);
});
test('the archive confirmation does not inherit a failed or invalid Edit save message',async({page})=>{
const f=page.fixture;await mountEditor(page);
await page.route(`**/prompt_manager/library/${f.positive_id}`,route=>route.request().method()==='PATCH'
?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Notes are too long.","code":null}'}):route.continue());
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Notes').fill('Rejected notes');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Not saved: Notes are too long. Your entries are kept.');
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Title').fill(' ');await button(dialog,'Save').click();await expect(note(dialog)).toHaveText('Title is required');
await button(dialog,'Archive…').click();await expect(dialog).toContainText('1 image and 2 recorded runs stay linked.');
await expect(note(dialog)).toBeHidden();await expect(button(dialog,'Archive')).toBeEnabled();
expect(page.writes).toHaveLength(1);
});
test('an unconfirmed Edit save says Retry reloads for review and resends nothing',async({page})=>{
const f=page.fixture;await mountEditor(page);let dropped=false;
await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{
if(route.request().method()==='PATCH'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;}
await route.continue();
});
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
await field(dialog,'Notes').fill('Unconfirmed notes');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.');
await button(dialog,'Retry').click();
await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.');
await expect(field(dialog,'Notes')).toHaveValue('Unconfirmed notes');expect(page.writes).toHaveLength(1);
});
function toastStyle(node) {
const style=getComputedStyle(node);
return Object.fromEntries(['position','bottom','left','transform','backgroundColor','borderTopWidth','borderTopColor','paddingTop','paddingLeft',
'borderTopLeftRadius','boxShadow','zIndex','maxWidth','fontSize','display'].map(key=>[key,style[key]]));
}
for(const width of [1600,390])test(`the shared workspace toast is one polite status matching the concept ${width}`,async({page})=>{
await page.setViewportSize({width,height:844});
await page.goto('/__test__/canonical/workspace#library');await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption('dark');
await page.locator('#toast').evaluate(node=>{node.textContent='Negative saved without adding another positive or gallery.';});const reference=await page.locator('#toast').evaluate(toastStyle);
await mountEditor(page,'dark');
const toast=page.locator('#workspace-toast');await expect(toast).toHaveCount(1);
await expect(toast).toHaveAttribute('role','status');await expect(toast).toHaveAttribute('aria-live','polite');await expect(toast).toBeHidden();
await page.evaluate(()=>window.editorHarness.showWorkspaceToast('Negative saved without adding another positive or gallery.'));
await expect(page.getByRole('status').filter({hasText:'Negative saved without adding another positive or gallery.'})).toBeVisible();
expect(await toast.evaluate(toastStyle)).toEqual(reference);
expect(reference).toMatchObject({position:'fixed',bottom:width<=700?'12px':'22px',borderTopWidth:'1px',paddingTop:'12px',paddingLeft:'18px',borderTopLeftRadius:'6px',fontSize:'12px'});
});
const conceptModes={
new:['library',async page=>page.getByRole('button',{name:'+ New prompt',exact:true}).click()],
metadata:['prompt/alpine',async page=>page.getByRole('button',{name:'Edit metadata',exact:true}).click()],
revision:['prompt/alpine',async page=>{await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await page.locator('#modal').getByRole('button',{name:'Create text revision',exact:true}).click();}],
negative:['prompt/alpine',async page=>page.getByRole('button',{name:'+ Add negative',exact:true}).click()],
};
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`dialog geometry, tokens and controls match the concept ${width} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});const f=page.fixture;const result={};
for(const mode of Object.keys(conceptModes)) {
const [hash,openMode]=conceptModes[mode];
await page.goto('about:blank');await page.goto(`/__test__/canonical/workspace#${hash}`);
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openMode(page);const modal=page.locator('#modal');await expect(modal).toBeVisible();
if(mode==='revision')await page.locator('#modal-close').focus();
const reference=await modal.evaluate(inventoryOf);
await captureCanonicalState(page,testInfo,`concept-editor-${mode}-${width}-${theme}`,'#modal');
result[mode]={reference};
}
await mountEditor(page,theme);
const options={new:{mode:'new'},metadata:{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id},revision:{mode:'revision',positiveId:f.positive_id,pairId:f.pair_id},negative:{mode:'negative',positiveId:f.positive_id}};
for(const mode of Object.keys(conceptModes)) {
const dialog=await openEditor(page,options[mode]);await ready(dialog);
result[mode].actual=await dialog.evaluate(inventoryOf);
await captureCanonicalState(page,testInfo,`production-editor-${mode}-${width}-${theme}`,'dialog.library-editor');
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
}
const path=testInfo.outputPath('editor-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
await testInfo.attach('editor-geometry',{path,contentType:'application/json'});
for(const [mode,{reference,actual}] of Object.entries(result)) {
const expected=mapConcept(reference);
expect.soft(reference.demo,`${mode} concept demo note`).toBe(true);
expect.soft(expected.actions.buttons.map(item=>item.text),`${mode} actions`).toEqual(ACTIONS[mode]);
expect.soft(actual.dialog,`${mode} dialog`).toEqual(expected.dialog);
for(const key of ['header','title','close','fields','twoCol','small','actions','demo'])expect.soft(actual[key],`${mode} ${key}`).toEqual(expected[key]);
// The UA modal max-inline-size (100% - 6px - 2em) also applies to the concept at 390px.
expect.soft(actual.dialog).toMatchObject({paddingTop:'24px',borderTopLeftRadius:'9px',borderTopWidth:'1px',width:Math.min(650,width-24,width-34),modal:true});
expect.soft(actual.title.fontSize).toBe('18px');
expect.soft(actual.actions).toMatchObject({justifyContent:'flex-end',columnGap:'8px',flexWrap:'wrap',marginTop:'23px'});
for(const item of actual.fields)expect.soft(item.field).toMatchObject({rowGap:'7px',fontSize:'12px'});
for(const item of actual.fields.filter(item=>item.tag==='textarea'))expect.soft(item.control.minHeight).toBe('100px');
if(actual.twoCol)expect.soft(actual.twoCol.columns,`${mode} two columns`).toBe(width<=700?1:2);
if(testInfo.project.name==='chromium')expect.soft(actual.dialog.backdrop).toBe('rgba(8, 11, 18, 0.72)');
}
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
});
// Real entry actions: Library, Images and prompt history headings, and the Negative variants panel.
const historyRegion=page=>page.getByRole('region',{name:'Prompt history',exact:true});
const headingActions=page=>page.locator('#workspace-main .workspace-heading .heading-actions');
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true});
const toast=page=>page.locator('#workspace-toast');
const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]');
async function openHistory(page,id) {
await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible();
}
async function selectPair(page,name) {
const dot=variantsPanel(page).getByRole('button',{name,exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
}
const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text});
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
const actions=headingActions(page).locator('button');
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
await actions.nth(1).click();const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await field(dialog,'Title').fill('Harbor at dawn');await field(dialog,'Positive prompt').fill('A quiet harbor at dawn');await field(dialog,'Negative prompt (optional)').fill('fog, noise');
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
const created=(await(await request.get('/prompt_manager/library?text=quiet%20harbor')).json()).items[0];
await expect(page).toHaveURL(new RegExp(`#/library/${created.id}$`));
await expect(page.locator('#workspace-main h1')).toHaveText('Harbor at dawn');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(toast(page)).toHaveText('Saved.');
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('fog, noise');
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']);
});
test('new from Images cancel preserves browse',async({page})=>{
await page.goto(`${workspace}#/images`);await expect(page.locator('#workspace-main h1')).toHaveText('Images');
const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['New prompt']);
const trigger=actions.first();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
await trigger.click();let dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
await field(dialog,'Title').fill('Discarded');await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();await expect(page).toHaveURL(/#\/images$/);await expect(page.locator('.library-shelf, .library-history')).toHaveCount(0);
expect(page.writes).toHaveLength(0);
await trigger.click();dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill('From images');await field(dialog,'Positive prompt').fill('A prompt saved from Images');await button(dialog,'Save').click();
await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('From images');await expect(page.locator('#workspace-main h1')).toBeFocused();
});
test('history heading follows the concept and sends the selected saved pair',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
const actions=headingActions(page).locator('button');
expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false');
await expect(send).toBeDisabled();
await selectPair(page,'Select negative variant 2');await expect(send).toBeEnabled();
await expect(savedTexts(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);await expect(send).toBeFocused();
await actions.nth(0).click();await expect(page).toHaveURL(/#\/library$/);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
expect(page.writes).toHaveLength(0);
});
test('add negative re-renders history with the saved or reused pair selected',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
const add=variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true});
await add.click();let dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill('grain, dust');await button(dialog,'Save').click();
await expect(dialog).toHaveCount(0);await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText('Negative saved without adding another positive or gallery.');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('grain, dust');
await expect(laneLabel(page,'Negative variant 1 ·')).toHaveCount(1);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click();
await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
await expect(laneLabel(page,'· 1 linked images')).toHaveCount(1);
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3');
expect(page.writes.map(write=>write.url)).toEqual([`/prompt_manager/library/${f.positive_id}/variants`,`/prompt_manager/library/${f.positive_id}/variants`]);
});
test('reused pair outside first forty remains selected without loading every page',async({page,request})=>{
test.setTimeout(90000);
const f=page.fixture;let current=await detail(request,f.positive_id,'active');
for(let index=0;index<41;index++) {
const response=await request.post(`/prompt_manager/library/${f.positive_id}/variants`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,negative_text:`bulk negative ${index}`}});
await expect(response).toBeOK();current={...current,positive:(await response.json()).positive};
}
const variantCursorRequests=[];page.on('request',item=>{if(item.url().includes('variant_cursor'))variantCursorRequests.push(item.url());});
await openHistory(page,f.positive_id);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');page.writes.length=0;
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();
const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.first_negative);
await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41);
expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1);
});
test('edit metadata stays on the page and re-reads the selected pair',async({page,request})=>{
const f=page.fixture;await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2');
const pairReads=[];page.on('request',item=>{if(item.method()==='GET'&&new URL(item.url()).pathname===`/prompt_manager/pairs/${f.pair_id}`)pairReads.push(item.url());});
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
const before=pairReads.length;
await field(dialog,'Title').fill('Edited in place');await field(dialog,'Category').fill('Studio work');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
await expect(page.locator('#workspace-main h1')).toHaveText('Edited in place');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(toast(page)).toHaveText('Saved.');
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('.library-surface .library-chip').first()).toHaveText('Studio work');
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 2');
await expect.poll(()=>pairReads.length).toBeGreaterThan(before);
await expect(headingActions(page).locator('button.send')).toHaveAccessibleName('Send prompt pair to ComfyUI: Edited in place');
expect((await detail(request,f.positive_id)).positive).toMatchObject({title:'Edited in place',category:'Studio work'});
});
test('saved refresh failure retries only reads',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
let failed=false;
await page.route(`**/prompt_manager/library/${f.positive_id}?variant_limit=40`,route=>{if(page.writes.length&&!failed){failed=true;return route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'});}return route.continue();});
await field(dialog,'Notes').fill('Refresh after failure');await button(dialog,'Save').click();
await expect(page.getByText('Saved; Library refresh failed.',{exact:true})).toBeVisible();
const writeRequests=page.writes;expect(writeRequests).toHaveLength(1);
await button(dialog,'Retry refresh').click();await expect(dialog).toHaveCount(0);expect(writeRequests).toHaveLength(1);
await expect(page.locator('#workspace-main h1')).toBeFocused();
});
test('restored storage after a confirmed save or before an archive offers an explicit Library reload',async({page,request})=>{
const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body();
const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}});
await openHistory(page,f.positive_id);
await page.route(`**/prompt_manager/library/${f.positive_id}`,async route=>{
if(route.request().method()!=='PATCH')return route.continue();
const response=await route.fetch();await expect(await restore()).toBeOK();return route.fulfill({response});
});
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Notes').fill('Lost to a restore');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.');
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);
await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1);
await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();
await expect(await restore()).toBeOK();await button(dialog,'Archive').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was archived.');
expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Archive']);expect(page.writes).toHaveLength(1);
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
expect((await pairRead(request,f.pair_id)).archived).toBe(false);
});
test('restored storage before an Edit or Add negative save offers only the Library reload exit',async({page,request})=>{
const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body();
const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}});
await openHistory(page,f.positive_id);
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await expect(await restore()).toBeOK();
await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']);
await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled();
expect(page.writes).toHaveLength(1);// the refused save
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
expect(page.writes).toHaveLength(1);expect((await detail(request,f.positive_id)).positive.notes).toBe(f.metadata.notes);
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(await restore()).toBeOK();
await field(dialog,'Negative prompt').fill('typed before the restore');await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Save']);
await expect(button(dialog,'Reload Library state')).toHaveCount(0);expect(page.writes).toHaveLength(2);
await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible();
expect(page.writes).toHaveLength(2);
expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false);
});
test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
let release;const held=new Promise(resolve=>{release=resolve;});
await page.route(`**/prompt_manager/pairs/${f.archived_pair_id}`,async route=>{if(route.request().method()==='PATCH')await held;await route.continue();});
const toggle=()=>variantsPanel(page).getByRole('button',{name:/^Show archived/});
const restoreButton=()=>variantsPanel(page).locator('.library-variant-archived').getByRole('button',{name:/^Restore/});
await toggle().click();await expect(restoreButton()).toBeEnabled();
await restoreButton().click();await expect.poll(()=>page.writes.length).toBe(1);await expect(restoreButton()).toBeDisabled();
// Hiding and showing archived variants rebuilds the lane while the restore is in flight.
await toggle().click();await toggle().click();
await expect(restoreButton()).toBeDisabled();await restoreButton().evaluate(node=>node.click());
release();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0);
expect(page.writes).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]);
expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false);
});
test('archive hides a pair, Show archived finds it and Restore keeps it selected with links and counts unchanged',async({page,request})=>{
const f=page.fixture;
const evidence=async()=>{
const pair=await pairRead(request,f.pair_id);const gallery=await(await request.get(`/prompt_manager/library/${f.positive_id}/images?limit=50`)).json();
const history=await(await request.get(`/prompt_manager/library/${f.positive_id}/history`)).json();const parent=(await detail(request,f.positive_id,'all')).positive;
return {counts:{images:pair.image_count,runs:pair.run_count},gallery:gallery.images.map(item=>item.id),history:history.recorded_run_count,parentImages:parent.image_count};
};
const before=await evidence();expect(before.counts).toEqual({images:1,runs:2});
await openHistory(page,f.positive_id);await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
expect(await evidence()).toEqual(before);
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false');
await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2);
const lane=archived.filter({hasText:'· 1 linked images · Archived'});await expect(lane).toHaveCount(1);
const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id));
await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled();
const writes=page.writes.length;
await lane.getByRole('button',{name:/^Restore/}).click();
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toBeVisible();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1);
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:false}}]);
expect((await pairRead(request,f.pair_id)).archived).toBe(false);expect(await evidence()).toEqual(before);
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
expect(galleryReads.at(-1).get('archive')).toBeNull();
});
test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone');
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);});
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click();
await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter');
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone');
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible();
expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true);
expect(shelfReads.at(-1).get('text')).toBe('Standalone');
});
test('export downloads the full Library JSON without a toast and reports a failure before any download',async({page,request})=>{
await page.addInitScript(()=>{window.revokedUrls=[];const revoke=URL.revokeObjectURL.bind(URL);URL.revokeObjectURL=url=>{window.revokedUrls.push(url);revoke(url);};});
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
const exportButton=page.getByRole('button',{name:'Export full Library JSON',exact:true});
const [download]=await Promise.all([page.waitForEvent('download'),exportButton.click()]);
expect(download.suggestedFilename()).toBe('prompt-manager-library-v1.json');
const saved=JSON.parse(await (await import('node:fs/promises')).readFile(await download.path(),'utf8'));
expect(saved).toEqual(await(await request.get('/prompt_manager/library/export')).json());
expect(saved).toMatchObject({schema_version:1,kind:'prompt-manager-library'});
await expect.poll(()=>page.evaluate(()=>window.revokedUrls.length)).toBe(1);await expect(toast(page)).toBeHidden();
await page.route('**/prompt_manager/library/export',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"down"}'}));
let downloads=0;page.on('download',()=>downloads++);await exportButton.click();
await expect(page.getByRole('alert').filter({hasText:'The Library export could not be downloaded.'})).toBeVisible();
expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0);
});
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Negative prompt').fill(f.archived_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
const message='This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.';
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true');
const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1);
await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.archived_negative);
await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id));
await uniqueDotNames(page);
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative);
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText(message);
await expect(variantsPanel(page).locator('.library-variant-archived .library-variant-dot')).toHaveAttribute('aria-pressed','true');
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
});
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{
const f=page.fixture;await page.goto(`${workspace}#/library`);
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative');
await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click();
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes');
await button(dialog,'Cancel').click();
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click();
expect(page.writes).toHaveLength(1);
});
test('turning Show archived on again after a hidden archive shows the newly archived pair',async({page})=>{
const f=page.fixture;await openHistory(page,f.positive_id);
const toggle=()=>variantsPanel(page).getByRole('button',{name:/^Show archived/});
await toggle().click();await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1);
await toggle().click();await expect(toggle()).toHaveAttribute('aria-pressed','false');await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(0);
await selectPair(page,'Select negative variant 2');
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
await expect(laneLabel(page,'· 1 linked images · Archived')).toHaveCount(1);await uniqueDotNames(page);
});
test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{
const f=page.fixture;await mountEditor(page,'dark');
const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
// Work on a copy of the production dialog so the live editor is untouched; ids are renamed to keep label/list wiring valid.
await dialog.evaluate(node=>{
const copy=node.cloneNode(true);copy.removeAttribute('open');copy.id='mutant';copy.classList.add('mutant');
const ids=new Set([...copy.querySelectorAll('[id]')].map(item=>item.id));
for(const item of copy.querySelectorAll('[id]'))item.id=`${item.id}-copy`;
for(const item of copy.querySelectorAll('[for],[list],[aria-labelledby],[aria-describedby]'))for(const attr of ['for','list','aria-labelledby','aria-describedby']) {
const value=item.getAttribute(attr);if(value&&ids.has(value))item.setAttribute(attr,`${value}-copy`);
}
copy.setAttribute('open','');node.parentElement.append(copy);
});
const mutant=page.locator('dialog.mutant');await expect(mutant).toBeVisible();
const baseline=await mutant.evaluate(inventoryOf);
expect(diffInventory(baseline,await mutant.evaluate(inventoryOf))).toEqual([]);
await page.addStyleTag({content:'dialog.mutant .danger{color:rgb(1,2,3)!important}dialog.mutant .field input{color:rgb(4,5,6)!important;background-color:rgb(7,8,9)!important}'});
await mutant.getByRole('button',{name:'Save',exact:true}).evaluate(node=>node.remove());
const paths=diffInventory(baseline,await mutant.evaluate(inventoryOf)).map(item=>item.path);
expect(paths).toContain('actions.buttons.length');
expect(paths).toContain('actions.buttons.1.color');
expect(paths).toContain('fields.0.control.color');
expect(paths).toContain('fields.0.control.backgroundColor');
await page.keyboard.press('Escape');
});
test('long and error states retain footer focus and reflow',async({page})=>{
await page.setViewportSize({width:390,height:844});
await mountEditor(page,'light');
const dialog=await openEditor(page,{mode:'new'});await ready(dialog);
const unbroken='W'.repeat(240);
await field(dialog,'Title').fill(`A very long title that keeps going ${unbroken} and going`);
await field(dialog,'Positive prompt').fill('A quiet harbor at dawn');
await field(dialog,'Notes').fill(Array.from({length:60},(_,index)=>`Long note line ${index} ${unbroken.slice(0,40)}`).join('\n'));
await page.route('**/prompt_manager/library',route=>route.request().method()==='POST'
?route.fulfill({status:400,contentType:'application/json',body:'{"success":false,"error":"Title is too long.","code":null}'}):route.continue(),{times:1});
await button(dialog,'Save').click();
await expect(note(dialog)).toHaveText('Not saved: Title is too long. Your entries are kept.');
// Save had focus when the write started and keeps it after the failure; focus never falls out of the modal.
await expect(button(dialog,'Save')).toBeFocused();
expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true);
const state=await dialog.evaluate(node=>({scrolls:node.scrollHeight>node.clientHeight,height:node.getBoundingClientRect().height,innerHeight,overflowY:getComputedStyle(node).overflowY,
pageOverflow:document.documentElement.scrollWidth-document.documentElement.clientWidth,dialogOverflow:node.scrollWidth-node.clientWidth,
left:node.getBoundingClientRect().left,right:node.getBoundingClientRect().right,viewport:innerWidth}));
expect(state.scrolls).toBe(true);expect(state.height).toBeLessThanOrEqual(state.innerHeight*.9+1);expect(['auto','scroll']).toContain(state.overflowY);
expect(state.pageOverflow).toBeLessThanOrEqual(1);expect(state.dialogOverflow).toBeLessThanOrEqual(1);expect(state.left).toBeGreaterThanOrEqual(0);expect(state.right).toBeLessThanOrEqual(state.viewport);
for(const name of ['Cancel','Save']) {
const item=button(dialog,name);await item.scrollIntoViewIfNeeded();await expect(item).toBeVisible();await expect(item).toBeEnabled();
await item.focus();await expect(item).toBeFocused();
const box=await item.boundingBox();expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(390);
}
// Keyboard: Tab from Cancel reaches Save inside the dialog; the draft survives the error.
await button(dialog,'Cancel').focus();await page.keyboard.press('Tab');await expect(button(dialog,'Save')).toBeFocused();
await expect(field(dialog,'Positive prompt')).toHaveValue('A quiet harbor at dawn');
expect(page.writes.filter(write=>write.method==='POST')).toHaveLength(1);
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
});
+5 -5
View File
@@ -123,7 +123,7 @@ test('returning from history restores library context and mobile content stays i
await openHistory(page);await selectVariant(page,0);
await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
await history(page).getByRole('link',{name:'Back to library',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).click();
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
@@ -148,7 +148,7 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor'
expect(await page.evaluate(()=>scrollY)).toBe(0);
const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
await page.setViewportSize({width:390,height:844});await page.getByRole('link',{name:'Back to library',exact:true}).click();
await page.setViewportSize({width:390,height:844});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape');
});
@@ -162,7 +162,7 @@ test('history detail failures retry and returning to the list ignores late detai
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible();
fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click();
await page.getByRole('link',{name:'Back to library',exact:true}).click();release();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();release();
await expect(cards(page)).toHaveCount(3);await expect(history(page)).toHaveCount(0);
} finally {release();}
});
@@ -184,7 +184,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json();
await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark'));
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
await saved(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
await page.locator('.workspace-heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
const sender=page.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({label:'Library ComfyUI'});
@@ -331,7 +331,7 @@ test('row measurement preserves anchor after width invalidation and append',asyn
const before=await firstVisibleParent(page);
await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
if(changedWidth)await page.setViewportSize({width:1600,height:1100});
await page.getByRole('link',{name:'Back to library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before);
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before);
expect(await cards(page).count()).toBeLessThan(80);
}
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
+6 -3
View File
@@ -101,7 +101,10 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true });
const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true });
expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
// As in the concept, the selected pair's send sits in the history heading, not among the saved texts.
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
await expect(page.locator('.workspace-heading button.send')).toBeEnabled();
await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2);
await page.locator('.library-shared-gallery .image-open').first().click();
await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png');
@@ -517,7 +520,7 @@ test('Library visual evidence keeps natural cards and returns from real history
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
await expect(page.locator('.library-history > div > .intro')).toHaveCount(1);
await page.getByRole('link',{name:'Back to library',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1);
expect(errors).toEqual([]);
@@ -616,7 +619,7 @@ test('embedded history gallery keeps the history heading and disposes its layout
await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(0);
}
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
await page.getByRole('link',{name:'Back to library',exact:true}).click();
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0);
}
});
@@ -0,0 +1,136 @@
"""Owned, deterministic Library data for the native recipe editor workflows."""
from __future__ import annotations
from pathlib import Path
import shutil
from typing import TYPE_CHECKING
from aiohttp import web
from PIL import Image
if TYPE_CHECKING:
from py.api import PromptManagerAPI
EDITOR_POSITIVE = "Editor portrait, soft window light"
FIRST_NEGATIVE = "blurry, watermark"
SELECTED_NEGATIVE = "low contrast, muddy shadows"
ARCHIVED_NEGATIVE = "oversaturated, harsh flash"
STANDALONE = "Standalone sketch without saved negatives"
EDITOR_METADATA = {
"title": "Editor portrait",
"category": "Portraits",
"tags": ["studio", "window light"],
"rating": 4,
"notes": "Keep the soft light.",
}
_TABLES = (
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
"generated_images",
"prompt_tags",
"tags",
"prompt_library_state",
"prompts",
)
def seed_library_editor(api: PromptManagerAPI, root: Path) -> dict:
"""Replace this server's disposable rows with one fixed editor Library."""
db = api.db
with db.model.get_connection() as conn:
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
shutil.rmtree(root / name)
(root / name).mkdir()
epoch = db.get_execution_epoch()
receipt = db.create_library_recipe(
EDITOR_POSITIVE,
FIRST_NEGATIVE,
expected_epoch=epoch,
parent_metadata=dict(EDITOR_METADATA),
)
positive_id = receipt["positive"]["id"]
first_pair_id = receipt["pair"]["id"]
pairs = {}
for name, text in (
("pair_id", SELECTED_NEGATIVE),
("empty_pair_id", ""),
("archived_pair_id", ARCHIVED_NEGATIVE),
):
receipt = db.add_library_negative(
positive_id,
text,
expected_epoch=epoch,
if_match=receipt["positive"]["edit_token"],
)
pairs[name] = receipt["pair"]["id"]
db.archive_prompt_pair(pairs["archived_pair_id"], True)
image_path = root / "output" / "editor" / "portrait.png"
image_path.parent.mkdir()
with Image.new("RGB", (64, 64), (40, 90, 120)) as image:
image.save(image_path)
image_id = db.link_image_to_prompt(positive_id, str(image_path))
db.link_image_to_pair(pairs["pair_id"], image_id, {"source": "editor fixture"})
db.record_prompt_pair_use(pairs["pair_id"], times=2)
standalone_id = db.save_prompt(STANDALONE, category="Sketches", tags=["draft"])
with db.model.get_connection() as conn:
negative_only_id = conn.execute(
"SELECT negative_prompt_id FROM prompt_pairs WHERE id=?",
(first_pair_id,),
).fetchone()[0]
api.invalidate_gallery_cache()
return {
"positive_id": positive_id,
"first_pair_id": first_pair_id,
**pairs,
"standalone_id": standalone_id,
"negative_only_id": negative_only_id,
"positive_text": EDITOR_POSITIVE,
"first_negative": FIRST_NEGATIVE,
"selected_negative": SELECTED_NEGATIVE,
"archived_negative": ARCHIVED_NEGATIVE,
"metadata": dict(EDITOR_METADATA),
}
class _LibraryEditorCommands:
"""One disposable fixture lifetime; an ordinary reset expires it."""
def __init__(self) -> None:
self.manifest: dict | None = None
def reset(self) -> None:
self.manifest = None
def register_library_editor_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
) -> _LibraryEditorCommands:
"""Register only fixed seed and snapshot commands; no paths or SQL input."""
state = _LibraryEditorCommands()
@routes.post("/__test__/library-editor/reset")
async def reset(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
if data != {} or request.query:
raise web.HTTPBadRequest()
state.manifest = await api._run_in_executor(seed_library_editor, api, root)
return web.json_response({"success": True, **state.manifest})
@routes.get("/__test__/library-editor/snapshot")
async def snapshot(request: web.Request) -> web.Response:
if state.manifest is None or request.query:
raise web.HTTPConflict()
path = root / "owned-library-editor-snapshot.db"
created = await api._run_in_executor(api.db.model.backup_database, str(path))
if not created:
raise web.HTTPInternalServerError()
return web.Response(
body=path.read_bytes(), content_type="application/octet-stream"
)
return state
+94 -1
View File
@@ -27,7 +27,8 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
const history=workspace.getByRole('region',{name:'Prompt history',exact:true});
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
await history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('button',{name:'Send prompt pair',exact:true}).click();
// The pair send lives in the history heading, as in the approved concept.
await workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,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});
@@ -44,3 +45,95 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
expect(state.effects).toEqual({queue:0,load:0});expect(errors).toEqual([]);
} finally {await workspace.close();}
});
test('native editor preview sends exact saved roles across renderer transitions',async({page,context,request},testInfo)=>{
test.setTimeout(150000);
const title=`Native editor host ${testInfo.project.name}`;const positive=`${title} positive ${Date.now()}`;
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 editor=page.locator('.pm-pair');
await editor.getByText('Composition · prepend / append',{exact:true}).click();
await editor.getByRole('textbox',{name:'Prepend',exact:true}).fill('Keep trigger');await editor.getByRole('textbox',{name:'Append',exact:true}).fill('Keep detail');
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
try {
// Created through the native editor entry action, with an explicitly empty negative.
await workspace.goto('/prompt_manager/workspace#/library');
await workspace.locator('.workspace-heading').getByRole('button',{name:'+ New prompt',exact:true}).click();
const dialog=workspace.locator('dialog.library-editor');await expect(dialog.getByRole('button',{name:'Save',exact:true})).toBeEnabled();
await dialog.getByLabel('Title',{exact:true}).fill(title);await dialog.getByLabel('Positive prompt',{exact:true}).fill(positive);
await dialog.getByRole('button',{name:'Save',exact:true}).click();await expect(dialog).toHaveCount(0);
await expect(workspace).toHaveURL(/#\/library\/\d+$/);const positiveId=Number(new URL(workspace.url()).hash.split('/').at(-1));
await expect(workspace.locator('#workspace-main h1')).toHaveText(title);
const saved=workspace.getByRole('region',{name:'Saved prompt texts',exact:true});
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
const evidence=async()=>{
const history=await(await request.get(`/prompt_manager/library/${positiveId}/history`)).json();
const detail=await(await request.get(`/prompt_manager/library/${positiveId}?archive=all`)).json();
return {history:{count:history.recorded_run_count,runs:history.items.map(item=>item.run_id)},
pairMetadata:detail.variants.map(({id,title,category,tags,archived,negative_text})=>({id,title,category,tags,archived,negative_text}))};
};
const before=await evidence();expect(before.pairMetadata).toHaveLength(1);
const send=workspace.locator('.workspace-heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${title}`,exact:true});
for(const renderer of [testInfo.project.metadata.initialRenderer,!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const node=app.graph.getNodeById(id);
node.widgets.find(widget=>widget.name==='text').value='Before positive';node.widgets.find(widget=>widget.name==='negative_text').value='Before negative';
window.editorEffects={queue:0,load:0};window.editorOriginals={};
for(const [method,key] of [['queuePrompt','queue'],['loadGraphData','load']]){const original=app[method];window.editorOriginals[method]=original;app[method]=function(...args){window.editorEffects[key]++;return original.apply(this,args);};}
},id);
try {
await expect(send).toBeEnabled();await send.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,'(empty)',`Keep trigger ${positive} Keep detail`])await expect(sender).toContainText(text);
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
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.editorEffects};
},id);
expect(state.effects).toEqual({queue:0,load:0});
for(const field of ['inputs','serialized'])expect(state[field]).toMatchObject({text:positive,negative_text:'',prepend_text:'Keep trigger',append_text:'Keep detail'});
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);await expect(send).toBeFocused();
} finally {await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.editorOriginals);});}
}
// A connected target rejects the whole preview: neither role changes, nothing queues or loads.
const link=await page.evaluate(async id=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=app.graph.getNodeById(id);const widget=node.widgets.find(item=>item.name==='negative_text');
if(!node.inputs.some(input=>input.name==='negative_text'))convertToInput(node,widget);
node.widgets.find(item=>item.name==='text').value='Before positive';widget.value='Before negative';
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);source.widgets.find(item=>item.name==='value').value='Upstream negative';
source.connect(0,node,node.inputs.findIndex(input=>input.name==='negative_text'));
window.editorEffects={queue:0,load:0};window.editorOriginals={};
for(const [method,key] of [['queuePrompt','queue'],['loadGraphData','load']]){const original=app[method];window.editorOriginals[method]=original;app[method]=function(...args){window.editorEffects[key]++;return original.apply(this,args);};}
return [String(source.id),0];
},id);
try {
await send.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();
await expect(sender.getByRole('alert')).toContainText(/connected/i);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
const rejected=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,widgets:Object.fromEntries(node.widgets.map(widget=>[widget.name,widget.value])),
serialized:Object.fromEntries(node.widgets.map((widget,index)=>[widget.name,serialized.widgets_values[index]])),effects:window.editorEffects};
},id);
expect(rejected.effects).toEqual({queue:0,load:0});
expect(rejected.inputs.negative_text).toEqual(link);
for(const field of ['widgets','serialized'])expect(rejected[field]).toMatchObject({text:'Before positive',negative_text:'Before negative',prepend_text:'Keep trigger',append_text:'Keep detail'});
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
} finally {await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.editorOriginals);});}
const after=await evidence();
expect(after.history).toEqual(before.history);expect(after.pairMetadata).toEqual(before.pairMetadata);
expect(errors).toEqual([]);
} finally {await workspace.close();}
});
+30
View File
@@ -253,3 +253,33 @@ test("ApiError exposes only validated history recovery actions on all error path
}
}
});
test("patch sends a JSON body with the PATCH method and forwards an AbortSignal", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
assert.equal(typeof api.patch, "function");
const controller = new AbortController();
const pending = api.patch("/prompt_manager/library/7", { metadata: { rating: null } }, { signal: controller.signal });
assert.equal(fetch.calls[0].init.method, "PATCH");
assert.equal(fetch.calls[0].init.body, JSON.stringify({ metadata: { rating: null } }));
assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
assert.equal(fetch.calls[0].init.signal, controller.signal);
fetch.calls[0].resolve(jsonResponse(409, { success: false, code: "metadata-conflict", error: "changed" }));
await assert.rejects(pending, error => error instanceof ApiError && error.status === 409 && error.code === "metadata-conflict");
});
test("get forwards an explicit cache mode and keeps the default request otherwise", async () => {
const fetch = fakeFetch();
const api = createApiClient(fetch);
const fresh = api.get("/prompt_manager/library/options", { cache: "no-store" });
const plain = api.get("/prompt_manager/library/options");
assert.equal(fetch.calls[0].init.cache, "no-store");
assert.equal("cache" in fetch.calls[1].init, false);
assert.deepEqual(fetch.calls[1].init, { method: "GET", headers: {} });
for (const call of fetch.calls) call.resolve(jsonResponse(200, { success: true }));
await Promise.all([fresh, plain]);
const writes = api.post("/x", {}, { cache: "no-store" });
assert.equal("cache" in fetch.calls[2].init, false, "only reads take a cache mode");
fetch.calls[2].resolve(jsonResponse(200, { success: true }));
await writes;
});
@@ -0,0 +1,790 @@
// Run with: node --test tests/js/workspace-library-editor-state.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const { createApiClient } = require('../../web/js/api-client.js');
// Only an absent module is tolerated, so RED is an assertion failure, not a crash.
const source = import('../../web/js/workspace/library-editor-state.mjs').catch(error => {
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
return {};
});
const EPOCH = '11111111-1111-4111-8111-111111111111';
const RESTORED = '22222222-2222-4222-8222-222222222222';
const TOKEN = 'a'.repeat(64);
const TOKEN2 = 'b'.repeat(64);
const flush = () => new Promise(resolve => setImmediate(resolve));
async function settle(times = 6) { for (let i = 0; i < times; i++) await flush(); }
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function response(status, body) {
return { ok: status >= 200 && status < 300, status, statusText: `Status ${status}`, json: async () => body };
}
/** Real createApiClient over a fetch whose answers come from handler(call). */
function harness(handler) {
const calls = [];
const fetch = (url, init = {}) => {
const call = { url, method: init.method, init, body: init.body === undefined ? undefined : JSON.parse(init.body), aborted: false };
calls.push(call);
return new Promise((resolve, reject) => {
init.signal?.addEventListener('abort', () => {
call.aborted = true;
reject(new DOMException('The operation was aborted.', 'AbortError'));
});
Promise.resolve().then(() => handler(call)).then(resolve, reject);
});
};
return { api: createApiClient(fetch), calls, writes: () => calls.filter(call => call.method !== 'GET') };
}
const positive = (overrides = {}) => ({
id: 7, text: 'First line\nsecond line', title: null, category: 'Studio', tags: ['soft', 'warm'],
rating: 3, notes: null, edit_token: TOKEN, created_at: '2026-01-01 00:00:00', variant_count: 1, image_count: 2,
...overrides,
});
const variant = (id, negative = 'blurry') => ({
id, negative_text: negative, category: null, title: null, archived: false,
explicit_empty: negative === '', image_count: 1, tags: [],
});
const pair = (overrides = {}) => ({
id: 31, positive_prompt_id: 7, negative_prompt_id: 12, title: null, category: null, rating: null, notes: null,
created_at: '', updated_at: '', last_used_at: null, run_count: 4, archived: false,
positive_text: 'First line\nsecond line', negative_text: 'blurry', image_count: 2, tags: [],
...overrides,
});
const detail = (epoch = EPOCH, owner = positive(), variants = [variant(31)]) => ({
success: true, library_epoch: epoch, positive: { ...owner, archived_variant_count: 0, previews: [] },
variants, unassigned_image_count: 0, variant_total: variants.length, variants_has_more: false, variants_next_cursor: null,
});
const options = (epoch = EPOCH) => ({ success: true, library_epoch: epoch, categories: ['Studio', 'Portrait'] });
const recipe = (overrides = {}) => ({
success: true, library_epoch: EPOCH, positive: positive({ id: 9, text: 'a portrait', title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' }),
pair: pair({ id: 40, positive_prompt_id: 9, positive_text: 'a portrait', negative_text: '', negative_prompt_id: null }),
positive_created: true, pair_created: true, parent_metadata_applied: true, ...overrides,
});
/** Answer the standard Library reads; `write` answers anything else. */
function library({ epoch = EPOCH, owner = positive(), variants = [variant(31)], selected = pair(), write } = {}) {
return call => {
const path = call.url.split('?')[0];
if (call.method === 'GET' && path === '/prompt_manager/library/options') return response(200, options(epoch));
if (call.method === 'GET' && path === `/prompt_manager/library/${owner.id}`) return response(200, detail(epoch, owner, variants));
if (call.method === 'GET' && path === `/prompt_manager/pairs/${selected.id}`) return response(200, { success: true, pair: selected });
if (write) return write(call);
throw new Error(`Unexpected ${call.method} ${call.url}`);
};
}
/** Reads for the owner a create receipt returns. */
const created = (write, epoch = EPOCH) => library({ epoch, owner: recipe().positive, selected: recipe().pair, write });
test('new prompt from Images waits for a fresh options epoch', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const read = deferred();
const committed = [], reconciled = [];
const server = harness(created(() => response(200, recipe())));
const original = server.api.get;
let first = true;
server.api.get = (url, init) => {
if (first) { first = false; return read.promise.then(() => original(url, init)); }
return original(url, init);
};
const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: c => committed.push(c), onReconciled: r => reconciled.push(r) });
const initialization = controller.initialize();
assert.equal(controller.state.phase, 'initializing');
controller.setField('title', ' Portrait ');
controller.setField('positive_text', 'a portrait');
await controller.submit();
assert.equal(server.writes().length, 0, 'pending initialization cannot save');
read.resolve();
await initialization;
const request = server.calls[0];
assert.equal(request.url, '/prompt_manager/library/options');
assert.equal(request.init.cache, 'no-store');
assert.equal(controller.state.phase, 'ready');
assert.equal(controller.state.draft.title, ' Portrait ', 'typing survives the pending read');
assert.deepEqual(controller.state.categories, ['Studio', 'Portrait']);
await controller.submit();
const [write] = server.writes();
assert.equal(write.method, 'POST');
assert.equal(write.url, '/prompt_manager/library');
const sentBody = write.body;
assert.equal(sentBody.expected_epoch, EPOCH);
assert.deepEqual(sentBody, {
expected_epoch: EPOCH, positive_text: 'a portrait', negative_text: '',
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' },
});
assert.equal(controller.state.phase, 'complete');
assert.equal(committed.length, 1);
assert.equal(committed[0].kind, 'create');
assert.equal(reconciled.length, 1);
assert.equal(reconciled[0].pair.id, 40);
assert.equal(reconciled[0].detail.positive.id, 9);
assert.equal(controller.state.receipt.pair.id, 40);
assert.equal(controller.state.outcome.metadataNotApplied, false);
await controller.submit();
assert.equal(server.writes().length, 1, 'submit once');
controller.dispose();
});
test('failed initialization retries only the read and retains draft', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let fail = 2;
const server = harness(call => {
if (fail-- > 0) return fail === 1 ? Promise.reject(new TypeError('Failed to fetch')) : response(200, { success: true, library_epoch: 'not-a-uuid', categories: [] });
return library()(call);
});
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
const pending = controller.initialize();
controller.setField('notes', 'kept\n');
await pending;
assert.equal(controller.state.phase, 'initialization-error');
await controller.submit();
await controller.retryInitialization();
assert.equal(controller.state.phase, 'initialization-error', 'invalid epoch is not ready');
await controller.retryInitialization();
assert.equal(controller.state.phase, 'ready');
assert.equal(controller.state.draft.notes, 'kept\n');
assert.equal(controller.state.error, null);
assert.equal(server.writes().length, 0);
assert.ok(server.calls.every(call => call.method === 'GET' && call.url === '/prompt_manager/library/options'));
assert.equal(server.calls.length, 3);
controller.dispose();
});
test('late initialization cannot enable a replacement dialog', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const answers = [deferred(), deferred(), deferred()];
let index = 0;
const server = harness(() => answers[index++].promise);
const old = createLibraryEditorController({ api: server.api, mode: 'new' });
const oldInit = old.initialize();
old.dispose();
assert.equal(server.calls[0].aborted, true, 'disposal aborts the read');
assert.equal(old.state.phase, 'disposed');
const replacement = createLibraryEditorController({ api: server.api, mode: 'new' });
const replacementInit = replacement.initialize();
await settle();
answers[0].resolve(response(200, options(RESTORED)));
await oldInit;
await settle();
assert.equal(replacement.state.phase, 'initializing', 'an older dialog cannot enable this one');
assert.equal(replacement.state.epoch, null);
// A retried read supersedes the earlier attempt inside the same dialog too.
answers[1].reject(new TypeError('offline'));
await replacementInit;
assert.equal(replacement.state.phase, 'initialization-error');
const retry = replacement.retryInitialization();
await settle();
answers[2].resolve(response(200, options(EPOCH)));
await retry;
assert.equal(replacement.state.epoch, EPOCH);
assert.equal(replacement.state.phase, 'ready');
replacement.dispose();
});
test('restore after initialization rejects the old New submission', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let epoch = EPOCH;
const server = harness(call => created(write => write.body.expected_epoch === RESTORED
? response(200, recipe({ library_epoch: RESTORED }))
: response(409, { success: false, code: 'storage-changed', error: 'Prompt storage was restored; refresh the library', library_epoch: RESTORED }), epoch)(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
await controller.initialize();
controller.setField('title', 'Portrait');
controller.setField('positive_text', 'a portrait');
epoch = RESTORED;
await controller.submit();
assert.equal(controller.state.phase, 'storage-changed');
assert.equal(controller.state.error.code, 'storage-changed');
assert.equal(controller.state.draft.title, 'Portrait');
assert.equal(controller.state.epoch, EPOCH, 'the error response epoch is never borrowed');
await controller.submit();
assert.equal(server.writes().length, 1, 'Save is disabled after storage changed');
await controller.reloadOwner({ keepDraft: true });
assert.equal(controller.state.phase, 'ready');
assert.equal(controller.state.epoch, RESTORED);
assert.equal(controller.state.reviewRequired, true);
assert.equal(controller.state.draft.positive_text, 'a portrait');
assert.equal(server.writes().length, 1, 'reload never replays the write');
await controller.submit();
assert.equal(server.writes().length, 2);
assert.equal(server.writes()[1].body.expected_epoch, RESTORED);
assert.equal(controller.state.phase, 'complete');
controller.dispose();
});
test('editor recovery preserves hyphenated ApiError codes', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const errors = [], phases = [];
for (const [code, extra] of [['metadata-conflict', { positive: positive({ edit_token: TOKEN2 }) }], ['storage-changed', {}], ['other-conflict', {}], [null, {}]]) {
const server = harness(library({ write: () => response(409, { success: false, code, error: 'Conflict', library_epoch: EPOCH, ...extra }) }));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('rating', 5);
await controller.submit();
phases.push(controller.state.phase);
if (controller.state.phase !== 'write-error') errors.push(controller.state.error);
assert.equal(controller.state.draft.rating, 5, 'draft preserved');
assert.equal(controller.state.receipt, null);
controller.dispose();
}
assert.deepEqual(errors.map(e => e.code), ['metadata-conflict', 'storage-changed']);
assert.equal(errors[0].status, 409);
assert.deepEqual(phases, ['conflict', 'storage-changed', 'write-error', 'write-error'], 'an unrelated 409 is generic');
});
test('lost save response never manufactures a receipt', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const committed = [];
let lose = true;
const reused = recipe({ positive_created: false, pair_created: false, parent_metadata_applied: false });
const server = harness(created(() => {
if (lose) { lose = false; return Promise.reject(new TypeError('Failed to fetch')); }
return response(200, reused);
}));
const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: commit => committed.push(commit) });
await controller.initialize();
controller.setField('title', 'Portrait');
controller.setField('positive_text', 'a portrait');
controller.setField('category', 'Uncategorized');
await controller.submit();
assert.equal(controller.state.receipt, null);
assert.equal(controller.state.phase, 'unknown-outcome');
assert.equal(committed.length, 0);
assert.equal(server.writes().length, 1, 'no automatic retry');
await settle();
assert.equal(server.writes().length, 1);
controller.setField('title', 'Changed after the loss');
await controller.submit();
assert.equal(server.writes().length, 1, 'Save cannot replace the unconfirmed write');
await controller.retryUnconfirmed();
assert.equal(server.writes().length, 2);
assert.deepEqual(server.writes()[1].body, server.writes()[0].body, 'identical payload');
assert.equal(controller.state.phase, 'complete');
assert.equal(controller.state.receipt.positive_created, false);
assert.equal(controller.state.outcome.metadataNotApplied, false, 'stored metadata equals the submitted draft');
assert.equal(committed.length, 1);
controller.dispose();
});
test('reused retry discloses unapplied metadata only when it differs', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let lose = true;
const other = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false,
positive: positive({ id: 9, text: 'a portrait', title: 'Someone else', tags: ['x'] }) });
const server = harness(created(() => {
if (lose) { lose = false; return Promise.reject(new TypeError('Failed to fetch')); }
return response(200, other);
}));
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
await controller.initialize();
controller.setField('title', 'Portrait');
controller.setField('positive_text', 'a portrait');
await controller.submit();
await controller.retryUnconfirmed();
assert.equal(controller.state.phase, 'complete');
assert.equal(controller.state.outcome.metadataNotApplied, true);
assert.equal(controller.state.outcome.pairCreated, true);
assert.equal(controller.state.outcome.positiveCreated, false);
controller.dispose();
});
test('confirmed save refresh retry is read only', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let failReads = 1;
const committed = [], reconciled = [];
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext', edit_token: TOKEN2 }) };
const base = library({ write: () => response(200, receipt) });
const server = harness(call => {
if (call.method === 'GET' && server.writes().length && failReads-- > 0) return Promise.reject(new TypeError('offline'));
return base(call);
});
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) });
await controller.initialize();
controller.setField('rating', null);
controller.setField('notes', 'keep\ntext');
await controller.submit();
assert.equal(controller.state.phase, 'saved-refresh-error');
assert.equal(controller.state.receipt.positive.edit_token, TOKEN2);
assert.equal(committed.length, 1);
assert.equal(committed[0].kind, 'metadata');
const writeCalls = server.writes();
await controller.submit();
await controller.retryReconciliation();
assert.equal(writeCalls.length, 1); // retryReconciliation only GETs
assert.equal(server.writes().length, 1);
assert.equal(controller.state.phase, 'complete');
assert.equal(reconciled.length, 1);
assert.equal(reconciled[0].commit, committed[0]);
assert.equal(reconciled[0].pair, null);
assert.equal(reconciled[0].options.library_epoch, EPOCH);
controller.dispose();
});
test('legacy title fallback is not an edit', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext' }) };
const server = harness(library({ write: () => response(200, receipt) }));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
assert.equal(controller.state.draft.title, 'First line', 'displayed fallback seeds the input');
assert.equal(controller.state.baseline.title, null, 'stored title stays distinct');
assert.equal(controller.state.draft.tags, 'soft, warm');
assert.equal(controller.state.draft.negative_text, 'blurry', 'the selected pair negative');
controller.setField('rating', null);
controller.setField('notes', 'keep\ntext');
controller.setField('tags', 'warm, soft,');
controller.setField('category', ' Studio ');
await controller.submit();
const [write] = server.writes();
assert.equal(write.method, 'PATCH');
assert.equal(write.url, '/prompt_manager/library/7');
const sentBody = write.body;
assert.equal(sentBody.expected_epoch, EPOCH);
assert.equal(sentBody.if_match, TOKEN);
assert.deepEqual(sentBody.metadata, { rating: null, notes: 'keep\ntext' });
const unchanged = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await unchanged.initialize();
unchanged.setField('notes', '');
await unchanged.submit();
assert.equal(server.writes().length, 1, 'unchanged edit submits nothing');
assert.equal(unchanged.state.phase, 'complete');
assert.equal(unchanged.state.receipt, null);
const edited = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await edited.initialize();
edited.setField('title', ' Studio portrait ');
edited.setField('category', '');
await edited.submit();
assert.deepEqual(server.writes()[1].body.metadata, { title: 'Studio portrait', category: '' }, 'Edit keeps a blank category blank');
for (const item of [controller, unchanged, edited]) item.dispose();
});
test('existing-parent forms read detail, options and the exact selected pair fresh', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const many = Array.from({ length: 40 }, (_, i) => variant(100 - i));
const outside = pair({ id: 31, run_count: 9 });
const server = harness(library({ variants: many, selected: outside }));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
assert.equal(controller.state.phase, 'ready');
assert.equal(controller.state.pair.id, 31);
assert.equal(controller.state.pair.run_count, 9);
assert.ok(server.calls.every(call => call.init.cache === 'no-store'));
assert.deepEqual(server.calls.map(call => call.url.split('?')[0]).sort(),
['/prompt_manager/library/7', '/prompt_manager/library/options', '/prompt_manager/pairs/31']);
controller.dispose();
for (const broken of [
library({ selected: pair({ positive_prompt_id: 8 }) }),
library({ selected: pair({ positive_text: 'other text' }) }),
call => call.url.startsWith('/prompt_manager/library/options') ? response(200, options(RESTORED)) : library()(call),
library({ owner: positive({ id: 7, edit_token: 'short' }) }),
]) {
const reads = harness(broken);
const rejected = createLibraryEditorController({ api: reads.api, mode: 'revision', positiveId: 7, pairId: 31 });
await rejected.initialize();
assert.equal(rejected.state.phase, 'initialization-error');
await rejected.submit();
assert.equal(reads.writes().length, 0);
rejected.dispose();
}
const wrongOwner = harness(call => call.url.startsWith('/prompt_manager/library/7') ? response(200, detail(EPOCH, positive({ id: 8 }))) : library()(call));
const mismatch = createLibraryEditorController({ api: wrongOwner.api, mode: 'negative', positiveId: 7 });
await mismatch.initialize();
assert.equal(mismatch.state.phase, 'initialization-error');
mismatch.dispose();
assert.throws(() => createLibraryEditorController({ api: server.api, mode: 'metadata' }), TypeError);
assert.throws(() => createLibraryEditorController({ api: server.api, mode: 'new', positiveId: 7 }), TypeError);
assert.throws(() => createLibraryEditorController({ api: server.api, mode: 'archive', positiveId: 7 }), TypeError);
});
test('metadata conflict reload keeps changed fields for review and never replays', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let owner = positive();
const server = harness(call => library({ owner, write: write => write.body.if_match === TOKEN2
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, rating: 5 } })
: response(409, { success: false, code: 'metadata-conflict', error: 'changed', library_epoch: EPOCH, positive: owner }) })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('rating', '5');
owner = positive({ title: 'Renamed elsewhere', notes: 'other writer', edit_token: TOKEN2 });
await controller.submit();
assert.equal(controller.state.phase, 'conflict');
await controller.submit();
assert.equal(server.writes().length, 1);
await controller.reloadOwner({ keepDraft: true });
assert.equal(controller.state.phase, 'ready');
assert.equal(controller.state.reviewRequired, true);
assert.equal(controller.state.draft.rating, 5, 'changed field kept');
assert.equal(controller.state.draft.title, 'Renamed elsewhere', 'untouched field follows the current owner');
assert.equal(controller.state.draft.notes, 'other writer');
assert.equal(server.writes().length, 1);
await controller.submit();
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { rating: 5 } });
assert.equal(controller.state.phase, 'complete');
controller.dispose();
const discard = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await discard.initialize();
discard.setField('rating', 1);
await discard.reloadOwner({ keepDraft: false });
assert.equal(discard.state.draft.rating, 3);
assert.equal(discard.state.reviewRequired, false);
discard.dispose();
});
test('metadata unknown outcome retry reloads the owner instead of replaying', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let owner = positive();
const server = harness(call => library({ owner, write: () => {
owner = positive({ rating: 5, edit_token: TOKEN2 });
return Promise.reject(new TypeError('Failed to fetch'));
} })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('rating', 5);
await controller.submit();
assert.equal(controller.state.phase, 'unknown-outcome');
await controller.retryUnconfirmed();
assert.equal(server.writes().length, 1, 'a metadata retry requires review, not a replay');
assert.equal(controller.state.phase, 'ready');
assert.equal(controller.state.reviewRequired, true);
assert.equal(controller.state.owner.edit_token, TOKEN2);
await controller.submit();
assert.equal(server.writes().length, 1, 'the applied edit is now unchanged');
assert.equal(controller.state.phase, 'complete');
controller.dispose();
});
test('pair archive and restore patch the selected pair and reconcile', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let selected = pair();
const committed = [], reconciled = [];
const server = harness(call => library({ selected, write: write => {
selected = pair({ archived: write.body.archived });
return response(200, { success: true, pair: selected });
} })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) });
await controller.initialize();
controller.setField('notes', 'unsaved');
await controller.setArchive(true);
const [write] = server.writes();
assert.equal(write.method, 'PATCH');
assert.equal(write.url, '/prompt_manager/pairs/31');
assert.deepEqual(write.body, { archived: true });
assert.equal(committed[0].kind, 'archive-pair');
assert.equal(controller.state.phase, 'complete');
assert.equal(reconciled[0].pair.archived, true);
controller.dispose();
const restore = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
onCommitted: commit => committed.push(commit) });
await restore.initialize();
assert.equal(restore.state.pair.archived, true);
await restore.setArchive(false);
assert.equal(committed[1].kind, 'restore-pair');
assert.deepEqual(server.writes()[1].body, { archived: false });
restore.dispose();
let optionReads = 0;
const restored = harness(call => call.url === '/prompt_manager/library/options' && ++optionReads > 1
? response(200, options(RESTORED)) : library()(call));
const guarded = createLibraryEditorController({ api: restored.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await guarded.initialize();
await guarded.setArchive(true);
assert.equal(guarded.state.phase, 'storage-changed', 'a restored database cannot archive a reused pair ID');
assert.equal(restored.writes().length, 0);
guarded.dispose();
const none = harness(library());
const noPair = createLibraryEditorController({ api: none.api, mode: 'metadata', positiveId: 7 });
await noPair.initialize();
assert.equal(noPair.state.pair, null);
assert.equal(noPair.state.draft.negative_text, '');
await noPair.setArchive(true);
assert.equal(none.writes().length, 0);
assert.match(noPair.state.error.message, /saved negative/);
noPair.dispose();
});
test('add negative sends only the negative under the loaded token', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const archived = recipe({ positive: positive(), pair: pair({ id: 55, archived: true, negative_text: '' }),
positive_created: false, pair_created: false, parent_metadata_applied: false });
const server = harness(library({ selected: pair({ id: 55, archived: true, negative_text: '' }), write: () => response(200, archived) }));
const committed = [];
const controller = createLibraryEditorController({ api: server.api, mode: 'negative', positiveId: 7, onCommitted: c => committed.push(c) });
await controller.initialize();
assert.ok(server.calls.every(call => !call.url.startsWith('/prompt_manager/library/options')), 'Add negative needs only the owner read');
await controller.submit();
const [write] = server.writes();
assert.equal(write.url, '/prompt_manager/library/7/variants');
assert.deepEqual(write.body, { expected_epoch: EPOCH, if_match: TOKEN, negative_text: '' });
assert.equal(committed[0].kind, 'add-negative');
assert.equal(controller.state.outcome.pairArchived, true);
assert.equal(controller.state.outcome.metadataNotApplied, false);
controller.dispose();
const forged = harness(library({ write: () => response(200, { ...archived, positive: positive({ id: 8 }) }) }));
const unconfirmed = createLibraryEditorController({ api: forged.api, mode: 'negative', positiveId: 7 });
await unconfirmed.initialize();
await unconfirmed.submit();
assert.equal(unconfirmed.state.phase, 'unknown-outcome', 'an invalid receipt is not confirmation');
assert.equal(unconfirmed.state.receipt, null);
unconfirmed.dispose();
});
test('revision seeds text from the selected pair and sends Uncategorized for a blank category', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const server = harness(library({ owner: positive({ title: 'Portrait', category: null, notes: 'n' }), variants: [variant(30, 'first'), variant(31)], write: () => response(200, recipe()) }));
const controller = createLibraryEditorController({ api: server.api, mode: 'revision', positiveId: 7, pairId: 31 });
await controller.initialize();
assert.equal(controller.state.draft.negative_text, 'blurry', 'never the first arbitrary negative');
assert.equal(controller.state.draft.positive_text, 'First line\nsecond line');
controller.setField('positive_text', 'First line\nrevised');
controller.setField('tags', ' a, b ,a,, ');
await controller.submit();
assert.deepEqual(server.writes()[0].body, {
expected_epoch: EPOCH, positive_text: 'First line\nrevised', negative_text: 'blurry',
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], rating: 3, notes: 'n' },
});
controller.dispose();
});
test('invalid drafts and server failures keep the draft without a receipt', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let status = 500;
const server = harness(library({ write: () => response(status, { success: false, code: null, error: 'Library command failed' }) }));
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
await controller.initialize();
controller.setField('title', ' ');
controller.setField('positive_text', 'x');
await controller.submit();
assert.equal(server.writes().length, 0);
assert.equal(controller.state.phase, 'ready');
assert.match(controller.state.error.message, /Title/);
assert.throws(() => controller.setField('unknown', 'x'), TypeError);
assert.throws(() => controller.setField('rating', 7), TypeError);
controller.setField('title', 'T');
await controller.submit();
assert.equal(controller.state.phase, 'write-error');
assert.equal(controller.state.draft.title, 'T');
status = 504;
await controller.submit();
assert.equal(controller.state.phase, 'unknown-outcome', 'a gateway timeout cannot confirm either outcome');
controller.dispose();
controller.setField('title', 'ignored');
assert.equal(controller.state.phase, 'disposed');
});
test('disposal suppresses late write results without aborting the write but still reports the commit', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
const answer = deferred();
const committed = [], changes = [];
const server = harness(library({ write: () => answer.promise }));
const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: c => committed.push(c), onChange: s => changes.push(s) });
await controller.initialize();
controller.setField('title', 'T');
controller.setField('positive_text', 'x');
const saving = controller.submit();
await settle();
assert.equal(controller.state.phase, 'saving');
assert.ok(Object.isFrozen(controller.state));
await controller.submit();
assert.equal(server.writes().length, 1, 'duplicate submit is disabled');
controller.dispose();
assert.equal(server.writes()[0].aborted, false, 'abort cannot undo a write');
const count = changes.length, calls = server.calls.length;
answer.resolve(response(200, recipe()));
await saving;
await settle();
// A committed receipt still invalidates cached Library views after the dialog went away.
assert.equal(committed.length, 1);
assert.equal(committed[0].kind, 'create');
assert.equal(committed[0].receipt.positive.id, 9);
assert.equal(server.calls.length, calls, 'no reconciliation read after dispose');
assert.equal(changes.length, count);
assert.equal(controller.state.phase, 'disposed');
});
test('disposal during an unconfirmed save reports no commit', async () => {
const { createLibraryEditorController } = await source;
const answer = deferred();
const committed = [];
const server = harness(library({ write: () => answer.promise }));
const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: c => committed.push(c) });
await controller.initialize();
controller.setField('title', 'T');
controller.setField('positive_text', 'x');
const saving = controller.submit();
await settle();
controller.dispose();
answer.resolve(response(200, { success: true }));
await saving;
await settle();
assert.equal(committed.length, 0, 'an invalid receipt is not a commit');
});
test('reconcileLibraryReceipt reads detail, options and the verified pair', async () => {
const { reconcileLibraryReceipt } = await source;
assert.equal(typeof reconcileLibraryReceipt, 'function', 'reconcile export');
const receipt = recipe({ positive: positive(), pair: pair() });
const server = harness(library());
const result = await reconcileLibraryReceipt(server.api, { kind: 'add-negative', receipt }, {});
assert.equal(result.detail.positive.id, 7);
assert.equal(result.pair.id, 31);
assert.ok(!result.detail.variants.some(item => item === result.pair), 'the verified pair stays separate');
assert.equal(server.writes().length, 0);
const restored = harness(library({ epoch: RESTORED }));
await assert.rejects(reconcileLibraryReceipt(restored.api, { kind: 'add-negative', receipt }, {}), error => error.code === 'storage-changed');
const moved = harness(library({ selected: pair({ positive_prompt_id: 8 }) }));
await assert.rejects(reconcileLibraryReceipt(moved.api, { kind: 'add-negative', receipt }, {}), TypeError);
await assert.rejects(reconcileLibraryReceipt(server.api, { kind: 'archive', receipt }, {}), TypeError);
});
test('unconfirmed pair archive retry is fenced against restored storage', async () => {
const { createLibraryEditorController } = await source;
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
let epoch = EPOCH;
const server = harness(call => library({ epoch, write: () => {
epoch = RESTORED;
return Promise.reject(new TypeError('Failed to fetch'));
} })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
await controller.setArchive(true);
assert.equal(controller.state.phase, 'unknown-outcome');
assert.equal(server.writes().length, 1);
await controller.retryUnconfirmed();
assert.equal(server.writes().length, 1, 'a restored database cannot take the retried pair write');
assert.equal(controller.state.phase, 'storage-changed');
assert.equal(controller.state.error.code, 'storage-changed');
controller.dispose();
});
test('refreshPair re-reads the selected pair through the owner check and reports failures without changing it', async () => {
const { createLibraryEditorController } = await source;
let selected = pair();
let fail = null;
const server = harness(call => {
if (fail && call.method === 'GET' && call.url.startsWith('/prompt_manager/pairs/31')) return fail();
return library({ selected })(call);
});
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
assert.equal(typeof controller.refreshPair, 'function', 'refreshPair export');
await controller.initialize();
assert.equal(controller.state.pairRead, 'fresh');
selected = pair({ image_count: 5, run_count: 9 });
const pending = controller.refreshPair();
assert.equal(controller.state.pairRead, 'reading');
await pending;
assert.equal(controller.state.pairRead, 'fresh');
assert.equal(controller.state.pair.image_count, 5);
assert.equal(controller.state.pair.run_count, 9);
assert.equal(controller.state.pairReadError, null);
const pairReads = server.calls.filter(call => call.url.startsWith('/prompt_manager/pairs/31'));
assert.equal(pairReads.at(-1).init.cache, 'no-store');
for (const [name, answer] of [
['other owner text', () => response(200, { success: true, pair: pair({ image_count: 77, positive_text: 'someone else' }) })],
['other owner id', () => response(200, { success: true, pair: pair({ image_count: 77, positive_prompt_id: 8 }) })],
['failed read', () => response(500, { success: false, error: 'down' })],
['success false', () => response(200, { success: false, error: 'gone' })],
]) {
fail = answer;
await controller.refreshPair();
assert.equal(controller.state.pairRead, 'error', name);
assert.ok(controller.state.pairReadError instanceof Error, name);
assert.equal(controller.state.pair.image_count, 5, name);
assert.equal(controller.state.phase, 'ready', name);
}
assert.equal(server.writes().length, 0);
controller.dispose();
});
test('refreshPair is inert without a selected pair, while busy and after dispose', async () => {
const { createLibraryEditorController } = await source;
const server = harness(library());
const none = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7 });
await none.initialize();
const before = server.calls.length;
await none.refreshPair();
assert.equal(server.calls.length, before);
assert.equal(none.state.pairRead, 'none');
none.dispose();
const disposed = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await disposed.initialize();
disposed.dispose();
const count = server.calls.length;
await disposed.refreshPair();
assert.equal(server.calls.length, count);
});
test('reused pair outside first forty remains selected without loading every page', async () => {
const { reconcileLibraryReceipt } = await source;
const { libraryPromptRoles } = await import('../../web/js/workspace/library-state.mjs');
const page = Array.from({ length: 40 }, (_, index) => variant(200 - index, `negative ${200 - index}`));
const oldPair = pair({ id: 3, negative_text: 'the oldest saved negative', negative_prompt_id: 99 });
const server = harness(call => {
const path = call.url.split('?')[0];
if (call.method === 'GET' && path === '/prompt_manager/library/7') {
return response(200, { ...detail(EPOCH, positive(), page), variant_total: 85, variants_has_more: true, variants_next_cursor: 'c'.repeat(16) });
}
return library({ selected: oldPair })(call);
});
const receipt = recipe({ positive: positive(), pair: oldPair, positive_created: false, pair_created: false, parent_metadata_applied: false });
const result = await reconcileLibraryReceipt(server.api, { kind: 'add-negative', receipt }, {});
const detailResult = result.detail;
assert.equal(detailResult.variants.length, 40);
assert.equal(detailResult.variant_total, 85);
assert.ok(!detailResult.variants.some(item => item.id === oldPair.id), 'the reused pair is outside the bounded page');
assert.equal(libraryPromptRoles(detailResult, oldPair.id, result.pair).negative.text, oldPair.negative_text);
const variantCursorRequests = server.calls.filter(call => call.url.includes('variant_cursor'));
assert.equal(variantCursorRequests.length, 0);
assert.equal(server.writes().length, 0);
});
test('a refresh failed by restored storage cannot recover through retryReconciliation', async () => {
const { createLibraryEditorController } = await source;
let restored = false;
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'saved', edit_token: TOKEN2 }) };
const server = harness(call => library({ epoch: restored ? RESTORED : EPOCH, write: () => {restored = true; return response(200, receipt);} })(call));
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
await controller.initialize();
controller.setField('notes', 'saved');
await controller.submit();
assert.equal(controller.state.phase, 'saved-refresh-error');
assert.equal(controller.state.error.code, 'storage-changed');
await controller.retryReconciliation();
assert.equal(controller.state.phase, 'saved-refresh-error', 'only an explicit Library reload can leave this state');
assert.equal(controller.state.error.code, 'storage-changed');
assert.equal(server.writes().length, 1);
controller.dispose();
});
+26
View File
@@ -53,3 +53,29 @@ test('negative variants append continuously without losing selected identity, an
const loader=createLibraryDetail({api:{get:async url=>{calls.push(url);count++;if(count===2)throw Error('offline');return {positive:item(1),variants:count===1?[v(9)]:[v(9),v(10)],variant_total:2,variants_has_more:count===1,variants_next_cursor:count===1?'next':null,unassigned_image_count:0};}},onChange:()=>{}});
await loader.load(1);assert.equal(loader.state.detail.variants.length,1);await loader.loadMore();assert.match(loader.state.variantsError.message,/offline/);assert.equal(loader.state.detail.variants[0].id,9);await loader.loadMore();assert.deepEqual(loader.state.detail.variants.map(row=>row.id),[9,10]);assert.match(calls[1],/variant_cursor=next/);assert.equal(calls[1],calls[2]);assert.equal(await loader.loadMore(),0);loader.dispose();
});
test('a separately verified pair outside the loaded page supplies roles only when it belongs to the parent',async()=>{
const {libraryPromptRoles}=await source;const owner=item(1);const detail={positive:owner,variants:[{id:9,negative_text:'',explicit_empty:true}]};
const outside={id:70,positive_prompt_id:1,positive_text:owner.text,negative_text:'old, kept',archived:false};
assert.equal(libraryPromptRoles(detail,70,outside).negative.text,'old, kept');assert.equal(libraryPromptRoles(detail,9,outside).negative.text,'');
assert.throws(()=>libraryPromptRoles(detail,70,{...outside,positive_prompt_id:2}));assert.throws(()=>libraryPromptRoles(detail,70,{...outside,positive_text:'other'}));
assert.throws(()=>libraryPromptRoles(detail,71,outside));assert.throws(()=>libraryPromptRoles(detail,70,null));
});
test('accept replaces the detail with validated reconciled data and aborts the request it supersedes',async()=>{
const {createLibraryDetail}=await source;const pending=[],states=[];const loader=createLibraryDetail({api:{get:(url,{signal})=>new Promise((resolve,reject)=>pending.push({url,signal,resolve,reject}))},onChange:state=>states.push(state)});
assert.equal(typeof loader.accept,'function');
const first=loader.load(1);pending[0].resolve({positive:item(1),variants:[{id:9,negative_text:'a',explicit_empty:false}],variant_total:1,variants_has_more:false,variants_next_cursor:null});await first;
const more=loader.load(1);
loader.accept({positive:{...item(1),category:'Edited'},variants:[{id:10,negative_text:'b',explicit_empty:false}],variant_total:85,variants_has_more:true,variants_next_cursor:'next'});
assert.equal(pending[1].signal.aborted,true);pending[1].resolve({positive:item(1),variants:[],variant_total:0,variants_has_more:false,variants_next_cursor:null});await more;
assert.equal(loader.state.detail.positive.category,'Edited');assert.deepEqual(loader.state.detail.variants.map(row=>row.id),[10]);assert.equal(loader.state.loading,false);assert.equal(loader.state.error,null);
assert.throws(()=>loader.accept({positive:item(2),variants:[]}));assert.throws(()=>loader.accept({positive:item(1),variants:[{id:-1}]}));assert.equal(loader.state.detail.positive.category,'Edited');
assert.equal(pending.length,2);loader.dispose();assert.throws(()=>loader.accept({positive:item(1),variants:[]}));
});
test('archived variant reads use their own scope and cursor without changing the active default',async()=>{
const {createLibraryDetail}=await source;const urls=[];const v=id=>({id,negative_text:`n${id}`,explicit_empty:false,archived:true});let call=0;
const loader=createLibraryDetail({api:{get:async url=>{urls.push(url);call++;return {positive:item(1),variants:[v(call)],variant_total:2,variants_has_more:call===1,variants_next_cursor:call===1?'arch':null};}},onChange:()=>{},archive:'archived'});
await loader.load(1);await loader.loadMore();assert.deepEqual(loader.state.detail.variants.map(row=>row.id),[1,2]);
for(const url of urls)assert.equal(new URL(url,'https://x').searchParams.get('archive'),'archived');assert.equal(new URL(urls[1],'https://x').searchParams.get('variant_cursor'),'arch');
const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false);
assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'}));
});
+2 -2
View File
@@ -15,14 +15,14 @@ test('real-ComfyUI suite registers Classic and Nodes 2.0 startup projects', () =
assert.match(output, /\[nodes2-start\]/);
});
test('execution script discovers only the new matrix and preserves all 78 stock cases', () => {
test('execution script discovers only the new matrix and preserves all 78 original stock cases plus the native editor host case', () => {
const manifest = JSON.parse(readFileSync(new URL('../../tools/vendor/package.json', import.meta.url), 'utf8'));
assert.equal(manifest.scripts['test:executions'], 'playwright test --config playwright.execution.config.mjs');
const list = config => execFileSync(process.execPath, ['tools/vendor/node_modules/@playwright/test/cli.js',
'test', '--config', config, '--list'], { cwd: root, encoding: 'utf8',
env: { ...process.env, PM_COMFY_ROOT: '/unused-pinned-checkout' } });
const stock = list('tools/vendor/comfyui.config.mjs');
assert.match(stock, /Total: 78 tests in 7 files/);
assert.match(stock, /Total: 80 tests in 7 files/);
assert.doesNotMatch(stock, /execution-history/);
assert.doesNotMatch(stock, /execution-outputs/);
const executions = list('tools/vendor/playwright.execution.config.mjs');
+12
View File
@@ -215,6 +215,18 @@
"functions": 98,
"statements": 98
},
"workspace/library-editor-state.mjs": {
"lines": 96,
"branches": 91,
"functions": 98,
"statements": 96
},
"workspace/library-editor.mjs": {
"lines": 98,
"branches": 88,
"functions": 92,
"statements": 98
},
"workspace/library-layout.mjs": {
"lines": 98,
"branches": 98,
+5 -2
View File
@@ -56,8 +56,9 @@
function createApiClient(fetchImpl) {
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
async function request(method, url, { body, signal } = {}) {
async function request(method, url, { body, signal, cache } = {}) {
const init = { method, headers: {} };
if (cache !== undefined) init.cache = cache;
if (body !== undefined) {
init.headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
@@ -85,9 +86,11 @@
}
return {
get: (url, { signal } = {}) => request("GET", url, { signal }),
// cache is a fetch RequestCache mode such as "no-store"; omitted keeps the default.
get: (url, { signal, cache } = {}) => request("GET", url, { signal, cache }),
post: (url, body, { signal } = {}) => request("POST", url, { body, signal }),
put: (url, body, { signal } = {}) => request("PUT", url, { body, signal }),
patch: (url, body, { signal } = {}) => request("PATCH", url, { body, signal }),
del: (url, { signal } = {}) => request("DELETE", url, { signal }),
};
}
+2
View File
@@ -11,6 +11,8 @@ const paths = Object.freeze({
"search": "M10.5 3a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15Zm5.5 13 5 5",
"info": "M12 8h.01M12 11v6M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z",
"copy": "M8 8h13v13H8z M16 8V3H3v13h5",
"send": "m22 2-7 20-4-9-9-4L22 2ZM22 2 11 13",
"plus": "M12 4v16M4 12h16",
"branch": "M6 3v12M18 9a9 9 0 0 1-9 9M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0M21 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0"
});
+4 -1
View File
@@ -3,7 +3,7 @@ import { createButton, createField, createScope, createSelect } from '../shared/
import { routes, mountRouter, summarizeStats } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages } from './images.mjs';
import { mountLibrary } from './library.mjs';
import { mountLibrary, mountNewPromptAction } from './library.mjs';
import { createWorkspaceIcon } from './icons.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -67,6 +67,8 @@ function mountWorkspace() {
}
document.title = `${route.label} · Prompt Manager`;
const heading = element('h1', route.label);
// Focus target after a save, as the concept's page title.
heading.tabIndex = -1;
const headingCopy = element('div', undefined, 'workspace-heading-copy');
headingCopy.append(heading);
const headingRegion = element('header', undefined, 'workspace-heading');
@@ -76,6 +78,7 @@ function mountWorkspace() {
firstRender = false;
document.getElementById('workspace-breadcrumb').textContent = route.label;
if (route.id === 'images') {
mountNewPromptAction(main, api, view);
mountImages(main, api, view);
return;
}
+394
View File
@@ -0,0 +1,394 @@
/**
* Native Library editor state: fresh initialization, guarded saves and honest recovery.
*
* UI-agnostic. A dialog renders `controller.state` and calls its methods. Every
* controller owns its reads: a newer read, a retry or dispose() supersedes older
* ones, so a late response can never enable Save or supply an epoch. Writes are
* sent once, without an abort signal (aborting a request cannot undo a write),
* and are never replayed automatically.
*/
const MODES = new Set(['new', 'metadata', 'revision', 'negative']);
const KINDS = new Set(['metadata', 'create', 'add-negative', 'archive-pair', 'restore-pair']);
const FIELDS = ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes', 'rating'];
const RECOVERY = {'metadata-conflict': 'conflict', 'storage-changed': 'storage-changed'};
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
const SUBMITTABLE = new Set(['ready', 'write-error']);
const RELOADABLE = new Set(['ready', 'write-error', 'conflict', 'storage-changed', 'unknown-outcome', 'initialization-error']);
const UNCATEGORIZED = 'Uncategorized';
const VARIANT_LIMIT = 40;
const OPTIONS_URL = '/prompt_manager/library/options';
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
const token = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
const id = value => Number.isSafeInteger(value) && value > 0;
const count = value => Number.isSafeInteger(value) && value >= 0;
const nullableString = value => value === null || typeof value === 'string';
const strings = value => Array.isArray(value) && value.every(item => typeof item === 'string');
const rating = value => value === null || (Number.isInteger(value) && value >= 1 && value <= 5);
function requireValue(valid, message = 'Invalid Library response') {if (!valid) throw new TypeError(message);}
function freeze(value) {
if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);}
return value;
}
function storageChanged() {
return Object.assign(new Error('Prompt storage was restored; refresh the library'), {code: 'storage-changed'});
}
// Duck-typed by name: this ES module cannot import the UMD api-client.js, and a
// browser page may load a different ApiError class instance than the Node tests.
function isApiError(error) {return error instanceof Error && error.name === 'ApiError' && Number.isInteger(error.status);}
function validateParent(value, positiveId) {
requireValue(object(value) && id(value.id) && typeof value.text === 'string' && nullableString(value.title) &&
nullableString(value.category) && strings(value.tags) && rating(value.rating) && nullableString(value.notes) &&
token(value.edit_token) && (positiveId === undefined || value.id === positiveId), 'Invalid prompt owner');
}
function validatePair(value, owner) {
requireValue(object(value) && id(value.id) && id(value.positive_prompt_id) && typeof value.positive_text === 'string' &&
typeof value.negative_text === 'string' && typeof value.archived === 'boolean' && count(value.image_count) &&
count(value.run_count), 'Invalid saved negative variant');
requireValue(value.positive_prompt_id === owner.id && value.positive_text === owner.text,
'Saved negative variant belongs to another prompt');
}
const fresh = (api, url, signal) => api.get(url, {signal, cache: 'no-store'});
async function readOptions(api, signal) {
const value = await fresh(api, OPTIONS_URL, signal);
requireValue(object(value) && value.success === true && uuid(value.library_epoch) && strings(value.categories), 'Invalid Library options');
return value;
}
async function readDetail(api, positiveId, signal) {
const value = await fresh(api, `/prompt_manager/library/${positiveId}?variant_limit=${VARIANT_LIMIT}`, signal);
requireValue(object(value) && value.success === true && uuid(value.library_epoch) && Array.isArray(value.variants), 'Invalid prompt history');
validateParent(value.positive, positiveId);
return value;
}
/** The exact pair by ID; ownership is checked against the parent read by the caller. */
async function readPair(api, pairId, signal) {
const value = await fresh(api, `/prompt_manager/pairs/${pairId}`, signal);
requireValue(object(value) && value.success === true && object(value.pair) && value.pair.id === pairId, 'Invalid saved negative variant');
return value.pair;
}
/** Detail, options and pair read together; snapshots from different epochs are rejected. */
async function readOwner(api, {positiveId, pairId, withOptions}, signal) {
const [detail, options, pair] = await Promise.all([readDetail(api, positiveId, signal),
withOptions ? readOptions(api, signal) : null, pairId === null ? null : readPair(api, pairId, signal)]);
if (options && options.library_epoch !== detail.library_epoch) throw storageChanged();
if (pair) validatePair(pair, detail.positive);
return {epoch: detail.library_epoch, owner: detail.positive, pair, categories: options ? options.categories : [], detail, options};
}
/** New prompt has no owner: only a fresh options epoch makes it ready. */
async function readNewSnapshot(api, signal) {
const options = await readOptions(api, signal);
return {epoch: options.library_epoch, owner: null, pair: null, categories: options.categories};
}
/**
* Re-read the saved owner after a confirmed receipt. Reads only: detail, options
* and (when the commit names one) the exact pair, kept apart from detail.variants.
*/
export async function reconcileLibraryReceipt(api, commit, {signal} = {}) {
requireValue(object(commit) && KINDS.has(commit.kind) && object(commit.receipt), 'Invalid editor commit');
const {receipt} = commit;
const positiveId = receipt.positive ? receipt.positive.id : receipt.pair?.positive_prompt_id;
requireValue(id(positiveId), 'Invalid editor commit');
const pairId = receipt.pair ? receipt.pair.id : null;
const {detail, options, pair} = await readOwner(api, {positiveId, pairId, withOptions: true}, signal);
if (receipt.library_epoch !== detail.library_epoch) throw storageChanged();
return freeze({commit, detail, pair, options});
}
export function fallbackTitle(text) {return text.split('\n')[0].trim();}
export function parseTags(value) {return [...new Set(value.split(',').map(tag => tag.trim()).filter(Boolean))];}
const sameTags = (a, b) => a.length === b.length && new Set([...a, ...b]).size === a.length;
const stored = value => (value ?? '').trim();
function normalizeRating(value) {
if (value === null || value === '') return null;
const number = typeof value === 'string' && /^[1-5]$/.test(value) ? Number(value) : value;
if (!rating(number)) throw new TypeError('Rating must be 1 to 5 or null');
return number;
}
const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: '', rating: null});
function seedDraft(mode, {owner, pair}) {
if (!owner) return blankDraft();
if (mode === 'negative') return {...blankDraft(), positive_text: owner.text};
return {title: stored(owner.title) ? owner.title : fallbackTitle(owner.text), positive_text: owner.text,
negative_text: pair ? pair.negative_text : '', category: owner.category ?? '', tags: owner.tags.join(', '),
notes: owner.notes ?? '', rating: owner.rating};
}
/** Keep fields the user changed from the previous seed; the rest follow the new seed. */
function overlay(next, draft, previous) {
return Object.fromEntries(FIELDS.map(name => [name, draft[name] !== previous[name] ? draft[name] : next[name]]));
}
function baselineOf(owner) {
return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], rating: owner.rating, notes: owner.notes};
}
/** Only changed fields; an untouched fallback title is not a title edit. */
function metadataChanges(draft, seed, baseline) {
const changes = {};
const title = draft.title.trim(), category = draft.category.trim(), tags = parseTags(draft.tags);
if (draft.title !== seed.title && title !== stored(baseline.title)) changes.title = title;
if (category !== stored(baseline.category)) changes.category = category;
if (!sameTags(tags, baseline.tags)) changes.tags = tags;
if (draft.rating !== baseline.rating) changes.rating = draft.rating;
if (draft.notes !== (baseline.notes ?? '')) changes.notes = draft.notes;
return changes;
}
function createMetadata(draft) {
return {title: draft.title.trim(), category: draft.category.trim() || UNCATEGORIZED, tags: parseTags(draft.tags),
rating: draft.rating, notes: draft.notes};
}
function sameMetadata(sent, owner) {
return sent.title === stored(owner.title) && sent.category === stored(owner.category) && sameTags(sent.tags, owner.tags) &&
sent.rating === owner.rating && sent.notes === (owner.notes ?? '');
}
function draftProblem(mode, draft) {
if (mode !== 'negative' && !draft.title.trim()) return Object.assign(new Error('Title is required'), {code: 'invalid-draft'});
if ((mode === 'new' || mode === 'revision') && !draft.positive_text.trim()) {
return Object.assign(new Error('Positive prompt is required'), {code: 'invalid-draft'});
}
return null;
}
/** Phase for a failed write: only an exact 409 code drives recovery. */
function failedPhase(error) {
if (!isApiError(error)) return 'unknown-outcome';
if (error.status === 409 && Object.hasOwn(RECOVERY, error.code)) return RECOVERY[error.code];
if ((error.status >= 200 && error.status < 300) || UNCONFIRMED_STATUS.has(error.status)) return 'unknown-outcome';
return 'write-error';
}
function validateReceipt(command, value, {positiveId, owner, pairId}) {
requireValue(object(value) && value.success === true, 'Invalid save receipt');
if (command.kind === 'archive-pair' || command.kind === 'restore-pair') {
validatePair(value.pair, owner);
requireValue(value.pair.id === pairId && value.pair.archived === (command.kind === 'archive-pair'), 'Invalid save receipt');
return {success: true, library_epoch: command.epoch, pair: value.pair};
}
requireValue(value.library_epoch === command.body.expected_epoch, 'Invalid save receipt');
validateParent(value.positive, command.kind === 'create' ? undefined : positiveId);
if (command.kind === 'metadata') return value;
validatePair(value.pair, value.positive);
requireValue(typeof value.positive_created === 'boolean' && typeof value.pair_created === 'boolean' &&
value.parent_metadata_applied === value.positive_created && (value.pair_created || !value.positive_created) &&
(command.kind === 'create' || !value.positive_created), 'Invalid save receipt');
return value;
}
function outcomeOf(command, receipt) {
const recipe = command.kind === 'create' || command.kind === 'add-negative';
return {kind: command.kind, positiveCreated: recipe && receipt.positive_created, pairCreated: recipe && receipt.pair_created,
metadataNotApplied: command.kind === 'create' && !receipt.parent_metadata_applied &&
!sameMetadata(command.body.parent_metadata, receipt.positive),
pairArchived: receipt.pair ? receipt.pair.archived : null};
}
export function createLibraryEditorController({api, mode, positiveId = null, pairId = null, onChange = () => {},
onCommitted = () => {}, onReconciled = () => {}} = {}) {
requireValue(MODES.has(mode), 'Unknown editor mode');
const existing = mode !== 'new';
requireValue(existing ? id(positiveId) : positiveId === null && pairId === null, 'Invalid editor owner');
requireValue(pairId === null || id(pairId), 'Invalid editor pair');
const withOptions = mode !== 'negative';
let disposed = false, generation = 0, reads = null, started = null, review = false;
let seed = blankDraft(), pending = null, commit = null;
let state = freeze({phase: 'initializing', draft: blankDraft(), baseline: null, receipt: null, error: null,
reviewRequired: false, epoch: null, owner: null, pair: null, pairRead: 'none', pairReadError: null, categories: [], outcome: null,
reconciled: null, unchanged: false});
const emit = patch => {if (disposed) return; state = freeze({...state, ...patch}); onChange(state);};
/** Start a read that supersedes every older one; current() is false once stale. */
function beginRead() {
reads?.abort();
const controller = reads = new AbortController(), attempt = ++generation;
return {signal: controller.signal, current() {
if (disposed || attempt !== generation) return false;
reads = null; return true;
}};
}
async function load(keepDraft) {
const read = beginRead();
emit({phase: 'initializing', error: null});
try {
const snapshot = existing ? await readOwner(api, {positiveId, pairId, withOptions}, read.signal)
: await readNewSnapshot(api, read.signal);
if (!read.current()) return;
const next = seedDraft(mode, snapshot);
const draft = keepDraft ? overlay(next, state.draft, seed) : next;
seed = next;
emit({phase: 'ready', draft, baseline: baselineOf(snapshot.owner), epoch: snapshot.epoch, owner: snapshot.owner,
pair: snapshot.pair, pairRead: snapshot.pair ? 'fresh' : 'none', pairReadError: null, categories: snapshot.categories,
error: null, reviewRequired: review && keepDraft});
} catch (error) {
if (read.current()) emit({phase: 'initialization-error', error});
}
}
function command() {
const {draft, epoch, owner} = state;
if (mode === 'metadata') {
const metadata = metadataChanges(draft, seed, state.baseline);
return Object.keys(metadata).length ? {kind: 'metadata', method: 'patch', url: `/prompt_manager/library/${positiveId}`,
body: {expected_epoch: epoch, if_match: owner.edit_token, metadata}} : null;
}
if (mode === 'negative') {
return {kind: 'add-negative', method: 'post', url: `/prompt_manager/library/${positiveId}/variants`,
body: {expected_epoch: epoch, if_match: owner.edit_token, negative_text: draft.negative_text}};
}
return {kind: 'create', method: 'post', url: '/prompt_manager/library', body: {expected_epoch: epoch,
positive_text: draft.positive_text, negative_text: draft.negative_text, parent_metadata: createMetadata(draft)}};
}
async function send(next) {
pending = next;
emit({phase: 'saving', error: null});
let value;
try {
value = await api[next.method](next.url, next.body);
} catch (error) {
if (disposed) return;
const phase = failedPhase(error);
if (phase !== 'unknown-outcome') pending = null;
emit({phase, error});
return;
}
let receipt;
try {
receipt = freeze(structuredClone(validateReceipt(next, value, {positiveId, owner: state.owner, pairId: state.pair?.id})));
} catch (error) {
emit({phase: 'unknown-outcome', error});
return;
}
pending = null;
commit = freeze({kind: next.kind, receipt});
// A confirmed write still invalidates cached Library views after the dialog went away; nothing else runs then.
if (disposed) {onCommitted(commit); return;}
emit({receipt, outcome: outcomeOf(next, receipt)});
onCommitted(commit);
await reconcile();
}
async function reconcile() {
const read = beginRead();
try {
const result = await reconcileLibraryReceipt(api, commit, {signal: read.signal});
if (!read.current()) return;
emit({phase: 'complete', reconciled: result, error: null});
onReconciled(result);
} catch (error) {
if (read.current()) emit({phase: 'saved-refresh-error', error});
}
}
/**
* The pair route has no epoch guard, so every pair write, first or retried,
* first confirms with a fresh read that storage was not restored since
* `next.epoch`. A failed check read keeps `failurePhase` and writes nothing.
*/
async function sendFencedPair(next, failurePhase) {
emit({phase: 'saving', error: null});
const read = beginRead();
let current;
try {
current = await readOptions(api, read.signal);
} catch (error) {
if (read.current()) emit({phase: failurePhase, error});
return;
}
if (!read.current()) return;
if (current.library_epoch !== next.epoch) {
pending = null;
emit({phase: 'storage-changed', error: storageChanged()});
return;
}
await send(next);
}
async function setArchive(value) {
if (typeof value !== 'boolean') throw new TypeError('Archive state must be a boolean');
if (disposed || !existing || !SUBMITTABLE.has(state.phase)) return;
if (!state.pair) {emit({error: new Error('Choose a saved negative variant first.')}); return;}
await sendFencedPair(freeze({kind: value ? 'archive-pair' : 'restore-pair', method: 'patch',
url: `/prompt_manager/pairs/${state.pair.id}`, body: {archived: value}, epoch: state.epoch}), 'write-error');
}
return {
get state() {return state;},
initialize() {
if (!started) started = disposed ? Promise.resolve() : load(true);
return started;
},
setField(name, value) {
if (!FIELDS.includes(name)) throw new TypeError(`Unknown editor field: ${name}`);
const next = name === 'rating' ? normalizeRating(value) : value;
if (name !== 'rating' && typeof next !== 'string') throw new TypeError(`${name} must be a string`);
emit({draft: {...state.draft, [name]: next}});
},
async submit() {
if (disposed || !SUBMITTABLE.has(state.phase)) return;
const problem = draftProblem(mode, state.draft);
if (problem) {emit({error: problem}); return;}
const next = command();
if (!next) {emit({phase: 'complete', error: null, unchanged: true}); return;}
await send(freeze(next));
},
setArchive,
/**
* Re-read the selected pair (for example its linked counts) through the same
* exact-ID read and owner check as initialization. A failure keeps the
* previous pair and is reported as `pairRead: 'error'` with `pairReadError`.
*/
async refreshPair() {
if (disposed || !existing || !state.pair || !SUBMITTABLE.has(state.phase)) return;
const read = beginRead();
emit({pairRead: 'reading', pairReadError: null});
try {
const pair = await readPair(api, state.pair.id, read.signal);
validatePair(pair, state.owner);
if (read.current()) emit({pair, pairRead: 'fresh'});
} catch (error) {
if (read.current()) emit({pairRead: 'error', pairReadError: error});
}
},
async reloadOwner({keepDraft} = {}) {
if (typeof keepDraft !== 'boolean') throw new TypeError('reloadOwner needs keepDraft');
if (disposed || !RELOADABLE.has(state.phase)) return;
pending = null;
review = keepDraft;
await load(keepDraft);
},
async retryInitialization() {
if (!disposed && state.phase === 'initialization-error') await load(true);
},
async retryReconciliation() {
if (!disposed && state.phase === 'saved-refresh-error') await reconcile();
},
/** Explicit user action only: resend the identical unconfirmed payload once. */
async retryUnconfirmed() {
if (disposed || state.phase !== 'unknown-outcome' || !pending) return;
if (pending.kind === 'archive-pair' || pending.kind === 'restore-pair') {
// The outcome stays unknown if the fence read itself fails.
await sendFencedPair(pending, 'unknown-outcome');
return;
}
if (pending.kind !== 'metadata') {await send(pending); return;}
// A metadata retry needs the current owner reviewed, never a replayed token.
pending = null;
review = true;
await load(true);
},
dispose() {
if (disposed) return;
disposed = true;
reads?.abort();
reads = null;
state = freeze({...state, phase: 'disposed'});
},
};
}
+27
View File
@@ -0,0 +1,27 @@
/* Native recipe editor and shared toast: geometry and tokens follow the approved concept (style.css 859-1007, 1424-1531). */
.library-editor, .toast { --editor-red: #ff8c98; --editor-warning: #e6bd78; }
[data-ca-theme="light"] :where(.library-editor, .toast) { --editor-red: #b71931; --editor-warning: #815514; }
.ca-tools .library-editor { color: var(--ca-text); background: var(--ca-surface); border: 1px solid var(--ca-border); border-radius: 9px; padding: 24px; width: min(650px, calc(100vw - 24px)); max-height: 90dvh; overflow: auto; }
.library-editor::backdrop { background: #080b12b8; }
.library-editor .dialog-header { display: flex; justify-content: space-between; align-items: center; gap: 18px; margin-bottom: 20px; }
.library-editor .dialog-header h2 { margin: 0; font-size: 18px; font-weight: 600; line-height: inherit; }
.library-editor :where(form, p) { margin: 0; line-height: inherit; }
.library-editor p + p { margin-top: 10px; }
.ca-tools .library-editor button { min-height: 32px; font-size: 12px; }
.ca-tools .library-editor .dialog-header > button { font-size: 20px; min-width: 32px; }
.library-editor .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; margin-top: 23px; }
.library-editor .field { display: grid; gap: 7px; font-size: 12px; margin: 15px 0; }
.library-editor .field :where(input, textarea, select) { width: 100%; font-size: 12px; }
.library-editor .field textarea { min-height: 100px; resize: vertical; }
.library-editor .two-col { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.library-editor .small { font-size: 12px; color: var(--ca-muted); }
.library-editor .warning-note { font-size: 11px; color: var(--editor-warning); margin: 13px 0; }
.ca-tools .library-editor .danger { color: var(--editor-red); border-color: var(--editor-red); }
.library-editor :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--ca-surface); border: 1px solid var(--ca-accent); padding: 12px 18px; border-radius: 6px; box-shadow: 0 8px 35px #0004; z-index: 10; max-width: min(650px, 90vw); font-size: 12px; }
.toast:empty { display: none; }
@media (max-width: 700px) {
.library-editor .two-col { grid-template-columns: 1fr; }
.toast { bottom: 12px; }
}
@media (pointer: coarse) { .ca-tools .library-editor button { min-height: 40px; } }
+362
View File
@@ -0,0 +1,362 @@
/**
* Native recipe editor dialogs: New prompt, Edit recipe metadata, Create text
* revision, Add a negative variant, and the pair archive confirmation.
*
* Markup, labels, order and native datalists follow the approved concept. All
* reads, writes, receipts and recovery live in library-editor-state.mjs; this
* module only renders `controller.state` and forwards user actions.
*/
import { createButton } from '../shared/toolkit/core.js';
import { createLibraryEditorController } from './library-editor-state.mjs';
const TITLES = {new: 'New prompt', metadata: 'Edit recipe metadata', revision: 'Create text revision', negative: 'Add a negative variant'};
const RATINGS = [['5', '5 / 5'], ['4', '4 / 5'], ['3', '3 / 5'], ['2', '2 / 5'], ['1', '1 / 5'], ['0', 'Unrated']];
const FIELD_IDS = {title: 'recipe-title', positive_text: 'recipe-positive', negative_text: 'recipe-negative', category: 'recipe-category',
tags: 'recipe-tags', rating: 'recipe-rating', notes: 'recipe-notes'};
const SUBMITTABLE = new Set(['ready', 'write-error']);
const NEGATIVE_HELP = 'Leave empty for an explicitly empty negative. The positive stays unchanged.';
const NO_PAIR = 'Archive needs a selected saved negative variant.';
const ALREADY_ARCHIVED = 'This negative variant is already archived.';
const REVIEW = 'Loaded the latest saved values. Review your entries, then save.';
const REVIEW_ARCHIVE = 'Loaded the latest saved values. Review them, then archive.';
const TOAST_MS = 6500;
const REFRESH = ['Reload Library', 'refresh'];
let toastTimer;
/** Show one outcome in the shared workspace toast (role="status"); it clears itself like the concept's. */
export function showWorkspaceToast(message) {
const toast = document.getElementById('workspace-toast');
if (!toast) return;
toast.textContent = message;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {toast.textContent = '';}, TOAST_MS);
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* Inline state copy and its one recovery action; `archive` selects the pair-archive
* wording. After a restore only New prompt may re-read: every other mode names an
* owner by its old numeric ID, so it offers only the Library refresh exit.
*/
function statusOf(state, archive, mode = null) {
const nothing = archive ? 'Nothing was archived.' : 'Nothing was saved.';
const message = state.error?.message;
switch (state.phase) {
case 'initializing': return {text: 'Loading the latest saved values…'};
case 'initialization-error': return {text: `The latest saved values could not be loaded. ${nothing}`, action: ['Retry', 'retryInitialization']};
case 'saving': return {text: archive ? 'Archiving… The dialog stays open until the server answers.' : 'Saving… The dialog stays open until the server answers.'};
case 'write-error': return {text: `${archive ? 'Not archived' : 'Not saved'}: ${message} Your entries are kept.`};
case 'conflict': return {text: archive ? 'This negative variant changed since it was loaded. Reload it and review it before archiving.'
: 'This prompt changed since it was loaded. Reload it and review your entries before saving.', action: ['Reload', 'reload']};
case 'storage-changed': return {text: `Prompt storage was restored since this dialog opened. ${nothing}`, action: mode === 'new' ? ['Reload Library state', 'reload'] : REFRESH};
case 'unknown-outcome': return {text: archive ? 'Archive could not be confirmed. Retry sends the same archive once.'
: mode === 'metadata' ? 'Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.'
: 'Save could not be confirmed. Your entries are kept; Retry sends the same save once.', action: ['Retry', 'retryUnconfirmed']};
case 'saved-refresh-error':
// A refresh that found restored storage cannot recover by re-reading; only reloading the Library view can.
if (state.error?.code === 'storage-changed') {
return {text: `${archive ? 'Archived' : 'Saved'}; Library refresh failed because prompt storage was restored. Reload the Library to see current data.`, action: REFRESH};
}
return {text: archive ? 'Archived; Library refresh failed.' : 'Saved; Library refresh failed.', action: ['Retry refresh', 'retryReconciliation']};
case 'ready': return message ? {text: message} : state.reviewRequired ? {text: archive ? REVIEW_ARCHIVE : REVIEW} : null;
default: return null;
}
}
/** One persistent inline status in the concept's .warning-note style, so live updates are announced. */
function statusLine() {
const note = el('p', '', 'warning-note');
note.id = 'library-editor-status';
note.setAttribute('role', 'status');
note.hidden = true;
return {note, set(text) {
if (note.textContent !== (text || '')) note.textContent = text || '';
note.hidden = !text;
}};
}
/** Re-parent only on a real change: moving a focused button would drop its focus. */
function setChildren(parent, nodes) {
const current = [...parent.children];
if (current.length !== nodes.length || current.some((node, index) => node !== nodes[index])) parent.replaceChildren(...nodes);
}
/** A button that held focus when saving began gets it back after a failed or conflicting write (disabling drops focus to the body). */
function restoreFocus(button, state) {
if (!button || state.phase === 'saving') return button;
if (state.phase !== 'complete' && button.isConnected && !button.disabled && (!document.activeElement || document.activeElement === document.body)) button.focus();
return null;
}
function field(label, control, id) {
const wrapper = el('div', undefined, 'field');
const caption = el('label', label);
caption.htmlFor = id;
control.id = id;
wrapper.append(caption, control);
return wrapper;
}
function datalist(id, values) {
const list = el('datalist');
list.id = id;
list.append(...values.map(value => Object.assign(document.createElement('option'), {value})));
return list;
}
/**
* `onReconciled(result, {outcome, draft})` receives the reconciled result with the
* controller's outcome and submitted draft. `onRefreshRequired()` is the explicit
* exit when prompt storage was restored; the dialog closes first.
*/
export function createLibraryEditor({api, parentScope, onCommitted = () => {}, onReconciled = () => {},
onRefreshRequired = () => window.location.reload()}) {
let session = null, disposed = false;
const dialog = el('dialog', undefined, 'library-editor');
dialog.setAttribute('aria-labelledby', 'library-editor-title');
const header = el('div', undefined, 'dialog-header');
const title = el('h2');
title.id = 'library-editor-title';
const close = createButton('×');
close.setAttribute('aria-label', 'Close dialog');
const body = el('div');
header.append(title, close);
dialog.append(header, body);
const busy = () => session?.controller.state.phase === 'saving';
close.addEventListener('click', () => finish());
dialog.addEventListener('cancel', event => {
event.preventDefault();
if (!busy()) finish();
});
// A browser may still force-close a modal; treat that as a dismissal without claiming rollback.
dialog.addEventListener('close', () => {if (!dialog.open) finish();});
function finish() {
if (!session) return;
const {controller, trigger, views} = session;
session = null;
views.abort();
controller.dispose();
if (dialog.open) dialog.close();
dialog.remove();
if (trigger?.isConnected) trigger.focus();
}
function show(heading) {
title.textContent = heading;
close.disabled = false;
if (!dialog.isConnected) document.body.append(dialog);
if (!dialog.open) dialog.showModal();
close.focus();
}
/** The one recovery action for a phase, created once per view and reused across renders. */
function recoveryButton(cache, action, signal) {
if (!action) return null;
const [label, method] = action;
if (!cache.has(label)) {
const control = createButton(label);
control.addEventListener('click', () => {
const {controller} = session;
if (method === 'refresh') {finish(); onRefreshRequired(); return;}
if (method === 'reload') controller.reloadOwner({keepDraft: true});
else controller[method]();
}, {signal});
cache.set(label, control);
}
return cache.get(label);
}
function controllerFor(mode, positiveId, pairId) {
const controller = createLibraryEditorController({api, mode, positiveId, pairId, onCommitted,
onReconciled: result => onReconciled(result, {outcome: controller.state.outcome, draft: controller.state.draft}),
onChange: state => session?.render(state)});
return controller;
}
function editorView({mode, positiveId, pairId, draft}) {
const views = new AbortController();
const controller = controllerFor(mode, positiveId, pairId);
const negativeOnly = mode === 'negative';
const readOnly = mode === 'metadata';
const form = el('form');
const controls = {};
const textarea = (name, required = false) => {
const node = el('textarea');
node.required = required;
node.readOnly = readOnly && (name === 'positive_text' || name === 'negative_text');
return controls[name] = node;
};
const input = (name, required = false) => {
const node = el('input');
node.required = required;
return controls[name] = node;
};
if (!negativeOnly) {
form.append(field('Title', input('title', true), FIELD_IDS.title),
field('Positive prompt', textarea('positive_text', true), FIELD_IDS.positive_text));
}
form.append(field(negativeOnly ? 'Negative prompt' : 'Negative prompt (optional)', textarea('negative_text'), FIELD_IDS.negative_text));
const categories = datalist('categories', []), tags = datalist('tag-options', []);
if (!negativeOnly) {
const columns = el('div', undefined, 'two-col');
const category = input('category'), tagInput = input('tags');
category.setAttribute('list', 'categories');
tagInput.setAttribute('list', 'tag-options');
columns.append(field('Category', category, FIELD_IDS.category), field('Tags, separated by commas', tagInput, FIELD_IDS.tags));
const rating = controls.rating = el('select');
for (const [value, text] of RATINGS) rating.add(new Option(text, value));
form.append(columns, categories, tags, field('Rating', rating, FIELD_IDS.rating), field('Notes', textarea('notes'), FIELD_IDS.notes));
} else form.append(el('p', NEGATIVE_HELP, 'small'));
const line = statusLine(), recovery = new Map();
const actions = el('div', undefined, 'dialog-actions');
const revise = createButton('Create text revision'), archive = createButton('Archive…', 'danger');
const cancel = createButton('Cancel'), save = createButton('Save', 'ca-primary');
save.type = 'submit';
form.append(line.note, actions);
for (const [name, node] of Object.entries(controls)) {
node.addEventListener(node.tagName === 'SELECT' ? 'change' : 'input', () => {
controller.setField(name, name === 'rating' && node.value === '0' ? null : node.value);
}, {signal: views.signal});
}
form.addEventListener('submit', event => {
event.preventDefault();
controller.submit();
}, {signal: views.signal});
cancel.addEventListener('click', () => finish(), {signal: views.signal});
revise.addEventListener('click', () => open({mode: 'revision', positiveId, pairId, trigger: session.trigger}), {signal: views.signal});
archive.addEventListener('click', () => archiveView(), {signal: views.signal});
if (!negativeOnly) loadVocabulary(tags, views.signal);
let categoryKey = null, refocus = null;
function render(state) {
for (const [name, node] of Object.entries(controls)) {
const value = name === 'rating' ? String(state.draft.rating ?? 0) : state.draft[name];
if (node.value !== value) node.value = value;
}
const key = state.categories.join('\n');
if (key !== categoryKey) {
categoryKey = key;
categories.replaceChildren(...datalist('', state.categories).children);
}
const saving = state.phase === 'saving';
if (saving && document.activeElement === save) refocus = save;
save.disabled = !SUBMITTABLE.has(state.phase);
cancel.disabled = close.disabled = revise.disabled = saving;
const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null;
archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived;
const status = statusOf(state, false, mode);
line.set(status?.text ?? pairProblem);
if (!status && pairProblem) archive.setAttribute('aria-describedby', line.note.id);
else archive.removeAttribute('aria-describedby');
const retry = recoveryButton(recovery, status?.action, views.signal);
setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]);
refocus = restoreFocus(refocus, state);
if (state.phase === 'complete') finish();
}
body.replaceChildren(form);
return {controller, views, render, start() {
if (draft) for (const [name, value] of Object.entries(draft)) controller.setField(name, value);
render(controller.state);
controller.initialize();
}};
}
async function loadVocabulary(list, signal) {
try {
const result = await api.get('/prompt_manager/tags', {signal, cache: 'no-store'});
if (signal.aborted || !Array.isArray(result.tags)) return;
list.replaceChildren(...datalist('', result.tags.filter(tag => typeof tag === 'string').map(tag => `${tag},`)).children);
} catch {
// Tag suggestions are auxiliary vocabulary; the field stays fully usable without them.
}
}
/** Swap the open Edit dialog to the pair archive confirmation, reusing its controller. */
function archiveView() {
const {controller, trigger} = session;
// An Edit save's failure or validation message is not about the pair; the confirmation starts clean.
const inherited = controller.state.error;
session.views.abort();
const views = new AbortController();
const block = el('section', undefined, 'prompt-block prompt-negative');
block.setAttribute('aria-label', 'Negative prompt');
const blockHeading = el('div', undefined, 'prompt-block-heading');
blockHeading.append(el('h3', 'Negative prompt'));
const negative = el('div');
block.append(blockHeading, negative);
const links = el('p', undefined, 'small');
const restore = el('p', 'You can restore it from Show archived in Negative variants.', 'small');
const line = statusLine(), recovery = new Map();
let refocus = null;
const actions = el('div', undefined, 'dialog-actions');
const reread = createButton('Retry');
reread.addEventListener('click', () => controller.refreshPair(), {signal: views.signal});
const cancel = createButton('Cancel'), confirm = createButton('Archive', 'danger');
cancel.addEventListener('click', () => finish(), {signal: views.signal});
confirm.addEventListener('click', () => controller.setArchive(true), {signal: views.signal});
let shownNegative = null;
function render(state) {
const {pair} = state;
if (pair && pair.negative_text !== shownNegative) {
shownNegative = pair.negative_text;
negative.replaceChildren(shownNegative === '' ? el('p', 'Empty negative prompt', 'prompt-empty') : el('pre', shownNegative, 'prompt-text'));
}
// Counts come only from the controller's fresh exact-ID pair read.
const counted = state.pairRead === 'fresh' && Boolean(pair), readFailed = state.pairRead === 'error';
if (counted) {
const images = pair.image_count, runs = pair.run_count;
links.textContent = `${images} image${images === 1 ? '' : 's'} and ${runs} recorded run${runs === 1 ? '' : 's'} stay linked.`;
}
const status = state.error && state.error === inherited ? null : statusOf(state, true);
line.set(status?.text ?? (readFailed ? 'Linked images and recorded runs could not be read. Nothing was archived.'
: counted ? null : 'Checking linked images and recorded runs…'));
const retry = recoveryButton(recovery, status?.action, views.signal) ?? (readFailed ? reread : null);
links.hidden = !counted;
const saving = state.phase === 'saving';
if (saving && document.activeElement === confirm) refocus = confirm;
confirm.disabled = !counted || !SUBMITTABLE.has(state.phase);
cancel.disabled = close.disabled = saving;
setChildren(actions, [...(retry ? [retry] : []), cancel, confirm]);
refocus = restoreFocus(refocus, state);
if (state.phase === 'complete') finish();
}
body.replaceChildren(block, links, restore, line.note, actions);
session = {controller, trigger, views, render};
show('Archive negative variant');
controller.refreshPair();
render(controller.state);
}
function open({mode, positiveId = null, pairId = null, trigger = null, draft = null}) {
if (disposed) return;
const previous = session;
if (previous) {
// Swap the same open dialog's content, as the concept does; the old controller stops here.
previous.views.abort();
previous.controller.dispose();
}
const view = editorView({mode, positiveId, pairId, draft});
session = {controller: view.controller, trigger: trigger ?? previous?.trigger ?? null, views: view.views, render: view.render};
show(TITLES[mode]);
view.start();
}
function dispose() {
if (disposed) return;
finish();
disposed = true;
}
if (parentScope.signal.aborted) dispose();
else parentScope.signal.addEventListener('abort', dispose, {once: true});
return {open, dispose};
}
+23 -5
View File
@@ -44,12 +44,23 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
},dispose(){disposed=true;request?.abort();request=null;}};
}
export function libraryPromptRoles(detail,pairId=null) {
const variant=pairId===null?null:detail.variants.find(item=>item.id===pairId);
/**
* Saved roles for one selection. A pair outside the bounded variant page is
* passed separately as `selectedPair` (an exact-ID read) and must belong to the
* same positive; it never enters `detail.variants`.
*/
export function libraryPromptRoles(detail,pairId=null,selectedPair=null) {
let variant=pairId===null?null:detail.variants.find(item=>item.id===pairId);
if(pairId!==null && !variant && selectedPair?.id===pairId) {
if(selectedPair.positive_prompt_id!==detail.positive.id || selectedPair.positive_text!==detail.positive.text || typeof selectedPair.negative_text!=='string') throw new TypeError('Saved negative variant belongs to another prompt');
variant=selectedPair;
}
if(pairId!==null && !variant) throw new TypeError('Saved negative variant is unavailable');
return {positive:{status:'resolved',text:detail.positive.text,source:'library'},negative:variant?{status:'resolved',text:variant.negative_text,source:'library'}:{status:'unresolved',text:null,source:null,message:'Choose a saved negative variant to inspect or send it.'}};
}
export function createLibraryDetail({api,onChange}) {
const ARCHIVE_SCOPES=new Set(['active','archived','all']);
export function createLibraryDetail({api,onChange,archive='active'}) {
if(!ARCHIVE_SCOPES.has(archive)) throw new TypeError('Invalid archive scope');
let request,disposed=false,id;
let state=Object.freeze({detail:null,loading:false,error:null,loadingVariants:false,variantsError:null});
const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);};
@@ -65,7 +76,7 @@ export function createLibraryDetail({api,onChange}) {
emit(append?{loadingVariants:true,variantsError:null}:{detail:null,loading:true,error:null,loadingVariants:false,variantsError:null});
try {
if(!validId(id)) throw new TypeError('Invalid positive prompt');
const query=new URLSearchParams({variant_limit:'40'});if(cursor) query.set('variant_cursor',cursor);
const query=new URLSearchParams({variant_limit:'40'});if(archive!=='active') query.set('archive',archive);if(cursor) query.set('variant_cursor',cursor);
const raw=await api.get(`/prompt_manager/library/${id}?${query}`,{signal:current.signal});
if(disposed || request!==current) return 0;
const detail=validate(raw);
@@ -76,7 +87,14 @@ export function createLibraryDetail({api,onChange}) {
request=null;emit({detail,loading:false,error:null,loadingVariants:false,variantsError:null});return detail.variants.length-(append?previous.variants.length:0);
} catch(error) {if(!disposed && request===current) {request=null;emit(append?{loadingVariants:false,variantsError:error}:{loading:false,error});}return 0;}
}
return {get state(){return state;},load(value){id=value;return fetch(false);},loadMore(){return fetch(true);},dispose(){disposed=true;request?.abort();}};
/** Same-parent reconciliation: a validated authoritative detail replaces the current one and supersedes any read. */
function accept(detail) {
if(disposed) throw new TypeError('Prompt history is disposed');
const next=validate(detail);
request?.abort();request=null;
emit({detail:next,loading:false,error:null,loadingVariants:false,variantsError:null});
}
return {get state(){return state;},load(value){id=value;return fetch(false);},loadMore(){return fetch(true);},accept,dispose(){disposed=true;request?.abort();}};
}
export function libraryWindow(count,columns,top,height,focused=-1) {
+16 -1
View File
@@ -31,7 +31,6 @@ main.library-main { --library-positive: #73dda9; }
.library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; }
.library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); }
.library-main .library-back { display: inline-block; margin-block: 0 12px; color: var(--ca-accent); }
.library-main .library-surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; margin-block: 18px; }
.library-main .library-branches { padding: 18px 20px; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); margin-block: 16px; }
.library-main .library-positive-lane { padding-block-end: 12px; border-block-end: 2px solid var(--library-positive); color: var(--library-positive); font-size: 12px; }
@@ -57,6 +56,7 @@ main.library-main { --library-negative: #ff8c98; }
.library-positive-lane > span, .history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); }
.library-main .library-variants h2 { font-size: 14px; line-height: 1.5; margin: 0; }
.library-main .library-variants > p.muted { font-size: 12px; line-height: 1.5; margin: 0 0 12px; }
.library-main .library-panel-heading p.muted { font-size: 12px; line-height: 1.5; margin: 0; }
.library-main .library-variants .library-branches { margin-block: 0; }
.library-variant-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-block-start: 14px; }
.library-variant-summary { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -122,3 +122,18 @@ main.library-main { --library-negative: #ff8c98; }
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
}
/* Heading and panel actions follow the concept (style.css 283-300, 540-549, 1408-1414). */
.workspace-heading .heading-actions, .library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.ca-tools .workspace-heading .heading-actions button, .ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
.workspace-heading .heading-actions { min-inline-size: 0; max-inline-size: 100%; }
.ca-tools .workspace-heading button.send { color: var(--ca-focus); border-color: var(--ca-line); display: inline-flex; align-items: center; gap: 6px; }
.workspace-heading h1:focus { outline: none; }
.workspace-heading h1:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; }
@media (max-width: 700px) { .workspace-heading .heading-actions { inline-size: 100%; } }
@media (pointer: coarse) { .ca-tools .workspace-heading .heading-actions button, .ca-tools .library-main .row-actions button { min-block-size: 40px; } }
/* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */
.library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; }
.ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; }
@media (pointer: coarse) { .ca-tools .library-main .library-variant-restore { min-block-size: 40px; } }
+330 -55
View File
@@ -3,18 +3,89 @@ import {createPromptSender} from './prompt-sender.mjs';
import {mountPromptBlocks} from './prompt-inspector.mjs';
import {mountImages} from './images.mjs';
import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs';
import {promptRoles} from './prompt-blocks.mjs';
import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs';
import {createLibraryEditorController} from './library-editor-state.mjs';
import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
import {createWorkspaceIcon} from './icons.mjs';
import {mountRecordedHistory} from './history.mjs';
let shelfVisit=null;
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
let writeGeneration=0, focusHeadingOnMount=false, savedParentId=null;
const variantSelections=new Map();
const historyVisits=new Map();
// Exact-ID pair reads for selections outside the bounded variant page; never merged into it.
const selectedPairs=new Map();
// Parent metadata a reuse did not apply, kept for an explicit Edit metadata action.
const metadataDrafts=new Map();
const archivedVisible=new Map();
const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.';
function el(tag,text,className) {
const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node;
}
function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;}
function title(text) {const first=text.split('\n')[0].trim();return first.length>80?`${first.slice(0,80)}…`:first;}
/** The full stored title; the first-line fallback only when it is blank. */
function displayTitle(positive) {return typeof positive.title==='string' && positive.title.trim()?positive.title:title(positive.text);}
function focusHeading(main) {const heading=main.querySelector('h1');if(!heading)return;heading.tabIndex=-1;heading.focus();}
function headingActions(main,fallback) {
const actions=el('div',undefined,'heading-actions');(main.querySelector('.workspace-heading') || fallback).append(actions);return actions;
}
/** A confirmed receipt makes cached shelf items, cursor and measured heights stale; filters and anchor survive. */
export function invalidateLibraryAfterWrite(commit) {
if(!commit || typeof commit.kind!=='string' || !commit.receipt || typeof commit.receipt!=='object') throw new TypeError('Invalid editor commit');
writeGeneration++;
if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null};
}
/** Remember the selection a reconciled save names, so the destination history opens on it. */
export function rememberLibraryResult(result) {
const positive=result?.detail?.positive, kind=result?.commit?.kind;
if(!positive || !Number.isSafeInteger(positive.id) || !Array.isArray(result.detail.variants) || typeof kind!=='string') throw new TypeError('Invalid reconciled Library result');
if(kind==='create') savedParentId=positive.id;
if(!result.pair || kind==='archive-pair') return;
variantSelections.set(positive.id,result.pair.id);
if(result.detail.variants.some(variant=>variant.id===result.pair.id)) selectedPairs.delete(positive.id);
else selectedPairs.set(positive.id,result.pair);
if(result.pair.archived) archivedVisible.set(positive.id,true);
}
/**
* Toast copy from the receipt: the concept's save toast for New, Revision and Edit and its
* Add-negative line (app.js 989, 1028); a reuse discloses what was not saved.
*/
function outcomeMessage(result) {
const {kind,receipt}=result.commit;
if(kind==='metadata') return 'Saved.';
if(kind==='archive-pair') return ARCHIVED_MESSAGE;
if(kind!=='create' && kind!=='add-negative') return null;
if(receipt.positive_created) return 'Saved.';
if(receipt.pair_created) return kind==='add-negative'?'Negative saved without adding another positive or gallery.'
:'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.';
if(receipt.pair.archived) return 'This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.';
return 'This prompt pair already exists. Showing the saved version.';
}
function rememberUnappliedMetadata(result,{outcome,draft}={}) {
if(result.commit.kind!=='create' || !outcome?.metadataNotApplied || !draft) return;
const {title:name,category,tags,rating,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,rating,notes});
}
/** New prompt, from Library or Images: a saved prompt always opens its own history, as in the concept. */
function createNewPromptEditor(api,scope,main) {
return createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:(result,context)=>{
rememberLibraryResult(result);rememberUnappliedMetadata(result,context);
const message=outcomeMessage(result);if(message) showWorkspaceToast(message);
const hash=`#/library/${result.detail.positive.id}`;
if(window.location.hash===hash) focusHeading(main);else {focusHeadingOnMount=true;window.location.hash=hash;}
}});
}
/** The Images heading's New prompt: opens the editor without mounting or navigating to the Library. */
export function mountNewPromptAction(main,api,parentScope) {
const actions=headingActions(main,main);
const button=createButton('','ca-primary');button.append(createWorkspaceIcon('plus'),el('span','New prompt'));actions.append(button);
const editor=createNewPromptEditor(api,parentScope,main);
parentScope.listen(button,'click',()=>editor.open({mode:'new',trigger:button}));
return button;
}
export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const scope=createScope();parentScope.cleanup(()=>scope.dispose());
@@ -22,11 +93,15 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const sender=createPromptSender({scope});
if(positiveId!==undefined) mountHistory();
else mountShelf();
if(focusHeadingOnMount) {focusHeadingOnMount=false;focusHeading(main);}
// Save the visit before removing the class that defines the shelf's scroll range.
scope.cleanup(()=>main.classList.remove('library-main'));
function mountShelf() {
const visit=shelfVisit;const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
const visit=shelfVisit, mountedGeneration=writeGeneration;
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
const restorable=visit?.state?visit:null;
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
const introduction=el('p','One positive. Reusable negatives. Every experiment stays connected.','intro');
const toolbar=el('search',undefined,'library-toolbar');
@@ -43,16 +118,42 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
const continuation=el('div',undefined,'library-continuation');
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link');
const actions=headingActions(main,section);
const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON');
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(exportButton,newPrompt);
const exportStatus=el('p','','library-options-status');exportStatus.hidden=true;exportStatus.setAttribute('role','alert');
const editor=createNewPromptEditor(api,scope,main);
scope.listen(newPrompt,'click',()=>editor.open({mode:'new',trigger:newPrompt}));
const exportUrls=new Set();let exporting=false;
scope.cleanup(()=>{for(const url of exportUrls)URL.revokeObjectURL(url);exportUrls.clear();});
// The exact full-Library document; a failure is reported before anything is downloaded.
async function exportLibrary() {
if(exporting) return;exporting=true;exportStatus.hidden=true;
try {
const response=await fetch(window.ApiClient.resolveUrl('/prompt_manager/library/export'),{cache:'no-store',credentials:'same-origin',signal:scope.signal});
if(!response.ok) throw new Error(`Export failed with status ${response.status}`);
const text=await response.text();const parsed=JSON.parse(text);
if(parsed?.kind!=='prompt-manager-library' || parsed.schema_version!==1 || !Array.isArray(parsed.positives)) throw new TypeError('Invalid Library export');
if(scope.signal.aborted) return;
const url=URL.createObjectURL(new Blob([text],{type:'application/json'}));exportUrls.add(url);
const anchor=el('a');anchor.href=url;anchor.download='prompt-manager-library-v1.json';anchor.hidden=true;section.append(anchor);anchor.click();anchor.remove();
setTimeout(()=>{if(exportUrls.delete(url))URL.revokeObjectURL(url);},0);
} catch {
if(!scope.signal.aborted) {exportStatus.textContent='The Library export could not be downloaded. Nothing was saved to this device.';exportStatus.hidden=false;}
} finally {exporting=false;}
}
scope.listen(exportButton,'click',exportLibrary);
(main.querySelector('.workspace-heading-copy') || section).append(introduction);
continuation.append(message,retry,more);section.append(toolbar,optionsStatus,caption,grid,continuation,legacy);main.append(section);
continuation.append(message,retry,more);section.append(toolbar,optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section);
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64;
let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map();
let anchor=visit?.layout?.anchor || null, pendingAnchor=anchor, observedScrollY=window.scrollY;
let restoreScrollY=Number.isFinite(visit?.scrollY) && visit.scrollY>=0?visit.scrollY:null;
if(visit?.layout?.inlineSize===inlineSize && visit.layout.columns===columnCount) for(const [id,height] of visit.layout.heights) heights.set(id,height);
let anchor=restorable?.layout?.anchor || null, pendingAnchor=anchor, observedScrollY=window.scrollY, retainedAnchor=restorable?null:visit?.anchor || null;
let restoreScrollY=Number.isFinite(restorable?.scrollY) && restorable.scrollY>=0?restorable.scrollY:null;
if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height);
const hiddenSave=restorable?null:savedParentId;savedParentId=null;
function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();}
function cardFor(item,index) {
const article=el('article',undefined,'library-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
@@ -67,7 +168,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
if(!previews.length) art.append(el('span','No available image previews','library-art-empty'));
const content=el('div',undefined,'library-card-content');
const heading=el('header',undefined,'library-card-title');
const name=link(title(item.text),`#/library/${item.id}`,'library-title-link');const h2=el('h2');h2.append(name);
const name=link(displayTitle(item),`#/library/${item.id}`,'library-title-link');const h2=el('h2');h2.append(name);
heading.append(h2,el('span',item.category || 'Uncategorized','library-chip'));
const prompts=el('div',undefined,'library-card-prompts');
const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive']});
@@ -193,15 +294,16 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
anchor=null;pendingAnchor=null;heights.clear();layout={rows:[],height:0};
for(const card of cards.values())removeCard(card);
cards.clear();rowNodes.clear();spacers.clear();grid.replaceChildren();
}
} else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;}
}
state=next;
caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`;
message.textContent=next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'';
const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':'';
message.textContent=(next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden;
message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading));
schedule();scheduleLoad();
}
const stream=createLibraryStream({api,onChange:render,restore:visit?{filters,state:visit.state}:undefined});
const stream=createLibraryStream({api,onChange:render,restore:restorable?{filters,state:restorable.state}:undefined});
function maybeLoad() {
if(!observer || !state?.hasMore || state.loading || state.error || scope.signal.aborted) return;
const bounds=continuation.getBoundingClientRect();if(bounds.top<=window.innerHeight+250 && bounds.bottom>=0) stream.loadMore();
@@ -223,72 +325,170 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
scope.listen(window,'scroll',()=>{reconcileScroll();schedule();});
// Mobile viewport resizing can adjust scroll before delivering its resize event.
scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();});
scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:pendingAnchor || anchor || captureAnchor()}}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
loadCategories();if(visit) {render(stream.state);if(visit.state.loading || visit.state.error)stream.loadMore();} else reset();
scope.cleanup(()=>{
const savedFilters={...filters,tags:[...filters.tags]},savedAnchor=pendingAnchor || anchor || captureAnchor();
// A write while mounted makes this stream stale: keep only what still means something.
shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null;
stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset();
}
function mountHistory() {
const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history);
const back=link('Back to library','#/library','library-back');
const body=el('div');history.append(back,body);
let blocks,galleryScope,detail,contentScope,updateBranches;
const body=el('div');history.append(body);
// Concept heading: ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
const actions=headingActions(main,history);
const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send');
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send);
let blocks,galleryScope,detail,contentScope,updateBranches,updateParent,current=null;
scope.cleanup(()=>{blocks?.dispose();galleryScope?.dispose();contentScope?.dispose();});
const loader=createLibraryDetail({api,onChange:render});scope.cleanup(()=>loader.dispose());
const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView});
scope.listen(back,'click',()=>{window.location.hash='#/library';});
scope.listen(edit,'click',()=>{
const draft=metadataDrafts.get(positiveId);metadataDrafts.delete(positiveId);
editor.open({mode:'metadata',positiveId,pairId:current?.activePairId() ?? null,trigger:edit,draft:draft || null});
});
scope.listen(send,'click',()=>{const roles=current?.sendRoles();if(roles)sender.open(roles,send);});
function reconciled(result,context) {
rememberLibraryResult(result);rememberUnappliedMetadata(result,context);
const message=outcomeMessage(result),id=result.detail.positive.id;
if(message) showWorkspaceToast(message);
if(id!==positiveId) {focusHeadingOnMount=true;window.location.hash=`#/library/${id}`;return;}
if(current) current.apply(result);else loader.load(positiveId);
focusHeading(main);
}
/** Explicit exit after restored storage: dispose the recorded view, then remount with a fresh visit. */
function reloadView() {
writeGeneration++;shelfVisit=shelfVisit && {filters:shelfVisit.filters,anchor:null};
variantSelections.delete(positiveId);selectedPairs.delete(positiveId);metadataDrafts.delete(positiveId);
current=null;contentScope?.dispose();contentScope=null;historyVisits.delete(positiveId);
loader.load(positiveId);focusHeading(main);
}
function render(state) {
if(detail && state.detail && !state.loading && !state.error) {detail=state.detail;updateBranches(state);return;}
contentScope?.dispose();contentScope=createScope();detail=null;updateBranches=null;
if(detail && state.detail && !state.loading && !state.error) {detail=state.detail;updateParent();updateBranches(state);return;}
contentScope?.dispose();contentScope=createScope();detail=null;updateBranches=null;updateParent=null;current=null;send.disabled=true;
blocks?.dispose();blocks=null;galleryScope?.dispose();galleryScope=null;body.replaceChildren();
if(state.loading || state.error) {
const message=el('p',state.loading?'Loading prompt history…':'Prompt history could not be loaded.','library-status');message.setAttribute('role',state.error?'alert':'status');body.append(message);
if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return;
}
detail=state.detail;main.querySelector('h1').textContent=title(detail.positive.text);
detail=state.detail;const content=contentScope;
const subtitle=el('p','One positive prompt. Reusable negative variants. One shared gallery.','intro');
const summary=el('article',undefined,'library-surface');const categoryChip=el('span',detail.positive.category || 'Uncategorized','library-chip');summary.append(categoryChip);
const summary=el('article',undefined,'library-surface');const categoryChip=el('span','','library-chip');summary.append(categoryChip);
const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer);
const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status');
const retryParent=createButton('Retry saved Library details'),reloadSaved=createButton('Reload saved Library view');retryParent.hidden=reloadSaved.hidden=true;summary.append(parentStatus,retryParent,reloadSaved);
const ownerText=detail.positive.text;let savedReadState='current',parentRequest,parentGeneration=0;
contentScope.cleanup(()=>{parentGeneration++;parentRequest?.abort();});
content.cleanup(()=>{parentGeneration++;parentRequest?.abort();});
const all=createButton('All images for this positive');
const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants');
variants.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted'));
const panelHeading=el('div',undefined,'library-panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions');
panelCopy.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted'));
const addNegative=createButton('+ Add negative'),showArchived=createButton('');showArchived.setAttribute('aria-pressed','false');
rowActions.append(addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading);
const branches=el('div',undefined,'library-branches');const positiveLane=el('p',undefined,'library-positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
const choices=[];
const lanes=new Map();const emptyNote=el('p','No saved negative variants. Existing image associations remain available.','muted');
const variantCount=el('p','','muted');variantCount.setAttribute('role','status');
const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true;
const restoreStatus=el('p','','muted library-restore-status');restoreStatus.setAttribute('role','alert');restoreStatus.hidden=true;
const restoreAction=createButton('');restoreAction.hidden=true;let restoreHandler=null;
content.listen(restoreAction,'click',()=>restoreHandler?.());
const more=createButton('Load more negative variants');const retry=createButton('Retry negative variants');retry.hidden=true;
contentScope.listen(more,'click',()=>{if(savedReadState==='current')loader.loadMore();});contentScope.listen(retry,'click',()=>{if(savedReadState==='current')loader.loadMore();});
const archivedLoader=createLibraryDetail({api,onChange:()=>{if(!content.signal.aborted)updateBranches(loader.state);},archive:'archived'});content.cleanup(()=>archivedLoader.dispose());
const restoringPairs=new Set();
let archivedShown=archivedVisible.get(positiveId)===true,archivedStale=false;
const archivedList=()=>archivedShown?archivedLoader.state.detail?.variants || []:[];
const separate=()=>{const pair=selectedPairs.get(positiveId);return pair && !detail.variants.some(variant=>variant.id===pair.id) && !archivedList().some(variant=>variant.id===pair.id)?pair:null;};
const moreTarget=()=>detail.variants_has_more?loader:archivedShown && archivedLoader.state.detail?.variants_has_more?archivedLoader:null;
content.listen(more,'click',()=>{if(savedReadState==='current')moreTarget()?.loadMore();});
content.listen(retry,'click',()=>{
if(savedReadState!=='current') return;
if(archivedLoader.state.error) archivedLoader.load(positiveId);else if(archivedLoader.state.variantsError) archivedLoader.loadMore();else loader.loadMore();
});
content.listen(addNegative,'click',()=>editor.open({mode:'negative',positiveId,trigger:addNegative}));
content.listen(showArchived,'click',()=>{
archivedShown=!archivedShown;archivedVisible.set(positiveId,archivedShown);
// A write while hidden makes the loaded archived list stale; reread it when it is shown again.
if(archivedShown && (archivedStale || (!archivedLoader.state.detail && !archivedLoader.state.loading))) {archivedStale=false;archivedLoader.load(positiveId);}
if(!archivedShown && archivedList().every(variant=>variant.id!==activePairId) && detail.variants.every(variant=>variant.id!==activePairId) && separate()?.id!==activePairId) select(null);
updateBranches(loader.state);
});
function laneName(entry) {
const {variant}=entry;
return variant.title || (variant.negative_text===''?'Explicitly empty negative':entry.kind==='separate'?'Selected saved negative variant':`Negative variant ${entry.number}`);
}
function laneEntries() {
const entries=detail.variants.map((variant,index)=>({key:`active:${variant.id}`,kind:'active',variant,number:index+1}));
const loadedArchived=archivedList(),outside=separate();
// Archived numbers follow the active total; a pair outside the loaded archived page follows them, so names stay unique.
if(outside) entries.push({key:`${outside.archived?'archived':'separate'}:${outside.id}`,kind:outside.archived?'archived':'separate',variant:outside,number:detail.variant_total+loadedArchived.length+1});
for(const [index,variant] of loadedArchived.entries()) entries.push({key:`archived:${variant.id}`,kind:'archived',variant,number:detail.variant_total+index+1});
return entries;
}
function laneFor(entry) {
let lane=lanes.get(entry.key);
if(!lane) {
const node=el('div',undefined,`library-variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','library-variant-dot');const label=el('span');
node.append(button,label);lane={node,button,label,text:document.createTextNode(''),entry};label.append(lane.text);
if(entry.kind==='archived') {lane.restore=createButton('Restore','library-variant-restore');label.append(lane.restore);content.listen(lane.restore,'click',()=>restorePair(lane.entry.variant.id,lane.restore));}
content.listen(button,'click',()=>select(lane.entry.variant.id));lanes.set(entry.key,lane);
}
lane.entry=entry;return lane;
}
updateBranches=state=>{
const known=new Set(choices.map(choice=>choice.variant.id));
for(const [index,variant] of detail.variants.entries()) if(!known.has(variant.id)) {
const lane=el('div',undefined,'library-variant-lane');lane.style.setProperty('--lane-offset',`${38+index*47}px`);const button=createButton('−','library-variant-dot');button.setAttribute('aria-label',`Select negative variant ${index+1}`);
button.setAttribute('aria-pressed',String(variantSelections.get(positiveId)===variant.id));
const label=el('span');lane.append(button,label);branches.append(lane);choices.push({button,variant,label});
contentScope.listen(button,'click',()=>select(variant.id));
}
for(const [index,choice] of choices.entries()) {
choice.variant=detail.variants.find(variant=>variant.id===choice.variant.id) || choice.variant;
const variant=choice.variant;choice.label.textContent=`${variant.title || (variant.explicit_empty?'Explicitly empty negative':`Negative variant ${index+1}`)} · ${variant.image_count} linked images`;
choice.button.disabled=savedReadState!=='current';
const entries=laneEntries(),keep=new Set(entries.map(entry=>entry.key)),active=document.activeElement;
for(const [key,lane] of lanes) if(!keep.has(key)) {lane.node.remove();lanes.delete(key);}
let previous=positiveLane;
for(const [index,entry] of entries.entries()) {
const lane=laneFor(entry),name=laneName(entry);
lane.node.style.setProperty('--lane-offset',`${38+index*47}px`);
lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative variant ${entry.number}`);
lane.button.setAttribute('aria-pressed',String(activePairId===entry.variant.id));lane.button.disabled=savedReadState!=='current';
lane.text.textContent=`${name} · ${entry.variant.image_count} linked images${entry.kind==='archived'?' · Archived':''}`;
if(lane.restore) {lane.restore.setAttribute('aria-label',`Restore ${name}`);lane.restore.disabled=savedReadState!=='current' || restoringPairs.has(entry.variant.id);}
if(previous.nextSibling!==lane.node) previous.after(lane.node);
previous=lane.node;
}
if(active?.isConnected && document.activeElement!==active) active.focus({preventScroll:true});
emptyNote.hidden=entries.length>0;
variantCount.textContent=`${detail.variants.length} of ${detail.variant_total} saved negative variants`;
more.hidden=!detail.variants_has_more || !!state.variantsError;more.setAttribute('aria-disabled',String(state.loadingVariants || savedReadState!=='current'));retry.hidden=!state.variantsError;
more.disabled=retry.disabled=savedReadState!=='current';all.disabled=savedReadState!=='current';
variantError.hidden=!state.variantsError;variantError.textContent=state.variantsError?'Negative variants could not be loaded. Retry to continue.':'';
const archivedState=archivedLoader.state,target=moreTarget();
const loadError=state.variantsError || (archivedShown && (archivedState.error || archivedState.variantsError));
more.hidden=!target || !!loadError;more.setAttribute('aria-disabled',String(state.loadingVariants || archivedState.loading || archivedState.loadingVariants || savedReadState!=='current'));retry.hidden=!loadError;
more.disabled=retry.disabled=savedReadState!=='current';all.disabled=savedReadState!=='current';addNegative.disabled=savedReadState!=='current';
variantError.hidden=!loadError;variantError.textContent=!loadError?'':state.variantsError?'Negative variants could not be loaded. Retry to continue.':'Archived negative variants could not be loaded. Retry to continue.';
const archivedCount=Number.isSafeInteger(detail.positive.archived_variant_count)?detail.positive.archived_variant_count:0;
showArchived.textContent=`Show archived (${archivedCount})`;showArchived.setAttribute('aria-pressed',String(archivedShown));
};
updateBranches(state);
if(!detail.variants.length) branches.append(el('p','No saved negative variants. Existing image associations remain available.','muted'));
updateParent=()=>{
main.querySelector('h1').textContent=displayTitle(detail.positive);categoryChip.textContent=detail.positive.category || 'Uncategorized';
send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${displayTitle(detail.positive)}`);
};
let activePairId;
updateParent();updateBranches(state);
branches.append(emptyNote);
const note=el('p','Saved combinations, not an execution timeline.','muted');
const variantFooter=el('div',undefined,'library-variant-footer'),variantSummary=el('div',undefined,'library-variant-summary');variantSummary.append(variantCount,note);variantFooter.append(variantSummary,all);
variants.append(branches,variantError,more,retry,variantFooter);
variants.append(branches,variantError,restoreStatus,restoreAction,more,retry,variantFooter);
const context=el('p','','library-variant-context');context.setAttribute('role','status');
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
const images=el('div',undefined,'library-linked-images');gallery.append(images);let activePairId;
const images=el('div',undefined,'library-linked-images');gallery.append(images);
const recorded=el('div',undefined,'library-recorded-container');
body.append(subtitle,summary,variants,recorded,gallery);
const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null);
const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true);
function rolesFor(pairId) {
if(pairId===null || detail.variants.some(variant=>variant.id===pairId)) return libraryPromptRoles(detail,pairId);
const outside=separate();if(outside?.id===pairId) return libraryPromptRoles(detail,pairId,outside);
return libraryPromptRoles({...detail,variants:[...detail.variants,...archivedList()]},pairId);
}
function updateSend() {
const roles=activePairId===null || activePairId===undefined?{}:promptRoles(rolesFor(activePairId));
send.disabled=savedReadState!=='current' || !Object.hasOwn(roles,'positive') || !Object.hasOwn(roles,'negative');
}
function setParentState(parent) {
if(contentScope.signal.aborted || (!parent.loading && !parent.error && !parent.detail)) return;
if(content.signal.aborted || (!parent.loading && !parent.error && !parent.detail)) return;
parentStatus.hidden=false;parentStatus.setAttribute('role',parent.error?'alert':'status');retryParent.hidden=true;reloadSaved.hidden=true;
if(parent.loading) {savedReadState='validating';parentStatus.textContent='Revalidating saved Library details…';}
else if(parent.error) {savedReadState='error';parentStatus.textContent='Saved Library details could not be revalidated. The saved selection and gallery have been retained.';retryParent.hidden=false;}
@@ -310,32 +510,107 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
reloadSaved.hidden=savedReadState==='current';
}
}
updateBranches(loader.state);blocks?.setState({prompts:libraryPromptRoles(detail,variantSelections.get(positiveId)??null),actionsEnabled:savedReadState==='current'});
updateBranches(loader.state);blocks?.setState({prompts:rolesFor(variantSelections.get(positiveId)??null),actionsEnabled:savedReadState==='current'});updateSend();
}
async function retrySavedParent() {
parentRequest?.abort();const request=parentRequest=new AbortController(),generation=++parentGeneration;setParentState({loading:true});
try {
const fresh=await api.get(`/prompt_manager/library/${positiveId}?variant_limit=40`,{signal:request.signal});
if(contentScope.signal.aborted || generation!==parentGeneration) return;
if(content.signal.aborted || generation!==parentGeneration) return;
if(!fresh?.positive || !Number.isSafeInteger(fresh.positive.id) || typeof fresh.positive.text!=='string' || !Array.isArray(fresh.variants) || fresh.variants.some(variant=>!Number.isSafeInteger(variant.id) || typeof variant.negative_text!=='string' || typeof variant.explicit_empty!=='boolean' || variant.explicit_empty!==(variant.negative_text===''))) throw new TypeError('Invalid saved Library details');
setParentState({detail:fresh});
} catch(error) {if(!contentScope.signal.aborted && generation===parentGeneration)setParentState({error});}
} catch(error) {if(!content.signal.aborted && generation===parentGeneration)setParentState({error});}
}
contentScope.listen(retryParent,'click',retrySavedParent);contentScope.listen(reloadSaved,'click',()=>{variantSelections.delete(positiveId);loader.load(positiveId);});
content.listen(retryParent,'click',retrySavedParent);content.listen(reloadSaved,'click',()=>{variantSelections.delete(positiveId);loader.load(positiveId);});
if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{});
mountRecordedHistory(recorded,{api,positiveId,parentScope:contentScope,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
if(contentScope.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent);
mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
if(content.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent);
}});
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:contentScope});
function select(pairId) {
if(savedReadState!=='current' || (galleryScope && activePairId===pairId)) return;activePairId=pairId;
variantSelections.set(positiveId,pairId);blocks.setState({prompts:libraryPromptRoles(detail,pairId),actionsEnabled:true});
for(const choice of choices) choice.button.setAttribute('aria-pressed',String(choice.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null));
context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':`Negative variant ${detail.variants.findIndex(variant=>variant.id===pairId)+1} · same positive prompt`;
// The heading carries the pair send, as in the concept; the saved texts keep their per-role actions.
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:content,includePairAction:false});
function select(pairId,{force=false}={}) {
if(savedReadState!=='current' || (!force && galleryScope && activePairId===pairId)) return;
if(pairId!==null && !recordFor(pairId)) pairId=null;
activePairId=pairId;variantSelections.set(positiveId,pairId);blocks.setState({prompts:rolesFor(pairId),actionsEnabled:true});
for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null));
const index=detail.variants.findIndex(variant=>variant.id===pairId),archived=pairId!==null && archivedSelection(pairId);
const lane=[...lanes.values()].find(item=>item.entry.variant.id===pairId);
context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':index>=0?`Negative variant ${index+1} · same positive prompt`:`${lane?laneName(lane.entry):'Selected saved negative variant'}${archived?' · Archived':''} · same positive prompt`;
galleryScope?.dispose();galleryScope=createScope();images.replaceChildren();
mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}});
// An archived pair's images are read in the archived scope; the gallery stays authoritative.
mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}});
updateSend();
}
contentScope.listen(all,'click',()=>select(null));const remembered=variantSelections.get(positiveId);select(detail.variants.some(variant=>variant.id===remembered)?remembered:null);
/** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */
async function rereadSelectedPair() {
const pairId=activePairId;if(pairId===null || pairId===undefined) return;
try {
const value=await api.get(`/prompt_manager/pairs/${pairId}`,{signal:content.signal,cache:'no-store'});
const pair=value?.pair;
if(content.signal.aborted || activePairId!==pairId || pair?.id!==pairId || pair.positive_prompt_id!==positiveId || pair.positive_text!==detail.positive.text || typeof pair.negative_text!=='string') return;
if(!detail.variants.some(variant=>variant.id===pairId) && !archivedList().some(variant=>variant.id===pairId)) {selectedPairs.set(positiveId,pair);updateBranches(loader.state);}
blocks.setState({prompts:rolesFor(pairId),actionsEnabled:savedReadState==='current'});updateSend();
} catch {
// The previous verified selection stays; a later explicit action reads it again.
}
}
function showRestoreProblem(text,label,handler) {
restoreStatus.textContent=text;restoreStatus.hidden=!text;restoreHandler=handler;restoreAction.textContent=label || '';restoreAction.hidden=!label;
}
/** Restore through the editor controller's fenced pair path; nothing restores silently. */
async function restorePair(pairId,button) {
// A rebuilt lane must not offer a second Restore while this one is in flight.
if(restoringPairs.has(pairId)) return;
restoringPairs.add(pairId);showRestoreProblem('');button.disabled=true;
const controller=createLibraryEditorController({api,mode:'metadata',positiveId,pairId,onCommitted:invalidateLibraryAfterWrite,onReconciled:result=>{
rememberLibraryResult(result);apply(result);
const dot=[...lanes.values()].find(lane=>lane.entry.kind!=='archived' && lane.entry.variant.id===pairId)?.button;dot?.focus();
}});
content.cleanup(()=>controller.dispose());
const settle=()=>{
const {phase,error}=controller.state;
if(phase==='complete' || phase==='disposed' || content.signal.aborted) return;
if(button.isConnected) button.disabled=savedReadState!=='current';
if(phase==='storage-changed' || (phase==='saved-refresh-error' && error?.code==='storage-changed')) showRestoreProblem(phase==='storage-changed'?'Prompt storage was restored since this view loaded. Nothing was restored.':'Restored; Library refresh failed because prompt storage was restored. Reload the Library to see current data.','Reload Library',reloadView);
else if(phase==='saved-refresh-error') showRestoreProblem('Restored; Library refresh failed.','Retry refresh',async()=>{await controller.retryReconciliation();settle();});
else if(phase==='unknown-outcome') showRestoreProblem('Restore could not be confirmed. Retry sends the same restore once.','Retry',async()=>{await controller.retryUnconfirmed();settle();});
else showRestoreProblem(`Not restored: ${error?.message || 'The saved negative variant could not be read.'}`);
};
try {
await controller.initialize();
if(controller.state.phase==='ready') await controller.setArchive(false);
} finally {
restoringPairs.delete(pairId);if(!content.signal.aborted) updateBranches(loader.state);
}
settle();
}
/** Same-parent reconciliation from one authoritative result; the separately verified pair stays outside the page. */
function apply(result) {
const kind=result.commit.kind,pair=result.pair,previous=detail.variants.map(variant=>variant.id);
// An authoritative reconciled read supersedes any stale or pending revalidation.
parentGeneration++;parentRequest?.abort();savedReadState='current';
parentStatus.hidden=true;parentStatus.textContent='';retryParent.hidden=reloadSaved.hidden=true;
loader.accept(result.detail);
// A reused archived pair is shown where it lives, under Show archived; archiving itself never turns the toggle on.
if(pair?.archived && kind!=='archive-pair') {archivedShown=true;archivedVisible.set(positiveId,true);}
if(archivedShown) archivedLoader.load(positiveId);else archivedStale=true;
if(kind==='metadata') {rereadSelectedPair();return;}
if(kind==='archive-pair') {
if(selectedPairs.get(positiveId)?.id===pair.id) selectedPairs.delete(positiveId);
const index=previous.indexOf(pair.id),next=detail.variants[Math.min(Math.max(index,0),detail.variants.length-1)];
select(next?.id ?? null,{force:true});return;
}
if(pair) select(pair.id,{force:true});
}
current={activePairId:()=>activePairId ?? null,sendRoles:()=>{const roles=activePairId===null || activePairId===undefined?{}:promptRoles(rolesFor(activePairId));return Object.hasOwn(roles,'positive') && Object.hasOwn(roles,'negative')?roles:null;},apply};
content.listen(all,'click',()=>select(null));
const remembered=variantSelections.get(positiveId),outside=selectedPairs.get(positiveId);
if(outside?.archived && outside.id===remembered && !archivedShown) {archivedShown=true;archivedVisible.set(positiveId,true);}
if(archivedShown) archivedLoader.load(positiveId);
let initial=null;
if(detail.variants.some(variant=>variant.id===remembered)) initial=remembered;
else if(outside?.id===remembered) {try {libraryPromptRoles(detail,remembered,outside);initial=remembered;} catch {selectedPairs.delete(positiveId);}}
updateBranches(state);select(initial);
}
loader.load(positiveId);
}
+2
View File
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="js/workspace/images.css">
<link rel="stylesheet" href="js/workspace/library.css">
<link rel="stylesheet" href="js/workspace/inspector.css">
<link rel="stylesheet" href="js/workspace/library-editor.css">
<script src="lib/viewerjs/viewer.min.js" defer></script>
<script src="js/comfy-metadata.js" defer></script>
<script src="js/gallery-settings.js" defer></script>
@@ -30,6 +31,7 @@
<footer class="page-footer">ComfyAssets <span>Local tools. Your library.</span></footer>
</div>
</div>
<div id="workspace-toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<noscript><p>This workspace needs JavaScript. <a href="admin">Open the existing dashboard</a>.</p></noscript>
</body>
</html>