From d29803aeffeeaa4b57730a029995b66cd8c208cf Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Mon, 27 Jul 2026 17:24:17 +0200 Subject: [PATCH] feat: incremental workflow analysis via node fingerprint cache and preserved loaded models refresh on widget changes --- core/workflow_analyzer.py | 461 ++++++++++++++++--- tests/test_downloads_tab_workflow_route.mjs | 71 ++- tests/test_workflow_analyzer.py | 135 ++++++ web/resolver/model_resolver.js | 1 + web/resolver/shell/workflow_state_methods.js | 4 +- web/resolver/views/tabs_loaded_methods.js | 26 +- 6 files changed, 616 insertions(+), 82 deletions(-) diff --git a/core/workflow_analyzer.py b/core/workflow_analyzer.py index 846149a..efce6eb 100644 --- a/core/workflow_analyzer.py +++ b/core/workflow_analyzer.py @@ -2235,10 +2235,162 @@ def _apply_promoted_widget_locator( ref["locate_via_promoted_widget"] = True +def _normalize_workflow_analysis_value(value: Any) -> Any: + if isinstance(value, dict): + is_node = "id" in value and "type" in value + return { + key: _normalize_workflow_analysis_value(item) + for key, item in value.items() + if not ( + is_node + and key in _WORKFLOW_MODEL_INVENTORY_IGNORED_NODE_KEYS + ) + } + if isinstance(value, list): + return [_normalize_workflow_analysis_value(item) for item in value] + return value + + +def _get_workflow_node_cache_key( + node: Dict[str, Any], + *, + subgraph_id: Any = "", + is_top_level: bool, +) -> tuple[str, str, str]: + scope = "top" if is_top_level else "subgraph" + return (scope, "" if is_top_level else str(subgraph_id or ""), str(node.get("id"))) + + +def _get_workflow_node_fingerprint(node: Dict[str, Any]) -> str: + serialized_node = json.dumps( + _normalize_workflow_analysis_value(node), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(serialized_node.encode("utf-8")).hexdigest() + + +def _get_workflow_node_fingerprints( + workflow_json: Dict[str, Any], +) -> Dict[tuple[str, str, str], str]: + fingerprints = {} + nodes = workflow_json.get("nodes", []) + if isinstance(nodes, list): + for node in nodes: + if not isinstance(node, dict): + continue + key = _get_workflow_node_cache_key(node, is_top_level=True) + fingerprints[key] = _get_workflow_node_fingerprint(node) + + definitions = workflow_json.get("definitions", {}) + subgraphs = definitions.get("subgraphs", []) if isinstance(definitions, dict) else [] + if not isinstance(subgraphs, list): + return fingerprints + + for subgraph in subgraphs: + if not isinstance(subgraph, dict): + continue + subgraph_id = subgraph.get("id") + subgraph_nodes = subgraph.get("nodes", []) + if not isinstance(subgraph_nodes, list): + continue + for node in subgraph_nodes: + if not isinstance(node, dict): + continue + key = _get_workflow_node_cache_key( + node, + subgraph_id=subgraph_id, + is_top_level=False, + ) + fingerprints[key] = _get_workflow_node_fingerprint(node) + + return fingerprints + + +def _get_promoted_widget_context_cache_key( + workflow_json: Dict[str, Any], +) -> str: + definitions = workflow_json.get("definitions", {}) + subgraphs = definitions.get("subgraphs", []) if isinstance(definitions, dict) else [] + if not isinstance(subgraphs, list): + subgraphs = [] + + subgraph_ids = { + str(subgraph.get("id")) + for subgraph in subgraphs + if isinstance(subgraph, dict) and subgraph.get("id") + } + context_subgraphs = [] + for subgraph in subgraphs: + if not isinstance(subgraph, dict): + continue + context_nodes = [] + nodes = subgraph.get("nodes", []) + if isinstance(nodes, list): + for node in nodes: + if not isinstance(node, dict): + continue + widgets_values = node.get("widgets_values", []) + context_node = { + **node, + "widgets_values": ( + [None] * len(widgets_values) + if isinstance(widgets_values, list) + else [] + ), + } + context_nodes.append( + _normalize_workflow_analysis_value(context_node) + ) + context_subgraph = { + key: value + for key, value in subgraph.items() + if key != "nodes" + } + context_subgraphs.append( + _normalize_workflow_analysis_value( + { + **context_subgraph, + "nodes": context_nodes, + } + ) + ) + + context_instances = [] + nodes = workflow_json.get("nodes", []) + if isinstance(nodes, list): + for node in nodes: + if not isinstance(node, dict) or str(node.get("type")) not in subgraph_ids: + continue + context_instances.append( + _normalize_workflow_analysis_value(node) + ) + + serialized_context = json.dumps( + { + "subgraphs": context_subgraphs, + "instances": context_instances, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(serialized_context.encode("utf-8")).hexdigest() + + def analyze_workflow_models( workflow_json: Dict[str, Any], available_models: Optional[List[Dict[str, Any]]] = None, progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None, + *, + previous_node_cache: Optional[ + Dict[tuple[str, str, str], Dict[str, Any]] + ] = None, + node_cache_out: Optional[ + Dict[tuple[str, str, str], Dict[str, Any]] + ] = None, + analysis_stats: Optional[Dict[str, int]] = None, ) -> List[Dict[str, Any]]: """ Extract all model references from a workflow, including nested subgraphs. @@ -2253,6 +2405,8 @@ def analyze_workflow_models( Each dict includes 'subgraph_id' if the model is in a subgraph """ all_model_refs = [] + reused_nodes = 0 + analyzed_nodes = 0 # Get subgraph definitions first to check if node types are subgraph UUIDs definitions = workflow_json.get("definitions", {}) @@ -2281,7 +2435,12 @@ def analyze_workflow_models( ) processed_nodes = 0 - def report_node_progress(node: Dict[str, Any], subgraph_name: Optional[str] = None) -> None: + def report_node_progress( + node: Dict[str, Any], + subgraph_name: Optional[str] = None, + *, + reused: bool = False, + ) -> None: nonlocal processed_nodes processed_nodes += 1 if not progress_callback: @@ -2296,10 +2455,16 @@ def analyze_workflow_models( "node_id": node.get("id"), "node_type": node_type, } + if reused: + payload["message"] = ( + f"Reusing workflow node {processed_nodes} of {total_nodes}" + ) + payload["cached"] = True if subgraph_name: payload["subgraph_name"] = subgraph_name payload["message"] = ( - f"Analyzing subgraph node {processed_nodes} of {total_nodes}" + f"{'Reusing' if reused else 'Analyzing'} subgraph node " + f"{processed_nodes} of {total_nodes}" ) try: @@ -2309,8 +2474,29 @@ def analyze_workflow_models( for node in nodes: try: - report_node_progress(node) - model_refs = get_node_model_info(node, available_models=available_models) + node_cache_key = _get_workflow_node_cache_key( + node, + is_top_level=True, + ) + node_fingerprint = _get_workflow_node_fingerprint(node) + cached_node = ( + previous_node_cache.get(node_cache_key) + if previous_node_cache is not None + else None + ) + reused = bool( + cached_node + and cached_node.get("fingerprint") == node_fingerprint + ) + if reused: + model_refs = cached_node.get("refs", []) + reused_nodes += 1 + else: + model_refs = get_node_model_info( + node, + available_models=available_models, + ) + analyzed_nodes += 1 node_type = node.get("type", "") # Check if node type is a subgraph UUID @@ -2323,18 +2509,25 @@ def analyze_workflow_models( # Mark with subgraph info if it's a subgraph node # For top-level subgraph instance nodes, subgraph_path is None # This distinguishes them from nodes within subgraph definitions - for ref in model_refs: - ref["subgraph_id"] = subgraph_id - ref["subgraph_name"] = subgraph_name - ref["subgraph_path"] = None # Top-level, not in definitions.subgraphs - ref["is_top_level"] = True # Flag to indicate this is a top-level node - if subgraph_id: - context = promoted_widget_contexts.get( - "instance_widgets", {} - ).get((str(node.get("id")), ref.get("widget_index"))) - _apply_instance_promoted_widget_context( - ref, context, available_models - ) + if not reused: + for ref in model_refs: + ref["subgraph_id"] = subgraph_id + ref["subgraph_name"] = subgraph_name + ref["subgraph_path"] = None + ref["is_top_level"] = True + if subgraph_id: + context = promoted_widget_contexts.get( + "instance_widgets", {} + ).get((str(node.get("id")), ref.get("widget_index"))) + _apply_instance_promoted_widget_context( + ref, context, available_models + ) + if node_cache_out is not None: + node_cache_out[node_cache_key] = { + "fingerprint": node_fingerprint, + "refs": model_refs, + } + report_node_progress(node, reused=reused) all_model_refs.extend(model_refs) except Exception as e: log.warning(f"Error analyzing node {node.get('id', 'unknown')}: {e}") @@ -2350,26 +2543,59 @@ def analyze_workflow_models( if not isinstance(subgraph_nodes, list): subgraph_nodes = [] - log.debug( - f"Analyzing subgraph: {subgraph_name} (ID: {subgraph_id}) with {len(subgraph_nodes)} nodes" - ) + analyzed_subgraph_nodes = 0 for node in subgraph_nodes: try: - report_node_progress(node, subgraph_name) - model_refs = get_node_model_info(node, available_models=available_models) - # Mark as belonging to this subgraph definition - for ref in model_refs: - ref["subgraph_id"] = subgraph_id - ref["subgraph_name"] = subgraph_name - ref["subgraph_path"] = [ - "definitions", - "subgraphs", - subgraph_id, - "nodes", - ] - ref["is_top_level"] = False # This is inside a subgraph definition - _apply_promoted_widget_locator(ref, promoted_widget_contexts) + node_cache_key = _get_workflow_node_cache_key( + node, + subgraph_id=subgraph_id, + is_top_level=False, + ) + node_fingerprint = _get_workflow_node_fingerprint(node) + cached_node = ( + previous_node_cache.get(node_cache_key) + if previous_node_cache is not None + else None + ) + reused = bool( + cached_node + and cached_node.get("fingerprint") == node_fingerprint + ) + if reused: + model_refs = cached_node.get("refs", []) + reused_nodes += 1 + else: + model_refs = get_node_model_info( + node, + available_models=available_models, + ) + analyzed_nodes += 1 + analyzed_subgraph_nodes += 1 + for ref in model_refs: + ref["subgraph_id"] = subgraph_id + ref["subgraph_name"] = subgraph_name + ref["subgraph_path"] = [ + "definitions", + "subgraphs", + subgraph_id, + "nodes", + ] + ref["is_top_level"] = False + _apply_promoted_widget_locator( + ref, + promoted_widget_contexts, + ) + if node_cache_out is not None: + node_cache_out[node_cache_key] = { + "fingerprint": node_fingerprint, + "refs": model_refs, + } + report_node_progress( + node, + subgraph_name, + reused=reused, + ) all_model_refs.extend(model_refs) except Exception as e: log.warning( @@ -2377,29 +2603,63 @@ def analyze_workflow_models( ) continue + if analyzed_subgraph_nodes: + log.debug( + f"Analyzing subgraph: {subgraph_name} (ID: {subgraph_id}) " + f"with {analyzed_subgraph_nodes} changed nodes" + ) + + if analysis_stats is not None: + analysis_stats.update( + { + "total_nodes": reused_nodes + analyzed_nodes, + "reused_nodes": reused_nodes, + "analyzed_nodes": analyzed_nodes, + } + ) + + if previous_node_cache is not None: + log.debug( + f"Incremental workflow analysis: analyzed {analyzed_nodes} nodes, " + f"reused {reused_nodes} nodes" + ) + return all_model_refs +def _build_workflow_node_cache( + workflow_json: Dict[str, Any], + model_refs: List[Dict[str, Any]], +) -> Dict[tuple[str, str, str], Dict[str, Any]]: + fingerprints = _get_workflow_node_fingerprints(workflow_json) + refs_by_node: Dict[tuple[str, str, str], List[Dict[str, Any]]] = { + key: [] for key in fingerprints + } + + for ref in model_refs: + is_top_level = ref.get("is_top_level") is not False + key = ( + "top" if is_top_level else "subgraph", + "" if is_top_level else str(ref.get("subgraph_id") or ""), + str(ref.get("node_id")), + ) + if key in refs_by_node: + refs_by_node[key].append(ref) + + return { + key: { + "fingerprint": fingerprint, + "refs": refs_by_node.get(key, []), + } + for key, fingerprint in fingerprints.items() + } + + def _get_workflow_model_inventory_cache_key( workflow_json: Dict[str, Any], ) -> str: - def normalize_for_analysis(value: Any) -> Any: - if isinstance(value, dict): - is_node = "id" in value and "type" in value - return { - key: normalize_for_analysis(item) - for key, item in value.items() - if not ( - is_node - and key in _WORKFLOW_MODEL_INVENTORY_IGNORED_NODE_KEYS - ) - } - if isinstance(value, list): - return [normalize_for_analysis(item) for item in value] - return value - serialized_workflow = json.dumps( - normalize_for_analysis(workflow_json), + _normalize_workflow_analysis_value(workflow_json), ensure_ascii=False, sort_keys=True, separators=(",", ":"), @@ -2429,41 +2689,89 @@ def get_workflow_model_inventory( from .scanner import get_model_files cache_key = _get_workflow_model_inventory_cache_key(workflow_json) + promoted_context_key = _get_promoted_widget_context_cache_key(workflow_json) + current_node_fingerprints = _get_workflow_node_fingerprints(workflow_json) now = time.monotonic() + cached = None + previous_inventory = None if force_rescan: invalidate_workflow_model_inventory_cache() else: with _WORKFLOW_MODEL_INVENTORY_CACHE_LOCK: + expired_keys = [ + key + for key, entry in _WORKFLOW_MODEL_INVENTORY_CACHE.items() + if now - entry["created_at"] + >= _WORKFLOW_MODEL_INVENTORY_CACHE_TTL_SECONDS + ] + for key in expired_keys: + _WORKFLOW_MODEL_INVENTORY_CACHE.pop(key, None) + cached = _WORKFLOW_MODEL_INVENTORY_CACHE.get(cache_key) if cached is not None: - age = now - cached["created_at"] - if age < _WORKFLOW_MODEL_INVENTORY_CACHE_TTL_SECONDS: - _WORKFLOW_MODEL_INVENTORY_CACHE.move_to_end(cache_key) - model_refs = cached["model_refs"] - if progress_callback: - progress_callback( - { - "stage": "analyzing", - "message": "Reusing shared workflow analysis...", - "current": len(model_refs), - "total": len(model_refs), - "cached": True, - } - ) - log.debug("Reusing shared workflow model analysis") - return { - "available_models": cached["available_models"], - "model_refs": model_refs, - } - _WORKFLOW_MODEL_INVENTORY_CACHE.pop(cache_key, None) + _WORKFLOW_MODEL_INVENTORY_CACHE.move_to_end(cache_key) + else: + best_score = 0 + for _, entry in reversed( + list(_WORKFLOW_MODEL_INVENTORY_CACHE.items()) + ): + if entry.get("promoted_context_key") != promoted_context_key: + continue + node_cache = entry.get("node_cache", {}) + score = sum( + 1 + for key, fingerprint in current_node_fingerprints.items() + if node_cache.get(key, {}).get("fingerprint") + == fingerprint + ) + if score > best_score: + best_score = score + previous_inventory = entry - available_models = get_model_files(force_rescan=force_rescan) - model_refs = analyze_workflow_models( - workflow_json, - available_models=available_models, - progress_callback=progress_callback, - ) + if cached is not None: + model_refs = cached["model_refs"] + if progress_callback: + progress_callback( + { + "stage": "analyzing", + "message": "Reusing shared workflow analysis...", + "current": len(cached.get("node_cache", {})), + "total": len(cached.get("node_cache", {})), + "cached": True, + } + ) + log.debug("Reusing shared workflow model analysis") + return { + "available_models": cached["available_models"], + "model_refs": model_refs, + } + + node_cache = {} + analysis_stats = {} + if previous_inventory is not None: + available_models = previous_inventory["available_models"] + model_refs = analyze_workflow_models( + workflow_json, + available_models=available_models, + progress_callback=progress_callback, + previous_node_cache=previous_inventory.get("node_cache", {}), + node_cache_out=node_cache, + analysis_stats=analysis_stats, + ) + else: + available_models = get_model_files(force_rescan=force_rescan) + model_refs = analyze_workflow_models( + workflow_json, + available_models=available_models, + progress_callback=progress_callback, + ) + node_cache = _build_workflow_node_cache(workflow_json, model_refs) + analysis_stats = { + "total_nodes": len(node_cache), + "reused_nodes": 0, + "analyzed_nodes": len(node_cache), + } with _WORKFLOW_MODEL_INVENTORY_CACHE_LOCK: expired_keys = [ @@ -2479,6 +2787,9 @@ def get_workflow_model_inventory( "created_at": time.monotonic(), "available_models": available_models, "model_refs": model_refs, + "node_cache": node_cache, + "promoted_context_key": promoted_context_key, + "analysis_stats": analysis_stats, } _WORKFLOW_MODEL_INVENTORY_CACHE.move_to_end(cache_key) while ( diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index bc2e125..9457fce 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -508,6 +508,7 @@ test('node context integration preserves existing menu hooks and refreshes after scheduleNodeContextMenuAnalysis: () => calls.push('refresh'), showResolvedNodeModelInResolver: () => {}, dialog: { + scheduleActiveWorkflowRefresh: reason => calls.push(`dialog-refresh:${reason}`), getCategoryDisplayName: () => 'checkpoint', showModelInfo: () => {}, openContainingFolder: () => {}, @@ -524,7 +525,75 @@ test('node context integration preserves existing menu hooks and refreshes after assert.equal(options[0].className, 'mdi mdi-link-variant mr-model-resolver-node-menu'); assert.equal(options[1].content, 'Original action'); assert.equal(node.onWidgetChanged('ckpt_name'), 'widget-result'); - assert.deepEqual(calls, ['original-menu', 'original-widget:ckpt_name', 'refresh']); + assert.deepEqual(calls, [ + 'original-menu', + 'original-widget:ckpt_name', + 'refresh', + 'dialog-refresh:node-widget-change', + ]); +}); + +test('background Loaded Models refresh keeps the current view until new data is ready', async () => { + const loadLoadedModels = eval(`(${extractMethod(tabsLoadedMethodsSource, 'loadLoadedModels')})`); + const workflow = { + nodes: [{ + id: 7, + type: 'LoraLoader', + widgets_values: ['model.safetensors', 0.75], + }], + }; + const contentElement = { + innerHTML: '
Existing loaded models
', + scrollTop: 48, + }; + let resolveFetch; + const fetchPromise = new Promise(resolve => { + resolveFetch = resolve; + }); + let progressRenderCount = 0; + let progressPollCount = 0; + const dialog = { + activeTab: 'loaded', + contentElement, + cachedLoadedModelsSignature: 'old-signature', + cachedLoadedModelsData: { loaded_models: [], total: 0 }, + syncWorkflowScopedQueue() {}, + getWorkflowSignature: () => 'new-signature', + renderLoadedModelsProgress() { + progressRenderCount += 1; + return '
Progress
'; + }, + pollLoadedModelsProgress() { + progressPollCount += 1; + return Promise.resolve(); + }, + fetchJson: () => fetchPromise, + saveLoadedModelsCacheForActiveWorkflow() {}, + displayLoadedModels(container, data) { + container.innerHTML = `
${data.loaded_models[0].strength}
`; + container.scrollTop = 0; + }, + }; + + const refreshPromise = loadLoadedModels.call( + dialog, + workflow, + { preserveContent: true } + ); + await Promise.resolve(); + + assert.equal(contentElement.innerHTML, '
Existing loaded models
'); + assert.equal(progressRenderCount, 0); + assert.equal(progressPollCount, 0); + + resolveFetch({ + loaded_models: [{ name: 'model.safetensors', strength: 0.75 }], + total: 1, + }); + await refreshPromise; + + assert.equal(contentElement.innerHTML, '
0.75
'); + assert.equal(contentElement.scrollTop, 48); }); test('pending node context model resolves against the current workflow analysis before action', async () => { diff --git a/tests/test_workflow_analyzer.py b/tests/test_workflow_analyzer.py index 1709009..fe397fb 100644 --- a/tests/test_workflow_analyzer.py +++ b/tests/test_workflow_analyzer.py @@ -1305,6 +1305,141 @@ class WorkflowModelInventoryCacheTests(unittest.TestCase): self.assertEqual(2, analyze_models.call_count) + def test_only_changed_loader_is_reanalyzed(self): + first_workflow = { + "nodes": [ + _workflow_with_model("first.safetensors")["nodes"][0], + { + **_workflow_with_model("unchanged.safetensors")["nodes"][0], + "id": 2, + }, + ] + } + second_workflow = { + "nodes": [ + _workflow_with_model("second.safetensors")["nodes"][0], + { + **_workflow_with_model("unchanged.safetensors")["nodes"][0], + "id": 2, + }, + ] + } + + with ( + patch( + "core.scanner.get_model_files", + return_value=[], + ), + patch.object( + workflow_analyzer, + "get_node_model_info", + wraps=workflow_analyzer.get_node_model_info, + ) as get_node_info, + ): + get_workflow_model_inventory(first_workflow) + result = get_workflow_model_inventory(second_workflow) + + self.assertEqual(3, get_node_info.call_count) + self.assertEqual( + {"second.safetensors", "unchanged.safetensors"}, + {ref["original_path"] for ref in result["model_refs"]}, + ) + + def test_new_loader_does_not_reanalyze_existing_loader(self): + first_workflow = _workflow_with_model("existing.safetensors") + second_workflow = { + "nodes": [ + first_workflow["nodes"][0], + { + **_workflow_with_model("new.safetensors")["nodes"][0], + "id": 2, + }, + ] + } + + with ( + patch( + "core.scanner.get_model_files", + return_value=[], + ), + patch.object( + workflow_analyzer, + "get_node_model_info", + wraps=workflow_analyzer.get_node_model_info, + ) as get_node_info, + ): + get_workflow_model_inventory(first_workflow) + get_workflow_model_inventory(second_workflow) + + self.assertEqual(2, get_node_info.call_count) + + def test_promoted_widget_context_change_uses_full_analysis(self): + def build_workflow(promoted_value): + return { + "nodes": [ + { + "id": 1, + "type": "subgraph-1", + "widgets_values": [promoted_value], + "properties": { + "proxyWidgets": [["10", "ckpt_name"]], + }, + "inputs": [], + "outputs": [], + }, + { + **_workflow_with_model("top.safetensors")["nodes"][0], + "id": 2, + }, + ], + "definitions": { + "subgraphs": [ + { + "id": "subgraph-1", + "name": "Promoted loader", + "inputs": [], + "nodes": [ + { + **_workflow_with_model( + "inner.safetensors" + )["nodes"][0], + "id": 10, + "inputs": [ + { + "name": "ckpt_name", + "widget": { + "name": "ckpt_name", + }, + "link": None, + } + ], + } + ], + } + ] + }, + } + + with ( + patch( + "core.scanner.get_model_files", + return_value=[], + ), + patch.object( + workflow_analyzer, + "get_node_model_info", + wraps=workflow_analyzer.get_node_model_info, + ) as get_node_info, + ): + get_workflow_model_inventory( + build_workflow("first.safetensors") + ) + get_workflow_model_inventory( + build_workflow("second.safetensors") + ) + + self.assertEqual(6, get_node_info.call_count) + def test_scanner_invalidation_clears_shared_inventory(self): workflow = _workflow_with_model("invalidated.safetensors") diff --git a/web/resolver/model_resolver.js b/web/resolver/model_resolver.js index 6576450..4c215ca 100644 --- a/web/resolver/model_resolver.js +++ b/web/resolver/model_resolver.js @@ -522,6 +522,7 @@ export class ModelResolver { nodeType.prototype.onWidgetChanged = function() { const result = originalOnWidgetChanged?.apply(this, arguments); owner.scheduleNodeContextMenuAnalysis(); + owner.dialog?.scheduleActiveWorkflowRefresh?.('node-widget-change'); return result; }; nodeType.prototype.__modelResolverContextMenuPatched = true; diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index a7a94ce..7a9719e 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -807,7 +807,9 @@ export const workflowStateMethods = { await this.loadWorkflowData(workflow); } else if (this.activeTab === 'loaded') { if (this.contentElement) this.contentElement.style.overflowY = 'auto'; - await this.loadLoadedModels(workflow); + await this.loadLoadedModels(workflow, { + preserveContent: !routeChanged && reason === 'node-widget-change' + }); } } }; diff --git a/web/resolver/views/tabs_loaded_methods.js b/web/resolver/views/tabs_loaded_methods.js index f1c4bfa..53302b8 100644 --- a/web/resolver/views/tabs_loaded_methods.js +++ b/web/resolver/views/tabs_loaded_methods.js @@ -76,12 +76,18 @@ export const tabsLoadedMethods = { return null; }, - async loadLoadedModels(workflow = null, { force = false } = {}) { + async loadLoadedModels( + workflow = null, + { force = false, preserveContent = false } = {} + ) { if (!this.contentElement) return; this._loadedModelsLoadToken = null; this._loadedModelsProgressToken = null; let loadToken = null; + const preservedScrollTop = preserveContent + ? this.contentElement.scrollTop + : null; const shouldRenderLoadedModels = () => ( this.activeTab === 'loaded' && loadToken && @@ -119,7 +125,7 @@ export const tabsLoadedMethods = { const loadedProgressId = `loaded-progress-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; this._loadedModelsProgressToken = loadedProgressId; - if (shouldRenderLoadedModels()) { + if (!preserveContent && shouldRenderLoadedModels()) { this.contentElement.innerHTML = this.renderLoadedModelsProgress({ status: 'starting', stage: 'starting', @@ -130,7 +136,9 @@ export const tabsLoadedMethods = { }); } - const progressPromise = this.pollLoadedModelsProgress(loadedProgressId, loadedProgressId); + const progressPromise = preserveContent + ? Promise.resolve() + : this.pollLoadedModelsProgress(loadedProgressId, loadedProgressId); let data; try { data = await this.fetchJson('/model_resolver/loaded', { @@ -150,13 +158,21 @@ export const tabsLoadedMethods = { } if (shouldRenderLoadedModels()) { this.displayLoadedModels(this.contentElement, data); + if (preservedScrollTop !== null) { + this.contentElement.scrollTop = preservedScrollTop; + } } } catch (error) { console.error('Model Resolver: Error loading loaded models:', error); - if (shouldRenderLoadedModels()) { + if (!preserveContent && shouldRenderLoadedModels()) { this.contentElement.innerHTML = `

Error: ${error.message}

`; - } else if (!loadToken && this.activeTab === 'loaded' && this.contentElement) { + } else if ( + !preserveContent && + !loadToken && + this.activeTab === 'loaded' && + this.contentElement + ) { this.contentElement.innerHTML = `

Error: ${error.message}

`; } }