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