Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

308 lines
13 KiB
JavaScript

// Shared chip lookup index. Loaded once per session (lazy) from the
// tag-builder bucket endpoints, then reused by anything that needs to
// recognize chip natlangs in arbitrary text (e.g. the CodeMirror
// chip-recognizer extension). Mirrors the in-memory shape that
// TagBuilder2 builds on its own — extracted here so consumers outside
// the Svelte component can share it without duplicating the load.
const BUCKETS = ["clothing", "appearance", "pose", "expression", "action", "scene", "nsfw_action", "cast"];
let cachedIndex = null;
let loadPromise = null;
// Event listeners notified when the index finishes loading or refreshes.
const listeners = new Set();
function notifyReady() {
for (const fn of listeners) {
try { fn(cachedIndex); } catch {}
}
}
export function onChipIndexReady(fn) {
listeners.add(fn);
if (cachedIndex) fn(cachedIndex);
return () => listeners.delete(fn);
}
export function getChipIndex() {
return cachedIndex;
}
// Forces a fresh load. Used after a character/chip mutation so consumers
// (tooltips, recognizers) pick up the new data on the next hover without
// a page reload. Resets the cache and re-runs loadChipIndex.
export async function reloadChipIndex() {
cachedIndex = null;
loadPromise = null;
return loadChipIndex();
}
// Fetches every bucket's items and builds a lookup index. First call
// kicks off the network load; subsequent calls share the same promise.
// On success, cachedIndex is populated and onChipIndexReady listeners
// fire.
//
// Characters are loaded too but only the normalized slice — the full
// table is 11k rows and we only need verified character base_natlangs
// in the recognizer. Matches the filter v2 uses for its character
// drilldown.
export function loadChipIndex() {
if (cachedIndex) return Promise.resolve(cachedIndex);
if (loadPromise) return loadPromise;
loadPromise = (async () => {
const buckets = {};
const bucketLoads = BUCKETS.map(async (b) => {
try {
const res = await fetch(`/promptchain/tag-builder/buckets/${b}/items`);
if (!res.ok) { buckets[b] = []; return; }
const data = await res.json();
buckets[b] = data.items || [];
} catch {
buckets[b] = [];
}
});
const charactersLoad = (async () => {
try {
const res = await fetch("/promptchain/tag-builder/characters?natlang_status=normalized&per_page=2000");
if (!res.ok) return [];
const data = await res.json();
return data.results || data.characters || [];
} catch {
return [];
}
})();
const overridesLoad = async (url, scope) => {
try {
const res = await fetch(url);
if (!res.ok) return [];
const data = await res.json();
return (data.overrides || []).map(o => ({ ...o, scope }));
} catch {
return [];
}
};
const charOvLoad = overridesLoad("/promptchain/tag-builder/character-overrides", "character");
const outfitOvLoad = overridesLoad("/promptchain/tag-builder/outfit-overrides", "outfit");
const poseOvLoad = overridesLoad("/promptchain/tag-builder/pose-overrides", "pose");
const [characters, charOvs, outfitOvs, poseOvs] = await Promise.all([
charactersLoad, charOvLoad, outfitOvLoad, poseOvLoad, ...bucketLoads,
]);
cachedIndex = buildIndex(buckets, characters, [...charOvs, ...outfitOvs, ...poseOvs]);
loadPromise = null;
notifyReady();
return cachedIndex;
})();
return loadPromise;
}
function buildIndex(bucketItems, characters, overrides = []) {
// phraseToChip: natlang phrase → chip info. First-write-wins across
// buckets so a clothing chip beats a scene chip on the same phrase.
const phraseToChip = new Map();
// multiCommaList: chips whose natlang itself contains commas (e.g. an
// armor chip whose base_natlang is a 6-piece description). Sorted by
// length DESC for longest-first matching in the scanner.
const multiCommaList = [];
// characterList: normalized characters with a non-empty base_natlang,
// sorted by length DESC for longest-first matching. Characters share
// the scanner's match-and-claim pass but get their own bucket label
// ("characters") so the thumbnail URL and tooltip render correctly.
const characterList = [];
// overrideList: per-character chip enhancer phrasings. The recognizer
// walks this list first so an override like Cammy's verbose scar text
// claims its span before generic chip natlangs get a chance to match
// anything overlapping.
const overrideList = [];
// Cross-bucket tag → info map, used when resolving override chip refs
// (the source chip can live in any bucket) and when looking up a
// character's appearance_chip_tags.
const chipByTag = new Map();
// tagToChip: tag form → chip info, used as a recognizer fallback so
// tag-mode output resolves even when it differs from base_natlang.
// Keyed by both the underscore item_tag ("double_bun") and its
// spaces variant ("double bun") so it matches whichever tag_format
// the model emits. First-write-wins across buckets.
const tagToChip = new Map();
// Hair-weld decomposition support. The server's
// compose_character_natlang_v2 binds a hair_length adjective onto the
// primary hair_style phrase ("long" + "twin braids" -> "long twin
// braids") so a single natlang feature stands in for two canonical
// tags. The recognizer reverses that to keep both chips hoverable:
// hairStyleByPhrase: "twin braids" -> twin_braids info (suffix match)
// hairLengthAdj: "long" -> long_hair info (prefix match)
// hairLengthAdj keys are the bare adjective (base_natlang minus the
// trailing "hair"/"hairs"), mirroring server _hair_length_adjective.
const hairStyleByPhrase = new Map();
const hairLengthAdj = new Map();
for (const bucket of BUCKETS) {
const items = bucketItems[bucket] || [];
for (const it of items) {
const nl = (it.base_natlang || "").trim().replace(/\.\s*$/, "");
const info = {
item_tag: it.item_tag,
display_name: it.display_name || it.item_tag,
item_group: it.item_group || "",
base_tags: it.base_tags || "",
base_natlang: nl,
natlang_status: it.natlang_status || "unprocessed",
bucket,
};
chipByTag.set(`${bucket}:${it.item_tag}`, info);
// Key the tag fallback by both the item_tag AND the emitted base_tags
// token (they differ for prefixed rows: item_tag
// "hair_style_side_drill" but base_tags "side_drill" — and the prompt
// emits base_tags). Register underscore + space forms of each so it
// resolves under either tag_format.
const tagKeys = new Set();
const addKey = (raw) => {
const k = (raw || "").trim().toLowerCase();
if (!k) return;
tagKeys.add(k);
tagKeys.add(k.replace(/_/g, " "));
};
addKey(it.item_tag);
// base_tags is the emitted form; only use it when it's a single clean
// token (no commas, no weight wrapper) so we don't key on a phrase.
const bt = (it.base_tags || "").trim();
if (bt && !bt.includes("(")) {
if (!bt.includes(",")) {
addKey(bt);
} else if (bucket === "cast") {
// Cast identity rows carry a multi-tag base_tags whose FIRST token is
// the emitted subject tag (e.g. "male_maid, otoko_no_ko, 1boy" emits
// "male maid"). Key that leading token so the subject chip is
// hoverable; item_tag itself is prefixed ("archetype_male_maid") and
// never appears in the prompt.
addKey(bt.split(",")[0]);
}
}
for (const k of tagKeys) if (!tagToChip.has(k)) tagToChip.set(k, info);
if (bucket === "appearance" && nl) {
const lower = nl.toLowerCase();
if (info.item_group === "hair_style" && !hairStyleByPhrase.has(lower)) {
hairStyleByPhrase.set(lower, info);
} else if (info.item_group === "hair_length") {
const words = lower.split(/\s+/);
if (words.length >= 2 && (words[words.length - 1] === "hair" || words[words.length - 1] === "hairs")) {
const adj = words.slice(0, -1).join(" ");
if (!hairLengthAdj.has(adj)) hairLengthAdj.set(adj, info);
}
}
}
if (!nl) continue;
if (nl.includes(",")) {
multiCommaList.push({ natlang: nl, info });
} else if (!phraseToChip.has(nl)) {
phraseToChip.set(nl, info);
}
}
}
multiCommaList.sort((a, b) => b.natlang.length - a.natlang.length);
// characterByTag: needed so override entries can carry the character's
// display name into their tooltip's "Enhancer for: Cammy White" sub-row.
const characterByTag = new Map();
for (const c of characters || []) {
const nl = (c.base_natlang || "").trim().replace(/\.\s*$/, "");
if (!nl) continue;
// Resolve appearance chips: if `appearance_chip_tags` is a JSON array,
// attach the full appearance chip info for each token. Falls back to
// empty so legacy unmigrated characters just render the flat-string
// tooltip we always had.
let appearanceChips = [];
if (c.appearance_chip_tags) {
try {
const tags = JSON.parse(c.appearance_chip_tags) || [];
for (const t of tags) {
const chipInfo = chipByTag.get(`appearance:${t}`);
if (chipInfo) appearanceChips.push(chipInfo);
}
} catch {}
}
const characterInfo = {
item_tag: c.tag,
display_name: c.display || c.tag,
item_group: c.series || "",
base_tags: c.base_tags || "",
base_natlang: nl,
natlang_status: c.natlang_status || "unprocessed",
bucket: "characters",
identity_token: `(${c.tag}:1.1)`,
base_extras: c.base_extras || "",
appearance_chips: appearanceChips,
migrated: !!(c.appearance_chip_tags),
};
characterByTag.set(c.tag, characterInfo);
characterList.push({ natlang: nl, info: characterInfo });
}
characterList.sort((a, b) => b.natlang.length - a.natlang.length);
// Overrides: each row is an enhancer phrasing pinned to either a
// character, outfit, or pose. The chip can live in any bucket; we
// resolve by walking every bucket's tag map. If the source chip is
// gone (deleted bucket row), we still surface the override but with
// sourceChip null so the tooltip degrades gracefully.
//
// Scope shape — for each entry:
// scope: "character" | "outfit" | "pose"
// scope_id: character_tag | outfit_id | pose_id
// scope_display: human-readable label ("Cammy White", "Delta Red", ...)
// scope_character_display: owning character (outfit/pose only) so
// the tooltip can show "Delta Red (Cammy White)" instead of just
// the outfit name which would lose context.
for (const o of overrides) {
const nl = (o.natlang || "").trim().replace(/\.\s*$/, "");
if (!nl) continue;
let sourceChip = null;
for (const bucket of BUCKETS) {
const c = chipByTag.get(`${bucket}:${o.chip_tag}`);
if (c) { sourceChip = c; break; }
}
let scopeId, scopeDisplay, scopeCharacterDisplay;
if (o.scope === "character") {
scopeId = o.character_tag;
scopeDisplay = o.character_display || o.character_tag;
scopeCharacterDisplay = null;
} else if (o.scope === "outfit") {
scopeId = o.outfit_id;
scopeDisplay = o.outfit_name || `Outfit ${o.outfit_id}`;
scopeCharacterDisplay = o.character_display || o.character_tag;
} else if (o.scope === "pose") {
scopeId = o.pose_id;
scopeDisplay = o.pose_name || `Pose ${o.pose_id}`;
scopeCharacterDisplay = o.character_display || o.character_tag;
}
overrideList.push({
natlang: nl,
info: {
// Override entries point back at the source chip's bucket so
// tooltips and thumbnails route to the right place, but carry
// override metadata in dedicated fields so the tooltip can
// render the Enhancer section.
item_tag: o.chip_tag,
display_name: sourceChip?.display_name || o.chip_tag,
item_group: sourceChip?.item_group || "",
base_tags: sourceChip?.base_tags || "",
base_natlang: sourceChip?.base_natlang || "",
natlang_status: sourceChip?.natlang_status || "unprocessed",
bucket: sourceChip?.bucket || "appearance",
override_scope: o.scope,
override_scope_id: scopeId,
override_scope_display: scopeDisplay,
override_scope_character_display: scopeCharacterDisplay,
override_natlang: nl,
override_status: o.status || "unprocessed",
},
});
}
overrideList.sort((a, b) => b.natlang.length - a.natlang.length);
return { phraseToChip, tagToChip, multiCommaList, characterList, overrideList, hairStyleByPhrase, hairLengthAdj };
}