feat: incremental workflow analysis via node fingerprint cache and preserved loaded models refresh on widget changes
This commit is contained in:
+386
-75
@@ -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 (
|
||||
|
||||
@@ -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: '<div>Existing loaded models</div>',
|
||||
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 '<div>Progress</div>';
|
||||
},
|
||||
pollLoadedModelsProgress() {
|
||||
progressPollCount += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
fetchJson: () => fetchPromise,
|
||||
saveLoadedModelsCacheForActiveWorkflow() {},
|
||||
displayLoadedModels(container, data) {
|
||||
container.innerHTML = `<div>${data.loaded_models[0].strength}</div>`;
|
||||
container.scrollTop = 0;
|
||||
},
|
||||
};
|
||||
|
||||
const refreshPromise = loadLoadedModels.call(
|
||||
dialog,
|
||||
workflow,
|
||||
{ preserveContent: true }
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(contentElement.innerHTML, '<div>Existing loaded models</div>');
|
||||
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, '<div>0.75</div>');
|
||||
assert.equal(contentElement.scrollTop, 48);
|
||||
});
|
||||
|
||||
test('pending node context model resolves against the current workflow analysis before action', async () => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 = `<p class="mr-error-text">Error: ${error.message}</p>`;
|
||||
} else if (!loadToken && this.activeTab === 'loaded' && this.contentElement) {
|
||||
} else if (
|
||||
!preserveContent &&
|
||||
!loadToken &&
|
||||
this.activeTab === 'loaded' &&
|
||||
this.contentElement
|
||||
) {
|
||||
this.contentElement.innerHTML = `<p class="mr-error-text">Error: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user