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