Remove illegal/unsafe content + fix character round-trip recognition

Safety: delete 19 curated tag-builder items that should not exist at all
(non-consent, bestiality, guro, and minor-sexualization tags across
appearance/expression/nsfw_action/cast items), per a content-safety audit.
Also: add /characters/identity-index so the picker recognizes all 1,486
normalized characters in existing prompts (was capped at 200).
This commit is contained in:
chrmc40
2026-06-29 09:59:00 -07:00
parent 5c57f1411a
commit 34b7cc6050
5 changed files with 1244 additions and 981 deletions
+18
View File
@@ -3528,6 +3528,24 @@ async def _api_character_counts(request):
return web.json_response({"counts": counts})
@routes.get("/promptchain/tag-builder/characters/identity-index")
async def _api_character_identity_index(request):
# Full normalized-character identity set for TagBuilder2's round-trip
# parser, which must recognize EVERY normalized character in existing
# prompt text. The public /characters route caps per_page at 200, so an
# onMount preload of `per_page=1000` silently saw only the first 200 of
# the ~1,486 normalized rows. Project just the five fields the rebind path
# reads (parseNatlangPrompt: tag/display/series/base_tags/base_natlang) so
# the payload stays small — no wiki_text or unused columns. Unpaginated by
# design. Registered above /characters/{tag} so it isn't matched as a tag.
db = get_db()
rows = db.execute(
"SELECT tag, display, series, base_tags, base_natlang "
"FROM characters WHERE natlang_status = 'normalized'"
).fetchall()
return web.json_response({"characters": [dict(r) for r in rows]})
@routes.get("/promptchain/tag-builder/characters/{tag}")
async def _api_character_detail(request):
tag = request.match_info["tag"]
Binary file not shown.
@@ -5745,6 +5745,23 @@
.pcr-atb2-browser-section-cat.svelte-ibd6yj { color: #888; }
.pcr-atb2-browser-section-sep.svelte-ibd6yj { color: #555; }
.pcr-atb2-browser-section-grp.svelte-ibd6yj { color: #d4b8ff; }
.pcr-atb2-recent-section.svelte-ibd6yj .pcr-atb2-browser-section-header:where(.svelte-ibd6yj) {
display: flex;
align-items: center;
gap: 8px;
}
.pcr-atb2-recent-clear.svelte-ibd6yj {
margin-left: auto;
background: none;
border: 1px solid #333;
color: #777;
border-radius: 4px;
font: inherit;
font-size: 10px;
padding: 1px 8px;
cursor: pointer;
}
.pcr-atb2-recent-clear.svelte-ibd6yj:hover { color: #ccc; border-color: #555; }
.pcr-atb2-card.svelte-ibd6yj {
background: #ffffff0d;
border: 1px solid #333;
File diff suppressed because it is too large Load Diff
@@ -272,6 +272,38 @@
maximized = !maximized;
try { localStorage.setItem(MAXIMIZED_KEY, maximized ? "1" : "0"); } catch {}
}
// Recently-used picks (character/cast identities + tag items), persisted to
// localStorage and surfaced as a pinned strip atop the default "all" view so
// the things reached for most are one click away — no searching, no schema.
// Disabled for now (user request) — implementation kept intact; flip
// RECENT_ENABLED back to true to re-enable the strip + its tracking.
const RECENT_ENABLED = false;
const RECENT_KEY = "pcr-atb2-recent";
const RECENT_MAX = 24;
let recentPicks = $state([]);
try {
const stored = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
if (Array.isArray(stored)) recentPicks = stored.slice(0, RECENT_MAX);
} catch {}
// Stable identity for dedupe: an item is keyed by where it lives + its tag;
// an identity by character/cast tag (+ cast group).
function recentKey(e) {
return e.type === "item"
? `item:${e.categoryKey}:${e.groupKey}:${e.item?.item_tag}`
: `id:${e.option?.kind}:${e.option?.group || ""}:${e.option?.tag}`;
}
function recordRecent(entry) {
if (!RECENT_ENABLED) return;
const k = recentKey(entry);
recentPicks = [entry, ...recentPicks.filter(e => recentKey(e) !== k)].slice(0, RECENT_MAX);
try { localStorage.setItem(RECENT_KEY, JSON.stringify(recentPicks)); } catch {}
}
function clearRecent() {
recentPicks = [];
try { localStorage.setItem(RECENT_KEY, JSON.stringify(recentPicks)); } catch {}
}
$effect(() => {
const el = browserEl;
if (!el) return;
@@ -733,7 +765,7 @@
// Preload normalized characters (tag + base_natlang etc.) so the
// round-trip parser can recognize both tag-form ("cammy_white") and
// natlang ("Cammy White from Street Fighter.") character identities.
const charTagsPromise = fetch(`/promptchain/tag-builder/characters?natlang_status=normalized&per_page=1000&sort=display`)
const charTagsPromise = fetch(`/promptchain/tag-builder/characters/identity-index`)
.then(r => r.ok ? r.json() : { characters: [] })
.then(d => {
const chars = d.characters || [];
@@ -1448,6 +1480,7 @@
// - active w/o identity -> bind directly to active
// - active with identity -> open swap-confirm modal
async function pickIdentityFromBrowser(option) {
recordRecent({ type: "identity", option });
const subj = activeSubject;
if (subj && subj.character) {
pendingIdentitySwap = { subjId: subj.id, current: subj.character, next: option };
@@ -3531,6 +3564,7 @@
toggleModifier(subj.id, item.item_tag);
return;
}
recordRecent({ type: "item", categoryKey, groupKey, item });
const cat = CATEGORIES.find(c => c.key === categoryKey);
if (!cat) return;
if (cat.scope === "furniture") {
@@ -5121,6 +5155,51 @@
</div>
{#if activeCategory === "all"}
{#snippet recentCard(r)}
{#if r.type === "identity"}
{@const o = r.option}
{@const isChar = o.kind === "character"}
{@const rHasImg = isChar ? characterThumbs.has(o.tag) : !!castThumbs[o.group]?.has(o.tag)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pcr-atb2-card" title={o.base_natlang || o.display || o.tag} onclick={() => pickIdentityFromBrowser(o)}>
<div class="pcr-atb2-card-thumb" class:has-image={rHasImg}>
{#if rHasImg}
<img src={isChar
? `/promptchain/tag-builder/thumb/characters/${encodeURIComponent(o.tag)}`
: `/promptchain/tag-builder/thumb/${encodeURIComponent(o.group)}/${encodeURIComponent(o.tag)}`} alt="" loading="lazy" />
{/if}
</div>
<div class="pcr-atb2-card-name">{o.display || o.tag}</div>
{#if o.series}<div class="pcr-atb2-card-group">{o.series}</div>{/if}
</div>
{:else}
{@const it = r.item}
{@const rBucket = CATEGORIES.find(c => c.key === r.categoryKey)?.bucket}
{@const rHasImg = !!rBucket && hasThumb(rBucket, it.item_tag)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pcr-atb2-card" title={it.base_natlang || it.display_name || it.item_tag} onclick={() => pickItemFromAll(it, r.categoryKey, r.groupKey)}>
<div class="pcr-atb2-card-thumb" class:has-image={rHasImg}>
{#if rHasImg}<img src={thumbUrl(rBucket, it.item_tag)} alt="" loading="lazy" />{/if}
</div>
<div class="pcr-atb2-card-name">{it.display_name || it.item_tag}</div>
</div>
{/if}
{/snippet}
{#if RECENT_ENABLED && recentPicks.length > 0 && !searchQuery.trim()}
<div class="pcr-atb2-browser-section pcr-atb2-recent-section">
<div class="pcr-atb2-browser-section-header">
<span class="pcr-atb2-browser-section-cat">🕘 Recently used</span>
<button class="pcr-atb2-recent-clear" onclick={clearRecent} title="Clear recently used">clear</button>
</div>
<div class="pcr-atb2-grid">
{#each recentPicks as r (recentKey(r))}
{@render recentCard(r)}
{/each}
</div>
</div>
{/if}
{#if allCategorySections.length === 0}
<div class="pcr-atb2-empty">{searchQuery ? `No items match "${searchQuery}"` : "Loading…"}</div>
{:else}
@@ -6270,6 +6349,23 @@
.pcr-atb2-browser-section-cat { color: #888; }
.pcr-atb2-browser-section-sep { color: #555; }
.pcr-atb2-browser-section-grp { color: #d4b8ff; }
.pcr-atb2-recent-section .pcr-atb2-browser-section-header {
display: flex;
align-items: center;
gap: 8px;
}
.pcr-atb2-recent-clear {
margin-left: auto;
background: none;
border: 1px solid #333;
color: #777;
border-radius: 4px;
font: inherit;
font-size: 10px;
padding: 1px 8px;
cursor: pointer;
}
.pcr-atb2-recent-clear:hover { color: #ccc; border-color: #555; }
.pcr-atb2-card {
background: #ffffff0d;
border: 1px solid #333;