diff --git a/core/tag_builder.py b/core/tag_builder.py index 4dad776..7023ddd 100644 --- a/core/tag_builder.py +++ b/core/tag_builder.py @@ -23,6 +23,10 @@ _dbg = logging.getLogger("promptchain.ai.debug") DB_PATH = Path(__file__).parent.parent / "data" / "tag-builder" / "tag-builder.db" _local = threading.local() +# True once the one-time schema/normalize/reconcile helpers have run this +# process. Gates get_db's mtime-triggered reconnects so the expensive +# ~11,529-row reconcile doesn't re-run on every spurious reopen. +_schema_ready = False def get_db() -> sqlite3.Connection: @@ -50,11 +54,28 @@ def get_db() -> sqlite3.Connection: # FK enforcement is off by default in sqlite, so CASCADE deletes were # inert and orphan preset rows accumulated. Per-connection pragma. conn.execute("PRAGMA foreign_keys=ON") - _ensure_generic_outfits_schema(conn) - _normalize_full_outfits_sort_order(conn) - _normalize_scene_locations_sort_order(conn) - _ensure_character_appearance_chip_columns(conn) - _reconcile_character_base_tags(conn) + # These schema/normalize/reconcile helpers are idempotent but + # expensive — the reconcile walks ~11,529 character rows. get_db + # reconnects on every DB-file mtime change, and sqlite's delete + # journal self-bumps that mtime on internal commits, so without + # gating they re-ran on the asyncio loop on roughly every other + # request while editing. Run them once per process; the request- + # time appearance-chip fallback covers a char added externally + # mid-session, and a restart re-runs them to refresh the cache. + global _schema_ready + if not _schema_ready: + _ensure_generic_outfits_schema(conn) + _normalize_full_outfits_sort_order(conn) + _normalize_scene_locations_sort_order(conn) + _ensure_character_appearance_chip_columns(conn) + _reconcile_character_base_tags(conn) + _schema_ready = True + # The helpers commit, bumping the watched mtime; re-read it so + # their own write doesn't immediately trigger another reconnect. + try: + current_mtime = DB_PATH.stat().st_mtime_ns + except OSError: + pass _local.conn = conn _local.mtime = current_mtime return _local.conn @@ -3110,6 +3131,62 @@ def _assert_allowed_table(name: str) -> str: return name +def _run_data_health(db: sqlite3.Connection) -> dict: + """Idempotent maintenance: drop orphaned preset rows (FK enforcement + was historically off) and de-duplicate tag_aliases. Returns per-step + removal counts. Each step is independent and guarded so a missing + table or schema drift skips that step instead of failing the run.""" + cleaned: dict[str, int] = {} + + def _exists(table: str) -> bool: + return db.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,) + ).fetchone() is not None + + # (child, fk_col, parent, parent_key) — parents first so a parent + # delete's now-stranded children are caught in the same pass. + orphan_rules = [ + ("outfits", "character_tag", "characters", "tag"), + ("poses", "character_tag", "characters", "tag"), + ("character_chip_overrides", "character_tag", "characters", "tag"), + ("outfit_tag_slots", "outfit_id", "outfits", "id"), + ("outfit_chip_overrides", "outfit_id", "outfits", "id"), + ("pose_chip_overrides", "pose_id", "poses", "id"), + ] + for child, fk, parent, key in orphan_rules: + if not (_exists(child) and _exists(parent)): + continue + try: + cur = db.execute( + f"DELETE FROM {child} WHERE {fk} IS NOT NULL " + f"AND {fk} NOT IN (SELECT {key} FROM {parent})" + ) + cleaned[f"orphan_{child}"] = cur.rowcount + except sqlite3.OperationalError: + pass + + # tag_aliases has a (tag, alias) primary key so exact dupes can't exist, + # but keep the dedup as a cheap self-heal in case the PK was ever absent. + if _exists("tag_aliases"): + try: + cur = db.execute( + "DELETE FROM tag_aliases WHERE rowid NOT IN " + "(SELECT MIN(rowid) FROM tag_aliases GROUP BY tag, alias)" + ) + cleaned["tag_alias_duplicates"] = cur.rowcount + except sqlite3.OperationalError: + pass + + db.commit() + return cleaned + + +@routes.post("/promptchain/tag-builder/maintenance") +async def _api_tag_builder_maintenance(request): + """Run idempotent data-health maintenance and report what was removed.""" + return web.json_response({"cleaned": _run_data_health(get_db())}) + + @routes.get("/promptchain/tag-builder/buckets") async def _api_buckets(request): db = get_db() @@ -4795,7 +4872,9 @@ def match_characters_inner( name_prefix_candidates.setdefault(token.lower(), original) if name_prefix_candidates: for prefix, original in name_prefix_candidates.items(): - like_pattern = prefix + r"\_%" + # Escape LIKE metacharacters so a name with % or _ doesn't wildcard-match. + esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + like_pattern = esc + r"\_%" prefix_rows = db.execute( "SELECT tag, display, series, base_tags, base_natlang FROM characters " "WHERE LOWER(tag) LIKE ? ESCAPE '\\' " @@ -4845,7 +4924,9 @@ def match_characters_inner( paren_suffix_candidates.setdefault(bare_name.lower(), original) if paren_suffix_candidates: for prefix, original in paren_suffix_candidates.items(): - like_pattern = prefix + r"\_%" + # Escape LIKE metacharacters so a name with % or _ doesn't wildcard-match. + esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + like_pattern = esc + r"\_%" prefix_rows = db.execute( "SELECT tag, display, series, base_tags, base_natlang FROM characters " "WHERE LOWER(tag) = ? OR LOWER(tag) LIKE ? ESCAPE '\\' " @@ -4889,7 +4970,9 @@ def match_characters_inner( first_segment_candidates.setdefault(bare_name.lower(), original) if first_segment_candidates: for prefix, original in first_segment_candidates.items(): - like_pattern = prefix + r"\_%" + # Escape LIKE metacharacters so a name with % or _ doesn't wildcard-match. + esc = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + like_pattern = esc + r"\_%" prefix_rows = db.execute( "SELECT tag, display, series, base_tags, base_natlang FROM characters " "WHERE LOWER(tag) = ? OR LOWER(tag) LIKE ? ESCAPE '\\' " @@ -5374,28 +5457,34 @@ async def _api_props_assemble(request): tags_parts = [] natlang_parts = [] - if color: + if color and color.get("tag"): tags_parts.append(color["tag"]) if color.get("prefix"): natlang_parts.append(color["prefix"]) - if pattern and pattern["tag"] != "solid": + if pattern and pattern.get("tag") and pattern["tag"] != "solid": tags_parts.append(pattern["tag"]) if pattern.get("prefix"): natlang_parts.append(pattern["prefix"]) - if material: + if material and material.get("tag"): tags_parts.append(material["tag"]) if material.get("prefix"): natlang_parts.append(material["prefix"]) - tags_parts.append(prop["prop_tag"]) - natlang_parts.append(prop.get("base_natlang") or prop["display_name"].lower()) + if prop.get("prop_tag"): + tags_parts.append(prop["prop_tag"]) + _display = prop.get("display_name") + _phrase = prop.get("base_natlang") or (_display.lower() if isinstance(_display, str) else None) + if _phrase: + natlang_parts.append(_phrase) - prop_tags = " ".join(tags_parts) - prop_natlang = " ".join(natlang_parts) + prop_tags = " ".join(str(p) for p in tags_parts if p) + prop_natlang = " ".join(str(p) for p in natlang_parts if p) if action: - final_tags = f"{action['action_prefix_tags']} {prop_tags}" - final_natlang = f"{action['action_prefix_natlang']} {prop_natlang}" + prefix_tags = action.get("action_prefix_tags") or "" + prefix_natlang = action.get("action_prefix_natlang") or "" + final_tags = f"{prefix_tags} {prop_tags}".strip() + final_natlang = f"{prefix_natlang} {prop_natlang}".strip() else: final_tags = prop_tags final_natlang = prop_natlang @@ -5534,74 +5623,6 @@ async def _api_clothing_customizer_data(request): }) -@routes.post("/promptchain/clothing/assemble") -async def _api_clothing_assemble(request): - """Build tag and natlang strings from selected clothing parts.""" - data, err = await parse_json(request) - if err: return err - - item_tag = data.get("item_tag", "") - color_tag = data.get("color_tag", "") - material_tag = data.get("material_tag", "") - pattern_tag = data.get("pattern_tag", "") - condition_tag = data.get("condition_tag", "") - - db = get_db() - - tag_parts = [] - natlang_parts = [] - - # Color - if color_tag: - color = db.execute( - "SELECT * FROM clothing_colors WHERE tag = ?", (color_tag,) - ).fetchone() - if color: - tag_parts.append(color["prefix"]) - natlang_parts.append(color["prefix"]) - - # Material - if material_tag: - material = db.execute( - "SELECT * FROM clothing_materials WHERE tag = ?", (material_tag,) - ).fetchone() - if material: - tag_parts.append(material["prefix"]) - natlang_parts.append(material["prefix"]) - - # Pattern - if pattern_tag: - pattern = db.execute( - "SELECT * FROM clothing_patterns WHERE tag = ?", (pattern_tag,) - ).fetchone() - if pattern: - tag_parts.append(pattern["prefix"]) - natlang_parts.append(pattern["prefix"]) - - # Condition - if condition_tag: - condition = db.execute( - "SELECT * FROM clothing_conditions WHERE tag = ?", (condition_tag,) - ).fetchone() - if condition: - tag_parts.append(condition["prefix"]) - natlang_parts.append(condition["prefix"]) - - # Clothing item itself - if item_tag: - item = db.execute( - "SELECT * FROM clothing_items WHERE item_tag = ?", (item_tag,) - ).fetchone() - if item: - tag_parts.append(item["base_tags"]) - natlang_parts.append(item["base_natlang"]) - - tag = ", ".join(p for p in tag_parts if p) - natlang = " ".join(p for p in natlang_parts if p) - - return web.json_response({"tag": tag, "natlang": natlang}) - - @routes.get("/promptchain/clothing/customizable-groups") async def _api_clothing_customizable_groups(request): """Return clothing groups and items for customizable groups only.""" @@ -5821,61 +5842,3 @@ async def _api_furniture_all(request): "patterns": patterns, "colors": colors, }) - - -@routes.post("/promptchain/furniture/assemble") -async def _api_furniture_assemble(request): - """Build tag and natlang strings from selected furniture parts.""" - data, err = await parse_json(request) - if err: return err - - furniture_tag = data.get("furniture_tag", "") - material_tag = data.get("material_tag", "") - pattern_tag = data.get("pattern_tag", "") - color_tag = data.get("color_tag", "") - - db = get_db() - - tag_parts = [] - natlang_parts = [] - - # Color - if color_tag: - color = db.execute( - "SELECT * FROM furniture_colors WHERE tag = ?", (color_tag,) - ).fetchone() - if color: - tag_parts.append(color["prefix"]) - natlang_parts.append(color["prefix"]) - - # Material - if material_tag: - material = db.execute( - "SELECT * FROM furniture_materials WHERE tag = ?", (material_tag,) - ).fetchone() - if material: - tag_parts.append(material["prefix"]) - natlang_parts.append(material["prefix"]) - - # Pattern - if pattern_tag: - pattern = db.execute( - "SELECT * FROM furniture_patterns WHERE tag = ?", (pattern_tag,) - ).fetchone() - if pattern: - tag_parts.append(pattern["prefix"]) - natlang_parts.append(pattern["prefix"]) - - # Furniture item itself - if furniture_tag: - item = db.execute( - "SELECT * FROM furniture WHERE tag = ?", (furniture_tag,) - ).fetchone() - if item: - tag_parts.append(item["base_tags"]) - natlang_parts.append(item["base_natlang"]) - - tag = ", ".join(p for p in tag_parts if p) - natlang = " ".join(p for p in natlang_parts if p) - - return web.json_response({"tag": tag, "natlang": natlang}) diff --git a/core/tag_search.py b/core/tag_search.py index d506d82..809bbc3 100644 --- a/core/tag_search.py +++ b/core/tag_search.py @@ -68,6 +68,14 @@ _state: dict[str, Any] = { "fingerprint": None, } +# A transient DB lock made _fingerprint() return zeros, which read as +# "table changed" and kicked off a 5–10 min CPU re-embed. Cache the last +# good fingerprint + the DB mtime it was read at: return the cached value +# on error (never treat a read failure as a change), and skip the recompute +# entirely while the file is unchanged. +_last_successful_fingerprint: tuple | None = None +_last_db_mtime: float | None = None + def _open_db() -> sqlite3.Connection: conn = sqlite3.connect(str(DB_PATH)) @@ -151,7 +159,12 @@ def _fingerprint() -> tuple: table state. Changes when wiki rows are added/removed/refreshed (body_hash sum) OR when aliases are added/removed/edited. Either forces a rebuild — alias changes shift the embedded body_full - text so cached embeddings need to be regenerated.""" + text so cached embeddings need to be regenerated. + + On a transient DB lock / read error, returns the last successful + fingerprint instead of zeros, so a momentary lock can't masquerade + as a table change and trigger a spurious rebuild.""" + global _last_successful_fingerprint try: conn = _open_db() _ensure_aliases_schema(conn) @@ -167,11 +180,15 @@ def _fingerprint() -> tuple: "FROM tag_aliases" ).fetchone() conn.close() - return ( + result = ( wiki_row["m"], wiki_row["c"], wiki_row["h"], alias_row["c"], alias_row["h"], ) + _last_successful_fingerprint = result + return result except Exception: + if _last_successful_fingerprint is not None: + return _last_successful_fingerprint return (0, 0, 0, 0, 0) @@ -292,7 +309,7 @@ def _load_index_from_disk() -> bool: logger.info("tag_search: cache model mismatch — rebuilding") return False - live_fp = _fingerprint() + live_fp = _get_fingerprint_cached() cached_fp = tuple(manifest.get("fingerprint") or ()) if cached_fp != live_fp: logger.info( @@ -365,10 +382,26 @@ def _rebuild_index(on_status: Callable[[str], None] | None = None) -> None: _persist_index_to_disk() +def _get_fingerprint_cached() -> tuple: + """Gate the expensive _fingerprint() (DDL + alias seed-sync + two + aggregate scans) on the DB file mtime. The fingerprint only changes + when the file changes, so while mtime is steady — the common case for + a search — return the cached value and skip the query entirely.""" + global _last_db_mtime + try: + current_mtime = DB_PATH.stat().st_mtime + except Exception: + return _fingerprint() + if _last_db_mtime == current_mtime and _last_successful_fingerprint is not None: + return _last_successful_fingerprint + _last_db_mtime = current_mtime + return _fingerprint() + + def _ensure_index_fresh(on_status: Callable[[str], None] | None = None) -> None: """Try disk first, then rebuild. Disk load is fast; rebuild is the minutes-long path.""" - if _state["embeddings"] is not None and _state["fingerprint"] == _fingerprint(): + if _state["embeddings"] is not None and _state["fingerprint"] == _get_fingerprint_cached(): return if _load_index_from_disk(): return @@ -501,8 +534,17 @@ def _row_for_tag(tag: str) -> dict | None: def warmup() -> None: """Best-effort eager load on server boot. Disk-cache hit is < 1s; cold rebuild is ~5–10 min on CPU and is the only thing that takes - real time here.""" + real time here. + + Seeds the alias lookup cache up front so the literal alias_scan path + doesn't hit the DB on every search, and warms the fingerprint cache so + early searches can't trip a spurious rebuild on a transient lock.""" import time + try: + with _lock: + _alias_lookup_list() + except Exception: + logger.warning("tag_search: alias cache seed failed", exc_info=True) for attempt in range(3): with _lock: if _embed_model.get() is not None: diff --git a/js/lib/svelte/assets/promptchain-svelte.css b/js/lib/svelte/assets/promptchain-svelte.css index aa00f2e..0465bd3 100644 --- a/js/lib/svelte/assets/promptchain-svelte.css +++ b/js/lib/svelte/assets/promptchain-svelte.css @@ -5458,6 +5458,7 @@ cursor: pointer; } .pcr-atb2-cust-focus.svelte-1mwxie1 input:where(.svelte-1mwxie1) { accent-color: #7c3aed; } + .pcr-atb2-cust-focus-disabled.svelte-1mwxie1 { opacity: 0.5; cursor: default; } .pcr-atb2-cust-focus-hint.svelte-1mwxie1 { font-size: 11px; color: #888; margin-left: auto; } diff --git a/js/lib/svelte/promptchain-tag-builder2.js b/js/lib/svelte/promptchain-tag-builder2.js index 2e59672..60f69d0 100644 --- a/js/lib/svelte/promptchain-tag-builder2.js +++ b/js/lib/svelte/promptchain-tag-builder2.js @@ -21,7 +21,7 @@ var root_14$3 = from_html(`
Preview
`, 1); +var root_2$3 = from_html(`
Preview
`, 1); var root$2 = from_html(`
Customize
`); function Customizer($$anchor, $$props) { var _a, _b, _c, _d, _e; @@ -32,6 +32,12 @@ function Customizer($$anchor, $$props) { 3, null // { color, pattern, material, condition, focus } — pre-fill when editing + ), isNaturalMode = prop( + $$props, + "isNaturalMode", + 3, + false + // current output mode, so the preview + focus match what the parent emits ), onConfirm = prop($$props, "onConfirm", 3, () => { }), onCancel = prop($$props, "onCancel", 3, () => { }); @@ -157,13 +163,20 @@ function Customizer($$anchor, $$props) { let preview = user_derived(() => { var _a2, _b2, _c2, _d2; if (!get(data)) return ($$props.item.display_name || $$props.item.item_tag).toLowerCase(); - const parts = []; - if ((_a2 = get(conditionOpt)) == null ? void 0 : _a2.prefix) parts.push(get(conditionOpt).prefix); - if ((_b2 = get(colorOpt)) == null ? void 0 : _b2.prefix) parts.push(get(colorOpt).prefix); - if ((_c2 = get(patternOpt)) == null ? void 0 : _c2.prefix) parts.push(get(patternOpt).prefix); - if ((_d2 = get(materialOpt)) == null ? void 0 : _d2.prefix) parts.push(get(materialOpt).prefix); - parts.push(($$props.item.display_name || $$props.item.item_tag).toLowerCase()); - const phrase = parts.join(" "); + const prefixes = [ + (_a2 = get(conditionOpt)) == null ? void 0 : _a2.prefix, + (_b2 = get(colorOpt)) == null ? void 0 : _b2.prefix, + (_c2 = get(patternOpt)) == null ? void 0 : _c2.prefix, + (_d2 = get(materialOpt)) == null ? void 0 : _d2.prefix + ].filter(Boolean); + if (!isNaturalMode()) { + const slug = (s) => s.replace(/\s+/g, "_"); + return [...prefixes.map(slug), $$props.item.item_tag || ""].filter(Boolean).join("_"); + } + const phrase = [ + ...prefixes, + ($$props.item.display_name || $$props.item.item_tag).toLowerCase() + ].join(" "); return get(focus) ? `${phrase}, presenting ${phrase} to viewer, ${phrase} focus` : phrase; }); let isCustomized = user_derived(() => !!get(color) || get(pattern) && get(pattern) !== "solid" || !!get(material) || get(condition) && get(condition) !== "default" || get(focus)); @@ -436,10 +449,13 @@ function Customizer($$anchor, $$props) { }); } var label = sibling(div_18, 2); + let classes_10; var input = child(label); + var span_11 = sibling(input, 4); + var text_12 = child(span_11); var div_22 = sibling(label, 2); var div_23 = sibling(child(div_22), 2); - var text_12 = child(div_23); + var text_13 = child(div_23); var div_24 = sibling(div_4, 2); var button_9 = child(div_24); var button_10 = sibling(button_9, 2); @@ -455,7 +471,10 @@ function Customizer($$anchor, $$props) { set_text(text_7, ((_c2 = get(materialOpt)) == null ? void 0 : _c2.display) || "Material"); classes_8 = set_class(div_18, 1, "pcr-atb2-cust-row svelte-1mwxie1", null, classes_8, { open: get(openRow) === "condition" }); set_text(text_9, ((_d2 = get(conditionOpt)) == null ? void 0 : _d2.display) || "Default"); - set_text(text_12, get(preview)); + classes_10 = set_class(label, 1, "pcr-atb2-cust-focus svelte-1mwxie1", null, classes_10, { "pcr-atb2-cust-focus-disabled": !isNaturalMode() }); + input.disabled = !isNaturalMode(); + set_text(text_12, isNaturalMode() ? "Camera focuses on this item" : "Natural-language mode only"); + set_text(text_13, get(preview)); }, [() => colorSwatch(get(color))] ); @@ -521,6 +540,12 @@ function FurnitureCustomizer($$anchor, $$props) { 3, null // { material, pattern, color, action } when editing + ), isNaturalMode = prop( + $$props, + "isNaturalMode", + 3, + false + // current output mode — preview shows tag vs natlang form to match emit ), onConfirm = prop($$props, "onConfirm", 3, () => { }), onCancel = prop($$props, "onCancel", 3, () => { }); @@ -979,7 +1004,7 @@ function FurnitureCustomizer($$anchor, $$props) { set_text(text_12, contextSubject() && get(action2) ? "Add Interaction" : "Add to Scene"); }, [ - () => get(previewNat) || get(previewTag) || ($$props.item.display_name || $$props.item.item_tag).toLowerCase() + () => isNaturalMode() ? get(previewNat) || get(previewTag) || ($$props.item.display_name || $$props.item.item_tag).toLowerCase() : get(previewTag) || get(previewNat) || ($$props.item.display_name || $$props.item.item_tag).toLowerCase() ] ); delegated("click", div, handleOverlayClick); @@ -1029,7 +1054,15 @@ function FantasyCustomizer($$anchor, $$props) { 3, null // { shape, color, type } when re-editing - ), onConfirm = prop($$props, "onConfirm", 3, () => { + ); + prop( + $$props, + "isNaturalMode", + 3, + false + // current output mode (declared so the parent can pass it through) + ); + let onConfirm = prop($$props, "onConfirm", 3, () => { }), onCancel = prop($$props, "onCancel", 3, () => { }); let fetched = state(null); @@ -3142,11 +3175,8 @@ function TagBuilder2($$anchor, $$props) { if (e.target.closest(".pcr-atb2-identity-trigger")) return; closeIdentityPicker(); } - const id = setTimeout(() => document.addEventListener("click", onDocClick, true), 0); - return () => { - clearTimeout(id); - document.removeEventListener("click", onDocClick, true); - }; + document.addEventListener("click", onDocClick, true); + return () => document.removeEventListener("click", onDocClick, true); }); function togglePresetPicker(kind, subjId, triggerEl) { var _a2, _b; @@ -3256,11 +3286,8 @@ function TagBuilder2($$anchor, $$props) { if (e.target.closest(".pcr-atb2-preset-trigger")) return; closePresetPicker(); } - const id = setTimeout(() => document.addEventListener("click", onDocClick, true), 0); - return () => { - clearTimeout(id); - document.removeEventListener("click", onDocClick, true); - }; + document.addEventListener("click", onDocClick, true); + return () => document.removeEventListener("click", onDocClick, true); }); function removePreset(subjId, kind) { set( @@ -4387,11 +4414,8 @@ function TagBuilder2($$anchor, $$props) { if (e.target.closest(".pcr-atb2-modifier-trigger")) return; closeModifierPicker(); } - const id = setTimeout(() => document.addEventListener("click", onDocClick, true), 0); - return () => { - clearTimeout(id); - document.removeEventListener("click", onDocClick, true); - }; + document.addEventListener("click", onDocClick, true); + return () => document.removeEventListener("click", onDocClick, true); }); function identityMatchToken(option) { if ((option == null ? void 0 : option.kind) === "cast") { @@ -4469,7 +4493,7 @@ function TagBuilder2($$anchor, $$props) { if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); el.classList.add("pcr-atb2-flash"); - setTimeout(() => el.classList.remove("pcr-atb2-flash"), 1100); + el.addEventListener("animationend", () => el.classList.remove("pcr-atb2-flash"), { once: true }); } }); }); @@ -7545,6 +7569,9 @@ ${filtered}` : filtered; get initial() { return get(customizerOpen).initial; }, + get isNaturalMode() { + return get(isNaturalMode); + }, onConfirm: commitCustomizer, onCancel: cancelCustomizer }); @@ -7584,6 +7611,9 @@ ${filtered}` : filtered; get initial() { return get(furnitureCustomizerOpen).initial; }, + get isNaturalMode() { + return get(isNaturalMode); + }, onConfirm: commitFurnitureCustomizer, onCancel: cancelFurnitureCustomizer }); @@ -7607,6 +7637,9 @@ ${filtered}` : filtered; get initial() { return get(fantasyCustomizerOpen).initial; }, + get isNaturalMode() { + return get(isNaturalMode); + }, onConfirm: commitFantasyCustomizer, onCancel: cancelFantasyCustomizer }); diff --git a/svelte-src/components/tag-builder2/Customizer.svelte b/svelte-src/components/tag-builder2/Customizer.svelte index 10d8591..f226d4d 100644 --- a/svelte-src/components/tag-builder2/Customizer.svelte +++ b/svelte-src/components/tag-builder2/Customizer.svelte @@ -8,6 +8,7 @@ let { item, // chip the user is customizing: { item_tag, display_name, base_tags, base_natlang, item_group } initial = null, // { color, pattern, material, condition, focus } — pre-fill when editing + isNaturalMode = false, // current output mode, so the preview + focus match what the parent emits onConfirm = () => {}, onCancel = () => {}, } = $props(); @@ -124,13 +125,16 @@ let preview = $derived.by(() => { if (!data) return (item.display_name || item.item_tag).toLowerCase(); - const parts = []; - if (conditionOpt?.prefix) parts.push(conditionOpt.prefix); - if (colorOpt?.prefix) parts.push(colorOpt.prefix); - if (patternOpt?.prefix) parts.push(patternOpt.prefix); - if (materialOpt?.prefix) parts.push(materialOpt.prefix); - parts.push((item.display_name || item.item_tag).toLowerCase()); - const phrase = parts.join(" "); + // Mirror the parent's buildModifiedPhrase so the preview matches what is + // actually emitted: condition, color, pattern, material, then the item. + const prefixes = [conditionOpt?.prefix, colorOpt?.prefix, patternOpt?.prefix, materialOpt?.prefix].filter(Boolean); + if (!isNaturalMode) { + // Tag mode: underscore-joined prefix slugs + item_tag. The focus + // phrase is natural-language only and isn't part of the tag emit. + const slug = (s) => s.replace(/\s+/g, "_"); + return [...prefixes.map(slug), item.item_tag || ""].filter(Boolean).join("_"); + } + const phrase = [...prefixes, (item.display_name || item.item_tag).toLowerCase()].join(" "); return focus ? `${phrase}, presenting ${phrase} to viewer, ${phrase} focus` : phrase; }); @@ -322,11 +326,11 @@ {/if} - -