When the user clicks the × on a tag pill, the tag is removed from the
image and a small '/Removed 'X' — [⊘ Blocklist] [×]/' control surfaces
below the tag input. One click on ⊘ Blocklist escalates the removal
to a global blocklist add (which also enqueues the retroactive sweep
Celery task, removing the tag from every other image that has it).
Fades after 8s, hides on image switch or modal close.
Targets the composition-descriptor case: WD14 tags like '1girl',
'breasts', 'simple background' that show up on many images. Without
this, after removing the pill the user has to navigate to Settings →
Maintenance → Suggestion blocklist to add the name, or wait for the
next suggestion chip to appear and use its ⊘. Now it's one click at
the point of removal.
Misclick recovery unchanged: the pill × alone only rejects per-image.
The inline offer is opt-in; ignore it or click × to dismiss and the
removal stays scoped to the current image only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two capabilities that work together to turn the suggestion system from
a 'review every noisy chip' UX into a 'curated tags only, configurable
auto-apply' UX for a solo-user library.
Auto-accept threshold
Service (tag_suggestions.py):
- New default auto_accept_general_threshold = 0.95 in _DEFAULTS
- get_suggestions splits general-category hits at/above the threshold
into a separate auto_accept_candidates list; the core 'character'/
'copyright'/'general' keys stay the same shape for existing callers
- get_bulk_suggestions ignores the new key (no side effects in bulk)
Route (main.py GET /image/<id>/suggestions):
- For each candidate: find-or-create Tag(kind='user', name=name), attach
to image, log SuggestionFeedback(source='wd14'|etc, decision='accepted')
- Response adds 'auto_accepted: [{id, name, display_name, kind,
confidence, source}, ...]' so the modal can review + undo
Config endpoints (main.py):
GET /api/suggestions/config/auto-accept-threshold
POST /api/suggestions/config/auto-accept-threshold {threshold}
Values > 1.0 effectively disable the feature
UI (view-modal.js):
- New renderAutoAccepted block at top of suggestions section with
green-tinted chips carrying ✕ (undo for this image, logs rejection)
and ⊘ (blocklist + remove from all images)
- loadSuggestions refreshes the tag pill list when auto_accepted has
items so the user sees them in the Tags section too
Settings:
- Maintenance tab gains a number input + save button backed by
/api/suggestions/config/auto-accept-threshold
Retroactive blocklist sweep
New Celery task app.tasks.maintenance.sweep_blocklisted_tag_from_images
on the maintenance queue. Enqueued automatically when a name is added
via POST /api/suggestions/blocklist or appears new in the bulk replace.
Task body: finds kind='user' Tag matching the name, deletes its
image_tags rows, deletes the Tag itself. Scope limited to kind='user'
so deliberate character/fandom/artist tags sharing a blocklisted name
aren't silently destroyed.
Verification (local dev):
- Threshold GET/POST round-trip correct (default 0.95, persisted in
tag_suggestion_config)
- Image 633 at threshold=0.9: 4 general tags auto-applied, each with
a SuggestionFeedback row, returned in auto_accepted
- Blocklist add of 'sweeptestonly' with an attached test tag: Redis
maintenance queue depth = 1, task body removes 1 image_tags row +
the Tag row
- get_bulk_suggestions still works (auto_accept_candidates key skipped)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a tag_suggestion_blocklist table + service-layer filter so the
user can permanently suppress specific canonical tag names from the
suggestion stream (WD14 anatomy/composition tags like '1girl',
'breasts', 'nipples' that aren't useful for the discoverability use
case this app targets).
Data model
- migration k26042201: tag_suggestion_blocklist(name TEXT PK, created_at)
- model TagSuggestionBlocklistEntry
Service
- tag_suggestions._blocklisted_names() snapshots the current set
- get_suggestions filters merged results before grouping, so both
WD14- and embedding-sourced suggestions respect the blocklist
- get_bulk_suggestions inherits the filter via its per-image call
to get_suggestions
API
- GET /api/suggestions/blocklist -> {ok, names}
- POST /api/suggestions/blocklist -> add one
- POST /api/suggestions/blocklist/delete -> remove one
- POST /api/suggestions/blocklist/bulk -> replace the whole list
(backs the settings textarea save button)
UI
- modal suggestion chip gets a third action button (⊘) alongside
accept (✓) / reject (✕). Clicking it adds the name to the
blocklist, logs a rejection for ML feedback on this image, and
sweeps every chip on the page carrying that same name.
- Settings -> Maintenance -> Suggestion blocklist section with a
monospaced textarea (one name per line) + Save. Loads current
entries on mount.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1. Hide 'archive' kind from /tags default view. Archive tags only
appear when the user explicitly requests them via ?kind=archive.
_get_tags_with_previews gains a default_excluded_kinds tuple that
applies when no explicit kind filter is set.
2. Hide the 'Add' submit button on desktop (>=768px). Enter in the
tag input (and in the fandom picker when no row is highlighted)
already submits; the button is only needed for mobile virtual
keyboards where Enter-to-submit is unreliable.
3. Fandom picker keyboard parity: ArrowUp/Down navigate rows, Enter
picks a highlighted row or (if no row is highlighted) submits the
whole add-tag form. Escape clears the highlight. Mirrors the main
tagInput's keyboard block.
4. Tag editor merge bug: the edit modal populated nameInput.value
with data.tag.display_name — for a character with a fandom, that
value is 'Name (Fandom)'. Saving without stripping the suffix
routed to a rename that never collided with the canonical bare
name, so the merge offer never fired. Now uses data.tag.name (the
bare form). The (Fandom) display is still shown in the fandom
input which is rendered separately.
5. Fandom picker styling: match the main tag-form input — same
padding, border, background, focus state. Drop the separate
'Fandom' label; the placeholder 'Fandom (optional)' now carries
the label role.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Accept suggestion requests now send 'name' (matches Task 8 backend)
and preserve source/confidence for feedback logging. New 409 handler
renders an inline picker with each candidate's display_name; user
clicks one, which re-POSTs with explicit tag_id.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
HTML/CSS for the inline fandom picker that slides in below the add-tag
input. JS wiring in the next commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a header counter ('⚠ N characters need a fandom') that filters to
?null_fandom=1, plus an inline chip on each qualifying character card.
Backend accepts null_fandom_only in the list query.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the floating autocomplete popup with a reserved ~5-row inline
area below the add-tag input. It never covers provenance or suggestion
chips — they stay visible side-by-side with the autocomplete list while
the user types. The area scrolls when more matches are loaded, and is
prefilled with top tags on every image load so it's never empty.
The base .tag-autocomplete class (still used by bulk-select and the
fandom picker) keeps its floating behavior; the modal opts in to the
new inline layout via an added .tag-autocomplete-inline modifier.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Consolidated merge of feat/tag-suggestions branch. Original 64-commit history
was lost to git-object corruption in a Nextcloud-synced checkout; this single
commit captures the equivalent diff.
Includes:
- pgvector-backed tag suggestion infra (WD14 + SigLIP centroids, ml-worker
container, Celery tasks, suggestion service, accept/reject endpoints + modal
UI with green/red chip buttons)
- Character/fandom integrity: title-case normalization on every write path,
fandom-id backfill, maintenance task + settings button, migrations g26041901
+ h26041901 to canonicalize legacy rows with case-only duplicate merging
- Tag-underscores + modal polish: WD14 name canonicalization at emit + accept
+ add/bulk-add paths, migration i26041901 for legacy-row rename-or-merge
across character/fandom/NULL kinds, suggestion-accept refresh parity via
awaited loadTags, persistent chip tint
Each settings tab is now a separate page load via ?tab= query param.
Flask renders only the active panel - no CSS or JS visibility logic needed.
Tab bar uses <a> links instead of buttons. Eliminates the display:none
cascade issue that was preventing Import and Maintenance tabs from showing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Filter source tags from gallery overlay refresh in view-modal.js and bulk-select.js
- Fix split(':', 1) bug causing series name to render as "undefined"
- Offset reader quick-nav above floating page indicator to prevent overlap
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove duplicate .reader-nav-thumb:hover and .reader-nav-thumb.active
blocks from the old sidebar styles. The newer rules using opacity and
border-left (added ~line 2646) supersede them.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>