diff --git a/core/tag_builder.py b/core/tag_builder.py index beeb1a1..4dad776 100644 --- a/core/tag_builder.py +++ b/core/tag_builder.py @@ -13,7 +13,7 @@ from pathlib import Path from aiohttp import web import server -from .api_utils import parse_json +from .api_utils import parse_json, error_response # Shares the AI debug channel so `match-characters` traces sit alongside # the rest of the Prompt Generator pipeline in comfyui.log. @@ -47,6 +47,9 @@ def get_db() -> sqlite3.Connection: conn = sqlite3.connect(str(DB_PATH), check_same_thread=False) conn.row_factory = sqlite3.Row conn.text_factory = lambda x: x.decode("utf-8", "replace") + # 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) @@ -3218,6 +3221,10 @@ def _safe_thumb_segment(value: str) -> bool: return False if "/" in value or "\\" in value: return False + # A ':' lets a Windows drive-letter token (e.g. "C:") reset the Path join + # and escape THUMBS_ROOT. + if ":" in value: + return False if ".." in value or value.startswith("."): return False return True @@ -3334,6 +3341,9 @@ async def _api_characters(request): per_page = int(request.query.get("per_page", 50)) except (ValueError, TypeError): return web.json_response({"error": "invalid page/per_page"}, status=400) + # Clamp so per_page=0 can't ZeroDivisionError the page-count math and a huge + # value can't dump the whole 11.5k-row table. + per_page = max(1, min(per_page, 200)) search = request.query.get("search", "").strip() sort = request.query.get("sort", "display") status = request.query.get("status", "") @@ -3747,7 +3757,7 @@ def _api_presets_query(request, table, name_col, tags_col, natlang_col, extra_co rows = db.execute( f"SELECT {base_cols} FROM {table} p JOIN characters c ON c.tag = p.character_tag" f"{where_sql} ORDER BY {order_sql} LIMIT ? OFFSET ?", - order_params + params + [per_page, offset], + params + order_params + [per_page, offset], ).fetchall() # Attach per-row chip overrides so the picker can pre-load them onto @@ -3944,7 +3954,10 @@ async def _api_detect_outfit(request): if not input_set: return web.json_response({"matches": []}) character_tag = (body.get("character_tag") or "").strip() or None - threshold = float(body.get("threshold", 0.8)) + try: + threshold = float(body.get("threshold", 0.8)) + except (TypeError, ValueError): + return error_response("threshold must be a number") db = get_db() where = "" @@ -3995,7 +4008,10 @@ async def _api_detect_pose(request): if not input_set: return web.json_response({"matches": []}) character_tag = (body.get("character_tag") or "").strip() or None - threshold = float(body.get("threshold", 0.8)) + try: + threshold = float(body.get("threshold", 0.8)) + except (TypeError, ValueError): + return error_response("threshold must be a number") db = get_db() where = "" diff --git a/data/tag-builder/tag-builder.db b/data/tag-builder/tag-builder.db index 6be3d3a..e40b438 100644 Binary files a/data/tag-builder/tag-builder.db and b/data/tag-builder/tag-builder.db differ diff --git a/js/lib/tag-builder2-bridge.js b/js/lib/tag-builder2-bridge.js index d19b483..fa2e853 100644 --- a/js/lib/tag-builder2-bridge.js +++ b/js/lib/tag-builder2-bridge.js @@ -53,8 +53,10 @@ function findInlineWildcardRange(doc, pos) { } // Regional-conditioning blocks: `$name { ... }`. Grammar mirrors -// region-highlight.js — `$[A-Za-z]\w*` then optional whitespace then `{`, -// and since regions don't nest the closer is simply the next `}`. Returns +// region-highlight.js — `$[A-Za-z]\w*` then optional whitespace then `{`. +// Regions don't nest, but a `{a|b}` wildcard can sit inside the body, so the +// closer is found by depth-tracking braces — not the first `}`, which would +// stop short at the wildcard and corrupt the prompt on insert. Returns // each block's wrapper bounds, interior bounds, and the indent of its first // content line (so re-wrapping on insert preserves the user's layout). const REGION_OPEN = /\$[A-Za-z]\w*\s*\{/g; @@ -64,7 +66,13 @@ function findRegionBlocks(doc) { let m; while ((m = REGION_OPEN.exec(doc)) !== null) { const openBrace = m.index + m[0].length - 1; - const close = doc.indexOf("}", openBrace + 1); + let depth = 1, i = openBrace + 1; + for (; i < doc.length && depth > 0; i++) { + const ch = doc[i]; + if (ch === "{") depth++; + else if (ch === "}") depth--; + } + const close = depth === 0 ? i - 1 : -1; if (close === -1) continue; // unterminated block — leave it alone const indentMatch = doc.slice(openBrace + 1, close).match(/\n([ \t]+)\S/); blocks.push({ diff --git a/svelte-src/lib/tag-builder-conflict-resolver.js b/svelte-src/lib/tag-builder-conflict-resolver.js deleted file mode 100644 index 72e2d8f..0000000 --- a/svelte-src/lib/tag-builder-conflict-resolver.js +++ /dev/null @@ -1,58 +0,0 @@ -// Conflict resolver: when user-added clothing items occupy the same slot -// as phrases baked into a character's selected outfit, strip the outfit -// phrases and route them into negatives so the model is pushed off the -// character's trained-in defaults. -// -// Inputs: -// outfitSlots: array of { slot, item, color, source_phrase } -// userClothingSelections: object keyed by slot name (state.selections.clothing) -// -// Output: -// { stripPhrases: Set, negatives: string[] } -// -// Slots like 'accessories' and 'modifiers' are additive — never auto-stripped -// since the outfit may legitimately wear multiple of them alongside user picks. - -const ADDITIVE_SLOTS = new Set(["accessories", "modifiers"]); - -export function resolveOutfitConflicts(outfitSlots, userClothingSelections) { - const stripPhrases = new Set(); - const negatives = []; - - if (!outfitSlots?.length || !userClothingSelections) { - return { stripPhrases, negatives }; - } - - const occupiedByUser = new Set(); - for (const groupName in userClothingSelections) { - const sel = userClothingSelections[groupName]; - const items = Array.isArray(sel) ? sel : [sel]; - if (items.some(it => it && (it.tag || it.tags))) { - occupiedByUser.add(groupName.toLowerCase()); - } - } - - for (const row of outfitSlots) { - const slot = (row.slot || "").toLowerCase(); - if (!slot || ADDITIVE_SLOTS.has(slot)) continue; - if (!occupiedByUser.has(slot)) continue; - if (!row.source_phrase) continue; - stripPhrases.add(row.source_phrase); - negatives.push(row.source_phrase); - } - - return { stripPhrases, negatives }; -} - -// Strip a set of comma-separated tag phrases from a tag blob without -// breaking surrounding tags. Handles weighted forms like (tag:1.1) too. -export function stripPhrasesFromTagBlob(tags, stripPhrases) { - if (!tags || !stripPhrases?.size) return tags; - let result = tags; - for (const phrase of stripPhrases) { - const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - result = result.replace(new RegExp(`\\(${escaped}:[^)]+\\),?\\s*`, "gi"), ""); - result = result.replace(new RegExp(`(^|,\\s*)${escaped}(?=,|$)`, "gi"), (m, sep) => sep ? "" : ""); - } - return result.replace(/,\s*,/g, ",").replace(/^\s*,\s*/, "").replace(/,\s*$/, "").trim(); -}