From 09b06d1dc706c9d822e9649c5c5312f5dbd74135 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Mon, 21 Sep 2026 02:02:50 +0800 Subject: [PATCH 1/4] fix(entry): resolve floating trigger position, boundary clamping, and refresh flicker --- ARCHITECTURE.md | 10 +- api/__init__.py | 13 ++ web/main.js | 9 +- web/modules/browser_entry.js | 281 +++++++++++++++++++--------- web/modules/entry_controls.js | 100 +++++++++- web/styles.css | 2 +- web/styles/00-foundation-models.css | 37 +++- 7 files changed, 343 insertions(+), 109 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b836e5d..0d0690b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,12 +85,12 @@ DOM or live LiteGraph state. - `model_policies.py` owns shared backend rename and protected-category policy. - `model_identity.py` owns file SHA-256 evidence shared with the standalone scanner. -### Frontend - -- `web/main.js` coordinates extension registration. `browser.js` owns the shared +- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared browser class and extracted-method wiring; `browser_entry.js` owns the single - browser instance plus floating/topbar/menu entry behavior; and - `interface_settings.js` owns language and theme preferences. + browser instance plus floating/topbar/menu entry behavior (with Dual-Binding PointerEvents drag capture, `lostpointercapture` fail-safe listeners, zero-drift viewport boundary clamping, flicker-free pre-mount coordinate binding and `anomalous-trigger-initializing` smooth opacity fade reveal, default safe placement in the top-left canvas area at `top: 80px; left: 80px;` gracefully avoiding the left sidebar dock, and versioned `anomalous_trigger_pos_v3` atomic JSON coordinate persistence); `entry_controls.js` + owns entry mode, trigger sizing/styling normalization, mathematical viewport-safe boundary + clamping (`clampFloatingTriggerPosition` with minimum safe boundary `minX=70` preventing left sidebar dock entrapment), clean `loadSavedTriggerPosition`/`saveTriggerPosition` storage drivers, and non-distorting coordinate validation + (`isValidSavedTriggerPosition`); `api/__init__.py` injects an aiohttp no-cache middleware (`Cache-Control: no-cache, no-store, must-revalidate`) for extension static files to eliminate browser memory/disk cache desynchronization across regular page refreshes; and `interface_settings.js` owns language and theme preferences. - `ui_sidebar.js` creates the browser shell and folder navigation. `ui_settings_hub.js` owns settings and model-card preferences; `ui_toolbox.js` owns the tool catalog, fixed shortcut bar, and tool dispatch; diff --git a/api/__init__.py b/api/__init__.py index e7920d3..aa45bd6 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -8,7 +8,20 @@ from .config import * from .notebooks import * from .parameters import * +from aiohttp import web + +@web.middleware +async def no_cache_extension_middleware(request, handler): + response = await handler(request) + if request.path.startswith('/extensions/Anomalous_Model_Browser'): + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + return response + def setup_routes(app): + if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares: + app.middlewares.append(no_cache_extension_middleware) app.router.add_get('/anomalous/folders', model_catalog.api_get_folders) app.router.add_get('/anomalous/all_folder_types', folder_types.api_get_all_folder_types) app.router.add_get('/anomalous/models', model_catalog.api_get_models) diff --git a/web/main.js b/web/main.js index e73d058..ef1c4d9 100644 --- a/web/main.js +++ b/web/main.js @@ -1,5 +1,12 @@ +try { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem('anomalous_btn_x'); + localStorage.removeItem('anomalous_btn_y'); + } +} catch (_) {} + import { app } from "../../scripts/app.js"; -import { createBrowserEntry } from "./modules/browser_entry.js"; +import { createBrowserEntry } from "./modules/browser_entry.js?v=20260921-flicker-free-1"; import { createInterfaceSettings, getCurrentLanguage, setAbyssalScarletTheme, t } from "./modules/interface_settings.js"; export { setAbyssalScarletTheme }; diff --git a/web/modules/browser_entry.js b/web/modules/browser_entry.js index 30c4643..31d202d 100644 --- a/web/modules/browser_entry.js +++ b/web/modules/browser_entry.js @@ -1,11 +1,24 @@ +try { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem('anomalous_btn_x'); + localStorage.removeItem('anomalous_btn_y'); + } +} catch (_) {} + import { app } from '../../../scripts/app.js'; import { AnomalousBrowser } from './browser.js'; import { clampFloatingTriggerPosition, + clearSavedTriggerPosition, + DEFAULT_SAFE_LEFT, + DEFAULT_SAFE_TOP, + isValidSavedTriggerPosition, + loadSavedTriggerPosition, normalizeEntryMode, normalizeFloatingTriggerSize, - normalizeFloatingTriggerStyle -} from './entry_controls.js'; + normalizeFloatingTriggerStyle, + saveTriggerPosition +} from './entry_controls.js?v=20260921-flicker-free-1'; import { createShortcutSettingControl, DEFAULT_BROWSER_SHORTCUT, @@ -107,18 +120,18 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) { triggerButton.replaceChildren(icon, label); triggerButton.title = t('mainOpenTitle'); triggerButton.setAttribute('aria-label', t('mainOpenTitle')); - requestAnimationFrame(() => triggerBoundsUpdater?.()); + if (loadSavedTriggerPosition()) { + requestAnimationFrame(() => triggerBoundsUpdater?.()); + } } function resetPosition() { - localStorage.removeItem('anomalous_btn_x'); - localStorage.removeItem('anomalous_btn_y'); + clearSavedTriggerPosition(); if (!triggerButton) return; triggerButton.style.left = ''; triggerButton.style.top = ''; - triggerButton.style.right = '30px'; - triggerButton.style.bottom = '30px'; - requestAnimationFrame(() => triggerBoundsUpdater?.()); + triggerButton.style.right = ''; + triggerButton.style.bottom = ''; } function ensureBrowser() { @@ -241,7 +254,7 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) { }); async function setup() { - const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=' + Date.now(); + const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=20260921-flicker-free-1'; if (!document.querySelector('link[href^="/extensions/Anomalous_Model_Browser/styles.css"]')) { const link = document.createElement('link'); link.rel = 'stylesheet'; @@ -250,86 +263,6 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) { document.head.appendChild(link); } - const btn = document.createElement('button'); - btn.id = 'anomalous-trigger-btn'; - btn.setAttribute('aria-label', 'Anomalous Model Browser'); - triggerButton = btn; - if (browserInstance) browserInstance.triggerButton = btn; - btn.title = t('mainOpenTitle'); - let isDragging = false; - let startX; - let startY; - let initialX; - let initialY; - - btn.addEventListener('mousedown', event => { - isDragging = true; - startX = event.clientX; - startY = event.clientY; - const rect = btn.getBoundingClientRect(); - initialX = rect.left; - initialY = rect.top; - btn.style.transition = 'none'; - }); - window.addEventListener('mousemove', event => { - if (!isDragging) return; - event.preventDefault(); - const nextPosition = clampFloatingTriggerPosition({ - x: initialX + (event.clientX - startX), - y: initialY + (event.clientY - startY), - width: btn.offsetWidth, - height: btn.offsetHeight, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - margin: 0 - }); - btn.style.left = nextPosition.x + 'px'; - btn.style.top = nextPosition.y + 'px'; - btn.style.right = 'auto'; - btn.style.bottom = 'auto'; - }); - window.addEventListener('mouseup', event => { - if (!isDragging) return; - isDragging = false; - btn.style.transition = 'transform 0.15s, box-shadow 0.15s'; - localStorage.setItem('anomalous_btn_x', btn.style.left); - localStorage.setItem('anomalous_btn_y', btn.style.top); - if (Math.abs(event.clientX - startX) < 5 && Math.abs(event.clientY - startY) < 5) open(); - }); - - let savedX = localStorage.getItem('anomalous_btn_x'); - let savedY = localStorage.getItem('anomalous_btn_y'); - const updateBtnBounds = () => { - savedX = localStorage.getItem('anomalous_btn_x'); - savedY = localStorage.getItem('anomalous_btn_y'); - const position = clampFloatingTriggerPosition({ - x: btn.style.left || savedX, - y: btn.style.top || savedY, - width: btn.offsetWidth, - height: btn.offsetHeight, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight - }); - btn.style.left = position.x + 'px'; - btn.style.top = position.y + 'px'; - btn.style.right = 'auto'; - btn.style.bottom = 'auto'; - }; - triggerBoundsUpdater = updateBtnBounds; - - if (savedX && savedY && savedX !== 'NaN' && savedY !== 'NaN') { - btn.style.right = 'auto'; - btn.style.bottom = 'auto'; - btn.style.left = savedX; - btn.style.top = savedY; - } - setTimeout(updateBtnBounds, 200); - window.addEventListener('resize', () => { - if (btn.style.left) updateBtnBounds(); - syncVisibility(); - }); - - document.body.appendChild(btn); try { const registeredSettings = app.extensionManager?.setting; entryMode = normalizeEntryMode(registeredSettings?.get(ENTRY_MODE_SETTING_ID)); @@ -338,11 +271,181 @@ export function createBrowserEntry({ translate, getCurrentLanguage }) { } catch (error) { console.warn('[Anomalous Model Browser] Unable to read entry preferences:', error); } + + const btn = document.createElement('button'); + btn.id = 'anomalous-trigger-btn'; + btn.setAttribute('aria-label', 'Anomalous Model Browser'); + // Prevent FOUC: Start hidden until presentation & exact coordinates are bound + btn.classList.add('anomalous-trigger-initializing'); + triggerButton = btn; + if (browserInstance) browserInstance.triggerButton = btn; + btn.title = t('mainOpenTitle'); + + // Pre-configure content & presentation BEFORE mounting applyPresentation(); + + // Pre-calculate and assign exact target coordinates BEFORE appending to DOM + const savedInitialPos = loadSavedTriggerPosition(); + if (savedInitialPos) { + const estSize = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60); + const initialClamped = clampFloatingTriggerPosition({ + x: savedInitialPos.x, + y: savedInitialPos.y, + width: estSize, + height: estSize, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + minX: 70 + }); + btn.style.position = 'fixed'; + btn.style.left = initialClamped.x + 'px'; + btn.style.top = initialClamped.y + 'px'; + btn.style.right = 'auto'; + btn.style.bottom = 'auto'; + } + + let isDragging = false; + let hasMoved = false; + let startPointerX = 0; + let startPointerY = 0; + let startBtnLeft = 0; + let startBtnTop = 0; + let currentDragX = null; + let currentDragY = null; + let activePointerId = null; + + const onPointerMove = (event) => { + if (!isDragging) return; + const dx = event.clientX - startPointerX; + const dy = event.clientY - startPointerY; + if (!hasMoved && Math.hypot(dx, dy) >= 4) { + hasMoved = true; + } + if (!hasMoved) return; + event.preventDefault(); + const btnW = btn.offsetWidth || 60; + const btnH = btn.offsetHeight || 60; + const clamped = clampFloatingTriggerPosition({ + x: startBtnLeft + dx, + y: startBtnTop + dy, + width: btnW, + height: btnH, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + minX: 70 + }); + currentDragX = clamped.x; + currentDragY = clamped.y; + btn.style.left = clamped.x + 'px'; + btn.style.top = clamped.y + 'px'; + btn.style.right = 'auto'; + btn.style.bottom = 'auto'; + }; + + const finishDrag = (event) => { + if (!isDragging) return; + isDragging = false; + + btn.removeEventListener('pointermove', onPointerMove); + btn.removeEventListener('pointerup', finishDrag); + btn.removeEventListener('pointercancel', finishDrag); + btn.removeEventListener('lostpointercapture', finishDrag); + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', finishDrag); + window.removeEventListener('pointercancel', finishDrag); + + if (activePointerId != null) { + try { + if (btn.hasPointerCapture(activePointerId)) { + btn.releasePointerCapture(activePointerId); + } + } catch (_) {} + activePointerId = null; + } + + btn.style.transition = ''; + + if (hasMoved && currentDragX != null && currentDragY != null) { + saveTriggerPosition({ x: currentDragX, y: currentDragY }); + } else if (!hasMoved) { + open(); + } + }; + + btn.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + isDragging = true; + hasMoved = false; + startPointerX = event.clientX; + startPointerY = event.clientY; + activePointerId = event.pointerId; + + const rect = btn.getBoundingClientRect(); + startBtnLeft = rect.left; + startBtnTop = rect.top; + currentDragX = rect.left; + currentDragY = rect.top; + + btn.style.transition = 'none'; + + try { + btn.setPointerCapture(event.pointerId); + } catch (_) {} + + btn.addEventListener('pointermove', onPointerMove, { passive: false }); + btn.addEventListener('pointerup', finishDrag); + btn.addEventListener('pointercancel', finishDrag); + btn.addEventListener('lostpointercapture', finishDrag); + window.addEventListener('pointermove', onPointerMove, { passive: false }); + window.addEventListener('pointerup', finishDrag); + window.addEventListener('pointercancel', finishDrag); + }); + + const updateBtnBounds = () => { + const saved = loadSavedTriggerPosition(); + if (saved) { + const estFallback = floatingTriggerSize === 'small' ? 44 : (floatingTriggerSize === 'large' ? 76 : 60); + const btnW = btn.offsetWidth || estFallback; + const btnH = btn.offsetHeight || btnW; + const clamped = clampFloatingTriggerPosition({ + x: saved.x, + y: saved.y, + width: btnW, + height: btnH, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + minX: 70 + }); + btn.style.left = clamped.x + 'px'; + btn.style.top = clamped.y + 'px'; + btn.style.right = 'auto'; + btn.style.bottom = 'auto'; + } else { + btn.style.left = ''; + btn.style.top = ''; + btn.style.right = ''; + btn.style.bottom = ''; + } + }; + triggerBoundsUpdater = updateBtnBounds; + + window.addEventListener('resize', () => { + updateBtnBounds(); + syncVisibility(); + }); + + // Mount pre-positioned and styled element to DOM + document.body.appendChild(btn); syncVisibility(); ensureBrowser(); installMaterialsShortcutFallback(); + // Reveal smoothly on first animation frame without any positional jump or flicker + requestAnimationFrame(() => { + updateBtnBounds(); + btn.classList.remove('anomalous-trigger-initializing'); + }); + window.anomalousDragGhostImg = new Image(); window.anomalousDragGhostImg.src = "data:image/svg+xml;utf8,W"; } diff --git a/web/modules/entry_controls.js b/web/modules/entry_controls.js index 5b01ece..7beaf24 100644 --- a/web/modules/entry_controls.js +++ b/web/modules/entry_controls.js @@ -1,3 +1,11 @@ +// Unconditionally purge legacy contaminated storage keys on module load +try { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem('anomalous_btn_x'); + localStorage.removeItem('anomalous_btn_y'); + } +} catch (_) {} + export const FLOATING_TRIGGER_SIZES = new Set(['small', 'medium', 'large']); export const FLOATING_TRIGGER_STYLES = new Set(['icon', 'pill']); export const ENTRY_MODES = new Set(['floating', 'topbar', 'menu']); @@ -14,6 +22,74 @@ export function normalizeEntryMode(value) { return ENTRY_MODES.has(value) ? value : 'floating'; } +export const DEFAULT_SAFE_TOP = 80; +export const DEFAULT_SAFE_LEFT = 80; // Safe position outside ComfyUI left sidebar dock +export const DEFAULT_SAFE_MARGIN_RIGHT = 24; +export const DEFAULT_MIN_SAFE_X = 70; // Avoid ComfyUI left sidebar dock +export const DEFAULT_TRIGGER_POSITION = Object.freeze({ top: DEFAULT_SAFE_TOP, left: DEFAULT_SAFE_LEFT }); +export const TRIGGER_POSITION_STORAGE_KEY = 'anomalous_trigger_pos_v3'; +export const LEGACY_TRIGGER_X_KEY = 'anomalous_btn_x'; +export const LEGACY_TRIGGER_Y_KEY = 'anomalous_btn_y'; + +export function isValidSavedTriggerPosition(x, y) { + if (x == null || y == null) return false; + const px = Number.parseFloat(x); + const py = Number.parseFloat(y); + // Must be a finite number, py >= 0, and px >= DEFAULT_MIN_SAFE_X (70px) to prevent docking inside sidebar + return Number.isFinite(px) && Number.isFinite(py) && px >= DEFAULT_MIN_SAFE_X && py >= 0; +} + +export function loadSavedTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) { + if (!storage) return null; + try { + const raw = storage.getItem(TRIGGER_POSITION_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (isValidSavedTriggerPosition(parsed?.x, parsed?.y)) { + return { x: Math.round(Number(parsed.x)), y: Math.round(Number(parsed.y)) }; + } else { + storage.removeItem(TRIGGER_POSITION_STORAGE_KEY); + } + } + } catch (_) {} + return null; +} + +export function saveTriggerPosition(pos, storage = (typeof localStorage !== 'undefined' ? localStorage : null)) { + if (!storage || !pos) return; + const x = Number.parseFloat(pos.x); + const y = Number.parseFloat(pos.y); + if (!isValidSavedTriggerPosition(x, y)) return; + try { + storage.setItem(TRIGGER_POSITION_STORAGE_KEY, JSON.stringify({ + x: Math.round(x), + y: Math.round(y) + })); + storage.removeItem(LEGACY_TRIGGER_X_KEY); + storage.removeItem(LEGACY_TRIGGER_Y_KEY); + storage.removeItem('anomalous_trigger_pos_v2'); + } catch (_) {} +} + +export function clearSavedTriggerPosition(storage = (typeof localStorage !== 'undefined' ? localStorage : null)) { + if (!storage) return; + try { + storage.removeItem(TRIGGER_POSITION_STORAGE_KEY); + storage.removeItem(LEGACY_TRIGGER_X_KEY); + storage.removeItem(LEGACY_TRIGGER_Y_KEY); + storage.removeItem('anomalous_trigger_pos_v2'); + } catch (_) {} +} + +export function sanitizeSavedTriggerPosition(x, y) { + if (!isValidSavedTriggerPosition(x, y)) return null; + return { x: Number.parseFloat(x), y: Number.parseFloat(y) }; +} + +export function normalizeSavedTriggerPosition(x, y) { + return sanitizeSavedTriggerPosition(x, y); +} + export function clampFloatingTriggerPosition({ x, y, @@ -21,19 +97,29 @@ export function clampFloatingTriggerPosition({ height, viewportWidth, viewportHeight, - margin = 30 + minX = DEFAULT_MIN_SAFE_X, + margin = 0 }) { const safeWidth = Math.max(1, Number(width) || 60); const safeHeight = Math.max(1, Number(height) || 60); - const maxX = Math.max(0, (Number(viewportWidth) || safeWidth) - safeWidth); - const maxY = Math.max(0, (Number(viewportHeight) || safeHeight) - safeHeight); - const fallbackX = Math.max(0, maxX - margin); - const fallbackY = Math.max(0, maxY - margin); + const rawVw = Number(viewportWidth) || (typeof window !== 'undefined' ? window.innerWidth : 1024) || 1024; + const rawVh = Number(viewportHeight) || (typeof window !== 'undefined' ? window.innerHeight : 768) || 768; + const safeVw = rawVw >= 300 ? rawVw : 1024; + const safeVh = rawVh >= 200 ? rawVh : 768; + const maxX = Math.max(minX, safeVw - safeWidth); + const maxY = Math.max(0, safeVh - safeHeight); + const parsedX = Number.parseFloat(x); const parsedY = Number.parseFloat(y); + const fallbackX = Math.max(minX, Math.min(maxX, DEFAULT_SAFE_LEFT)); + const fallbackY = Math.min(maxY, DEFAULT_SAFE_TOP); + + const targetX = Number.isFinite(parsedX) ? parsedX : fallbackX; + const targetY = Number.isFinite(parsedY) ? parsedY : fallbackY; + return { - x: Math.min(maxX, Math.max(0, Number.isFinite(parsedX) ? parsedX : fallbackX)), - y: Math.min(maxY, Math.max(0, Number.isFinite(parsedY) ? parsedY : fallbackY)) + x: Math.min(maxX, Math.max(minX, Math.round(targetX))), + y: Math.min(maxY, Math.max(0, Math.round(targetY))) }; } diff --git a/web/styles.css b/web/styles.css index 8ad0920..4456d33 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,4 +1,4 @@ -@import url("./styles/00-foundation-models.css?v=20260918-model-sources-1"); +@import url("./styles/00-foundation-models.css?v=20260921-flicker-free-1"); @import url("./styles/01-shell-tools.css?v=20260918-model-sources-1"); @import url("./styles/02-notebooks.css?v=20260918-model-sources-1"); @import url("./styles/03-gallery-recipes.css?v=20260918-model-sources-1"); diff --git a/web/styles/00-foundation-models.css b/web/styles/00-foundation-models.css index 9bf6dfd..8882c30 100644 --- a/web/styles/00-foundation-models.css +++ b/web/styles/00-foundation-models.css @@ -77,8 +77,10 @@ html.theme-abyssal-scarlet, --anomalous-trigger-font-size: 28px; --anomalous-trigger-pill-padding: 18px; position: fixed; - bottom: 30px; - right: 30px; + top: 80px; + left: 80px; + right: auto; + bottom: auto; width: var(--anomalous-trigger-size); height: var(--anomalous-trigger-size); border-radius: 50%; @@ -93,15 +95,20 @@ html.theme-abyssal-scarlet, box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12); display: flex !important; visibility: visible !important; - opacity: 1 !important; + opacity: 1; align-items: center !important; justify-content: center !important; user-select: none; - transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); + transition: opacity 0.18s ease-out, transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease; gap: 8px; white-space: nowrap; } +#anomalous-trigger-btn.anomalous-trigger-initializing { + opacity: 0 !important; + pointer-events: none !important; +} + #anomalous-trigger-btn .anomalous-trigger-icon { display: flex; align-items: center; @@ -195,12 +202,30 @@ body:has(.anomalous-prompt-studio-drawer) #anomalous-trigger-btn { box-shadow: none; } -.anomalous-topbar-entry { +.anomalous-topbar-entry, +button:has(.anomalous-topbar-entry), +[data-testid="anomalous-topbar-entry"], +.side-bar-button:has(.anomalous-topbar-entry) { display: none !important; + visibility: hidden !important; + width: 0 !important; + height: 0 !important; + padding: 0 !important; + margin: 0 !important; + border: none !important; + pointer-events: none !important; + overflow: hidden !important; } -html.anomalous-topbar-entry-enabled .anomalous-topbar-entry { +html.anomalous-topbar-entry-enabled .anomalous-topbar-entry, +html.anomalous-topbar-entry-enabled button:has(.anomalous-topbar-entry), +html.anomalous-topbar-entry-enabled .side-bar-button:has(.anomalous-topbar-entry) { display: inline-flex !important; + visibility: visible !important; + width: auto !important; + height: auto !important; + pointer-events: auto !important; + overflow: visible !important; } html:not(.anomalous-floating-entry-enabled) [data-setting-id="Anomalous.ModelBrowser.FloatingTriggerSize"], From 83bce45af642f6dbff02c41a088e30fa8158cf14 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Wed, 23 Sep 2026 09:43:26 +0000 Subject: [PATCH 2/4] fix(models): import MODEL_EXTENSIONS in model_resolution Since 9cebdbd9 model_resolution.py used MODEL_EXTENSIONS without importing it. /anomalous/all_hashes swallowed the NameError and always returned {}, and resolve_hash / resolve_hash_batch (recipe model matching) failed while collecting candidates. The all-hashes walk is now collect_model_hash_index() so the output gallery search can reuse it; the endpoint behaves as before. Verified: tests/test_image_search.py::ModelResolutionImportTests; on the local library the index now returns 191 keys (176 with SHA256) instead of 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5 --- api/model_resolution.py | 120 +++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 58 deletions(-) diff --git a/api/model_resolution.py b/api/model_resolution.py index f603075..d916ec1 100644 --- a/api/model_resolution.py +++ b/api/model_resolution.py @@ -18,7 +18,7 @@ try: except ImportError: from model_policies import requires_hash_for_model_recovery from .metadata import get_metadata -from .model_constants import RESOLVABLE_MODEL_TYPES +from .model_constants import MODEL_EXTENSIONS, RESOLVABLE_MODEL_TYPES from .utils import atomic_write_json def _parse_resolution_types(expected_types_raw): @@ -266,66 +266,70 @@ async def api_resolve_hash_batch(request): return web.json_response({"results": await asyncio.to_thread(resolve_batch)}) +def collect_model_hash_index(): + """Scanned models keyed by relative path and basename -> {hash, size, url}. + + Ambiguous keys (same name, different hash) are dropped. Shared by the + all-hashes endpoint and the output gallery's hash search. + """ + hashes = {} + ambiguous_keys = set() + + def add_hash(key, value): + if key in ambiguous_keys: + return + existing = hashes.get(key) + if existing is None or existing == value: + hashes[key] = value + else: + hashes.pop(key, None) + ambiguous_keys.add(key) + + types = RESOLVABLE_MODEL_TYPES + seen_dirs = set() + for t in types: + try: + paths = folder_paths.get_folder_paths(t) + if not paths: continue + for base_dir in paths: + if base_dir in seen_dirs: continue + seen_dirs.add(base_dir) + if not os.path.exists(base_dir): continue + for root, dirs, files in os.walk(base_dir): + for file in files: + if file.lower().endswith(MODEL_EXTENSIONS): + file_path = os.path.join(root, file) + try: + size_bytes = os.path.getsize(file_path) + except Exception: + size_bytes = 0 + + meta = get_metadata(file_path) + hash_val = "" + if meta and meta.get("hash"): + hash_val = meta["hash"] + model_url = "" + if meta: + model_url = meta.get("source_url") or meta.get("civitai_url") or "" + + rel_path = os.path.relpath(file_path, base_dir) + if rel_path.startswith('.\\') or rel_path.startswith('./'): + rel_path = rel_path[2:] + rel_path = rel_path.replace('\\', '/') + basename = os.path.basename(file_path) + + val = {"hash": hash_val, "size": size_bytes, "url": model_url} + add_hash(rel_path, val) + add_hash(basename, val) + except Exception: + pass + return hashes + + async def api_get_all_hashes(request): """ Returns a dictionary of all scanned models with their hash and size. Keyed by both relative path and basename for maximum frontend resilience. """ - import asyncio - - def fetch_all(): - hashes = {} - ambiguous_keys = set() - - def add_hash(key, value): - if key in ambiguous_keys: - return - existing = hashes.get(key) - if existing is None or existing == value: - hashes[key] = value - else: - hashes.pop(key, None) - ambiguous_keys.add(key) - - types = RESOLVABLE_MODEL_TYPES - seen_dirs = set() - for t in types: - try: - paths = folder_paths.get_folder_paths(t) - if not paths: continue - for base_dir in paths: - if base_dir in seen_dirs: continue - seen_dirs.add(base_dir) - if not os.path.exists(base_dir): continue - for root, dirs, files in os.walk(base_dir): - for file in files: - if file.lower().endswith(MODEL_EXTENSIONS): - file_path = os.path.join(root, file) - try: - size_bytes = os.path.getsize(file_path) - except Exception: - size_bytes = 0 - - meta = get_metadata(file_path) - hash_val = "" - if meta and meta.get("hash"): - hash_val = meta["hash"] - model_url = "" - if meta: - model_url = meta.get("source_url") or meta.get("civitai_url") or "" - - rel_path = os.path.relpath(file_path, base_dir) - if rel_path.startswith('.\\') or rel_path.startswith('./'): - rel_path = rel_path[2:] - rel_path = rel_path.replace('\\', '/') - basename = os.path.basename(file_path) - - val = {"hash": hash_val, "size": size_bytes, "url": model_url} - add_hash(rel_path, val) - add_hash(basename, val) - except Exception: - pass - return hashes - - hashes = await asyncio.to_thread(fetch_all) + hashes = await asyncio.to_thread(collect_model_hash_index) return web.json_response(hashes) From 1476fafd8a90d47839b71706e4435123261f5626 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Wed, 23 Sep 2026 09:43:27 +0000 Subject: [PATCH 3/4] feat(gallery): search output images by prompt, model, seed and model hash - api/image_search.py reads only the PNG text chunks before IDAT (ComfyUI prompt/workflow, A1111 parameters), builds one small record per image and caches it by mtime; all space-separated terms must match - hex terms of 8+ characters also match SHA256 values recorded in the workflow (extra.anomalous_hashes) and local model files with that hash, so renamed models are still found; A1111 "Model hash" text matches as text - gallery_images accepts q; the search box above the gallery debounces input, shows the match count, keeps paging/infinite scroll and the detail viewer on the filtered list, and lets a newer query supersede one still loading On the local output (774 PNG) the first search reads ~10 MB of text chunks; later searches use the cache. Verified: tests/test_image_search.py (prompt, seed, AND terms, A1111, file names, recorded and local-index hashes, cache, chunk reader), full Python suite (106), jsdom walkthrough of the gallery search box. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5 --- ARCHITECTURE.md | 6 + api/gallery_routes.py | 10 +- api/image_search.py | 195 ++++++++++++++++++++++++++++++ docs/architecture/backend.md | 5 +- web/modules/locales.js | 10 ++ web/modules/ui_gallery.js | 63 ++++++++-- web/modules/ui_sidebar.js | 3 +- web/styles/03-gallery-recipes.css | 42 +++++++ 8 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 api/image_search.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0d0690b..4fa9da5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -84,6 +84,12 @@ DOM or live LiteGraph state. `api/folder_types.py` own the formerly mixed utility route families. - `model_policies.py` owns shared backend rename and protected-category policy. - `model_identity.py` owns file SHA-256 evidence shared with the standalone scanner. +- `api/image_search.py` powers the output gallery search (`gallery_images?q=`): + it reads only the PNG text chunks before the pixel data (ComfyUI prompt and + workflow, A1111 `parameters`), caches one record per image by mtime, and + matches all query terms; hex terms of 8+ characters also match recorded model + SHA256 values and, via `collect_model_hash_index` in `model_resolution.py`, + local model files with that hash. `ui_gallery.js` owns the search box. - `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared browser class and extracted-method wiring; `browser_entry.js` owns the single diff --git a/api/gallery_routes.py b/api/gallery_routes.py index 81780ff..8711590 100644 --- a/api/gallery_routes.py +++ b/api/gallery_routes.py @@ -8,6 +8,7 @@ import time from aiohttp import web import folder_paths +from .image_search import filter_images from .path_utils import require_filename, resolve_within @@ -55,11 +56,18 @@ def _invalidate_gallery_snapshot(): _gallery_snapshot.update(root=None, expires=0, images=[]) +def _gallery_listing(output_dir, refresh, query): + images = _gallery_images(output_dir, refresh) + return filter_images(os.path.realpath(output_dir), images, query) if query else images + + async def api_get_gallery_images(request): + """GET /anomalous/gallery_images?page&limit&refresh&q - Output images, optionally searched by embedded generation info.""" try: page = max(1, int(request.query.get('page', 1))) limit = min(200, max(1, int(request.query.get('limit', 50)))) - images = await asyncio.to_thread(_gallery_images, folder_paths.get_output_directory(), request.query.get('refresh') == '1') + query = request.query.get('q', '').strip() + images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query) total = len(images) start = (page - 1) * limit return web.json_response({"images": images[start:start + limit], "total": total, diff --git a/api/image_search.py b/api/image_search.py new file mode 100644 index 0000000..1bbfd4e --- /dev/null +++ b/api/image_search.py @@ -0,0 +1,195 @@ +"""Search output images by the generation info embedded in them. + +Reads only the PNG text chunks before the pixel data (ComfyUI `prompt` / +`workflow`, A1111/Forge `parameters`), caches one small record per file keyed by +its mtime, and matches every query term against it. Terms that look like a +hash also match model SHA256 values recorded in the workflow and, through the +scanned model index, the local model files that carry that hash. +""" + +import json +import os +import re +import struct +import threading +import time +import zlib + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +MAX_CHUNK_BYTES = 32 * 1024 * 1024 +MAX_CACHED_RECORDS = 60_000 +MODEL_INDEX_SECONDS = 120 +HASH_TERM = re.compile(r"^[0-9a-f]{8,64}$", re.IGNORECASE) +MODEL_FILE = re.compile(r"\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$", re.IGNORECASE) +NUMERIC_KEYS = {"seed", "noise_seed", "steps", "cfg", "denoise", "width", "height"} + +_record_cache = {} +_cache_lock = threading.Lock() +_model_index = {"expires": 0, "entries": {}} +_model_index_lock = threading.Lock() + + +def _decode_text(raw): + """tEXt is Latin-1 by spec, but ComfyUI/PIL may store UTF-8 bytes in it.""" + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + return raw.decode("latin-1", "replace") + + +def read_png_text(path): + """Text chunks that precede IDAT, as {keyword: text}. Pixel data is never read.""" + chunks = {} + with open(path, "rb") as handle: + if handle.read(8) != PNG_SIGNATURE: + return chunks + while True: + header = handle.read(8) + if len(header) < 8: + break + length, kind = struct.unpack(">I4s", header) + if kind in (b"IDAT", b"IEND") or length > MAX_CHUNK_BYTES: + break + data = handle.read(length) + handle.seek(4, os.SEEK_CUR) + try: + if kind == b"tEXt": + key, _, value = data.partition(b"\x00") + chunks[key.decode("latin-1")] = _decode_text(value) + elif kind == b"zTXt": + key, _, rest = data.partition(b"\x00") + chunks[key.decode("latin-1")] = _decode_text(zlib.decompress(rest[1:])) + elif kind == b"iTXt": + key, _, rest = data.partition(b"\x00") + compressed, rest = rest[0], rest[2:] + _, _, rest = rest.partition(b"\x00") # language tag + _, _, text = rest.partition(b"\x00") # translated keyword + chunks[key.decode("latin-1")] = (zlib.decompress(text) if compressed else text).decode("utf-8", "replace") + except (zlib.error, IndexError, ValueError): + continue + return chunks + + +def _normalise_model_ref(value): + ref = str(value).replace("\\", "/").strip().lower() + return {ref, ref.rsplit("/", 1)[-1]} + + +def build_record(path): + """Searchable text, recorded model hashes and referenced model files for one image.""" + name = os.path.basename(path) + texts = [name] + hashes = set() + models = set() + if path.lower().endswith(".png"): + try: + chunks = read_png_text(path) + except OSError: + chunks = {} + prompt = _load_json(chunks.get("prompt")) + if isinstance(prompt, dict): + for node in prompt.values(): + if not isinstance(node, dict): + continue + texts.append(str(node.get("class_type", ""))) + for key, value in (node.get("inputs") or {}).items(): + if isinstance(value, str): + texts.append(value) + if MODEL_FILE.search(value): + models |= _normalise_model_ref(value) + elif isinstance(value, (int, float)) and not isinstance(value, bool) and key in NUMERIC_KEYS: + texts.append(f"{key}:{value} {value}") + workflow = _load_json(chunks.get("workflow")) + recorded = (workflow or {}).get("extra", {}).get("anomalous_hashes") if isinstance(workflow, dict) else None + if isinstance(recorded, dict): + for key, entry in recorded.items(): + sha = entry.get("hash") if isinstance(entry, dict) else entry + if isinstance(sha, str) and sha: + hashes.add(sha.upper()) + model_name = str(key).split("_", 1)[-1] + if MODEL_FILE.search(model_name): + models |= _normalise_model_ref(model_name) + if chunks.get("parameters"): + texts.append(chunks["parameters"]) + return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models} + + +def _load_json(raw): + if not raw: + return None + try: + return json.loads(raw) + except ValueError: + return None + + +def cached_record(path, mtime=None): + """Record for one image; `mtime` from the gallery listing avoids a stat per file.""" + if mtime is None: + try: + mtime = os.stat(path).st_mtime + except OSError: + return None + key = mtime + with _cache_lock: + cached = _record_cache.get(path) + if cached and cached[0] == key: + return cached[1] + if not os.path.isfile(path): + return None + record = build_record(path) + with _cache_lock: + if len(_record_cache) >= MAX_CACHED_RECORDS: + _record_cache.clear() + _record_cache[path] = (key, record) + return record + + +def _local_models_for_hash(term): + """Model file refs whose scanned SHA256 starts with `term` (cached briefly).""" + # Imported lazily: api.utils re-exports gallery_routes, which imports this module. + from .model_resolution import collect_model_hash_index + + with _model_index_lock: + if time.monotonic() >= _model_index["expires"]: + _model_index["entries"] = collect_model_hash_index() + _model_index["expires"] = time.monotonic() + MODEL_INDEX_SECONDS + entries = _model_index["entries"] + wanted = term.upper() + refs = set() + for key, value in entries.items(): + sha = str((value or {}).get("hash") or "").upper() + if sha and sha.startswith(wanted): + refs |= _normalise_model_ref(key) + return refs + + +def parse_query(query): + return [term for term in str(query or "").lower().split() if term] + + +def filter_images(output_dir, images, query): + """Keep gallery entries whose record matches every term of `query`.""" + terms = parse_query(query) + if not terms: + return images + hash_refs = {term: _local_models_for_hash(term) for term in terms if HASH_TERM.match(term)} + matched = [] + for image in images: + record = cached_record(os.path.join(output_dir, image.get("subfolder", ""), image["filename"]), image.get("mtime")) + if record is None: + continue + if all(_term_matches(term, record, hash_refs) for term in terms): + matched.append(image) + return matched + + +def _term_matches(term, record, hash_refs): + if term in record["text"]: + return True + if term in hash_refs: + upper = term.upper() + if any(sha.startswith(upper) for sha in record["hashes"]): + return True + return bool(hash_refs[term] & record["models"]) + return False diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index b88797f..504c719 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -63,8 +63,9 @@ live in `material_schema.py`, `material_assets.py`, and `material_store.py`; The former mixed utility routes are separated: `media_routes.py` owns card thumbnails and model/output media lookups, `gallery_routes.py` owns the bounded -output snapshot and deletion, `translation_routes.py` owns provider fallback, -and `folder_types.py` owns configured visibility and scan scope. +output snapshot and deletion (searched through `image_search.py`, which reads +PNG text chunks only and caches per-image records by mtime), `translation_routes.py` +owns provider fallback, and `folder_types.py` owns configured visibility and scan scope. Offline inference sidecars use non-positive Civitai IDs as sentinels. Metadata normalization must not expose those values as release-page URLs or resolved diff --git a/web/modules/locales.js b/web/modules/locales.js index b6bec38..05b7f29 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -477,6 +477,11 @@ export const i18n = { galleryScrollMore: '向下滚动加载更多', galleryLoadFailed: '加载失败', galleryEmpty: '图库为空', + gallerySearchPlaceholder: '搜索提示词、模型、LoRA、seed、文件名或模型哈希', + gallerySearchHelp: '多个词用空格分开,需同时满足。8 位以上的十六进制按模型哈希匹配(例如 667B5E3B),改过名的模型文件也能找到。首次搜索需要读取图片信息,之后会快很多。', + gallerySearchCount: '找到 {count} 张', + gallerySearchEmpty: '没有找到匹配的图片', + gallerySearching: '正在搜索…', galleryClose: '关闭图库', galleryHistoryTitle: '历史生成图库: {name}', galleryScanning: '加载中,正在扫描图片元数据...', @@ -1721,6 +1726,11 @@ export const i18n = { galleryScrollMore: 'Scroll down to load more', galleryLoadFailed: 'Load failed', galleryEmpty: 'Gallery is empty', + gallerySearchPlaceholder: 'Search prompt, model, LoRA, seed, file name or model hash', + gallerySearchHelp: 'Separate words with spaces; all must match. Hex of 8+ characters matches model hashes (e.g. 667B5E3B), even for renamed model files. The first search reads image info; later ones are much faster.', + gallerySearchCount: '{count} found', + gallerySearchEmpty: 'No matching images', + gallerySearching: 'Searching…', galleryClose: 'Close Gallery', galleryHistoryTitle: 'Generated History: {name}', galleryScanning: 'Loading, scanning metadata...', diff --git a/web/modules/ui_gallery.js b/web/modules/ui_gallery.js index 1dd7a35..a6d655a 100644 --- a/web/modules/ui_gallery.js +++ b/web/modules/ui_gallery.js @@ -25,14 +25,59 @@ export async function refreshGalleryImages() { -export async function loadGalleryImages(page = 1, reset = false) { - if (this.galleryLoading) return; +const GALLERY_SEARCH_DELAY_MS = 350; + +/** Search box above the output gallery; the query lives on the browser as `gallerySearchQuery`. */ +export function createGallerySearchBar(owner) { + const bar = document.createElement('label'); + bar.className = 'anomalous-gallery-search'; + bar.title = t('gallerySearchHelp'); + const icon = document.createElement('span'); + icon.className = 'anomalous-gallery-search-icon'; + icon.innerHTML = ''; + const input = document.createElement('input'); + input.type = 'search'; + input.placeholder = t('gallerySearchPlaceholder'); + const count = document.createElement('span'); + count.className = 'anomalous-gallery-search-count'; + owner.gallerySearchCount = count; + let timer = null; + input.oninput = () => { + clearTimeout(timer); + timer = setTimeout(() => { + const query = input.value.trim(); + if (query === (owner.gallerySearchQuery || '')) return; + owner.gallerySearchQuery = query; + owner.loadGalleryImages(1, true, { refresh: false }); + }, GALLERY_SEARCH_DELAY_MS); + }; + bar.append(icon, input, count); + return bar; +} + +export async function loadGalleryImages(page = 1, reset = false, { refresh = reset } = {}) { + if (this.galleryLoading) { + // A newer query arrived while loading: run it once the current request settles. + if (reset) this.galleryReloadPending = true; + return; + } this.galleryLoading = true; - this.gallerySentinel.textContent = t('galleryLoading'); + const query = this.gallerySearchQuery || ''; + this.gallerySentinel.textContent = query && page === 1 ? t('gallerySearching') : t('galleryLoading'); try { - const res = await fetch(`/anomalous/gallery_images?page=${page}&limit=50${reset ? "&refresh=1" : ""}`); + const params = new URLSearchParams({ page: String(page), limit: '50' }); + if (refresh) params.set('refresh', '1'); + if (query) params.set('q', query); + const res = await fetch(`/anomalous/gallery_images?${params}`); const data = await res.json(); + if (query !== (this.gallerySearchQuery || '')) { + this.galleryReloadPending = true; + return; + } + if (this.gallerySearchCount) { + this.gallerySearchCount.textContent = query && Number.isFinite(data.total) ? t('gallerySearchCount', { count: data.total }) : ''; + } if (reset) { // Clear existing cards @@ -290,14 +335,18 @@ export async function loadGalleryImages(page = 1, reset = false) { } } else { this.galleryHasMore = false; - this.gallerySentinel.textContent = reset ? t('galleryEmpty') : t('galleryNoMore'); + this.gallerySentinel.textContent = reset ? t(query ? 'gallerySearchEmpty' : 'galleryEmpty') : t('galleryNoMore'); } } catch (e) { console.error('Failed to load gallery images', e); this.gallerySentinel.textContent = t('galleryLoadFailed'); + } finally { + this.galleryLoading = false; + if (this.galleryReloadPending) { + this.galleryReloadPending = false; + void this.loadGalleryImages(1, true, { refresh: false }); + } } - - this.galleryLoading = false; } diff --git a/web/modules/ui_sidebar.js b/web/modules/ui_sidebar.js index 1bc7511..17ce94e 100644 --- a/web/modules/ui_sidebar.js +++ b/web/modules/ui_sidebar.js @@ -10,6 +10,7 @@ import { showUpdateGuide } from './ui_update_guide.js'; import { setScanButtonState } from './ui_scan_wizard.js'; import { createSettingsHub } from './ui_settings_hub.js'; import { createToolbox } from './ui_toolbox.js'; +import { createGallerySearchBar } from './ui_gallery.js'; const t = (key, params) => translate(key, params); @@ -488,7 +489,7 @@ export function createDOM() { this.galleryGrid = document.createElement('div'); this.galleryGrid.className = 'anomalous-gallery-grid'; - this.galleryPanel.appendChild(this.galleryGrid); + this.galleryPanel.append(createGallerySearchBar(this), this.galleryGrid); this.gallerySentinel = document.createElement('div'); this.gallerySentinel.className = 'anomalous-gallery-sentinel'; diff --git a/web/styles/03-gallery-recipes.css b/web/styles/03-gallery-recipes.css index 68e5db3..8affe77 100644 --- a/web/styles/03-gallery-recipes.css +++ b/web/styles/03-gallery-recipes.css @@ -2849,3 +2849,45 @@ dialog.anomalous-gallery-viewer[open] { transform: translateY(-2px); } + +/* Output gallery search (embedded generation info, model hashes) */ +.anomalous-gallery-search { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 20px 0; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.25); + color: #94a3b8; + flex-shrink: 0; +} + +.anomalous-gallery-search:focus-within { + border-color: rgba(129, 140, 248, 0.5); +} + +.anomalous-gallery-search-icon { + display: inline-flex; +} + +.anomalous-gallery-search input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + outline: none; + color: #f1f5f9; + font-size: 13px; +} + +.anomalous-gallery-search-count { + font-size: 12px; + color: #a5b4fc; + white-space: nowrap; +} + +.anomalous-gallery-search-count:empty { + display: none; +} From e0776891c82b2b27e2918184ec8fda5c3ea73287 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Wed, 23 Sep 2026 09:57:47 +0000 Subject: [PATCH 4/4] feat(gallery): turn search terms into visible, removable search blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting the query on spaces hid how it was read and made phrases like "cyberpunk city" impossible to search. - ui_search_chips.js: Enter or a comma (also "," / "、" inserted by an IME) turns the typed text into a block; blocks keep their spaces and match as a phrase; hash-like and numeric blocks are labelled; × removes a block, Backspace in an empty box removes the last one, one button clears all; text still being typed joins the search after a short pause; IME composition never commits a block - gallery_images accepts repeated `term` params (q still works) Verified: tests/test_image_search.py (phrase blocks vs split query, 107 Python tests in total) and a jsdom walkthrough of blocks, IME commas, backspace, remove, clear and the request parameters. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5 --- ARCHITECTURE.md | 4 +- api/gallery_routes.py | 6 +- api/image_search.py | 11 +- web/modules/locales.js | 16 ++- web/modules/ui_gallery.js | 46 +++----- web/modules/ui_search_chips.js | 170 ++++++++++++++++++++++++++++++ web/styles/03-gallery-recipes.css | 103 +++++++++++++++--- 7 files changed, 305 insertions(+), 51 deletions(-) create mode 100644 web/modules/ui_search_chips.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4fa9da5..9cb801c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -89,7 +89,9 @@ DOM or live LiteGraph state. workflow, A1111 `parameters`), caches one record per image by mtime, and matches all query terms; hex terms of 8+ characters also match recorded model SHA256 values and, via `collect_model_hash_index` in `model_resolution.py`, - local model files with that hash. `ui_gallery.js` owns the search box. + local model files with that hash. Terms arrive as repeated `term` params, one + phrase each. `ui_search_chips.js` is the reusable search-block input (Enter or a + comma commits a block); `ui_gallery.js` places it above the gallery. - `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared browser class and extracted-method wiring; `browser_entry.js` owns the single diff --git a/api/gallery_routes.py b/api/gallery_routes.py index 8711590..4a424d0 100644 --- a/api/gallery_routes.py +++ b/api/gallery_routes.py @@ -62,11 +62,13 @@ def _gallery_listing(output_dir, refresh, query): async def api_get_gallery_images(request): - """GET /anomalous/gallery_images?page&limit&refresh&q - Output images, optionally searched by embedded generation info.""" + """GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q) - Output images, optionally searched by embedded generation info.""" try: page = max(1, int(request.query.get('page', 1))) limit = min(200, max(1, int(request.query.get('limit', 50)))) - query = request.query.get('q', '').strip() + # Repeated `term` params keep phrases whole; `q` is split on whitespace. + terms = [term for term in request.query.getall('term', []) if term.strip()] + query = terms or request.query.get('q', '').strip() images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query) total = len(images) start = (page - 1) * limit diff --git a/api/image_search.py b/api/image_search.py index 1bbfd4e..def6045 100644 --- a/api/image_search.py +++ b/api/image_search.py @@ -165,11 +165,18 @@ def _local_models_for_hash(term): def parse_query(query): - return [term for term in str(query or "").lower().split() if term] + """Search terms: a list keeps each entry as one phrase; a string splits on whitespace.""" + raw = query if isinstance(query, (list, tuple)) else str(query or "").split() + terms = [] + for term in raw: + term = " ".join(str(term).lower().split()) + if term and term not in terms: + terms.append(term) + return terms def filter_images(output_dir, images, query): - """Keep gallery entries whose record matches every term of `query`.""" + """Keep gallery entries whose record matches every term (phrases may contain spaces).""" terms = parse_query(query) if not terms: return images diff --git a/web/modules/locales.js b/web/modules/locales.js index 05b7f29..a7ba821 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -477,8 +477,12 @@ export const i18n = { galleryScrollMore: '向下滚动加载更多', galleryLoadFailed: '加载失败', galleryEmpty: '图库为空', - gallerySearchPlaceholder: '搜索提示词、模型、LoRA、seed、文件名或模型哈希', - gallerySearchHelp: '多个词用空格分开,需同时满足。8 位以上的十六进制按模型哈希匹配(例如 667B5E3B),改过名的模型文件也能找到。首次搜索需要读取图片信息,之后会快很多。', + gallerySearchPlaceholder: '输入后按回车变成一个搜索块:提示词、模型、LoRA、seed、文件名或模型哈希', + gallerySearchHelp: '每个搜索块单独匹配,所有块需同时满足;块里可以有空格,按整段短语匹配。8 位以上的十六进制按模型哈希匹配(例如 667B5E3B),改过名的模型文件也能找到。输入框为空时按退格删除最后一块。首次搜索需要读取图片信息,之后会快很多。', + searchChipHash: '哈希', + searchChipNumber: '数字', + searchChipRemove: '移除这个搜索块', + searchChipsClear: '清空搜索', gallerySearchCount: '找到 {count} 张', gallerySearchEmpty: '没有找到匹配的图片', gallerySearching: '正在搜索…', @@ -1726,8 +1730,12 @@ export const i18n = { galleryScrollMore: 'Scroll down to load more', galleryLoadFailed: 'Load failed', galleryEmpty: 'Gallery is empty', - gallerySearchPlaceholder: 'Search prompt, model, LoRA, seed, file name or model hash', - gallerySearchHelp: 'Separate words with spaces; all must match. Hex of 8+ characters matches model hashes (e.g. 667B5E3B), even for renamed model files. The first search reads image info; later ones are much faster.', + gallerySearchPlaceholder: 'Type and press Enter to add a search block: prompt, model, LoRA, seed, file name or model hash', + gallerySearchHelp: 'Each search block matches on its own and all blocks must match; a block may contain spaces and matches as a phrase. Hex of 8+ characters matches model hashes (e.g. 667B5E3B), even for renamed model files. Backspace in an empty box removes the last block. The first search reads image info; later ones are much faster.', + searchChipHash: 'hash', + searchChipNumber: 'number', + searchChipRemove: 'Remove this search block', + searchChipsClear: 'Clear search', gallerySearchCount: '{count} found', gallerySearchEmpty: 'No matching images', gallerySearching: 'Searching…', diff --git a/web/modules/ui_gallery.js b/web/modules/ui_gallery.js index a6d655a..f71afca 100644 --- a/web/modules/ui_gallery.js +++ b/web/modules/ui_gallery.js @@ -6,6 +6,7 @@ import { app } from "../../../scripts/app.js"; import { translate } from './locales.js'; import { showImageMaterialDetail } from './ui_materials.js'; +import { createSearchChips } from './ui_search_chips.js'; const t = (key, params) => translate(key, params); @@ -25,34 +26,19 @@ export async function refreshGalleryImages() { -const GALLERY_SEARCH_DELAY_MS = 350; - -/** Search box above the output gallery; the query lives on the browser as `gallerySearchQuery`. */ +/** Search blocks above the output gallery; the terms live on the browser as `gallerySearchTerms`. */ export function createGallerySearchBar(owner) { - const bar = document.createElement('label'); - bar.className = 'anomalous-gallery-search'; - bar.title = t('gallerySearchHelp'); - const icon = document.createElement('span'); - icon.className = 'anomalous-gallery-search-icon'; - icon.innerHTML = ''; - const input = document.createElement('input'); - input.type = 'search'; - input.placeholder = t('gallerySearchPlaceholder'); - const count = document.createElement('span'); - count.className = 'anomalous-gallery-search-count'; - owner.gallerySearchCount = count; - let timer = null; - input.oninput = () => { - clearTimeout(timer); - timer = setTimeout(() => { - const query = input.value.trim(); - if (query === (owner.gallerySearchQuery || '')) return; - owner.gallerySearchQuery = query; + const search = createSearchChips({ + placeholder: t('gallerySearchPlaceholder'), + help: t('gallerySearchHelp'), + onChange: (terms) => { + owner.gallerySearchTerms = terms; owner.loadGalleryImages(1, true, { refresh: false }); - }, GALLERY_SEARCH_DELAY_MS); - }; - bar.append(icon, input, count); - return bar; + }, + }); + search.element.classList.add('anomalous-gallery-search'); + owner.gallerySearchCount = search.countElement; + return search.element; } export async function loadGalleryImages(page = 1, reset = false, { refresh = reset } = {}) { @@ -62,16 +48,18 @@ export async function loadGalleryImages(page = 1, reset = false, { refresh = res return; } this.galleryLoading = true; - const query = this.gallerySearchQuery || ''; + const terms = [...(this.gallerySearchTerms || [])]; + const termsKey = JSON.stringify(terms); + const query = terms.length > 0; this.gallerySentinel.textContent = query && page === 1 ? t('gallerySearching') : t('galleryLoading'); try { const params = new URLSearchParams({ page: String(page), limit: '50' }); if (refresh) params.set('refresh', '1'); - if (query) params.set('q', query); + terms.forEach(term => params.append('term', term)); const res = await fetch(`/anomalous/gallery_images?${params}`); const data = await res.json(); - if (query !== (this.gallerySearchQuery || '')) { + if (termsKey !== JSON.stringify(this.gallerySearchTerms || [])) { this.galleryReloadPending = true; return; } diff --git a/web/modules/ui_search_chips.js b/web/modules/ui_search_chips.js new file mode 100644 index 0000000..5a2bcda --- /dev/null +++ b/web/modules/ui_search_chips.js @@ -0,0 +1,170 @@ +import { translate } from './locales.js'; + +/** + * Search box whose terms are visible blocks. Enter (or a comma) turns the typed + * text into a block; a block may contain spaces and matches as one phrase. + * The text still being typed also takes part in the search. + */ + +const t = (key, params) => translate(key, params); +const COMMIT_KEYS = new Set(['Enter', ',', ',', '、']); +const DRAFT_DELAY_MS = 350; + +/** 'hash' for 8+ hex characters, 'number' for digits (seed, steps…), else 'text'. */ +export function termKind(term) { + const value = String(term || '').trim(); + if (/^\d+$/.test(value)) return 'number'; + if (/^[0-9a-f]{8,64}$/i.test(value)) return 'hash'; + return 'text'; +} + +function normaliseTerm(term) { + return String(term || '').replace(/\s+/g, ' ').trim(); +} + +export function createSearchChips({ placeholder = '', help = '', onChange } = {}) { + const root = document.createElement('div'); + root.className = 'anomalous-search-chips'; + if (help) root.title = help; + + const icon = document.createElement('span'); + icon.className = 'anomalous-search-chips-icon'; + icon.innerHTML = ''; + + const list = document.createElement('div'); + list.className = 'anomalous-search-chips-list'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'anomalous-search-chips-input'; + input.placeholder = placeholder; + input.setAttribute('aria-label', placeholder); + + const count = document.createElement('span'); + count.className = 'anomalous-search-chips-count'; + + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'anomalous-search-chips-clear'; + clearBtn.textContent = '×'; + clearBtn.title = t('searchChipsClear'); + clearBtn.setAttribute('aria-label', t('searchChipsClear')); + + const chips = []; + let lastEmitted = '[]'; + let timer = null; + + const currentTerms = () => { + const terms = []; + for (const term of [...chips, normaliseTerm(input.value)]) { + const key = term.toLowerCase(); + if (term && !terms.some(existing => existing.toLowerCase() === key)) terms.push(term); + } + return terms; + }; + + const emit = () => { + clearTimeout(timer); + const terms = currentTerms(); + clearBtn.hidden = terms.length === 0; + const key = JSON.stringify(terms.map(term => term.toLowerCase())); + if (key === lastEmitted) return; + lastEmitted = key; + onChange?.(terms); + }; + + const renderChips = () => { + list.replaceChildren(...chips.map((term, index) => { + const chip = document.createElement('span'); + const kind = termKind(term); + chip.className = `anomalous-search-chip is-${kind}`; + if (kind !== 'text') { + const badge = document.createElement('span'); + badge.className = 'anomalous-search-chip-kind'; + badge.textContent = t(kind === 'hash' ? 'searchChipHash' : 'searchChipNumber'); + chip.appendChild(badge); + } + const label = document.createElement('span'); + label.className = 'anomalous-search-chip-text'; + label.textContent = term; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'anomalous-search-chip-remove'; + remove.textContent = '×'; + remove.title = t('searchChipRemove'); + remove.setAttribute('aria-label', t('searchChipRemove')); + remove.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + chips.splice(index, 1); + renderChips(); + emit(); + input.focus(); + }; + chip.append(label, remove); + return chip; + })); + input.placeholder = chips.length ? '' : placeholder; + }; + + const commitDraft = () => { + const term = normaliseTerm(input.value); + input.value = ''; + if (term && !chips.some(existing => existing.toLowerCase() === term.toLowerCase())) chips.push(term); + renderChips(); + emit(); + }; + + input.addEventListener('keydown', (e) => { + if (e.isComposing) return; // IME candidate selection also uses Enter + if (COMMIT_KEYS.has(e.key)) { + e.preventDefault(); + commitDraft(); + } else if (e.key === 'Backspace' && !input.value && chips.length) { + e.preventDefault(); + chips.pop(); + renderChips(); + emit(); + } else if (e.key === 'Escape' && input.value) { + e.stopPropagation(); + input.value = ''; + emit(); + } + }); + input.addEventListener('input', (e) => { + // IMEs insert "," / "、" as text instead of a key event: treat them as separators too. + if (!e.isComposing && /[,,、]/.test(input.value)) { + const parts = input.value.split(/[,,、]/); + const rest = parts.pop(); + for (const part of parts) { + const term = normaliseTerm(part); + if (term && !chips.some(existing => existing.toLowerCase() === term.toLowerCase())) chips.push(term); + } + input.value = rest.trimStart(); + renderChips(); + emit(); + return; + } + clearBtn.hidden = currentTerms().length === 0; + clearTimeout(timer); + timer = setTimeout(emit, DRAFT_DELAY_MS); + }); + input.addEventListener('blur', () => { if (normaliseTerm(input.value)) commitDraft(); }); + clearBtn.onclick = () => { + chips.length = 0; + input.value = ''; + renderChips(); + emit(); + input.focus(); + }; + root.addEventListener('mousedown', (e) => { + if (e.target === root || e.target === list) { + e.preventDefault(); + input.focus(); + } + }); + + clearBtn.hidden = true; + root.append(icon, list, input, count, clearBtn); + return { element: root, countElement: count, getTerms: currentTerms }; +} diff --git a/web/styles/03-gallery-recipes.css b/web/styles/03-gallery-recipes.css index 8affe77..17694e8 100644 --- a/web/styles/03-gallery-recipes.css +++ b/web/styles/03-gallery-recipes.css @@ -2850,44 +2850,121 @@ dialog.anomalous-gallery-viewer[open] { } -/* Output gallery search (embedded generation info, model hashes) */ -.anomalous-gallery-search { +/* Search blocks (ui_search_chips.js); used above the output gallery */ +.anomalous-search-chips { display: flex; + flex-wrap: wrap; align-items: center; - gap: 8px; - margin: 12px 20px 0; - padding: 6px 12px; + gap: 6px; + padding: 5px 10px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(0, 0, 0, 0.25); color: #94a3b8; - flex-shrink: 0; + cursor: text; } -.anomalous-gallery-search:focus-within { +.anomalous-search-chips:focus-within { border-color: rgba(129, 140, 248, 0.5); } -.anomalous-gallery-search-icon { +.anomalous-search-chips-icon { display: inline-flex; } -.anomalous-gallery-search input { - flex: 1; - min-width: 0; +.anomalous-search-chips-list { + display: contents; +} + +.anomalous-search-chips-input { + flex: 1 1 160px; + min-width: 120px; background: transparent; border: none; outline: none; color: #f1f5f9; font-size: 13px; + padding: 3px 0; } -.anomalous-gallery-search-count { +.anomalous-search-chip { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 100%; + padding: 2px 4px 2px 9px; + border-radius: 999px; + border: 1px solid rgba(129, 140, 248, 0.4); + background: rgba(99, 102, 241, 0.18); + color: #e0e7ff; + font-size: 12px; + line-height: 1.5; +} + +.anomalous-search-chip.is-hash { + border-color: rgba(251, 191, 36, 0.5); + background: rgba(245, 158, 11, 0.14); + color: #fde68a; + font-family: monospace; +} + +.anomalous-search-chip.is-number { + border-color: rgba(52, 211, 153, 0.45); + background: rgba(16, 185, 129, 0.14); + color: #a7f3d0; +} + +.anomalous-search-chip-kind { + font-size: 10px; + opacity: 0.75; + font-family: inherit; +} + +.anomalous-search-chip-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.anomalous-search-chip-remove, +.anomalous-search-chips-clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: inherit; + font-size: 14px; + line-height: 1; + cursor: pointer; + opacity: 0.7; +} + +.anomalous-search-chip-remove:hover, +.anomalous-search-chips-clear:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.12); +} + +.anomalous-search-chips-clear[hidden] { + display: none; +} + +.anomalous-search-chips-count { font-size: 12px; color: #a5b4fc; white-space: nowrap; } -.anomalous-gallery-search-count:empty { +.anomalous-search-chips-count:empty { display: none; } + +.anomalous-gallery-search { + margin: 12px 20px 0; + flex-shrink: 0; +}