fix(materials, doctor, scan): polymorphic material drag, third-party prompt nodes, model resolution, and scan wizard improvements
This commit is contained in:
@@ -83,6 +83,7 @@ tests/*
|
||||
!tests/test_export_availability.py
|
||||
!tests/test_route_manifest.py
|
||||
!tests/test_model_source_catalog.py
|
||||
!tests/test_model_doctor_resolution.py
|
||||
!tests/model_sources_hub.mjs
|
||||
!tests/model_source_components.mjs
|
||||
!tests/shortcut_layout.mjs
|
||||
|
||||
+2
-2
@@ -94,10 +94,10 @@ DOM or live LiteGraph state.
|
||||
`ui_settings_hub.js` owns settings and model-card preferences;
|
||||
`ui_toolbox.js` owns the tool catalog, fixed shortcut bar, and tool dispatch;
|
||||
`ui_browser_navigation.js` owns shared panel hiding/cleanup and workspace return.
|
||||
Scan-wizard launch, single-model precision scans (`triggerDirectModelScan` with strictly factual Civitai vs non-Civitai feedback reporting inferred base-model or match status directly within the bottom-right progress panel and toasts without blocking browser alerts), and polling live in `ui_scan_wizard.js`; folder visibility/order lives in
|
||||
Scan-wizard launch, single-model precision scans (`triggerDirectModelScan` with strictly factual Civitai vs non-Civitai feedback reporting inferred base-model or match status directly within the bottom-right progress panel and toasts without blocking browser alerts), modal lifecycle ergonomics (backdrop click and Escape key dismissal with listener detachment, scrollable content area with sticky footer actions), post-scan frontend hash and native combo refreshes (`app.refreshComboInNodes()`, `window.anomalous_reload_hashes()`), and polling live in `ui_scan_wizard.js`; folder visibility/order lives in
|
||||
`ui_folder_manager.js`; and help content lives in `ui_help.js`.
|
||||
- `ui_model_sources.js` owns the Model Sources Hub, managing workflow-model and global-library source detection, Civitai/HuggingFace URL attribution, sidecar persistence, and resilient scope switching between active workflow and full local library (with cached library state preservation and reliable re-rendering).
|
||||
- `ui_materials.js`, `ui_material_cards.js`, and `ui_material_application.js` own the Material Library UI, category navigation, card presentation (with grab cursor affordances and explicit drag tooltips), context-aware drag guidance, and the structured empty state onboarding blueprint guiding users through collection, canvas drag, and prompt studio mixing.
|
||||
- `ui_materials.js`, `ui_material_cards.js`, and `ui_material_application.js` own the Material Library UI, category navigation, card presentation (with grab cursor affordances, explicit drag tooltips, and polymorphic card dragging via `bindPolymorphicMaterialCardDrag`), context-aware drag guidance, relaxed third-party node prompt widget sniffing and injection, and the structured empty state onboarding blueprint guiding users through collection, canvas drag, and prompt studio mixing. Drag precedence prioritizes node hits over blank canvas drops; blank canvas drops auto-instantiate `CLIPTextEncode` nodes for prompt materials with standard colors or open full workflows.
|
||||
- `ui_update_guide.js` and `update_guide_data.js` own the non-intrusive update guide modal (accessible via header button `#anomalous-update-notice-btn` and Help modal; version ID `2026-09-recipes-and-studios`), presenting a 4-step milestone walkthrough (Workflow Recipe Studio, Material Library & Prompt Studio, Model Sources Hub, and Precision Direct Scan with canvas addition) with full bilingual localization. `ui_spotlight_tour.js` provides the interactive spotlight mask tour (`startSpotlightTour`), gliding smooth focal box highlights across topbar workspaces and bottom dock actions with directional tooltip cards and keyboard navigation.
|
||||
- `sidebar_actions.js` owns the sidebar bottom action hover-reveal short labels (100ms), singleton dynamic DOM tooltip bubbles (`#anomalous-sidebar-tooltip-bubble`, 600ms), click/pointerdown instant text/tooltip suppression guards, `isBottomModalOpen` tooltip occlusion guards, and anti-flicker pointer stability.
|
||||
- `tool_registry.js` centralizes metadata, SVG icons (enlarged 20px crisp vector outlines with 2px stroke, #cbd5e1 contrast), and stable IDs for the 9 catalog tools (including Prompt Notes / 提示词笔记) and 2 fixed anchors (Toolbox and Settings).
|
||||
|
||||
@@ -176,9 +176,9 @@ def _resolve_from_candidates(candidates, target_hash="", target_size=None, filen
|
||||
return {"found": False, "ambiguous": True}
|
||||
|
||||
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
|
||||
if hash_matches:
|
||||
if hash_matches or size_matches:
|
||||
return {"found": False, "identity_conflict": True}
|
||||
return {"found": False, "identity_conflict": True}
|
||||
return {"found": False}
|
||||
|
||||
if has_target_hash:
|
||||
hash_matches = [candidate for candidate in candidates if target_hash in _candidate_hashes(candidate)]
|
||||
|
||||
@@ -295,26 +295,39 @@ beginning/end buttons for a selected node, copy controls and guarded undo.
|
||||
|
||||
## Canvas drag and drop
|
||||
|
||||
`material_drag.js` binds native drag handles to parameter-bearing library cards
|
||||
and to each prompt field's drag button. Only an active, same-page drag is trusted;
|
||||
transfer data is a marker, not an external mutation command. During drag, the
|
||||
browser window becomes transparent and stops intercepting pointer events. End,
|
||||
drop, Escape and loss of focus restore it and remove temporary event listeners.
|
||||
There is no persistent drag polling or detail request during hover.
|
||||
`material_drag.js` binds native drag handles to all material cards and to each
|
||||
prompt field's drag button (`bindPolymorphicMaterialCardDrag`). Only an active,
|
||||
same-page drag is trusted; transfer data is a marker, not an external mutation
|
||||
command. During drag, the browser window becomes transparent and stops intercepting
|
||||
pointer events. End, drop, Escape and loss of focus restore it and remove temporary
|
||||
event listeners. There is no persistent drag polling or detail request during hover.
|
||||
|
||||
Hit testing uses the host canvas coordinate conversion (including pan/zoom),
|
||||
canvas bounds and the graph's live node lookup. Canvas and DOM-widget surfaces are
|
||||
accepted. The graph/canvas identities are captured at drag start and checked again
|
||||
at drop; async material application rechecks the graph/node after loading detail.
|
||||
The actual drop node is the target, regardless of the previously selected node.
|
||||
Receipts name that target and provide the shared guarded undo.
|
||||
Hit testing evaluates live node target acceptance before canvas blank surface drop.
|
||||
Because nodes sit on the canvas element, testing canvas surface first would hijack
|
||||
node drops; evaluating `accepts(node, data)` first guarantees node targeting takes
|
||||
precedence. Live node lookup uses ComfyUI canvas coordinate conversion (including pan/zoom),
|
||||
canvas bounds, and graph node hit-testing. DOM-widget surfaces are accepted. The
|
||||
graph/canvas identities are captured at drag start and checked again at drop. The actual
|
||||
drop node is the target, regardless of the previously selected node. Receipts name
|
||||
that target and provide the shared guarded undo.
|
||||
|
||||
Parameter-bearing materials fetch their scoped node blocks only after drop and
|
||||
replace compatible widget values through the same transaction as Node Assistant,
|
||||
preserving seed, node position and links. Multiple matching blocks require a
|
||||
choice, and known positive/negative prompt roles are shown on those choices. No
|
||||
full workflow is loaded. Prompt-panel drops instead insert the chosen
|
||||
role's text before or after existing node text; multiple eligible text widgets
|
||||
require choosing one. Empty/unsupported targets and canceled drags do not mutate
|
||||
the graph. Neither drop path queues generation. Actual host drag visuals and
|
||||
third-party/Vue node compatibility remain part of the deferred browser acceptance.
|
||||
When dropped onto a compatible node:
|
||||
- Parameter-bearing materials fetch scoped node blocks only after drop and replace
|
||||
compatible widget values through the transactional parameter path, preserving seed,
|
||||
node position, and links.
|
||||
- Prompt materials inject prompt text into target nodes via semantic prompt widget
|
||||
sniffing (`customtext`, `multiline`, `text_g`, `text_l`, `prompt`, `positive`,
|
||||
`negative`, and localized labels), relaxing strict node-type matching for third-party
|
||||
text nodes. Text is cleanly inserted without synthetic prefixes ("负向:" / "正向:").
|
||||
|
||||
When dropped onto blank canvas:
|
||||
- Workflow materials (`image_workflow_snapshot`) trigger full workflow loading
|
||||
via `openMaterialWorkflow`.
|
||||
- Prompt materials (prompt notes, prompt plans, prompt node selections) auto-instantiate
|
||||
native `CLIPTextEncode` nodes at canvas drop coordinates, populating the pure prompt
|
||||
text, setting bilingual titles (`CLIP Text Encode (Negative/Positive)` /
|
||||
`CLIP 文本编码器 (负向/正向)`), and applying standard LiteGraph dark theme colors
|
||||
(`#532323` dark red for negative, `#235327` dark green for positive).
|
||||
|
||||
Empty/unsupported targets and canceled drags do not mutate the graph. Neither drop path
|
||||
queues generation.
|
||||
|
||||
@@ -19,7 +19,10 @@ Without a hash, one unique in-category size match is only a candidate. Model
|
||||
Doctor may show it during an explicit manual check, but it cannot redirect the
|
||||
node until the user confirms that candidate. Confirmation applies to the current
|
||||
node only; it does not create a persistent binding. If both hash and size are
|
||||
present and point to conflicting identity, resolution is rejected.
|
||||
present and point to conflicting physical files (both match distinct local files),
|
||||
resolution reports an identity conflict and is rejected. When a requested model hash
|
||||
simply does not exist in any local file, it returns `{"found": False}` cleanly without
|
||||
falsely flagging a conflict.
|
||||
|
||||
Paths, filenames, source filenames, display/custom names, previews, workflow
|
||||
fingerprints, and fuzzy/visual similarity are never candidate evidence. They may
|
||||
|
||||
@@ -172,6 +172,18 @@ let wizardModal = f.document.getElementById('anomalous-wizard-modal');
|
||||
await f.button(wizardModal, 'Cancel').click();
|
||||
assert.equal(wizardModal.isConnected, false, 'closing the wizard does not launch a scan');
|
||||
|
||||
// Test backdrop click dismissal
|
||||
wizard.openScanWizard.call(scanOwner, { isGlobal: true });
|
||||
wizardModal = f.document.getElementById('anomalous-wizard-modal');
|
||||
wizardModal.onclick({ target: wizardModal });
|
||||
assert.equal(wizardModal.isConnected, false, 'clicking backdrop dismisses wizard');
|
||||
|
||||
// Test Escape key dismissal
|
||||
wizard.openScanWizard.call(scanOwner, { isGlobal: true });
|
||||
wizardModal = f.document.getElementById('anomalous-wizard-modal');
|
||||
f.document.dispatch('keydown', { key: 'Escape' });
|
||||
assert.equal(wizardModal.isConnected, false, 'pressing Escape dismisses wizard');
|
||||
|
||||
wizard.openScanWizard.call(scanOwner, { targetFiles: 'model.safetensors' });
|
||||
wizardModal = f.document.getElementById('anomalous-wizard-modal');
|
||||
await f.button(wizardModal, 'Execute').click();
|
||||
@@ -184,6 +196,10 @@ assert.deepEqual(f.errors, [], 'feature flows complete without user-facing error
|
||||
|
||||
// Direct single-model precision scan without opening wizard modal
|
||||
let directScanRefreshed = false;
|
||||
let hashesReloaded = false;
|
||||
let combosRefreshed = false;
|
||||
f.window.anomalous_reload_hashes = async () => { hashesReloaded = true; };
|
||||
f.app.refreshComboInNodes = async () => { combosRefreshed = true; };
|
||||
const directScanOwner = {
|
||||
currentType: 'loras',
|
||||
currentPathIdx: 0,
|
||||
@@ -214,6 +230,8 @@ assert.ok(directScanReq, 'direct scan triggers POST /anomalous/scan');
|
||||
assert.deepEqual(JSON.parse(directScanReq[1].body).target_files, ['my_lora.safetensors']);
|
||||
assert.equal(JSON.parse(directScanReq[1].body).skip_rename, true, 'direct single model scan preserves physical file name');
|
||||
assert.equal(directScanRefreshed, true, 'browser.loadModels called after scan completes');
|
||||
assert.equal(hashesReloaded, true, 'anomalous_reload_hashes called after direct scan');
|
||||
assert.equal(combosRefreshed, true, 'app.refreshComboInNodes called after direct scan');
|
||||
assert.equal(dummyBtn.classList.contains('anomalous-radar-spinning'), false, 'radar spinning class removed after completion');
|
||||
const scanProgressPanel = f.document.getElementById('anomalous-scan-progress');
|
||||
assert.ok(scanProgressPanel, 'scan progress panel displayed in DOM');
|
||||
|
||||
@@ -72,4 +72,40 @@ const event = target => ({ target, clientX: 50, clientY: 50, dataTransfer: { set
|
||||
source.listeners.dragstart(event(source)); await events.drop(event(new Element('button'))); assert.equal(drops, 0);
|
||||
source.listeners.dragstart(event(source)); context.app.graph = {}; await events.drop(event(surface)); assert.equal(drops, 0); context.app.graph = graph;
|
||||
source.listeners.dragstart(event(source)); await events.drop(event(surface)); assert.equal(drops, 1); assert.equal(events.drop, undefined);
|
||||
console.log('studio contracts: recipe scope/tags/readiness, prompt detail shapes, active material drag cleanup passed');
|
||||
|
||||
// Verify node drop takes priority over canvas drop
|
||||
let nodeDrops = 0; let canvasDrops = 0;
|
||||
const testNode = { id: 99, type: 'Any' };
|
||||
graph.getNodeOnPos = () => testNode;
|
||||
graph.getNodeById = id => id === 99 ? testNode : null;
|
||||
drags.bindMaterialDrag(source, {}, {
|
||||
payload: () => ({}),
|
||||
accepts: () => true,
|
||||
drop: () => { nodeDrops++; },
|
||||
dropOnCanvas: () => { canvasDrops++; }
|
||||
});
|
||||
source.listeners.dragstart(event(source));
|
||||
await events.drop(event(surface));
|
||||
assert.equal(nodeDrops, 1);
|
||||
assert.equal(canvasDrops, 0);
|
||||
|
||||
// Verify fallback to canvas drop when node does not accept
|
||||
nodeDrops = 0; canvasDrops = 0;
|
||||
drags.bindMaterialDrag(source, {}, {
|
||||
payload: () => ({}),
|
||||
accepts: () => false,
|
||||
drop: () => { nodeDrops++; },
|
||||
dropOnCanvas: () => { canvasDrops++; }
|
||||
});
|
||||
source.listeners.dragstart(event(source));
|
||||
await events.drop(event(surface));
|
||||
assert.equal(nodeDrops, 0);
|
||||
assert.equal(canvasDrops, 1);
|
||||
|
||||
const detail = await moduleFor('ui_material_detail.js', '');
|
||||
const negativePromptMat = { name: 'Dark scenery [negative]', tags: ['negative'], note: { promptEn: 'ugly, deformed' } };
|
||||
const promptInfo = detail.getMaterialPromptInfo(negativePromptMat);
|
||||
assert.equal(promptInfo.text, 'ugly, deformed');
|
||||
assert.equal(promptInfo.role, 'negative');
|
||||
|
||||
console.log('studio contracts: recipe scope/tags/readiness, prompt detail shapes, active material drag cleanup and node priority passed');
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import tempfile
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parents[1]
|
||||
COMFY_ROOT = PLUGIN_DIR.parents[1]
|
||||
sys.path.insert(0, str(COMFY_ROOT))
|
||||
sys.path.insert(0, str(PLUGIN_DIR))
|
||||
|
||||
from api import models
|
||||
from api import utils
|
||||
|
||||
|
||||
class ModelDoctorResolutionTests(unittest.TestCase):
|
||||
def test_default_folder_types_contains_unet(self):
|
||||
paths = utils.get_active_scan_paths()
|
||||
# In current physical mode or default abstract mode, scan paths must be non-empty
|
||||
self.assertTrue(isinstance(paths, list))
|
||||
self.assertIn("unet", models.RESOLVABLE_MODEL_TYPES)
|
||||
|
||||
def test_foundation_component_types_are_resolvable(self):
|
||||
for folder_type in ("vae", "vae_approx", "clip", "text_encoders", "clip_vision"):
|
||||
self.assertIn(folder_type, models.RESOLVABLE_MODEL_TYPES)
|
||||
|
||||
def test_dynamic_hash_resolution_for_unindexed_model(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
model_content = b"TEST_MODEL_SAFESENSOR_CONTENT_DATA_123456"
|
||||
model_size = len(model_content)
|
||||
model_hash = hashlib.sha256(model_content).hexdigest().upper()
|
||||
|
||||
model_file = os.path.join(temp_dir, "renamed_model.safetensors")
|
||||
with open(model_file, "wb") as f:
|
||||
f.write(model_content)
|
||||
|
||||
candidate = {
|
||||
"type": "unet",
|
||||
"filename": "renamed_model.safetensors",
|
||||
"path": model_file,
|
||||
"size": model_size
|
||||
}
|
||||
|
||||
# Candidates have no pre-existing hash metadata
|
||||
candidates = [candidate]
|
||||
|
||||
# Resolve using target_hash and target_size
|
||||
result = models._resolve_from_candidates(candidates, target_hash=model_hash, target_size=model_size)
|
||||
|
||||
self.assertTrue(result.get("found"))
|
||||
self.assertEqual(result.get("filename"), "renamed_model.safetensors")
|
||||
self.assertTrue(result.get("matched_by_hash"))
|
||||
self.assertTrue(result.get("matched_by_size"))
|
||||
|
||||
# Check that fallback info was created
|
||||
info_file = os.path.join(temp_dir, "renamed_model.info")
|
||||
self.assertTrue(os.path.exists(info_file))
|
||||
with open(info_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self.assertEqual(data["files"][0]["hashes"]["SHA256"], model_hash.lower())
|
||||
|
||||
def test_dynamic_hash_mismatch_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
model_content = b"ANOTHER_MODEL_CONTENT_WITH_SAME_SIZE_BYTES"
|
||||
model_size = len(model_content)
|
||||
|
||||
model_file = os.path.join(temp_dir, "different_model.safetensors")
|
||||
with open(model_file, "wb") as f:
|
||||
f.write(model_content)
|
||||
|
||||
candidate = {
|
||||
"type": "unet",
|
||||
"filename": "different_model.safetensors",
|
||||
"path": model_file,
|
||||
"size": model_size
|
||||
}
|
||||
|
||||
fake_target_hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
result = models._resolve_from_candidates([candidate], target_hash=fake_target_hash, target_size=model_size)
|
||||
|
||||
self.assertFalse(result.get("found"))
|
||||
self.assertTrue(result.get("identity_conflict"))
|
||||
|
||||
def test_absent_model_with_hash_and_size_returns_not_found_without_conflict(self):
|
||||
# When model does not exist locally (no candidates at all, or no matching size/hash)
|
||||
result = models._resolve_from_candidates([], target_hash="A" * 64, target_size=123456)
|
||||
self.assertFalse(result.get("found"))
|
||||
self.assertFalse(result.get("identity_conflict"))
|
||||
|
||||
# When candidates exist but have completely different size and different hash
|
||||
unrelated_candidate = {
|
||||
"type": "unet",
|
||||
"filename": "other.safetensors",
|
||||
"path": "/fake/other.safetensors",
|
||||
"size": 999999,
|
||||
"hashes": {"B" * 64},
|
||||
}
|
||||
result2 = models._resolve_from_candidates([unrelated_candidate], target_hash="A" * 64, target_size=123456)
|
||||
self.assertFalse(result2.get("found"))
|
||||
self.assertFalse(result2.get("identity_conflict"))
|
||||
|
||||
def test_hash_match_with_size_mismatch_returns_identity_conflict(self):
|
||||
# Candidate has matching hash but differing size -> true identity conflict
|
||||
candidate = {
|
||||
"type": "unet",
|
||||
"filename": "model.safetensors",
|
||||
"path": "/fake/model.safetensors",
|
||||
"size": 1000,
|
||||
"hashes": {"A" * 64},
|
||||
}
|
||||
result = models._resolve_from_candidates([candidate], target_hash="A" * 64, target_size=2000)
|
||||
self.assertFalse(result.get("found"))
|
||||
self.assertTrue(result.get("identity_conflict"))
|
||||
|
||||
def test_unique_size_match_requires_manual_confirmation(self):
|
||||
candidate = {
|
||||
"type": "loras",
|
||||
"filename": "renamed_lora.safetensors",
|
||||
"path": __file__,
|
||||
"size": 1234,
|
||||
}
|
||||
result = models._resolve_from_candidates([candidate], target_size=1234)
|
||||
self.assertFalse(result.get("found"))
|
||||
self.assertTrue(result.get("confirmation_required"))
|
||||
self.assertTrue(result.get("matched_by_size"))
|
||||
self.assertEqual(result.get("filename"), "renamed_lora.safetensors")
|
||||
|
||||
def test_foundation_component_offers_size_candidate_without_auto_recovery(self):
|
||||
candidate = {
|
||||
"type": "vae",
|
||||
"filename": "renamed_vae.safetensors",
|
||||
"path": __file__,
|
||||
"size": 1234,
|
||||
}
|
||||
result = models._resolve_from_candidates(
|
||||
[candidate],
|
||||
target_size=1234,
|
||||
require_hash=True,
|
||||
)
|
||||
self.assertFalse(result.get("found"))
|
||||
self.assertTrue(result.get("hash_required"))
|
||||
self.assertTrue(result.get("confirmation_required"))
|
||||
self.assertEqual(result.get("filename"), "renamed_vae.safetensors")
|
||||
|
||||
def test_foundation_component_recovers_different_name_by_hash(self):
|
||||
candidate = {
|
||||
"type": "text_encoders",
|
||||
"filename": "new_clip_name.safetensors",
|
||||
"path": __file__,
|
||||
"size": 4321,
|
||||
"hashes": {"A" * 64},
|
||||
}
|
||||
result = models._resolve_from_candidates(
|
||||
[candidate],
|
||||
target_hash="A" * 64,
|
||||
require_hash=True,
|
||||
)
|
||||
self.assertTrue(result.get("found"))
|
||||
self.assertEqual(result.get("filename"), "new_clip_name.safetensors")
|
||||
self.assertTrue(result.get("matched_by_hash"))
|
||||
|
||||
def test_model_search_falls_back_to_virtual_display_name(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
model_file = os.path.join(temp_dir, "opaque_name.safetensors")
|
||||
with open(model_file, "wb") as handle:
|
||||
handle.write(b"model")
|
||||
with open(os.path.splitext(model_file)[0] + ".civitai.info", "w", encoding="utf-8") as handle:
|
||||
json.dump({"anomalous_custom_name": "Readable Qwen VAE"}, handle)
|
||||
|
||||
with mock.patch.object(models.folder_paths, "folder_names_and_paths", {"vae": object()}), mock.patch.object(
|
||||
models.folder_paths,
|
||||
"get_folder_paths",
|
||||
return_value=[temp_dir],
|
||||
):
|
||||
result = models._find_model_sync("readable qwen")
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["filename"], "opaque_name.safetensors")
|
||||
self.assertEqual(result["file_path"], os.path.abspath(model_file))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,14 @@ import { anomalousAlert } from './ui_dialog.js';
|
||||
|
||||
let activeDrag = null;
|
||||
|
||||
export function getCanvasPosition(event, canvas) {
|
||||
if (!canvas) return null;
|
||||
let position = null;
|
||||
if (canvas.convertEventToCanvasOffset) position = canvas.convertEventToCanvasOffset(event);
|
||||
else if (canvas.adjustMouseEvent) { canvas.adjustMouseEvent(event); position = [event.canvasX, event.canvasY]; }
|
||||
return position && position.every(Number.isFinite) ? position : null;
|
||||
}
|
||||
|
||||
export function materialDropNode(event, canvas, graph) {
|
||||
const surface = canvas?.canvas;
|
||||
if (!surface || !graph || canvas.graph !== graph) return null;
|
||||
@@ -13,10 +21,8 @@ export function materialDropNode(event, canvas, graph) {
|
||||
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) return null;
|
||||
// DOM text widgets overlay the canvas, so accept their drop surface too.
|
||||
if (event.target !== surface && !event.target?.closest?.('.dom-widget')) return null;
|
||||
let position;
|
||||
if (canvas.convertEventToCanvasOffset) position = canvas.convertEventToCanvasOffset(event);
|
||||
else if (canvas.adjustMouseEvent) { canvas.adjustMouseEvent(event); position = [event.canvasX, event.canvasY]; }
|
||||
if (!position?.every(Number.isFinite)) return null;
|
||||
const position = getCanvasPosition(event, canvas);
|
||||
if (!position) return null;
|
||||
const node = graph.getNodeOnPos?.(position[0], position[1]);
|
||||
return node && graph.getNodeById(node.id) === node ? node : null;
|
||||
}
|
||||
@@ -54,33 +60,41 @@ export function bindMaterialDrag(element, owner, { payload, accepts, drop, dropO
|
||||
const target = event => app.graph === graph && app.canvas === canvas ? materialDropNode(event, canvas, graph) : null;
|
||||
const over = event => {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
if (dropOnCanvas && isOverCanvasSurface(event)) {
|
||||
const node = target(event);
|
||||
const validNode = !!node && accepts?.(node, data);
|
||||
if (validNode) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
hint.classList.add('is-target-valid');
|
||||
hint.textContent = t('materialDropTarget', { name: materialNodeHeading(node) });
|
||||
} else if (dropOnCanvas && isOverCanvasSurface(event)) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
hint.classList.add('is-target-valid');
|
||||
hint.textContent = data.dragTargetHint || defaultHint;
|
||||
} else {
|
||||
const node = target(event);
|
||||
const valid = !!node && accepts?.(node, data);
|
||||
event.dataTransfer.dropEffect = valid ? 'copy' : 'none';
|
||||
hint.classList.toggle('is-target-valid', valid);
|
||||
hint.textContent = valid ? t('materialDropTarget', { name: materialNodeHeading(node) }) : defaultHint;
|
||||
event.dataTransfer.dropEffect = 'none';
|
||||
hint.classList.remove('is-target-valid');
|
||||
hint.textContent = defaultHint;
|
||||
}
|
||||
hint.style.left = `${Math.max(8, Math.min(event.clientX + 16, window.innerWidth - 250))}px`;
|
||||
hint.style.top = `${Math.max(8, event.clientY - 48)}px`;
|
||||
};
|
||||
const finish = async event => {
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
const overCanvas = isOverCanvasSurface(event);
|
||||
const node = target(event);
|
||||
const validNode = !!node && accepts?.(node, data);
|
||||
const overCanvas = isOverCanvasSurface(event);
|
||||
cleanup();
|
||||
if (validNode) {
|
||||
try { await drop(node, data, graph); }
|
||||
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); }
|
||||
return;
|
||||
}
|
||||
if (dropOnCanvas && overCanvas) {
|
||||
try { await dropOnCanvas(event, data, graph); }
|
||||
const pos = getCanvasPosition(event, canvas);
|
||||
try { await dropOnCanvas(event, data, graph, pos); }
|
||||
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('recipeOpenError') : t(error.message)); }
|
||||
return;
|
||||
}
|
||||
if (!node || !accepts?.(node, data)) return;
|
||||
try { await drop(node, data, graph); }
|
||||
catch (error) { await anomalousAlert(t(error.message) === error.message ? t('materialApplyFailed') : t(error.message)); }
|
||||
};
|
||||
const escape = event => { if (event.key === 'Escape') cleanup(); };
|
||||
const listeners = [['dragover', over], ['drop', finish], ['dragend', cleanup], ['keydown', escape], ['blur', cleanup]];
|
||||
|
||||
@@ -113,9 +113,13 @@ export function applyMaterialBlock(app, node, block, workflowHashes) {
|
||||
}
|
||||
|
||||
export function promptWidgetTargets(node) {
|
||||
const promptNameRegex = /^(text|text_g|text_l|prompt|positive|negative|caption|string|value|文本|提示词|正面|负面|正向|反向|正面提示词|负面提示词|正向提示词|反向提示词|描述|内容)$/i;
|
||||
return (node?.widgets || []).flatMap((widget, index) => {
|
||||
if (!widget) return [];
|
||||
const name = String(widget.name || '');
|
||||
return typeof widget.value === 'string' && /^(text|text_g|text_l|prompt|positive|negative)$/i.test(name)
|
||||
&& !widget.options?.values ? [{ index, name }] : [];
|
||||
const label = String(widget.label || '');
|
||||
const isNotCombo = !widget.options?.values || !Array.isArray(widget.options.values);
|
||||
const matchesName = promptNameRegex.test(name) || promptNameRegex.test(label) || widget.type === 'customtext' || widget.type === 'text' || !!widget.options?.multiline;
|
||||
return typeof widget.value === 'string' && matchesName && isNotCombo ? [{ index, name: name || label || 'text' }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { text, jsonResponse } from './ui_dom.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
import { escapeHtml } from './safe_dom.js';
|
||||
import { applyPromptRolesToBlocks, materialNodeHeading } from './material_inspector.js';
|
||||
import { applyMaterialBlock, selectedMaterialNode } from './node_material_actions.js';
|
||||
import { applyMaterialBlock, applyNodeMaterialValues, promptWidgetTargets, selectedMaterialNode } from './node_material_actions.js';
|
||||
import { getMaterialPromptInfo } from './ui_material_detail.js';
|
||||
|
||||
export function showMaterialApplication(parent, result, node) {
|
||||
parent.querySelector('.anomalous-material-application-result')?.remove();
|
||||
@@ -112,12 +113,57 @@ export async function applyLibraryMaterial(owner, material, droppedNode = null,
|
||||
if (app.graph !== graph || graph.getNodeById(node.id) !== node || (!droppedNode && selectedMaterialNode(app) !== node)) throw new Error('materialTargetChanged');
|
||||
if (owner.nbPanel?.style.display !== 'flex' || owner.materialView?.style.display !== 'flex'
|
||||
|| (owner.modal && !owner.modal.classList.contains('visible'))) return;
|
||||
const blocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles)
|
||||
let blocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles)
|
||||
.filter(block => block.type === node.type && block.widgets_values?.length);
|
||||
const promptTargets = promptWidgetTargets(node);
|
||||
if (!blocks.length && promptTargets.length > 0) {
|
||||
const allBlocks = applyPromptRolesToBlocks(payload.node_blocks || [], payload.prompt_roles);
|
||||
blocks = allBlocks.filter(b => Array.isArray(b.widgets_values) && b.widgets_values.some(v => typeof v === 'string' && v.trim()));
|
||||
if (!blocks.length) {
|
||||
const info = getMaterialPromptInfo(payload.data || material);
|
||||
if (info.text) {
|
||||
blocks = [{
|
||||
node_id: 1,
|
||||
type: node.type,
|
||||
title: info.role === 'negative'
|
||||
? (window.anomalous_browser_lang === 'zh' ? '负向提示词' : 'Negative Prompt')
|
||||
: (window.anomalous_browser_lang === 'zh' ? '正向提示词' : 'Positive Prompt'),
|
||||
promptRole: info.role,
|
||||
widgets_values: [info.text],
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!blocks.length) throw new Error('materialNoCompatibleValues');
|
||||
const apply = droppedNode ? applyMaterialToNode : applyMaterialToSelectedNode;
|
||||
|
||||
const applyTarget = (targetBlock) => {
|
||||
if (targetBlock.type === node.type) {
|
||||
const apply = droppedNode ? applyMaterialToNode : applyMaterialToSelectedNode;
|
||||
return apply(node, targetBlock, payload.workflow_hashes, owner.materialContext);
|
||||
}
|
||||
if (selectedMaterialNode(app) !== node && !droppedNode) throw new Error('materialTargetChanged');
|
||||
const targetWidget = promptWidgetTargets(node)[0];
|
||||
if (!targetWidget) throw new Error('materialNoCompatibleValues');
|
||||
let textVal = '';
|
||||
if (Array.isArray(targetBlock.widgets_values)) {
|
||||
for (const v of targetBlock.widgets_values) {
|
||||
if (typeof v === 'string' && v.trim()) {
|
||||
textVal = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!textVal) throw new Error('materialNoCompatibleValues');
|
||||
const result = applyNodeMaterialValues(app, node, [{ index: targetWidget.index, value: textVal }], {
|
||||
sourceNodeId: targetBlock.node_id,
|
||||
workflowHashes: payload.workflow_hashes,
|
||||
});
|
||||
showMaterialApplication(owner.materialContext, result, node);
|
||||
return result;
|
||||
};
|
||||
|
||||
if (blocks.length === 1) {
|
||||
apply(node, blocks[0], payload.workflow_hashes, owner.materialContext);
|
||||
applyTarget(blocks[0]);
|
||||
} else {
|
||||
owner.materialBlockDialog?.close();
|
||||
const dialog = document.createElement('dialog'); dialog.className = 'anomalous-material-choice';
|
||||
@@ -206,7 +252,7 @@ export async function applyLibraryMaterial(owner, material, droppedNode = null,
|
||||
choose.onclick = async () => {
|
||||
try {
|
||||
if (app.graph !== graph) throw new Error('materialTargetChanged');
|
||||
apply(node, block, payload.workflow_hashes, owner.materialContext);
|
||||
applyTarget(block);
|
||||
dialog.close();
|
||||
} catch (error) {
|
||||
status.textContent = t(error.message) === error.message ? t('materialApplyFailed') : t(error.message);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
/** Material Library list-card rendering and inline title editing. */
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { bindMaterialDrag } from './material_drag.js';
|
||||
import { translate } from './locales.js';
|
||||
import { anomalousAlert } from './ui_dialog.js';
|
||||
import { text, jsonResponse } from './ui_dom.js';
|
||||
import { materialNodeHeading } from './material_inspector.js';
|
||||
import { promptWidgetTargets } from './node_material_actions.js';
|
||||
import { applyLibraryMaterial } from './ui_material_application.js';
|
||||
import {
|
||||
deleteMaterial,
|
||||
getMaterialPlaceholderSvg,
|
||||
getMaterialPromptInfo,
|
||||
isPromptMaterial,
|
||||
materialAssetUrl,
|
||||
openMaterialWorkflow,
|
||||
@@ -81,19 +84,115 @@ function startInlineTitleEdit(owner, material, titleRow, cardTitle, editBtn) {
|
||||
input.onblur = () => finish(true);
|
||||
}
|
||||
|
||||
function bindPolymorphicMaterialCardDrag(card, owner, material) {
|
||||
const isZh = window.anomalous_browser_lang === 'zh';
|
||||
const isWorkflow = (material.capabilities || []).includes('open_workflow') || material.has_workflow;
|
||||
const isPrompt = isPromptMaterial(material) || material.kind === 'prompt_plan';
|
||||
|
||||
let cardTitleText = `${material.name || t('materialUntitled')}`;
|
||||
let dragHint = '';
|
||||
let dragTargetHint = '';
|
||||
|
||||
if (isWorkflow) {
|
||||
cardTitleText += ` — ${isZh ? '按住拖至空白画布载入完整工作流' : 'Drag to blank canvas to load workflow'}`;
|
||||
dragHint = isZh ? '拖拽至空白画布载入工作流' : 'Drag to blank canvas to load workflow';
|
||||
dragTargetHint = isZh ? '松开以载入完整工作流' : 'Release to load workflow';
|
||||
} else if (isPrompt) {
|
||||
cardTitleText += ` — ${isZh ? '按住拖至文本节点注入提示词,或拖至空白处创建提示词节点' : 'Drag to text node to inject prompt, or to blank canvas to create node'}`;
|
||||
dragHint = isZh ? '拖拽提示词至文本节点或空白画布' : 'Drag prompt to text node or blank canvas';
|
||||
dragTargetHint = isZh ? '松开以在空白画布创建提示词节点' : 'Release to create prompt node on blank canvas';
|
||||
} else {
|
||||
cardTitleText += ` — ${isZh ? '按住拖至节点注入参数,或拖至空白处新建对应节点' : 'Drag to node to apply parameters, or to blank canvas to create node'}`;
|
||||
dragHint = t('materialDragParameters') || (isZh ? '拖拽素材参数至目标节点' : 'Drag parameters to target node');
|
||||
dragTargetHint = isZh ? '松开以在空白画布创建对应节点' : 'Release to create node on blank canvas';
|
||||
}
|
||||
|
||||
card.title = cardTitleText;
|
||||
|
||||
bindMaterialDrag(card, owner, {
|
||||
payload: () => ({
|
||||
...material,
|
||||
node_types: Array.isArray(material.node_types) ? [...material.node_types] : [],
|
||||
dragHint,
|
||||
dragTargetHint,
|
||||
}),
|
||||
accepts: (node, source) => {
|
||||
if (!node) return false;
|
||||
const promptMat = isPromptMaterial(source) || source.kind === 'prompt_plan';
|
||||
if (promptMat) {
|
||||
return promptWidgetTargets(node).length > 0 || (source.node_types || []).includes(node.type);
|
||||
}
|
||||
return (source.node_types || []).includes(node.type);
|
||||
},
|
||||
drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph),
|
||||
dropOnCanvas: async (event, source, graph, position) => {
|
||||
if (isWorkflow) {
|
||||
await openMaterialWorkflow(owner, source.filename);
|
||||
return;
|
||||
}
|
||||
|
||||
const creator = (typeof LiteGraph !== 'undefined' ? LiteGraph?.createNode : null)
|
||||
|| globalThis.LiteGraph?.createNode
|
||||
|| window.LiteGraph?.createNode;
|
||||
if (!creator) return;
|
||||
|
||||
const pos = position || (app.canvas?.convertEventToCanvasOffset
|
||||
? app.canvas.convertEventToCanvasOffset(event)
|
||||
: [100, 100]);
|
||||
|
||||
if (isPrompt) {
|
||||
const info = getMaterialPromptInfo(source);
|
||||
const isNegative = info.role === 'negative';
|
||||
const node = creator.call(LiteGraph, 'CLIPTextEncode');
|
||||
if (!node) return;
|
||||
node.title = isNegative
|
||||
? (isZh ? 'CLIP 文本编码器 (负向)' : 'CLIP Text Encode (Negative)')
|
||||
: (isZh ? 'CLIP 文本编码器 (正向)' : 'CLIP Text Encode (Positive)');
|
||||
node.color = isNegative ? '#532323' : '#235327';
|
||||
node.bgcolor = isNegative ? '#381616' : '#143818';
|
||||
node.pos = [pos[0], pos[1]];
|
||||
|
||||
const tw = (node.widgets || []).find(w => /^(text|prompt)/i.test(w.name) || w.type === 'customtext') || node.widgets?.[0];
|
||||
if (tw) {
|
||||
tw.value = info.text;
|
||||
if (Array.isArray(node.widgets_values)) node.widgets_values[0] = info.text;
|
||||
tw.callback?.call(tw, tw.value, app.canvas, node);
|
||||
node.onWidgetChanged?.(0, tw.value, '', tw);
|
||||
}
|
||||
|
||||
graph.add(node);
|
||||
app.canvas?.selectNode?.(node);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
graph.change?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.node_types?.length) {
|
||||
const nodeType = source.node_types[0];
|
||||
const node = creator.call(LiteGraph, nodeType);
|
||||
if (node) {
|
||||
node.pos = [pos[0], pos[1]];
|
||||
graph.add(node);
|
||||
app.canvas?.selectNode?.(node);
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
graph.change?.();
|
||||
try {
|
||||
await applyLibraryMaterial(owner, source, node, graph);
|
||||
} catch (err) {
|
||||
console.warn('[AMB] Error applying parameters to newly created node:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function renderMaterialCard(owner, material) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'anomalous-material-card';
|
||||
card.title = `${material.name || t('materialUntitled')} — ${t('materialCardDragHint') || '按住可拖拽至画布节点注入参数,或拖至空白处载入工作流'}`;
|
||||
const activate = () => showMaterialDetail(owner, material);
|
||||
card.onclick = activate;
|
||||
if (material.node_types?.length) {
|
||||
bindMaterialDrag(card, owner, {
|
||||
payload: () => ({ ...material, node_types: [...material.node_types], dragHint: t('materialDragParameters') || '拖拽素材参数至目标节点' }),
|
||||
accepts: (node, source) => source.node_types.includes(node.type),
|
||||
drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph),
|
||||
});
|
||||
}
|
||||
bindPolymorphicMaterialCardDrag(card, owner, material);
|
||||
card.tabIndex = 0;
|
||||
card.setAttribute('aria-label', `${material.name} — ${t('materialViewDetails')}`);
|
||||
card.onkeydown = event => {
|
||||
|
||||
@@ -16,8 +16,62 @@ import { selectedMaterialNode } from './node_material_actions.js';
|
||||
import { fetchMaterial, applyLibraryMaterial } from './ui_material_application.js';
|
||||
|
||||
const t = (key, params) => translate(key, params);
|
||||
export const isPromptMaterial = material => ['prompt_note_bundle', 'prompt_text'].includes(material.kind);
|
||||
export const promptKindLabel = material => t(material.kind === 'prompt_text' ? 'materialPromptTextKind' : 'materialPromptNoteBundle');
|
||||
export const isPromptMaterial = material => ['prompt_note_bundle', 'prompt_text'].includes(material?.kind);
|
||||
export const promptKindLabel = material => t(material?.kind === 'prompt_text' ? 'materialPromptTextKind' : 'materialPromptNoteBundle');
|
||||
|
||||
export function getMaterialPromptInfo(material) {
|
||||
const isZh = window.anomalous_browser_lang === 'zh';
|
||||
let text = '';
|
||||
let role = 'positive';
|
||||
|
||||
const note = material?.data?.note || material?.note;
|
||||
if (note) {
|
||||
text = (isZh && note.promptZh) ? note.promptZh : (note.promptEn || note.promptZh || '');
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
const plan = material?.data?.plan || material?.plan;
|
||||
if (plan) {
|
||||
if (plan.negative && !plan.positive) {
|
||||
text = plan.negative;
|
||||
role = 'negative';
|
||||
} else if (plan.positive) {
|
||||
text = plan.positive;
|
||||
role = 'positive';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!text && Array.isArray(material?.node_blocks)) {
|
||||
for (const block of material.node_blocks) {
|
||||
if (Array.isArray(block.widgets_values)) {
|
||||
for (const val of block.widgets_values) {
|
||||
if (typeof val === 'string' && val.trim()) {
|
||||
text = val.trim();
|
||||
if (block.promptRole === 'negative') role = 'negative';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
text = material?.summary || material?.name || '';
|
||||
}
|
||||
|
||||
if (role !== 'negative') {
|
||||
const lowerName = String(material?.name || '').toLowerCase();
|
||||
const tags = Array.isArray(material?.tags) ? material.tags.map(t => String(t).toLowerCase()) : [];
|
||||
if (lowerName.includes('negative') || lowerName.includes('负向') || lowerName.includes('反向')
|
||||
|| tags.some(t => t.includes('negative') || t.includes('负向') || t.includes('反向'))) {
|
||||
role = 'negative';
|
||||
}
|
||||
}
|
||||
|
||||
return { text: text.trim(), role };
|
||||
}
|
||||
|
||||
export function getMaterialPlaceholderSvg(material, size = 36) {
|
||||
if (isPromptMaterial(material) || material?.kind === 'prompt_plan') {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Scan wizard UI and scan launch/polling coordination.
|
||||
*/
|
||||
|
||||
import { app } from '../../../scripts/app.js';
|
||||
import { translate } from './locales.js';
|
||||
import { updateScanProgress, finishScanProgress, failScanProgress } from './scan_progress.js';
|
||||
import { configureSidebarAction } from './sidebar_actions.js';
|
||||
@@ -26,7 +27,10 @@ function setActiveScanButtonState(isScanning) {
|
||||
|
||||
export function openScanWizard({ isGlobal = false, targetFiles = null } = {}) {
|
||||
let wizard = document.getElementById('anomalous-wizard-modal');
|
||||
if (wizard) document.body.removeChild(wizard);
|
||||
if (wizard) {
|
||||
if (typeof wizard.cleanupModal === 'function') wizard.cleanupModal();
|
||||
else if (wizard.parentNode) wizard.parentNode.removeChild(wizard);
|
||||
}
|
||||
|
||||
wizard = document.createElement('div');
|
||||
wizard.id = 'anomalous-wizard-modal';
|
||||
@@ -42,14 +46,32 @@ export function openScanWizard({ isGlobal = false, targetFiles = null } = {}) {
|
||||
wizard.style.alignItems = 'center';
|
||||
wizard.style.fontFamily = 'Roboto, "Segoe UI", sans-serif';
|
||||
|
||||
const closeWizard = () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
if (wizard && wizard.parentNode) {
|
||||
wizard.parentNode.removeChild(wizard);
|
||||
}
|
||||
};
|
||||
wizard.cleanupModal = closeWizard;
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'Escape') closeWizard();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
wizard.onclick = (e) => {
|
||||
if (e.target === wizard) closeWizard();
|
||||
};
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.style.background = '#1E1E1E';
|
||||
content.style.borderRadius = '12px';
|
||||
content.style.padding = '32px';
|
||||
content.style.padding = '28px 32px 20px 32px';
|
||||
content.style.width = '760px';
|
||||
content.style.maxWidth = '95%';
|
||||
content.style.maxHeight = '90vh';
|
||||
content.style.overflowY = 'auto';
|
||||
content.style.maxHeight = '85vh';
|
||||
content.style.display = 'flex';
|
||||
content.style.flexDirection = 'column';
|
||||
content.style.boxShadow = '0 11px 15px -7px rgba(0,0,0,0.2), 0 24px 38px 3px rgba(0,0,0,0.14), 0 9px 46px 8px rgba(0,0,0,0.12)';
|
||||
content.style.color = '#fff';
|
||||
content.style.position = 'relative';
|
||||
@@ -123,6 +145,10 @@ export function openScanWizard({ isGlobal = false, targetFiles = null } = {}) {
|
||||
formGroup.style.display = 'flex';
|
||||
formGroup.style.flexDirection = 'column';
|
||||
formGroup.style.gap = '24px';
|
||||
formGroup.style.overflowY = 'auto';
|
||||
formGroup.style.flex = '1';
|
||||
formGroup.style.minHeight = '0';
|
||||
formGroup.style.paddingRight = '6px';
|
||||
|
||||
const createChoiceCard = (id, icon, titleKey, descKey, isSelected) => {
|
||||
const card = document.createElement('div');
|
||||
@@ -525,7 +551,7 @@ export function openScanWizard({ isGlobal = false, targetFiles = null } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.removeChild(wizard);
|
||||
closeWizard();
|
||||
const activeScanBtn = document.getElementById('anomalous-scan-btn');
|
||||
if (activeScanBtn) {
|
||||
setScanButtonState(activeScanBtn, true);
|
||||
@@ -593,6 +619,12 @@ export function openScanWizard({ isGlobal = false, targetFiles = null } = {}) {
|
||||
|
||||
setActiveScanButtonState(false);
|
||||
finishScanProgress();
|
||||
try {
|
||||
if (app?.refreshComboInNodes) await app.refreshComboInNodes();
|
||||
if (window.anomalous_reload_hashes) await window.anomalous_reload_hashes();
|
||||
} catch (e) {
|
||||
console.warn('[AMB] Error reloading hashes or combo nodes:', e);
|
||||
}
|
||||
if (enableAutoCheck && window.anomalous_resolve_all_missing_nodes) {
|
||||
window.anomalous_resolve_all_missing_nodes(true);
|
||||
}
|
||||
@@ -644,6 +676,12 @@ export function openScanWizard({ isGlobal = false, targetFiles = null } = {}) {
|
||||
else finishScanProgress();
|
||||
setActiveScanButtonState(false);
|
||||
|
||||
try {
|
||||
if (app?.refreshComboInNodes) await app.refreshComboInNodes();
|
||||
if (window.anomalous_reload_hashes) await window.anomalous_reload_hashes();
|
||||
} catch (e) {
|
||||
console.warn('[AMB] Error reloading hashes or combo nodes:', e);
|
||||
}
|
||||
if (enableAutoCheck && window.anomalous_resolve_all_missing_nodes) {
|
||||
window.anomalous_resolve_all_missing_nodes(true);
|
||||
}
|
||||
@@ -654,7 +692,7 @@ export function openScanWizard({ isGlobal = false, targetFiles = null } = {}) {
|
||||
setActiveScanButtonState(false);
|
||||
}
|
||||
}, 2000);
|
||||
document.body.removeChild(wizard);
|
||||
closeWizard();
|
||||
return; // return early so we don't remove wizard again below
|
||||
} else {
|
||||
failScanProgress(t('sidebarScanFailed') + data.message);
|
||||
@@ -665,14 +703,17 @@ export function openScanWizard({ isGlobal = false, targetFiles = null } = {}) {
|
||||
alert("Error: " + e);
|
||||
}
|
||||
|
||||
document.body.removeChild(wizard);
|
||||
closeWizard();
|
||||
};
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.style.marginTop = '32px';
|
||||
footer.style.marginTop = '16px';
|
||||
footer.style.paddingTop = '16px';
|
||||
footer.style.borderTop = '1px solid rgba(255,255,255,0.08)';
|
||||
footer.style.display = 'flex';
|
||||
footer.style.justifyContent = 'flex-end';
|
||||
footer.style.gap = '8px';
|
||||
footer.style.flexShrink = '0';
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.textContent = t('sidebarCancel');
|
||||
@@ -688,7 +729,7 @@ closeBtn.textContent = t('sidebarCancel');
|
||||
closeBtn.style.transition = 'background 0.2s';
|
||||
closeBtn.onmouseover = () => closeBtn.style.background = 'rgba(255, 255, 255, 0.08)';
|
||||
closeBtn.onmouseout = () => closeBtn.style.background = 'transparent';
|
||||
closeBtn.onclick = () => document.body.removeChild(wizard);
|
||||
closeBtn.onclick = closeWizard;
|
||||
|
||||
const startBtn = document.createElement('button');
|
||||
startBtn.textContent = t('sidebarExecute');
|
||||
@@ -850,11 +891,17 @@ export async function triggerDirectModelScan(model, triggerBtn = null, browserIn
|
||||
const data = await res.json();
|
||||
if (data.status === 'ok') {
|
||||
showWorkbenchToast(isZh ? `开始精准扫描: ${modelLabel}` : `Scanning model: ${modelLabel}`);
|
||||
pollDirectScanStatus(params, titleText, model, () => {
|
||||
pollDirectScanStatus(params, titleText, model, async () => {
|
||||
resetBtn();
|
||||
if (typeof browser.loadModels === 'function') {
|
||||
browser.loadModels();
|
||||
}
|
||||
try {
|
||||
if (app?.refreshComboInNodes) await app.refreshComboInNodes();
|
||||
if (window.anomalous_reload_hashes) await window.anomalous_reload_hashes();
|
||||
} catch (e) {
|
||||
console.warn('[AMB] Error reloading hashes or combo nodes:', e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resetBtn();
|
||||
|
||||
Reference in New Issue
Block a user