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,";
}
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;
+}