feat(suggestions): auto-accept threshold + retroactive blocklist sweep
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>
This commit is contained in:
@@ -71,6 +71,7 @@ def make_celery(app=None):
|
||||
'app.tasks.scan.cleanup_old_tasks': {'queue': 'maintenance'},
|
||||
'app.tasks.scan.update_system_stats': {'queue': 'maintenance'},
|
||||
'app.tasks.scan.update_batch_stats': {'queue': 'maintenance'},
|
||||
'app.tasks.maintenance.sweep_blocklisted_tag_from_images': {'queue': 'maintenance'},
|
||||
|
||||
# Import tasks - handled by worker (heavy processing)
|
||||
'app.tasks.import_file.*': {'queue': 'import'},
|
||||
|
||||
+114
-4
@@ -743,9 +743,57 @@ def list_tags(image_id):
|
||||
|
||||
@main.get("/image/<int:image_id>/suggestions")
|
||||
def get_image_suggestions(image_id):
|
||||
"""Return the modal's suggestion shape AND auto-apply general-category
|
||||
suggestions above the auto_accept_general_threshold config. Each
|
||||
auto-applied tag is attached to the image and a SuggestionFeedback row
|
||||
logged, and the applied tags are surfaced in `auto_accepted` so the UI
|
||||
can show them for review.
|
||||
"""
|
||||
from app.services.tag_suggestions import get_suggestions
|
||||
suggestions = get_suggestions(image_id)
|
||||
return jsonify({'ok': True, 'suggestions': suggestions})
|
||||
from app.models import SuggestionFeedback
|
||||
|
||||
img = ImageRecord.query.get(image_id)
|
||||
if img is None:
|
||||
return jsonify({'ok': False, 'error': 'image_not_found'}), 404
|
||||
|
||||
grouped = get_suggestions(image_id)
|
||||
candidates = grouped.pop('auto_accept_candidates', []) or []
|
||||
|
||||
auto_accepted = []
|
||||
for cand in candidates:
|
||||
name = cand['name']
|
||||
# General-category auto-accepts become kind='user' tags (same shape
|
||||
# as a manual accept of a general suggestion via accept_image_suggestion).
|
||||
tag = Tag.query.filter_by(kind='user', name=name).first()
|
||||
if tag is None:
|
||||
tag = Tag(kind='user', name=name)
|
||||
db.session.add(tag)
|
||||
db.session.flush()
|
||||
if tag not in img.tags:
|
||||
img.tags.append(tag)
|
||||
db.session.add(SuggestionFeedback(
|
||||
image_id=image_id,
|
||||
tag_name=name,
|
||||
suggestion_source=cand.get('source', 'wd14'),
|
||||
confidence=cand.get('confidence', 0.0),
|
||||
decision='accepted',
|
||||
))
|
||||
auto_accepted.append({
|
||||
'id': tag.id,
|
||||
'name': tag.name,
|
||||
'display_name': tag.display_name,
|
||||
'kind': tag.kind,
|
||||
'confidence': cand.get('confidence', 0.0),
|
||||
'source': cand.get('source', 'wd14'),
|
||||
})
|
||||
if auto_accepted:
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'suggestions': grouped,
|
||||
'auto_accepted': auto_accepted,
|
||||
})
|
||||
|
||||
|
||||
@main.post("/api/suggestions/bulk")
|
||||
@@ -952,6 +1000,19 @@ def list_suggestion_blocklist():
|
||||
return jsonify(ok=True, names=[r.name for r in rows])
|
||||
|
||||
|
||||
def _enqueue_blocklist_sweep(name: str) -> None:
|
||||
"""Enqueue the Celery task that removes blocklisted name from image_tags.
|
||||
Best-effort: if enqueue fails, log and continue so the blocklist add
|
||||
itself still succeeds."""
|
||||
try:
|
||||
from app.tasks.maintenance import sweep_blocklisted_tag_from_images
|
||||
sweep_blocklisted_tag_from_images.apply_async(args=[name], queue='maintenance')
|
||||
except Exception as e:
|
||||
current_app.logger.warning(
|
||||
"failed to enqueue blocklist sweep for %r: %s", name, e,
|
||||
)
|
||||
|
||||
|
||||
@main.post("/api/suggestions/blocklist")
|
||||
def add_suggestion_blocklist():
|
||||
from app.models import TagSuggestionBlocklistEntry
|
||||
@@ -964,6 +1025,9 @@ def add_suggestion_blocklist():
|
||||
return jsonify(ok=True, added=False, name=name)
|
||||
db.session.add(TagSuggestionBlocklistEntry(name=name))
|
||||
db.session.commit()
|
||||
# Fire-and-forget retroactive cleanup: remove the now-blocklisted name
|
||||
# from every image that still has it attached.
|
||||
_enqueue_blocklist_sweep(name)
|
||||
return jsonify(ok=True, added=True, name=name)
|
||||
|
||||
|
||||
@@ -982,20 +1046,66 @@ def remove_suggestion_blocklist():
|
||||
return jsonify(ok=True, removed=False, name=name)
|
||||
|
||||
|
||||
@main.get("/api/suggestions/config/auto-accept-threshold")
|
||||
def get_auto_accept_threshold():
|
||||
from app.models import TagSuggestionConfig
|
||||
row = TagSuggestionConfig.query.filter_by(key='auto_accept_general_threshold').first()
|
||||
try:
|
||||
value = float(row.value) if row else 0.95
|
||||
except (TypeError, ValueError):
|
||||
value = 0.95
|
||||
return jsonify(ok=True, threshold=value)
|
||||
|
||||
|
||||
@main.post("/api/suggestions/config/auto-accept-threshold")
|
||||
def set_auto_accept_threshold():
|
||||
from app.models import TagSuggestionConfig
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
try:
|
||||
threshold = float(payload.get('threshold'))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify(ok=False, error='invalid_threshold'), 400
|
||||
if threshold < 0:
|
||||
threshold = 0.0
|
||||
row = TagSuggestionConfig.query.filter_by(key='auto_accept_general_threshold').first()
|
||||
if row is None:
|
||||
row = TagSuggestionConfig(
|
||||
key='auto_accept_general_threshold',
|
||||
value=str(threshold),
|
||||
description='General-category suggestions at or above this confidence are auto-applied by the modal suggestions load. Set to 1.1 or greater to disable.',
|
||||
)
|
||||
db.session.add(row)
|
||||
else:
|
||||
row.value = str(threshold)
|
||||
db.session.commit()
|
||||
return jsonify(ok=True, threshold=threshold)
|
||||
|
||||
|
||||
@main.post("/api/suggestions/blocklist/bulk")
|
||||
def bulk_replace_suggestion_blocklist():
|
||||
"""Replace the entire blocklist with the provided names. Textarea-save path."""
|
||||
"""Replace the entire blocklist with the provided names. Textarea-save path.
|
||||
Enqueues a cleanup sweep for every name that wasn't already blocklisted —
|
||||
names that were already in the list skip the sweep since they've presumably
|
||||
already been swept when first added."""
|
||||
from app.models import TagSuggestionBlocklistEntry
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
names = payload.get('names', [])
|
||||
if not isinstance(names, list):
|
||||
return jsonify(ok=False, error='names_must_be_list'), 400
|
||||
cleaned = sorted({str(n).strip() for n in names if str(n) and str(n).strip()})
|
||||
|
||||
previous = {row.name for row in TagSuggestionBlocklistEntry.query.all()}
|
||||
newly_added = [n for n in cleaned if n not in previous]
|
||||
|
||||
TagSuggestionBlocklistEntry.query.delete()
|
||||
for name in cleaned:
|
||||
db.session.add(TagSuggestionBlocklistEntry(name=name))
|
||||
db.session.commit()
|
||||
return jsonify(ok=True, count=len(cleaned), names=cleaned)
|
||||
|
||||
for name in newly_added:
|
||||
_enqueue_blocklist_sweep(name)
|
||||
|
||||
return jsonify(ok=True, count=len(cleaned), names=cleaned, newly_added=len(newly_added))
|
||||
|
||||
|
||||
@main.post("/image/<int:image_id>/tags/add")
|
||||
|
||||
@@ -57,6 +57,10 @@ _DEFAULTS = {
|
||||
'threshold_meta': 0.5,
|
||||
'threshold_embedding': 0.85,
|
||||
'min_reference_images': 5.0,
|
||||
# Above this confidence, general-category suggestions are auto-applied
|
||||
# to the image by the route layer (see app.main.get_image_suggestions).
|
||||
# Use 1.1 or greater to effectively disable. Default is conservative.
|
||||
'auto_accept_general_threshold': 0.95,
|
||||
'wd14_model_version': 'wd-eva02-large-tagger-v3',
|
||||
'siglip_model_version': 'siglip-so400m-patch14-384',
|
||||
}
|
||||
@@ -246,17 +250,34 @@ def get_suggestions(
|
||||
if existing_all is None:
|
||||
existing_all = _existing_tag_names()
|
||||
|
||||
# Separate out general-category suggestions above the auto-accept
|
||||
# threshold. The route layer applies these to the image directly;
|
||||
# we just identify them here so the service remains side-effect-free.
|
||||
try:
|
||||
auto_accept_threshold = float(cfg.get('auto_accept_general_threshold',
|
||||
_DEFAULTS['auto_accept_general_threshold']))
|
||||
except (TypeError, ValueError):
|
||||
auto_accept_threshold = _DEFAULTS['auto_accept_general_threshold']
|
||||
auto_accept_candidates: list[dict] = []
|
||||
|
||||
grouped: dict[str, list[dict]] = {'character': [], 'copyright': [], 'general': []}
|
||||
for s in merged:
|
||||
if s['category'] not in grouped:
|
||||
continue # meta / rating / artist / unknown are dropped
|
||||
s['exists_in_db'] = s['name'] in existing_all
|
||||
if s['category'] == 'general' and s['confidence'] >= auto_accept_threshold:
|
||||
auto_accept_candidates.append(s)
|
||||
continue
|
||||
grouped[s['category']].append(s)
|
||||
|
||||
for cat in grouped:
|
||||
grouped[cat].sort(key=lambda x: x['confidence'], reverse=True)
|
||||
grouped[cat] = grouped[cat][:top_k_per_category]
|
||||
|
||||
# Stash candidates in a way the shape documents but legacy callers can
|
||||
# ignore. We keep the three core keys at the top level for backwards
|
||||
# compat and add a 4th list.
|
||||
grouped['auto_accept_candidates'] = auto_accept_candidates
|
||||
return grouped
|
||||
|
||||
|
||||
@@ -308,6 +329,11 @@ def get_bulk_suggestions(
|
||||
existing_all=existing_all,
|
||||
)
|
||||
for category, items in grouped.items():
|
||||
# `auto_accept_candidates` is returned by get_suggestions for the
|
||||
# single-image route to apply; skip in bulk (bulk has no single
|
||||
# image to attach to).
|
||||
if category == 'auto_accept_candidates':
|
||||
continue
|
||||
for item in items:
|
||||
name = item['name']
|
||||
stat = tag_stats.get(name)
|
||||
|
||||
@@ -242,13 +242,109 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
const r = await fetch(`/image/${imageId}/suggestions`);
|
||||
const j = await r.json();
|
||||
if (j.ok) renderSuggestions(j.suggestions || {}, imageId);
|
||||
else clearSuggestions();
|
||||
if (!j.ok) { clearSuggestions(); return; }
|
||||
// Render regular suggestions first — it wipes innerHTML. Then prepend
|
||||
// the auto-accepted block so both coexist.
|
||||
renderSuggestions(j.suggestions || {}, imageId);
|
||||
renderAutoAccepted(j.auto_accepted || [], imageId);
|
||||
if ((j.auto_accepted || []).length > 0) {
|
||||
suggestionsSection.style.display = '';
|
||||
}
|
||||
// Auto-accepted tags are now attached to the image — refresh the
|
||||
// pill list so the user sees them in the Tags section.
|
||||
if ((j.auto_accepted || []).length > 0 && typeof loadTags === 'function') {
|
||||
await loadTags(imageId);
|
||||
}
|
||||
} catch {
|
||||
clearSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
function renderAutoAccepted(items, imageId) {
|
||||
if (!suggestionsList) return;
|
||||
// Remove any prior auto-accepted block from the current modal view.
|
||||
suggestionsList.querySelectorAll('.suggestions-auto-accepted').forEach(el => el.remove());
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
const block = document.createElement('div');
|
||||
block.className = 'suggestions-auto-accepted';
|
||||
const label = document.createElement('div');
|
||||
label.className = 'suggestions-category-label';
|
||||
label.textContent = `Auto-accepted (${items.length})`;
|
||||
block.appendChild(label);
|
||||
|
||||
const chips = document.createElement('div');
|
||||
chips.className = 'suggestions-chips';
|
||||
for (const item of items) {
|
||||
chips.appendChild(buildAutoAcceptedChip(item, imageId));
|
||||
}
|
||||
block.appendChild(chips);
|
||||
suggestionsList.prepend(block);
|
||||
}
|
||||
|
||||
function buildAutoAcceptedChip(item, imageId) {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'suggestion-chip suggestion-chip-auto';
|
||||
chip.dataset.tagId = String(item.id);
|
||||
chip.dataset.tagName = item.name;
|
||||
chip.dataset.source = item.source || 'wd14';
|
||||
chip.dataset.confidence = String(item.confidence || 0);
|
||||
chip.title = `Auto-accepted from ${item.source || 'wd14'} at ${Math.round((item.confidence || 0) * 100)}%`;
|
||||
const pct = Math.round((item.confidence || 0) * 100);
|
||||
chip.innerHTML = `
|
||||
<span class="sugg-body">
|
||||
<span class="sugg-label">${escapeHtml(item.display_name || item.name)}</span>
|
||||
<span class="sugg-confidence">${pct}%</span>
|
||||
</span>
|
||||
<button type="button" class="sugg-btn sugg-undo" aria-label="Undo (remove from this image)" title="Remove from this image">✕</button>
|
||||
<button type="button" class="sugg-btn sugg-block" aria-label="Blocklist this tag" title="Never suggest '${escapeHtml(item.name)}' again — removes from all images">⊘</button>
|
||||
`;
|
||||
chip.querySelector('.sugg-undo')?.addEventListener('click', () => undoAutoAccepted(chip, imageId));
|
||||
chip.querySelector('.sugg-block')?.addEventListener('click', () => blocklistSuggestion(chip, imageId));
|
||||
return chip;
|
||||
}
|
||||
|
||||
async function undoAutoAccepted(chip, imageId) {
|
||||
if (chip.dataset.disabled === 'true') return;
|
||||
chip.dataset.disabled = 'true';
|
||||
chip.style.pointerEvents = 'none';
|
||||
const name = chip.dataset.tagName;
|
||||
try {
|
||||
// Remove the tag from this image (uses the same remove endpoint the
|
||||
// manual tag-pill '×' button uses).
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
const r = await fetch(`/image/${imageId}/tags/remove`, { method: 'POST', body: fd });
|
||||
const j = await r.json();
|
||||
if (!j.ok) {
|
||||
chip.dataset.disabled = 'false';
|
||||
chip.style.pointerEvents = '';
|
||||
if (tagActionFeedback) tagActionFeedback.textContent = `Couldn't undo: ${j.error || 'error'}`;
|
||||
return;
|
||||
}
|
||||
// Log the rejection for ML feedback so future threshold tuning sees this.
|
||||
try {
|
||||
await fetch(`/image/${imageId}/suggestions/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tag_name: name,
|
||||
source: chip.dataset.source || 'wd14',
|
||||
confidence: Number(chip.dataset.confidence) || 0,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
if (tagActionFeedback) tagActionFeedback.textContent = `Removed '${name}'`;
|
||||
animateChipOut(chip);
|
||||
if (typeof loadTags === 'function') loadTags(imageId);
|
||||
} catch {
|
||||
chip.dataset.disabled = 'false';
|
||||
chip.style.pointerEvents = '';
|
||||
}
|
||||
}
|
||||
|
||||
function animateChipOut(chip) {
|
||||
chip.classList.add('leaving');
|
||||
setTimeout(() => { chip.remove(); }, 200);
|
||||
|
||||
+40
-1
@@ -1020,7 +1020,46 @@ header {
|
||||
border-color: rgba(255, 180, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Settings: blocklist textarea */
|
||||
/* Auto-accepted suggestion block (top of suggestions section) */
|
||||
.suggestions-auto-accepted {
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(139, 231, 139, 0.04);
|
||||
border: 1px solid rgba(139, 231, 139, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.suggestions-auto-accepted .suggestions-category-label {
|
||||
color: #8be78b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.suggestion-chip.suggestion-chip-auto {
|
||||
border-color: rgba(139, 231, 139, 0.3);
|
||||
background: rgba(139, 231, 139, 0.08);
|
||||
}
|
||||
.suggestion-chip .sugg-btn.sugg-undo {
|
||||
color: #e78b8b;
|
||||
border-color: rgba(231, 139, 139, 0.35);
|
||||
}
|
||||
.suggestion-chip .sugg-btn.sugg-undo:hover {
|
||||
background: rgba(231, 139, 139, 0.12);
|
||||
border-color: rgba(231, 139, 139, 0.6);
|
||||
}
|
||||
|
||||
/* Settings: threshold number input + blocklist textarea */
|
||||
.settings-input {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.settings-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--btn-primary);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.settings-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
|
||||
@@ -1,4 +1,58 @@
|
||||
"""Maintenance-queue Celery tasks. Currently empty — the
|
||||
sync_character_fandoms task was removed on 2026-04-21 as part of the
|
||||
bare-name refactor (tag.fandom_id is now authoritative; no drift to sync).
|
||||
"""Maintenance-queue Celery tasks.
|
||||
|
||||
Currently holds the blocklist cleanup task that retroactively removes
|
||||
blocklisted tag names from the library. The earlier sync_character_fandoms
|
||||
task was removed on 2026-04-21 as part of the bare-name refactor.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import text
|
||||
|
||||
from app import db
|
||||
from app.models import Tag
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(
|
||||
name='app.tasks.maintenance.sweep_blocklisted_tag_from_images',
|
||||
soft_time_limit=60,
|
||||
time_limit=120,
|
||||
)
|
||||
def sweep_blocklisted_tag_from_images(name: str) -> dict:
|
||||
"""Remove a blocklisted tag name from every image that has it attached,
|
||||
then delete the Tag row itself.
|
||||
|
||||
Scope is limited to kind='user' tags — that's the kind WD14's general
|
||||
category gets materialized as when accepted, which is the vast majority
|
||||
of blocklist hits. Character / fandom / artist / series / post / archive
|
||||
tags sharing the same name are left alone: those are deliberate, curated
|
||||
entities, and removing them silently because of a blocklist text match
|
||||
would be destructive.
|
||||
|
||||
Returns a summary dict so the Celery result is introspectable.
|
||||
"""
|
||||
tag = Tag.query.filter_by(kind='user', name=name).first()
|
||||
if tag is None:
|
||||
log.info("sweep_blocklisted_tag_from_images: no kind='user' tag named %r; nothing to do", name)
|
||||
return {'name': name, 'tag_found': False, 'image_tags_deleted': 0, 'tag_deleted': False}
|
||||
|
||||
result = db.session.execute(
|
||||
text("DELETE FROM image_tags WHERE tag_id = :tid"),
|
||||
{'tid': tag.id},
|
||||
)
|
||||
rowcount = result.rowcount or 0
|
||||
db.session.delete(tag)
|
||||
db.session.commit()
|
||||
|
||||
log.info(
|
||||
"sweep_blocklisted_tag_from_images: removed %r (tag_id=%d) from %d images",
|
||||
name, tag.id, rowcount,
|
||||
)
|
||||
return {
|
||||
'name': name,
|
||||
'tag_found': True,
|
||||
'image_tags_deleted': rowcount,
|
||||
'tag_deleted': True,
|
||||
}
|
||||
|
||||
@@ -458,6 +458,23 @@
|
||||
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h3>Auto-accept threshold (general tags)</h3>
|
||||
<p class="settings-hint">
|
||||
When the modal loads suggestions for an image, any general-category
|
||||
tag with confidence at or above this threshold is auto-applied and
|
||||
shown in a reviewable "Auto-accepted" block at the top of the
|
||||
suggestions section. Lower values accept more tags (riskier);
|
||||
values above 1.0 effectively disable the feature. Default is 0.95.
|
||||
</p>
|
||||
<div class="mt-sm">
|
||||
<input type="number" id="autoAcceptThresholdInput" class="settings-input"
|
||||
min="0" max="1.1" step="0.01" style="width: 7rem;" />
|
||||
<button type="button" id="autoAcceptThresholdSaveBtn" class="btn secondary-btn">Save threshold</button>
|
||||
<span id="autoAcceptThresholdStatus" class="settings-hint" style="margin-left: 0.5rem;"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h3>Suggestion blocklist</h3>
|
||||
<p class="settings-hint">
|
||||
@@ -465,8 +482,11 @@
|
||||
post-canonicalization WD14 output (e.g. <code>1girl</code>,
|
||||
<code>breasts</code>). Blocklisted names never appear in the
|
||||
modal's auto-suggestions — either WD14 or embedding-similarity
|
||||
sourced. You can also add entries inline by clicking the ⊘ button
|
||||
on any suggestion chip.
|
||||
sourced. Adding a name also enqueues a cleanup task that removes
|
||||
the tag from every image that has it attached (kind='user' only;
|
||||
deliberate character / artist / fandom tags sharing the same name
|
||||
are left alone). You can also add entries inline by clicking the
|
||||
⊘ button on any suggestion chip.
|
||||
</p>
|
||||
<textarea id="suggestionBlocklistArea" class="settings-textarea" rows="10"
|
||||
placeholder="1girl 2girls breasts nipples penis"></textarea>
|
||||
@@ -521,6 +541,54 @@
|
||||
saveBtn.addEventListener('click', save);
|
||||
load();
|
||||
})();
|
||||
|
||||
(function(){
|
||||
const input = document.getElementById('autoAcceptThresholdInput');
|
||||
const saveBtn = document.getElementById('autoAcceptThresholdSaveBtn');
|
||||
const status = document.getElementById('autoAcceptThresholdStatus');
|
||||
if (!input || !saveBtn) return;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/api/suggestions/config/auto-accept-threshold');
|
||||
const j = await r.json();
|
||||
if (j.ok) input.value = String(j.threshold);
|
||||
} catch { /* leave empty */ }
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saveBtn.disabled = true;
|
||||
status.textContent = 'Saving…';
|
||||
const threshold = parseFloat(input.value);
|
||||
if (isNaN(threshold) || threshold < 0) {
|
||||
status.textContent = 'Enter a number >= 0 (use > 1 to disable).';
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/suggestions/config/auto-accept-threshold', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ threshold }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (j.ok) {
|
||||
status.textContent = `Saved (threshold = ${j.threshold}).`;
|
||||
input.value = String(j.threshold);
|
||||
} else {
|
||||
status.textContent = 'Save failed: ' + (j.error || 'unknown');
|
||||
}
|
||||
} catch (e) {
|
||||
status.textContent = 'Save failed: ' + e;
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
setTimeout(() => { status.textContent = ''; }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.addEventListener('click', save);
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user