diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index fa24a7b..de0df01 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -21,6 +21,7 @@ import { startSplitterDrag } from '../web/resolver/utils/splitter_drag.js'; import { baseModelAliasMethods } from '../web/resolver/search/base_model_alias_methods.js'; import { searchHashMethods } from '../web/resolver/search/search_hash_methods.js'; import { missingModelStateMethods } from '../web/resolver/views/missing_model_state_methods.js'; +import { workflowIdentityMethods } from '../web/resolver/shell/workflow_identity_methods.js'; import { getCustomNodeModelAdapter, getCustomNodeModelCategory, @@ -1832,13 +1833,15 @@ test('Power Lora Loader strength updates only its Loaded Models chip and cache', }); test('changed workflow model keeps the selected row and batch checkbox for the same loader slot', () => { - const encodeMissingModelKeyPart = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'encodeMissingModelKeyPart')})`); - const getMissingModelIdentityPart = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'getMissingModelIdentityPart')})`); - const getMissingModelKey = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'getMissingModelKey')})`); - const getMissingModelWorkflowSlotKeys = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'getMissingModelWorkflowSlotKeys')})`); - const findMissingModelReplacement = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'findMissingModelReplacement')})`); - const resolvePreservedMissingModelKey = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'resolvePreservedMissingModelKey')})`); - const remapMissingModelKeys = eval(`(${extractMethod(lifecycleGraphMethodsSource, 'remapMissingModelKeys')})`); + const { + encodeMissingModelKeyPart, + getMissingModelIdentityPart, + getMissingModelKey, + getMissingModelWorkflowSlotKeys, + findMissingModelReplacement, + resolvePreservedMissingModelKey, + remapMissingModelKeys, + } = workflowIdentityMethods; const dialog = { encodeMissingModelKeyPart, getMissingModelIdentityPart, diff --git a/tests/test_missing_model_identity_methods.mjs b/tests/test_missing_model_identity_methods.mjs index 10fe877..7bbd149 100644 --- a/tests/test_missing_model_identity_methods.mjs +++ b/tests/test_missing_model_identity_methods.mjs @@ -1,58 +1,6 @@ -import fs from 'node:fs'; -import path from 'node:path'; import test from 'node:test'; import assert from 'node:assert/strict'; - -const projectRoot = path.resolve(import.meta.dirname, '..'); -const lifecycleGraphMethodsSource = fs.readFileSync( - path.join(projectRoot, 'web/resolver/shell/lifecycle_graph_methods.js'), - 'utf8' -); - -function extractMethod(source, methodName) { - const signature = new RegExp(`\\n\\s+${methodName}\\s*\\(`); - const match = signature.exec(source); - assert.ok(match, `Could not find ${methodName}`); - const parenStart = source.indexOf('(', match.index); - let parenDepth = 0; - let parenEnd = -1; - for (let i = parenStart; i < source.length; i += 1) { - if (source[i] === '(') parenDepth += 1; - if (source[i] === ')') parenDepth -= 1; - if (parenDepth === 0) { - parenEnd = i; - break; - } - } - const params = source.slice(parenStart + 1, parenEnd); - const braceStart = source.indexOf('{', parenEnd); - let braceDepth = 0; - for (let i = braceStart; i < source.length; i += 1) { - if (source[i] === '{') braceDepth += 1; - if (source[i] === '}') braceDepth -= 1; - if (braceDepth === 0) { - return `function ${methodName}(${params}) ${source.slice(braceStart, i + 1)}`; - } - } - throw new Error(`Could not parse ${methodName}`); -} - -globalThis.getCustomNodeOriginalIdentity = missing => missing.original_identity || ''; - -const methodNames = [ - 'encodeMissingModelKeyPart', - 'getMissingModelIdentityPart', - 'getMissingModelKey', - 'getMissingModelWorkflowSlotKeys', - 'findMissingModelReplacement', - 'resolvePreservedMissingModelKey', - 'remapMissingModelKeys', - 'getMissingModelDomKey', -]; -const methods = Object.fromEntries(methodNames.map(methodName => [ - methodName, - eval(`(${extractMethod(lifecycleGraphMethodsSource, methodName)})`), -])); +import { workflowIdentityMethods as methods } from '../web/resolver/shell/workflow_identity_methods.js'; test('missing model identities and keys encode workflow scope deterministically', () => { const dialog = { ...methods, getMissingModelIdentityPart: methods.getMissingModelIdentityPart }; @@ -65,7 +13,7 @@ test('missing model identities and keys encode workflow scope deterministically' }; assert.equal(dialog.encodeMissingModelKeyPart('check point'), 'check%20point'); - assert.equal(dialog.getMissingModelIdentityPart({ original_identity: 'custom-id' }), 'custom-id'); + assert.equal(dialog.getMissingModelIdentityPart({ custom_node_original_identity: 'custom-id' }), 'custom-id'); assert.equal(dialog.getMissingModelIdentityPart(missing), missing.original_path); assert.equal( dialog.getMissingModelKey(missing), diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 47837fb..4a61876 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -18,6 +18,7 @@ import { searchHashMethods } from "./search/search_hash_methods.js"; import { searchStateMethods } from "./search/search_state_methods.js"; import { missingModelStateMethods } from "./views/missing_model_state_methods.js"; import { modelHashCompareMethods } from "./views/model_hash_compare_methods.js"; +import { workflowIdentityMethods } from "./shell/workflow_identity_methods.js"; import { queueMethods } from "./actions/queue_methods.js"; import { queueStorageMethods } from "./actions/queue_storage_methods.js"; import { downloadProgressMethods } from "./actions/download_progress_methods.js"; @@ -345,6 +346,7 @@ applyDialogMethods( searchStateMethods, missingModelStateMethods, modelHashCompareMethods, + workflowIdentityMethods, searchPanelMethods, modelInfoMethods, renderFormatMethods, diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index 531beb8..2e7abe4 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -1,5 +1,4 @@ import { app } from "../../../../../scripts/app.js"; -import { getCustomNodeOriginalIdentity } from "../custom_nodes/registry.js"; import { safeStorage } from "../utils/html_utils.js"; export const lifecycleGraphMethods = { async show(workflow = null) { @@ -804,160 +803,4 @@ export const lifecycleGraphMethods = { } }, - encodeMissingModelKeyPart(value) { - return encodeURIComponent(String(value ?? '').trim()); - }, - - getMissingModelIdentityPart(missing = {}) { - return getCustomNodeOriginalIdentity(missing) - || missing.original_path - || missing.expected_filename - || missing.name - || missing.filename - || missing.urn_string - || ''; - }, - - getMissingModelKey(missing = {}) { - if (missing.missing_key) { - return String(missing.missing_key); - } - - const nodeId = missing.node_id ?? ''; - const widgetIndex = missing.widget_index ?? ''; - const subgraphId = missing.subgraph_id || ''; - const scope = missing.is_top_level !== false ? 'T' : 'F'; - const nestedKey = missing.nested_key || ''; - const category = missing.category || ''; - const identity = this.getMissingModelIdentityPart(missing); - return [ - nodeId, - widgetIndex, - subgraphId, - scope, - nestedKey, - category, - identity - ].map(value => this.encodeMissingModelKeyPart(value)).join(':'); - }, - - getMissingModelWorkflowSlotKeys(missing = {}) { - const references = [missing]; - if (Array.isArray(missing.all_node_refs)) { - references.push(...missing.all_node_refs); - } - - const seen = new Set(); - const keys = []; - for (const reference of references) { - const nodeId = reference?.node_id; - if (nodeId === undefined || nodeId === null || nodeId === '') continue; - - const scope = reference.is_top_level !== false ? 'T' : 'F'; - const key = [ - scope, - reference.subgraph_id || '', - nodeId, - reference.widget_index ?? '', - reference.nested_key || '' - ].map(value => this.encodeMissingModelKeyPart(value)).join(':'); - if (seen.has(key)) continue; - seen.add(key); - keys.push(key); - } - return keys; - }, - - findMissingModelReplacement(previousMissing = {}, missingModels = [], preferredIndex = 0) { - const previousSlots = this.getMissingModelWorkflowSlotKeys(previousMissing); - if (!previousSlots.length || !Array.isArray(missingModels) || !missingModels.length) { - return null; - } - - const previousSlotPositions = new Map( - previousSlots.map((slotKey, index) => [slotKey, index]) - ); - let best = null; - missingModels.forEach((missing, index) => { - const candidateSlots = this.getMissingModelWorkflowSlotKeys(missing); - let slotRank = Number.POSITIVE_INFINITY; - candidateSlots.forEach((slotKey, candidateSlotIndex) => { - const previousSlotIndex = previousSlotPositions.get(slotKey); - if (previousSlotIndex === undefined) return; - slotRank = Math.min(slotRank, previousSlotIndex + candidateSlotIndex); - }); - if (!Number.isFinite(slotRank)) return; - - const rank = [ - slotRank, - Math.abs(index - preferredIndex), - index - ]; - if ( - !best - || rank[0] < best.rank[0] - || (rank[0] === best.rank[0] && rank[1] < best.rank[1]) - || (rank[0] === best.rank[0] && rank[1] === best.rank[1] && rank[2] < best.rank[2]) - ) { - best = { missing, rank }; - } - }); - return best?.missing || null; - }, - - resolvePreservedMissingModelKey(missingModels = [], previousMissingModels = [], previousKey = '') { - if (!previousKey || !Array.isArray(missingModels) || !missingModels.length) return ''; - - const exact = missingModels.find(missing => this.getMissingModelKey(missing) === previousKey); - if (exact) return previousKey; - - const previousIndex = previousMissingModels.findIndex( - missing => this.getMissingModelKey(missing) === previousKey - ); - if (previousIndex < 0) return ''; - - const replacement = this.findMissingModelReplacement( - previousMissingModels[previousIndex], - missingModels, - previousIndex - ); - return replacement ? this.getMissingModelKey(replacement) : ''; - }, - - remapMissingModelKeys( - missingModels = [], - previousMissingModels = [], - previousKeys = null, - currentMissingModels = null - ) { - const currentKeys = new Set( - (Array.isArray(currentMissingModels) ? currentMissingModels : missingModels) - .map(missing => this.getMissingModelKey(missing)) - ); - const remappedKeys = new Set(); - for (const previousKey of previousKeys || []) { - if ( - currentKeys.has(previousKey) - && !missingModels.some(missing => this.getMissingModelKey(missing) === previousKey) - ) { - continue; - } - const nextKey = this.resolvePreservedMissingModelKey( - missingModels, - previousMissingModels, - previousKey - ); - if (nextKey) remappedKeys.add(nextKey); - } - return remappedKeys; - }, - - getMissingModelDomKey(missing = {}) { - const key = this.getMissingModelKey(missing); - try { - return btoa(key).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); - } catch (_e) { - return key.replace(/[^A-Za-z0-9_-]/g, char => `_${char.charCodeAt(0).toString(16)}_`); - } - } }; diff --git a/web/resolver/shell/workflow_identity_methods.js b/web/resolver/shell/workflow_identity_methods.js new file mode 100644 index 0000000..5a7806b --- /dev/null +++ b/web/resolver/shell/workflow_identity_methods.js @@ -0,0 +1,160 @@ +import { getCustomNodeOriginalIdentity } from "../custom_nodes/registry.js"; + +export const workflowIdentityMethods = { + encodeMissingModelKeyPart(value) { + return encodeURIComponent(String(value ?? '').trim()); + }, + + getMissingModelIdentityPart(missing = {}) { + return getCustomNodeOriginalIdentity(missing) + || missing.original_path + || missing.expected_filename + || missing.name + || missing.filename + || missing.urn_string + || ''; + }, + + getMissingModelKey(missing = {}) { + if (missing.missing_key) { + return String(missing.missing_key); + } + + const nodeId = missing.node_id ?? ''; + const widgetIndex = missing.widget_index ?? ''; + const subgraphId = missing.subgraph_id || ''; + const scope = missing.is_top_level !== false ? 'T' : 'F'; + const nestedKey = missing.nested_key || ''; + const category = missing.category || ''; + const identity = this.getMissingModelIdentityPart(missing); + return [ + nodeId, + widgetIndex, + subgraphId, + scope, + nestedKey, + category, + identity + ].map(value => this.encodeMissingModelKeyPart(value)).join(':'); + }, + + getMissingModelWorkflowSlotKeys(missing = {}) { + const references = [missing]; + if (Array.isArray(missing.all_node_refs)) { + references.push(...missing.all_node_refs); + } + + const seen = new Set(); + const keys = []; + for (const reference of references) { + const nodeId = reference?.node_id; + if (nodeId === undefined || nodeId === null || nodeId === '') continue; + + const scope = reference.is_top_level !== false ? 'T' : 'F'; + const key = [ + scope, + reference.subgraph_id || '', + nodeId, + reference.widget_index ?? '', + reference.nested_key || '' + ].map(value => this.encodeMissingModelKeyPart(value)).join(':'); + if (seen.has(key)) continue; + seen.add(key); + keys.push(key); + } + return keys; + }, + + findMissingModelReplacement(previousMissing = {}, missingModels = [], preferredIndex = 0) { + const previousSlots = this.getMissingModelWorkflowSlotKeys(previousMissing); + if (!previousSlots.length || !Array.isArray(missingModels) || !missingModels.length) { + return null; + } + + const previousSlotPositions = new Map( + previousSlots.map((slotKey, index) => [slotKey, index]) + ); + let best = null; + missingModels.forEach((missing, index) => { + const candidateSlots = this.getMissingModelWorkflowSlotKeys(missing); + let slotRank = Number.POSITIVE_INFINITY; + candidateSlots.forEach((slotKey, candidateSlotIndex) => { + const previousSlotIndex = previousSlotPositions.get(slotKey); + if (previousSlotIndex === undefined) return; + slotRank = Math.min(slotRank, previousSlotIndex + candidateSlotIndex); + }); + if (!Number.isFinite(slotRank)) return; + + const rank = [ + slotRank, + Math.abs(index - preferredIndex), + index + ]; + if ( + !best + || rank[0] < best.rank[0] + || (rank[0] === best.rank[0] && rank[1] < best.rank[1]) + || (rank[0] === best.rank[0] && rank[1] === best.rank[1] && rank[2] < best.rank[2]) + ) { + best = { missing, rank }; + } + }); + return best?.missing || null; + }, + + resolvePreservedMissingModelKey(missingModels = [], previousMissingModels = [], previousKey = '') { + if (!previousKey || !Array.isArray(missingModels) || !missingModels.length) return ''; + + const exact = missingModels.find(missing => this.getMissingModelKey(missing) === previousKey); + if (exact) return previousKey; + + const previousIndex = previousMissingModels.findIndex( + missing => this.getMissingModelKey(missing) === previousKey + ); + if (previousIndex < 0) return ''; + + const replacement = this.findMissingModelReplacement( + previousMissingModels[previousIndex], + missingModels, + previousIndex + ); + return replacement ? this.getMissingModelKey(replacement) : ''; + }, + + remapMissingModelKeys( + missingModels = [], + previousMissingModels = [], + previousKeys = null, + currentMissingModels = null + ) { + const currentKeys = new Set( + (Array.isArray(currentMissingModels) ? currentMissingModels : missingModels) + .map(missing => this.getMissingModelKey(missing)) + ); + const remappedKeys = new Set(); + for (const previousKey of previousKeys || []) { + if ( + currentKeys.has(previousKey) + && !missingModels.some(missing => this.getMissingModelKey(missing) === previousKey) + ) { + continue; + } + const nextKey = this.resolvePreservedMissingModelKey( + missingModels, + previousMissingModels, + previousKey + ); + if (nextKey) remappedKeys.add(nextKey); + } + return remappedKeys; + }, + + getMissingModelDomKey(missing = {}) { + const key = this.getMissingModelKey(missing); + try { + return btoa(key).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + } catch (_e) { + return key.replace(/[^A-Za-z0-9_-]/g, char => `_${char.charCodeAt(0).toString(16)}_`); + } + }, +};