File size: 20,755 Bytes
289daab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
import { app } from "../../scripts/app.js";

// Global cache for hashes: filename -> hash
window.anomalous_hash_cache = window.anomalous_hash_cache || {};

// Function to update cache, can be called from main.js
window.anomalous_update_hash_cache = function (models) {
    if (!models) return;
    for (const m of models) {
        if (m.filename && m.metadata && m.metadata.hash) {
            window.anomalous_hash_cache[m.filename] = {
                hash: m.metadata.hash,
                size: m.size_bytes || ""
            };
        }
    }
};

function inferExpectedModelTypes(node, widget) {
    const widgetName = String(widget?.name || '').toLowerCase();
    const nodeType = String(node?.type || '').toLowerCase();
    const key = `${widgetName} ${nodeType}`;

    if (widgetName.includes('vae') || nodeType === 'vaeloader' || key.includes('vae loader')) return 'vae';
    if (widgetName.includes('lora') || key.includes('lora')) return 'loras';
    if (widgetName.includes('control_net') || widgetName.includes('controlnet') || key.includes('controlnet')) return 'controlnet';
    if (widgetName.includes('ckpt') || widgetName.includes('checkpoint') || key.includes('checkpoint')) return 'checkpoints';
    if (widgetName.includes('unet') || widgetName.includes('diffusion_model') || key.includes('unet loader')) return 'diffusion_models,unet';
    return '';
}

window.anomalous_resolve_all_missing_nodes = async function (is_manual = false, silent = false) {
    if (!is_manual && localStorage.getItem('anomalous_auto_scan_enabled') !== 'true') return;
    if (!app.graph || !app.graph._nodes) return;

    // Fast path: if there are no missing nodes and it's not a manual check, abort early to save performance
    let has_missing = false;
    for (const node of app.graph._nodes) {
        if (node.color === "#FF3333" || node.bgcolor === "#FF3333" || node.color === "#f66" || (node.flags && node.flags.collapsed && node.color === "#FF3333")) {
            has_missing = true;
            break;
        }
        if (node.widgets) {
            for (let i = 0; i < node.widgets.length; i++) {
                const w = node.widgets[i];
                if (typeof w.value === 'string' && (w.value.endsWith('.safetensors') || w.value.endsWith('.ckpt') || w.value.endsWith('.pt'))) {
                    if (w.options && w.options.values && !w.options.values.includes(w.value)) {
                        has_missing = true;
                        break;
                    }
                }
            }
        }
        if (has_missing) break;
    }

    if (!has_missing && !is_manual) return;

    // Refresh ComfyUI's frontend folder paths cache to prevent false-positive red nodes
    if (app.refreshComboInNodes) {
        try {
            await app.refreshComboInNodes();
        } catch (e) {
            console.warn("[Anomalous Hash Resolver] Failed to refresh ComfyUI node combos", e);
        }
    }

    const hashes = (app.graph.extra && app.graph.extra.anomalous_hashes) || {};
    let needsGlobalHashRefresh = false;
    provenanceCheck:
    for (const node of app.graph._nodes) {
        for (const widget of (node.widgets || [])) {
            const value = widget.value;
            if (typeof value !== 'string' || !(value.endsWith('.safetensors') || value.endsWith('.ckpt') || value.endsWith('.pt'))) continue;
            if (!hashes[`${node.id}_${value}`]) {
                needsGlobalHashRefresh = true;
                break provenanceCheck;
            }
        }
    }

    // Provenance-rich workflows already carry the identity data needed by the
    // doctor. Refresh the full local filename->hash cache only for legacy items
    // that actually need that fallback.
    if (needsGlobalHashRefresh) {
        try {
            const res = await fetch('/anomalous/all_hashes?t=' + Date.now());
            const data = await res.json();
            const hashesObj = data.hashes ? data.hashes : data;
            Object.assign(window.anomalous_hash_cache, hashesObj);
        } catch (e) {
            console.warn("[Anomalous] Failed to fetch hashes for manual resolution", e);
        }
    }

    let fixed_count = 0;

    const findHashData = (node, widget, value) => {
        let hashData = hashes[`${node.id}_${value}`];
        if (!hashData && window.anomalous_hash_cache) {
            const parts = value.split(/[/\\]/);
            const basename = parts[parts.length - 1];
            const cacheData = window.anomalous_hash_cache[value] || window.anomalous_hash_cache[basename];
            if (cacheData) hashData = typeof cacheData === 'string' ? { hash: cacheData, size: "" } : cacheData;
        }
        return hashData;
    };

    // Resolve all model references in one request. The backend groups items by
    // required model category and scans each category once. Native slash-only
    // normalization remains in the existing loop below and needs no disk scan.
    const batchItems = [];
    for (const node of app.graph._nodes) {
        if (!node.widgets) continue;
        for (let i = 0; i < node.widgets.length; i++) {
            const widget = node.widgets[i];
            const value = widget.value;
            if (typeof value !== 'string' || !(value.endsWith('.safetensors') || value.endsWith('.ckpt') || value.endsWith('.pt'))) continue;
            if (widget.options && widget.options.values && !widget.options.values.includes(value)) {
                const normalized = value.replace(/\\/g, '/');
                const nativeMatch = widget.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normalized);
                if (nativeMatch && nativeMatch !== value) continue;
            }
            const hashData = findHashData(node, widget, value);
            if (!hashData) continue;
            const hash = typeof hashData === 'string' ? hashData : (hashData.hash || "");
            const size = typeof hashData === 'string' ? "" : (hashData.size || "");
            if (!hash && !size) continue;
            batchItems.push({
                key: `${node.id}:${i}:${value}`,
                hash,
                size,
                type: inferExpectedModelTypes(node, widget)
            });
        }
    }

    const batchResults = new Map();
    if (batchItems.length) {
        try {
            for (let offset = 0; offset < batchItems.length; offset += 256) {
                const batchResponse = await fetch('/anomalous/resolve_hash_batch', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ items: batchItems.slice(offset, offset + 256) })
                });
                if (!batchResponse.ok) throw new Error(`HTTP ${batchResponse.status}`);
                const batchData = await batchResponse.json();
                for (const entry of (batchData.results || [])) batchResults.set(entry.key, entry.result);
            }
        } catch (error) {
            console.warn('[Anomalous Hash Resolver] Batch resolution failed; falling back to single-item requests.', error);
        }
    }

    for (const node of app.graph._nodes) {
        if (node.widgets) {
            for (let i = 0; i < node.widgets.length; i++) {
                const w = node.widgets[i];
                const val = w.value;
                if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {

                    // Native slash mismatch fix: if val isn't in options, but matches if we normalize slashes
                    if (w.options && w.options.values && !w.options.values.includes(val)) {
                        const normVal = val.replace(/\\/g, '/');
                        const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normVal);
                        if (exactMatch && exactMatch !== val) {
                            console.log(`[Anomalous Hash Resolver] Fixed slash mismatch: ${val} -> ${exactMatch}`);
                            w.value = exactMatch;
                            const wIdx = node.widgets.indexOf(w);
                            if (wIdx !== -1 && node.widgets_values) {
                                node.widgets_values[wIdx] = exactMatch;
                            }
                            delete node.color;
                            delete node.bgcolor;
                            node.has_errors = false;
                            
                            if (app.lastNodeErrors && app.lastNodeErrors[node.id]) {
                                delete app.lastNodeErrors[node.id];
                            }
                            
                            if (w.callback) {
                                w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
                            }
                            app.graph.setDirtyCanvas(true, true);
                            fixed_count++; // Mark as fixed so the global graph update is triggered!
                            continue; // Already fixed natively, skip backend lookup
                        }
                    }

                    const hashData = findHashData(node, w, val);

                    if (hashData) {
                        let h = typeof hashData === 'string' ? hashData : hashData.hash;
                        let s = typeof hashData === 'string' ? "" : (hashData.size || "");
                        try {
                            const expectedTypes = inferExpectedModelTypes(node, w);
                            const typeQuery = expectedTypes ? `&type=${encodeURIComponent(expectedTypes)}` : '';
                            const batchKey = `${node.id}:${i}:${val}`;
                            let resData = batchResults.get(batchKey);
                            if (!batchResults.has(batchKey)) {
                                const res = await fetch(`/anomalous/resolve_hash?hash=${encodeURIComponent(h)}&size=${encodeURIComponent(s)}${typeQuery}`);
                                resData = await res.json();
                            }

                            if (resData.found) {
                                const normVal = val.replace(/\\/g, '/');
                                const normRes = resData.filename.replace(/\\/g, '/');

                                let finalValue = resData.filename;
                                let optionsCacheStale = false;
                                if (w.options && w.options.values) {
                                    const exactMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes);
                                    if (exactMatch) {
                                        finalValue = exactMatch;
                                    } else {
                                        optionsCacheStale = true;
                                    }
                                }

                                if (finalValue !== val || normRes !== normVal || optionsCacheStale || node.has_errors || node.color) {
                                    if (optionsCacheStale) {
                                        console.log(`[Anomalous Hash Resolver] Found ${finalValue} on disk, but ComfyUI frontend dropdown is stale. Forcing backend cache clear...`);
                                        let refreshedMatch = null;
                                        try {
                                            await fetch('/anomalous/clear_cache', { method: 'POST' });
                                            await app.refreshComboInNodes();
                                            // Re-evaluate exact match after refreshing
                                            if (w.options && w.options.values) {
                                                refreshedMatch = w.options.values.find(v => typeof v === 'string' && v.replace(/\\/g, '/') === normRes) || null;
                                            }
                                        } catch (e) {
                                            console.warn("Failed to clear cache:", e);
                                        }
                                        if (!refreshedMatch) {
                                            console.warn(`[Anomalous Hash Resolver] Rejected cross-type or invalid model path for ${node.type}.${w.name}: ${finalValue}`);
                                            continue;
                                        }
                                        finalValue = refreshedMatch;
                                    }

                                    console.log(`[Anomalous Hash Resolver] Auto-fixed missing model: ${val} -> ${finalValue}`);
                                    w.value = finalValue;
                                    const wIdx = node.widgets.indexOf(w);
                                    if (wIdx !== -1 && node.widgets_values) {
                                        node.widgets_values[wIdx] = finalValue;
                                    }
                                    delete node.color;
                                    delete node.bgcolor;
                                    node.has_errors = false;
                                    node.anomalous_auto_resolved = true;
                                    node.anomalous_original_missing_val = val;
                                    if (w.callback) {
                                        w.callback(w.value, app.canvas, node, app.canvas.graph_mouse, null);
                                    }
                                    app.graph.setDirtyCanvas(true, true);
                                    fixed_count++;
                                }
                            }
                        } catch (err) {
                            console.error("[Anomalous Hash Resolver] Error:", err);

                        }
                    }
                }
            }
        }
    }

    if (fixed_count > 0) {
        // Force ComfyUI v1 side panels (Workflow Overview/Parameters) to re-evaluate and clear errors
        if (app.graph && app.graph.change) app.graph.change();
        try {
            window.dispatchEvent(new CustomEvent("graphChanged"));
        } catch (e) { }

        // Deep clear ComfyUI native error caches
        if (app.lastNodeErrors) {
            Object.keys(app.lastNodeErrors).forEach(key => delete app.lastNodeErrors[key]);
        }
        if (typeof app.clearErrors === 'function') app.clearErrors();

        // Note: We removed the aggressive "ghost clicker" that automatically clicked the Refresh button in the Vue side panel.
        // It was too intrusive and could cause focus issues. Instead, we now gently remind the user via an alert.
        // 注:我们移除了主动点击 Vue 侧边栏刷新按钮的“幽灵连点器”,因为它侵入性过强且容易引发焦点问题。改为在弹窗中进行善意提醒。
    }

    if (is_manual && !silent) {
        // Detect current UI language (Default to 'zh' if not set)
        // 获取当前界面语言(未设置时默认使用中文)
        const lang = (window.anomalous_browser_lang === 'en') ? 'en' : 'zh';

        if (fixed_count > 0) {
            if (lang === 'en') {
                alert(`🪄 Anomalous successfully fixed ${fixed_count} missing model(s)!\n\n💡 Tip: ComfyUI V1 caches errors heavily. Please manually click the [Refresh] button in the "Workflow Overview" side panel, or refresh your browser (F5) to prevent them from turning red again on workflow switch.`);
            } else {
                alert(`🪄 Anomalous 成功修复了 ${fixed_count} 个缺失的模型!\n\n💡 提示:ComfyUI V1 对报错的缓存极深。请务必手动点击侧边栏【工作流总览】中的【刷新】按钮,或者直接按 F5 刷新浏览器,否则切换工作流时节点可能会再次变红。`);
            }
        }
    }
};



app.registerExtension({
    name: "Anomalous.ModelBrowser.HashResolver",

    async setup() {
        // Expose global reload function so scans can trigger it
        window.anomalous_reload_hashes = async function () {
            try {
                const resp = await fetch('/anomalous/all_hashes');
                const data = await resp.json();
                window.anomalous_hash_cache = data.hashes ? data.hashes : data;
                window.anomalous_is_empty_state = Object.keys(window.anomalous_hash_cache).length === 0;
            } catch (e) {
                window.anomalous_hash_cache = {};
                window.anomalous_is_empty_state = true;
                console.warn("[Anomalous] Failed to fetch hashes", e);
            }
        };

        // Pre-fetch all hashes on startup so that dragging generated images (without opening UI) still intercepts
        await window.anomalous_reload_hashes();

        // Intercept graph serialization to inject hashes
        const origSerialize = LGraph.prototype.serialize;
        window.anomalous_has_warned_unscanned = false;
        window.anomalous_unscanned_models = [];
        LGraph.prototype.serialize = function () {
            const data = origSerialize.apply(this, arguments);

            if (localStorage.getItem('anomalous_inject_hash') === 'false') {
                return data;
            }

            // Clone extra to avoid mutating the live graph's extra object
            const extraObj = data.extra ? JSON.parse(JSON.stringify(data.extra)) : {};
            extraObj.anomalous_hashes = {};
            let unscanned_models = [];

            if (data.nodes) {
                const liveNodes = this._nodes || [];
                for (const node of data.nodes) {
                    const liveNode = liveNodes.find(n => n.id === node.id);

                    if (node.widgets_values && node.widgets_values.length > 0) {
                        for (const val of node.widgets_values) {
                            if (typeof val === 'string' && (val.endsWith('.safetensors') || val.endsWith('.ckpt') || val.endsWith('.pt'))) {
                                const parts = val.split(/[/\\]/);
                                const basename = parts[parts.length - 1];

                                let valIsMissing = false;
                                if (liveNode && liveNode.widgets) {
                                    const matchingWidget = liveNode.widgets.find(w => w.value === val && w.type === "combo");
                                    if (matchingWidget && matchingWidget.options && matchingWidget.options.values && !matchingWidget.options.values.includes(val)) {
                                        valIsMissing = true;
                                    }
                                }

                                if (!valIsMissing) {
                                    const cache_data = window.anomalous_hash_cache[val] || window.anomalous_hash_cache[basename];
                                    if (cache_data) {
                                        if (typeof cache_data === 'string') {
                                            extraObj.anomalous_hashes[`${node.id}_${val}`] = { hash: cache_data, size: "" };
                                        } else {
                                            extraObj.anomalous_hashes[`${node.id}_${val}`] = cache_data;
                                        }
                                    } else {
                                        if (!unscanned_models.includes(basename)) {
                                            unscanned_models.push(basename);
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
            data.extra = extraObj;

            data.extra = extraObj;
            window.anomalous_unscanned_models = unscanned_models;
            return data;
        };



        // Intercept loadGraphData to resolve missing models
        const origLoadGraphData = app.loadGraphData;
        app.loadGraphData = function (graphData) {
            // Proceed with original loadGraphData synchronously first
            const ret = origLoadGraphData.apply(this, arguments);

            return ret;
        };
    }
});