diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 62f7000..4c58a9f 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -3002,6 +3002,36 @@ test('Missing Models filter toggles persist state and rerender once per change', } }); +test('metadata refresh updates prompts even when the model workflow signature is unchanged', async () => { + const refresh = eval(`(${extractMethod(workflowStateMethodsSource, 'refreshForActiveWorkflowChange')})`); + const workflow = { nodes: [{ id: 1, type: 'CLIPTextEncode', widgets_values: ['edited prompt'] }] }; + let promptRefreshes = 0; + const dialog = { + _workflowRefreshGeneration: 1, + activeWorkflowRouteKey: 'workflow-a', + activeWorkflowSignature: 'same-model-signature', + activeTab: 'metadata', + isVisible: () => true, + getActiveWorkflowRouteKey: () => 'workflow-a', + getCurrentWorkflow: () => workflow, + getWorkflowSignature: () => 'same-model-signature', + getMissingWorkflowSignature: () => 'same-model-signature', + refreshMetadataPromptPreview(value) { + assert.equal(value, workflow); + promptRefreshes += 1; + return true; + }, + openMetadataTransfer() { assert.fail('Prompt-only edits must not rescan models'); }, + syncWorkflowScopedQueue() { assert.fail('Prompt-only edits must preserve model state'); }, + }; + await refresh.call(dialog, { reason: 'node-widget-change', generation: 1 }); + assert.equal(promptRefreshes, 1); + assert.equal(dialog._workflowRefreshRetryTimer, undefined); + dialog._applyWorkflowRefreshSuppressionDepth = 1; + await refresh.call(dialog, { reason: 'node-widget-change', generation: 1 }); + assert.equal(promptRefreshes, 1); +}); + test('node widget changes request a content-preserving Missing Models refresh', async () => { const log = { debug() {} }; const refreshForActiveWorkflowChange = eval( diff --git a/tests/test_prompt_transfer.mjs b/tests/test_prompt_transfer.mjs index cafedd9..0621b11 100644 --- a/tests/test_prompt_transfer.mjs +++ b/tests/test_prompt_transfer.mjs @@ -73,6 +73,28 @@ function createContext(sourceWorkflow, currentWorkflow) { }; } +test('live prompt preview detects positive and negative text changes without changing imported metadata', () => { + const source = createRoleWorkflow('imported positive', 'imported negative'); + const current = createRoleWorkflow('current positive', 'current negative'); + const dialog = createContext(source, current); + let refreshes = 0; + dialog.renderMetadataPromptTransferPanelInPlace = () => { + refreshes += 1; + dialog.renderMetadataPromptTransferPanel(dialog.imageInspectorState); + return true; + }; + dialog.renderMetadataPromptTransferPanel(dialog.imageInspectorState); + assert.equal(dialog.refreshMetadataPromptPreview(current), false); + current.nodes[0].widgets_values[0] = 'edited positive'; + assert.equal(dialog.refreshMetadataPromptPreview(current), true); + assert.match(dialog.renderMetadataPromptTransferPanel(dialog.imageInspectorState), /edited positive/); + current.nodes[1].widgets_values[0] = 'edited negative'; + assert.equal(dialog.refreshMetadataPromptPreview(current), true); + assert.equal(dialog.refreshMetadataPromptPreview(current), false); + assert.equal(refreshes, 2); + assert.equal(source.nodes[0].widgets_values[0], 'imported positive'); +}); + test('workflow prompt extraction distinguishes positive and negative CLIP text nodes', () => { const prompts = extractWorkflowPrompts(createRoleWorkflow()); diff --git a/web/resolver/model_resolver.js b/web/resolver/model_resolver.js index 489e286..3242331 100644 --- a/web/resolver/model_resolver.js +++ b/web/resolver/model_resolver.js @@ -955,6 +955,15 @@ export class ModelResolver { const focusHandler = () => { window.__ModelResolverWorkflowChangeOwner?.handleActiveWorkflowRouteChange('window-focus'); }; + const promptInputHandler = (event) => { + const dialog = window.__ModelResolverWorkflowChangeOwner?.dialog; + const target = event.target instanceof Element ? event.target : null; + if (!dialog?.isVisible() || dialog.activeTab !== 'metadata') return; + if (target?.closest('#model-resolver-modal, .model-resolver-backdrop')) return; + if (!target?.matches('textarea, input[type="text"], [contenteditable="true"]')) return; + dialog.scheduleActiveWorkflowRefresh('node-widget-change'); + }; + document.addEventListener('input', promptInputHandler); const visibilityHandler = () => { if (document.visibilityState === 'visible') { window.__ModelResolverWorkflowChangeOwner?.handleActiveWorkflowRouteChange('visibility-change'); @@ -970,6 +979,7 @@ export class ModelResolver { document.addEventListener('visibilitychange', visibilityHandler); window.__ModelResolverWorkflowChangeHandlers = [ + { target: document, event: 'input', handler: promptInputHandler }, { target: window, event: 'hashchange', handler: routeHandler }, { target: window, event: 'popstate', handler: routeHandler }, { target: window, event: 'model-resolver-locationchange', handler: routeHandler }, diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index 2994e3c..8763798 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -872,6 +872,10 @@ export const workflowStateMethods = { const missingSignatureChanged = missingSignature && missingSignature !== this.activeMissingWorkflowSignature; const graphStillLooksOld = routeChanged && previousSignature && signature === previousSignature; + if (this.activeTab === 'metadata' && !routeChanged) { + const promptsChanged = this.refreshMetadataPromptPreview?.(workflow); + if (promptsChanged && !signatureChanged) return; + } const workflowChangeStillPending = ( String(reason || '').startsWith('node-') && !routeChanged diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index 68a84fe..d4f2302 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -851,6 +851,7 @@ export const imageMetadataMethods = { const imageState = state || getImageInspectorState(this); const promptTransfer = this.getMetadataPromptTransferState(imageState); const data = this.getMetadataPromptTransferData(imageState); + this._metadataPromptPreviewSignature = JSON.stringify(data.targetPrompts); const sourceSummary = data.sourcePrompts.length ? `${data.sourcePrompts.length} editable prompt${data.sourcePrompts.length === 1 ? '' : 's'} found in the imported workflow.` : imageState.loading @@ -977,6 +978,12 @@ export const imageMetadataMethods = { `; }, + refreshMetadataPromptPreview(workflow) { + const signature = JSON.stringify(extractWorkflowPrompts(workflow || {}).filter(prompt => !prompt.linked)); + if (signature === this._metadataPromptPreviewSignature) return false; + return this.renderMetadataPromptTransferPanelInPlace({ fallback: false }); + }, + renderMetadataPromptTransferPanelInPlace({ fallback = true } = {}) { const result = this.contentElement?.querySelector('[data-image-inspector-result]'); const currentPanel = result?.querySelector('.mr-image-prompt-transfer');