diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7200fc5..f274af2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
+## [1.33.0] — 2026-09-19
+
+### Fixed
+- **A matched image pair for a character's base appearance was never fully saved.** `_charAutoGenerateConsistentImages` persisted only the avatar; the concept/reference image and the pair metadata (prompt version, fingerprint, digests, `identity_consistent`) were set on the in-memory object and lost on reload, and the generic visual save wrote a base-appearance concept image to the record root instead of `sheet`. All visual results now go through one save path (`_libSaveCharacterVisual`) that writes the active form or the base correctly, digests included.
+- **Avatar-only generation could use a stale LLM-written prompt** and left an existing pair looking consistent. It now uses the deterministic portrait prompt (a hand-edited prompt is honoured) and marks an existing pair for review, since a lone regeneration gives a new face (same for reference-only).
+- **Sheet extraction no longer asks the LLM for `image_prompt`** (server and browser prompts) — the root cause of avatar/concept drift. The deterministic prompt is generated right after extraction.
+- **Prompts written while an appearance form was active landed on the base sheet** (regenerate and manual save) while the page kept showing the form's stale value; appearance prompts now go to the active form, character-wide prompts to the base. Inline Physical/Clothing edits likewise change only the active form.
+- **Visual-pair review ignored forms**: a character with an active form always looked unfingerprinted. Each form with images is now checked against its own metadata and fingerprint and reported under the form name.
+- **Fingerprint parity**: server and browser hashed list-valued fields and non-BMP characters differently, so such characters always looked stale; the fingerprint now also covers the `visual_*` overrides that change the prompt (appended only when set, so existing pairs stay valid). A test runs the browser code in Node and compares.
+
+### Added
+- **Appearance forms panel** in the character detail page: add (starts as a copy of the base look), rename, delete (confirmed), show, per-form stale flag, generate matched images for one form or every form (prompts rebuilt per form first, active appearance restored), max 8 (server-enforced).
+- `architecture/character-visual-identity.md`; tests in `tests/test_character_identity.py` and `tests/characters.spec.js` (the persistence tests fail on the previous code).
+
+---
+
## [1.32.0] — 2026-09-19
### Added
diff --git a/TODO.md b/TODO.md
index d239e7f..ae2ce1c 100644
--- a/TODO.md
+++ b/TODO.md
@@ -348,8 +348,7 @@ codebase; benefit is relative user/reliability impact.
- [x] **P1 / L — Very high benefit — Unify visual identity.** Portrait and concept-sheet generation now use one
canonical identity description, stored prompt version, identity fingerprint, asset digests, and stale-asset review
path; shared-anchor and metadata persistence are covered by automated tests.
-- [ ] **P2 / M–L — Medium–high benefit — Enrich character-form UX.** Add richer inline form editing and per-form
- bulk-generation controls while preserving one character ID and one voice.
+- [x] **P2 / M–L — Medium–high benefit — Enrich character-form UX.** Done 2026-09-19: forms panel (add/rename/delete/show, per-form stale flag, per-form and all-forms image generation), Physical/Clothing edits scoped to the active form. See `architecture/character-visual-identity.md`.
## Magazine reader roadmap
@@ -564,48 +563,25 @@ LLM writes it as one of the 53 character-sheet fields, while
facts the concept sheet uses. The deterministic one keeps both images consistent; the
LLM-written one omits identity anchors (age, hair, build) so the model invents them.
-### Status — partially working
+### Status — resolved 2026-09-19
-**Working:**
+Verified against the code and covered by tests (see `architecture/character-visual-identity.md`):
-- Character records, structured profile fields and portrait images are saved and loaded
- independently without losing the stored image data.
-- The concept-sheet path already builds from structured character facts and is the more
- consistent identity source.
-- Portrait generation now uses baseline appearance guidance, explicit head-and-shoulders
- framing, neutral backgrounds and book-period grounding.
-- Existing scene/style/background contamination in saved portrait prompts is filtered in
- the portrait path.
+- [x] One canonical identity description feeds both the avatar and the concept sheet
+ (`_character_visual_facts`; portrait and reference prompts are built deterministically, the
+ active appearance form is overlaid first).
+- [x] `image_prompt` is no longer authored by the LLM during sheet extraction (server and browser
+ prompts); the deterministic prompt is generated right after extraction, before avatar-only
+ generation and before per-form generation. Hand-edited prompts are never overwritten.
+- [x] Prompt version, identity fingerprint and asset digests are stored with every generated pair
+ — for the base appearance too (they used to be lost on reload) and per form.
+- [x] Review indicator: per-character banner and `GET /api/characters/visual-pair-review`, now
+ form-aware; a pair is also flagged when one image was regenerated alone.
+- [x] Tests: server/browser fingerprint parity, prompt anchors shared by both builders, review
+ endpoint (base + forms), and Playwright persistence tests that fail on the previous code.
-**Not working reliably:**
-
-- The profile/avatar portrait and the character concept sheet can still depict different
- hair colour, age, facial features, build or overall identity.
-- Character-sheet extraction can write an LLM-authored `image_prompt` that lacks the
- identity anchors used by the deterministic concept-sheet prompt.
-- Regenerating a portrait does not guarantee that it uses exactly the same identity
- description as the concept sheet.
-- Evidence-window profile regeneration can produce behaviour or actions in `physical`
- instead of stable appearance details.
-
-**Needs to be fixed:**
-
-- Make one canonical identity description the input for both avatar portraits and concept
- sheets: age range, hair, eyes, face, build, skin tone, distinctive marks and clothing
- era.
-- Remove `image_prompt` from the general sheet-extraction output, or overwrite it only
- after extraction using the canonical structured fields.
-- Store the resolved identity prompt/version with each generated image so old images can
- be identified as stale and regenerated deterministically.
-- Add a consistency check or review indicator when the avatar and concept-sheet images
- were generated from different prompt versions.
-- Add an automated test covering extraction → prompt generation → avatar and concept
- sheet generation, asserting that both image requests contain the same identity anchors.
-
-The app's own flow already calls `/api/character-generate-prompts` after extraction, so
-**this is mainly a process trap**: any script that regenerates sheets must call that
-endpoint too, or it leaves an LLM-authored prompt in place. Consider removing
-`image_prompt` from the sheet-extraction field list entirely so there is only one source.
+Still true, and deliberately not "fixed": the resolved prompt *text* is not stored per image
+(only its version and the fingerprint of the facts it was built from).
### Known regression from the 2026-09-02 batch
Regenerating the top-10 profiles from evidence windows (passages around each character's
diff --git a/VERSION b/VERSION
index 8602511..f0fed91 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.32.0
\ No newline at end of file
+1.33.0
\ No newline at end of file
diff --git a/architecture/character-visual-identity.md b/architecture/character-visual-identity.md
new file mode 100644
index 0000000..da8f74d
--- /dev/null
+++ b/architecture/character-visual-identity.md
@@ -0,0 +1,55 @@
+# SOP — Character visual identity and appearance forms
+
+Status: implemented. Covers how a character's avatar and concept (reference) sheet stay the same
+person, and how one character holds several appearances ("forms") without splitting into several
+characters or voices.
+
+## Single source of truth
+
+* Appearance facts come from the sheet's `physical`, `clothing`, `age_estimate`, `race_species`,
+ `gender`, `title`, `profession`, `archetype` (and the user overrides `visual_appearance`,
+ `visual_species`, `visual_costume`). Both prompts are built by
+ `_character_visual_facts` in `routes/conversation.py`; the active form is overlaid first
+ (`_character_form_sheet`, which never lets a form change its own `id`/`name`).
+* `image_prompt` (portrait) and `concept_art_prompt` (reference sheet) are **deterministic** —
+ built server-side from those facts with fixed camera/lighting/period text. Sheet extraction no
+ longer asks the LLM to write `image_prompt` (it drifted from the concept sheet); the prompt is
+ generated right after extraction by `csAutoGenerateExternalPrompts`, and again before any
+ avatar-only or per-form generation. A hand-edited prompt (`*_user_edited`) is never overwritten.
+* Only the two prompts describe an appearance. `voice_design_prompt` and the SillyTavern prompt
+ belong to the character as a whole, so they always live on the base sheet.
+
+## Stale detection
+
+`_character_identity_fingerprint` (server) and `_libIdentityFingerprint` (browser) hash the same
+field list; a test runs the browser code in Node and compares, including list values and non-BMP
+characters. The three `visual_*` overrides are appended only when set, so pairs made before they
+existed keep a valid fingerprint. A pair needs review when its prompt version is not current, its
+fingerprint is missing or differs, or `identity_consistent` is false. Regenerating **only** the
+avatar or **only** the reference image sets `identity_consistent = false` on an existing pair,
+because a lone regeneration produces a new face.
+
+## Where the data lives
+
+| | base appearance | a form |
+|---|---|---|
+| profile image | record `image` | `form.image` |
+| reference image, version, fingerprint, digests, `identity_consistent` | `sheet.*` | `form.*` |
+| Physical / Clothing text | `sheet.physical/clothing` | `form.physical/clothing` |
+
+All visual results are written through one function (`_libSaveCharacterVisual`), which re-reads the
+stored record, writes to the active form or the base, and saves. (It used to write the base
+reference image to the record root, and the consistent-pair generator kept it in memory only, so a
+base pair lost its reference image and metadata on reload.)
+
+`GET /api/characters/visual-pair-review` checks the base pair and every form that has images, each
+against its own metadata and its own fingerprint, and reports form pairs under the form name.
+
+## Forms (max 8 per character, enforced in `core/validation.py`)
+
+Add (starts as a copy of the base Physical/Clothing; only non-empty keys are stored because an
+empty key would blank the base value when overlaid), rename (unique, ≤160 chars), delete (confirm;
+the character, its voice and the base look stay), show, and generate matched images for one form or
+for all forms. Generation rebuilds that form's prompts first (unless hand-edited), saves the pair on
+that form, then restores whichever appearance was active. Editing Physical/Clothing while a form is
+active changes only that form.
diff --git a/routes/characters.py b/routes/characters.py
index 4815c90..4b17910 100644
--- a/routes/characters.py
+++ b/routes/characters.py
@@ -109,6 +109,32 @@ def _visual_asset_digest(value: object) -> str:
return ""
+def _pair_scopes(record: dict) -> list[tuple[dict, str, str, str, dict, str]]:
+ """The visual pairs one character can hold, as
+ (fingerprint sheet, form id, form name, profile image, stored metadata, reference image).
+
+ The base appearance keeps its pair on the record/sheet; every appearance form that
+ has images keeps its own on the form entry, so each is judged against its own
+ identity fingerprint (base fields overlaid with that form) and its own metadata.
+ """
+ sheet = record.get("sheet") if isinstance(record.get("sheet"), dict) else {}
+ scopes = [(
+ {**sheet, "active_form_id": ""}, "", "",
+ record.get("image") or sheet.get("image"), sheet, sheet.get("concept_art_image"),
+ )]
+ forms = sheet.get("forms") if isinstance(sheet.get("forms"), list) else []
+ for form in forms:
+ if not isinstance(form, dict) or not form.get("id"):
+ continue
+ if not (form.get("image") or form.get("concept_art_image")):
+ continue
+ scopes.append((
+ {**sheet, "active_form_id": str(form["id"])}, str(form["id"]), str(form.get("name") or form["id"]),
+ form.get("image"), form, form.get("concept_art_image"),
+ ))
+ return scopes
+
+
@router.get("/api/characters/visual-pair-review")
async def characters_visual_pair_review():
"""Report stored profile/reference identity problems without rewriting records."""
@@ -118,43 +144,44 @@ async def characters_visual_pair_review():
digest_owners: dict[str, list[str]] = {}
reports = []
for record in records:
- sheet = record.get("sheet") if isinstance(record.get("sheet"), dict) else {}
- profile = record.get("image") or sheet.get("image")
- reference = sheet.get("concept_art_image")
- profile_digest = _visual_asset_digest(profile)
- reference_digest = _visual_asset_digest(reference)
- for kind, digest in (("profile", profile_digest), ("reference", reference_digest)):
- if digest:
- digest_owners.setdefault(digest, []).append(f"{record.get('id')}:{kind}")
- issues = []
- if bool(profile) != bool(reference):
- issues.append("incomplete_pair")
- if profile and reference:
- if profile_digest and profile_digest == reference_digest:
- issues.append("same_asset_for_profile_and_reference")
- stored_profile_digest = str(sheet.get("profile_asset_digest") or "")
- stored_reference_digest = str(sheet.get("reference_asset_digest") or "")
- if stored_profile_digest and profile_digest and stored_profile_digest != profile_digest:
- issues.append("profile_asset_changed_since_generation")
- if stored_reference_digest and reference_digest and stored_reference_digest != reference_digest:
- issues.append("reference_asset_changed_since_generation")
- expected = _character_identity_fingerprint(sheet)
- if sheet.get("consistent_prompt_version") != _CHARACTER_VISUAL_PROMPT_VERSION:
- issues.append("legacy_or_missing_prompt_version")
- if not sheet.get("consistent_identity_fingerprint"):
- issues.append("missing_identity_fingerprint")
- elif sheet.get("consistent_identity_fingerprint") != expected:
- issues.append("appearance_changed_since_generation")
- if sheet.get("identity_consistent") is False:
- issues.append("marked_inconsistent")
- if issues:
- reports.append({"id": record.get("id"), "name": record.get("name", ""), "issues": issues})
+ for fp_sheet, form_id, form_name, profile, meta, reference in _pair_scopes(record):
+ profile_digest = _visual_asset_digest(profile)
+ reference_digest = _visual_asset_digest(reference)
+ for kind, digest in (("profile", profile_digest), ("reference", reference_digest)):
+ if digest:
+ digest_owners.setdefault(digest, []).append(f"{record.get('id')}:{kind}")
+ issues = []
+ if bool(profile) != bool(reference):
+ issues.append("incomplete_pair")
+ if profile and reference:
+ if profile_digest and profile_digest == reference_digest:
+ issues.append("same_asset_for_profile_and_reference")
+ stored_profile_digest = str(meta.get("profile_asset_digest") or "")
+ stored_reference_digest = str(meta.get("reference_asset_digest") or "")
+ if stored_profile_digest and profile_digest and stored_profile_digest != profile_digest:
+ issues.append("profile_asset_changed_since_generation")
+ if stored_reference_digest and reference_digest and stored_reference_digest != reference_digest:
+ issues.append("reference_asset_changed_since_generation")
+ expected = _character_identity_fingerprint(fp_sheet)
+ if meta.get("consistent_prompt_version") != _CHARACTER_VISUAL_PROMPT_VERSION:
+ issues.append("legacy_or_missing_prompt_version")
+ if not meta.get("consistent_identity_fingerprint"):
+ issues.append("missing_identity_fingerprint")
+ elif meta.get("consistent_identity_fingerprint") != expected:
+ issues.append("appearance_changed_since_generation")
+ if meta.get("identity_consistent") is False:
+ issues.append("marked_inconsistent")
+ if issues:
+ report = {"id": record.get("id"), "name": record.get("name", ""), "issues": issues}
+ if form_id:
+ report["form_id"], report["form_name"] = form_id, form_name
+ reports.append(report)
for owners in digest_owners.values():
character_ids = {owner.rsplit(":", 1)[0] for owner in owners}
if len(character_ids) > 1:
for owner in owners:
char_id = owner.rsplit(":", 1)[0]
- report = next((item for item in reports if item["id"] == char_id), None)
+ report = next((item for item in reports if item["id"] == char_id and "form_id" not in item), None)
if report is None:
report = {"id": char_id, "name": next((r.get("name", "") for r in records if r.get("id") == char_id), ""), "issues": []}
reports.append(report)
diff --git a/routes/conversation.py b/routes/conversation.py
index cc0e9ae..504039a 100644
--- a/routes/conversation.py
+++ b/routes/conversation.py
@@ -80,6 +80,11 @@ _CHARACTER_IDENTITY_FIELDS = (
"age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species",
"gender", "marks", "scars", "nationality_background", "clothing", "era", "profession",
)
+# User overrides that _character_visual_facts prefers over the extracted text. They are
+# appended to the fingerprint only when set, so a pair made before they existed keeps a
+# valid fingerprint while editing an override now marks the pair stale. The browser keeps
+# the same two lists (static/js/library-characters.js); tests assert they match.
+_CHARACTER_IDENTITY_OVERRIDE_FIELDS = ("visual_appearance", "visual_species", "visual_costume")
class _CastJobRequest:
@@ -151,15 +156,30 @@ async def _run_cast_job(job_id: str, data: dict, headers: dict[str, str]) -> Non
_cast_job_set(job_id, stage="error", detail="Failed", done=True, error=message)
+def _fingerprint_text(value) -> str:
+ """Text exactly as the browser's _libStr renders it, so both sides hash the same string."""
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ return value
+ if isinstance(value, (list, tuple)):
+ return ", ".join(_fingerprint_text(item) for item in value if item)
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
+
+
def _character_identity_fingerprint(sheet: dict) -> str:
sheet = _character_form_sheet(sheet)
- values = {key: sheet.get(key, "") for key in _CHARACTER_IDENTITY_FIELDS}
- raw = "|".join(f"{key}:{values[key] if values[key] is not None else ''}" for key in _CHARACTER_IDENTITY_FIELDS)
- # Keep this compact hash algorithm identical to the browser-side stale
- # check; the fingerprint is only a change detector, not a security token.
+ raw = "|".join(f"{key}:{_fingerprint_text(sheet.get(key))}" for key in _CHARACTER_IDENTITY_FIELDS)
+ for key in _CHARACTER_IDENTITY_OVERRIDE_FIELDS:
+ text = _fingerprint_text(sheet.get(key))
+ if text:
+ raw += f"|{key}:{text}"
+ # Same compact hash as the browser-side stale check (which walks UTF-16 code
+ # units); the fingerprint is only a change detector, not a security token.
value = 0
- for char in raw:
- value = ((value << 5) - value + ord(char)) & 0xFFFFFFFF
+ units = raw.encode("utf-16-le")
+ for i in range(0, len(units), 2):
+ value = ((value << 5) - value + int.from_bytes(units[i:i + 2], "little")) & 0xFFFFFFFF
return format(value, "x")
@@ -1003,7 +1023,7 @@ def _charsheets_prepare(data: dict) -> dict:
f"capabilities, backstory, relationships, motivation, fears, mannerisms, communication_style, voice_pattern, "
f"secret, conflict_style, win_condition, archetype, aliases, first_name, last_name, title, age_estimate, "
f"race_species, languages, nationality_background, social_class, profession, reputation, religious_beliefs, notes.\n"
- f"voice_design_prompt and image_prompt should be concise English tool prompts for generation tools.\n"
+ f"voice_design_prompt should be a concise English tool prompt for generation tools.\n"
f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n"
f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n"
) if language else ""
@@ -1086,7 +1106,6 @@ def _charsheets_prepare(data: dict) -> dict:
"- voice_design_prompt: concise English voice-design prompt, maximum 480 characters. Use only objective, audible traits; no lore-only labels or repeated synonyms. Include gender as a hard audible constraint, age impression, pitch, timbre, pace, accent/register, emotion and audiobook use. Translate species/role into audible cues (orc: chest-led/gravel-edged; vampire: cool/velvety/controlled; elf: airy/precise/adult; demon: dark/subtly otherworldly; warrior: disciplined command cadence; blacksmith: work-worn/practical; mage: measured/precise). Give one distinctive vocal fingerprint. "
+ ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though the prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ")
+ "Do NOT mention plot spoilers.\n"
- "- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.\n"
"- inventory: 1-3 defining items/props/clothing (array of short strings)\n"
"- secret: dark secret or fatal flaw\n"
"- conflict_style: fight, flight, or manipulate — how they act when cornered\n"
@@ -1107,7 +1126,7 @@ def _charsheets_prepare(data: dict) -> dict:
'{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"",'
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
- '"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",'
+ '"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"",'
'"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"",'
'"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think'
)
diff --git a/static/index.html b/static/index.html
index 06e5d34..96d868d 100644
--- a/static/index.html
+++ b/static/index.html
@@ -10,7 +10,7 @@
-
+
@@ -27,12 +27,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/static/js/character-sheets.js b/static/js/character-sheets.js
index 3474fa7..eaea5fe 100644
--- a/static/js/character-sheets.js
+++ b/static/js/character-sheets.js
@@ -46,8 +46,7 @@ For each character output these fields:
- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.
- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)
- voice_design_prompt: concise English voice-design prompt, maximum 480 characters. Use only objective, audible traits; no lore-only labels or repeated synonyms. Include gender as a hard audible constraint, age impression, pitch, timbre, pace, accent/register, emotion and audiobook use. Translate species/role into audible cues (orc: chest-led/gravel-edged; vampire: cool/velvety/controlled; elf: airy/precise/adult; demon: dark/subtly otherworldly; warrior: disciplined command cadence; blacksmith: work-worn/practical; mage: measured/precise). Give one distinctive vocal fingerprint. Do NOT mention plot spoilers.
-- image_prompt: detailed English image-generation prompt describing WHO this character is: face, age impression, build, hair/eyes/skin if known, clothing, posture, props, visible symbols, and overall bearing. Mark inferred traits with '*'. Describe their normal appearance, never a wound or death scene. Describe ONLY the person: face, hair, build, clothing and what they carry. Do NOT describe a setting, location, landscape, room, weather, background, or an action they are performing (no "standing in a grassy landscape", "riding a horse", "the background shows a campfire"), and do NOT specify an art style, medium, rendering technique, framing or lighting (no "painterly", "illustration", "concept art", "dramatic lighting"). The application appends its own framing and rendering directive; scene or style written here fights it and turns the casting portrait into a full-body scene.
-- forms: array of distinct transformations, disguises, or physical appearances for this same character (empty when none). Each form is {id, name, physical, clothing, image_prompt, concept_art_prompt, inventory}; keep one character and one voice, and only add a form when the text establishes a genuinely different appearance.
+- forms: array of distinct transformations, disguises, or physical appearances for this same character (empty when none). Each form is {id, name, physical, clothing, inventory}; keep one character and one voice, and only add a form when the text establishes a genuinely different appearance.
- inventory: 1-3 defining items/props/clothing (array of short strings)
- secret: dark secret or fatal flaw
- conflict_style: fight, flight, or manipulate — how they act when cornered
@@ -61,7 +60,7 @@ IDENTITY MERGING: A character may appear under multiple names in the book (first
STRICT PER-CHARACTER ATTRIBUTION: a passage often describes several characters together (a leader addressing followers, a group scene, a comparison between two people). Attribute every fact — title, role, profession, race/species, physical trait, relationship, backstory detail, or any other field — ONLY to the character it explicitly and specifically describes. NEVER copy a detail onto another character's sheet just because that character appears in the same scene, sentence, or paragraph, is compared or contrasted with them, or belongs to the same faction/group (e.g. only the actual leader of a group gets 'leader' in their profession/title — the group's other members do NOT). If it is ambiguous which character a detail belongs to, leave it out of every sheet rather than guessing.
Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.
Respond with STRICT JSON only:
-{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_design_prompt":"","image_prompt":"","forms":[],"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}
+{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_design_prompt":"","forms":[],"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}
/no-think`;
// Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the
@@ -2347,7 +2346,7 @@ async function csAutoGenerateExternalPrompts(book, sheets) {
if (!Array.isArray(sheets) || !sheets.length) return;
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
let done = 0, failed = 0, repeatMsg = '', repeatCount = 0;
- toast(`Generating SillyTavern + Concept Art prompts for ${sheets.length} character${sheets.length !== 1 ? 's' : ''}…`, 'info');
+ toast(`Generating portrait, SillyTavern and Concept Art prompts for ${sheets.length} character${sheets.length !== 1 ? 's' : ''}…`, 'info');
for (const s of sheets) {
if (!s || !s.name) continue;
try {
@@ -2358,11 +2357,15 @@ async function csAutoGenerateExternalPrompts(book, sheets) {
body: JSON.stringify({
name: s.name, book, sheet: s, language,
llm_url: target.url, model: target.model,
- fields: ['silly_tavern_prompt', 'concept_art_prompt'],
+ fields: ['image_prompt', 'silly_tavern_prompt', 'concept_art_prompt'],
}),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const d = await r.json();
+ // The portrait prompt is built server-side from the same structured facts as the
+ // concept sheet (no LLM), so the two images cannot drift apart; extraction no
+ // longer writes one. A hand-edited prompt is never overwritten.
+ if (d.image_prompt && s.image_prompt_user_edited !== true) s.image_prompt = d.image_prompt;
if (d.silly_tavern_prompt) s.silly_tavern_prompt = d.silly_tavern_prompt;
if (d.concept_art_prompt) s.concept_art_prompt = d.concept_art_prompt;
done++; repeatCount = 0;
diff --git a/static/js/library-characters.js b/static/js/library-characters.js
index 03c4fe2..a62c85c 100644
--- a/static/js/library-characters.js
+++ b/static/js/library-characters.js
@@ -30,7 +30,7 @@ async function _libReviewVisualPairs(button, container) {
} else {
panel.innerHTML = ' Review ' + payload.issues.length + ' character pair' + (payload.issues.length === 1 ? '' : 's') + ': '
+ payload.issues.map(function (item) {
- return '' + escHtml(item.name || item.id) + ' — ' + escHtml(item.issues.join(', ')) + '';
+ return '' + escHtml(item.name || item.id) + (item.form_name ? ' / ' + escHtml(item.form_name) : '') + ' — ' + escHtml(item.issues.join(', ')) + '';
}).join('');
if (typeof toast === 'function') toast(payload.issues.length + ' visual pair(s) need review', 'warning');
}
@@ -650,6 +650,16 @@ function _charAlignHtml(sh) {
+ '';
}
+// Must stay identical to routes/conversation.py _character_identity_fingerprint (a test enforces the field lists).
+const LIB_IDENTITY_FIELDS = ['age', 'age_estimate', 'hair', 'eyes', 'physical', 'build', 'skin_tone', 'race_species', 'gender', 'marks', 'scars', 'nationality_background', 'clothing', 'era', 'profession'];
+const LIB_IDENTITY_OVERRIDE_FIELDS = ['visual_appearance', 'visual_species', 'visual_costume'];
+function _libIdentityFingerprint(sheet) {
+ let raw = LIB_IDENTITY_FIELDS.map(function (key) { return key + ':' + _libStr(sheet[key]); }).join('|');
+ LIB_IDENTITY_OVERRIDE_FIELDS.forEach(function (key) { if (sheet[key]) raw += '|' + key + ':' + _libStr(sheet[key]); });
+ let hash = 0;
+ for (let i = 0; i < raw.length; i++) hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
+ return (hash >>> 0).toString(16);
+}
function _libStr(v) {
if (v == null) return '';
if (typeof v === 'string') return v;
@@ -1232,6 +1242,121 @@ function _charLineCount(c) {
return Number(c.sheet && c.sheet.line_count) || 0;
}
+// ── Appearance forms: manage, edit, generate ────────────────────────────────
+// One character keeps one ID and one voice; forms only change how they look. The pure helpers
+// below take a sheet and return a new one (or throw an Error with a message meant for the user).
+const LIB_MAX_FORMS = 8; // matches core/validation.py
+
+function _libFormId(name, forms) {
+ const base = String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'form';
+ const taken = new Set((forms || []).map(function (f) { return String(f && f.id); }));
+ let id = base, n = 2;
+ while (taken.has(id)) id = base + '-' + n++;
+ return id;
+}
+
+function _libCleanFormName(name, forms, exceptId) {
+ const clean = String(name || '').replace(/\s+/g, ' ').trim();
+ if (!clean) throw new Error('Give the form a name.');
+ if (clean.length > 160) throw new Error('The form name is too long (max 160 characters).');
+ const dupe = (forms || []).some(function (f) {
+ return String(f && f.id) !== String(exceptId || '') && String(f && f.name || '').trim().toLowerCase() === clean.toLowerCase();
+ });
+ if (dupe) throw new Error('Another form already uses that name.');
+ return clean;
+}
+
+// A new form starts as a copy of the base look (physical + clothing) so it is immediately usable;
+// only non-empty keys are stored, because an empty key would blank the base value when overlaid.
+function _libAddForm(sheet, name) {
+ const forms = Array.isArray(sheet.forms) ? sheet.forms : [];
+ if (forms.length >= LIB_MAX_FORMS) throw new Error('A character can have at most ' + LIB_MAX_FORMS + ' forms.');
+ const clean = _libCleanFormName(name, forms);
+ const form = { id: _libFormId(clean, forms), name: clean };
+ if (_libStr(sheet.physical)) form.physical = _libStr(sheet.physical);
+ if (_libStr(sheet.clothing)) form.clothing = _libStr(sheet.clothing);
+ return Object.assign({}, sheet, { forms: forms.concat([form]), active_form_id: form.id });
+}
+
+function _libRenameForm(sheet, id, name) {
+ const forms = Array.isArray(sheet.forms) ? sheet.forms : [];
+ const clean = _libCleanFormName(name, forms, id);
+ return Object.assign({}, sheet, { forms: forms.map(function (f) { return String(f && f.id) === String(id) ? Object.assign({}, f, { name: clean }) : f; }) });
+}
+
+function _libDeleteForm(sheet, id) {
+ const forms = Array.isArray(sheet.forms) ? sheet.forms : [];
+ const next = Object.assign({}, sheet, { forms: forms.filter(function (f) { return String(f && f.id) !== String(id); }) });
+ if (String(sheet.active_form_id || '') === String(id)) next.active_form_id = '';
+ return next;
+}
+
+// 'none' = no images yet, 'ok', or 'review' (older prompt version, changed appearance, or a
+// pair that was broken by regenerating one image alone) - same rule the server review applies.
+function _libFormPairState(sheet, form) {
+ if (!form || !form.image || !form.concept_art_image) return 'none';
+ const fingerprint = _libIdentityFingerprint(Object.assign({}, sheet, form));
+ const stale = form.consistent_prompt_version !== 'v3' || !form.consistent_identity_fingerprint
+ || form.consistent_identity_fingerprint !== fingerprint || form.identity_consistent === false;
+ return stale ? 'review' : 'ok';
+}
+
+function _libSetFormField(rec, key, value) {
+ const form = _libActiveCharacterForm(rec);
+ rec.sheet = Object.assign({}, rec.sheet || {});
+ if (!form) { rec.sheet[key] = value; return; }
+ rec.sheet.forms = (rec.sheet.forms || []).map(function (f) {
+ return String(f && f.id) === String(form.id) ? Object.assign({}, f, { [key]: value }) : f;
+ });
+}
+
+async function _libPersistSheet(rec, sheet) {
+ rec.sheet = sheet;
+ rec.updated = new Date();
+ if (typeof clPut === 'function') await clPut(rec);
+}
+
+// Rebuild the deterministic portrait + reference prompts for the appearance that is active now,
+// unless the user hand-edited them. Needed before generating images for a form: without it the
+// form would be drawn from the base look's prompts.
+async function _libEnsureAppearancePrompts(rec) {
+ const form = _libActiveCharacterForm(rec);
+ const sh = form ? Object.assign({}, rec.sheet || {}, form) : (rec.sheet || {});
+ const fields = ['image_prompt', 'concept_art_prompt'].filter(function (k) { return sh[k + '_user_edited'] !== true; });
+ if (!fields.length) return;
+ const r = await fetch('/api/character-generate-prompts', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: rec.name || '', book: rec.book || '', sheet: rec.sheet || {},
+ book_profile: typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {},
+ visual_style: _libStr(sh.visual_style) === 'concept' ? 'concept' : 'cinematic', fields: fields,
+ }),
+ });
+ if (!r.ok) throw new Error('Could not build the prompts: ' + ((await r.json().catch(function () { return {}; })).detail || r.statusText));
+ const d = await r.json();
+ fields.forEach(function (k) { if (d[k]) _libSetSheetPrompt(rec, k, d[k], false); });
+ rec.updated = new Date();
+ if (typeof clPut === 'function') await clPut(rec);
+}
+
+// Matched profile + reference images for the given forms, one after another, then restore
+// whichever appearance was active. Stops at the first failure so nothing is silently skipped.
+async function _libGenerateFormImages(rec, formIds, style, onProgress) {
+ const previous = String((rec.sheet || {}).active_form_id || '');
+ try {
+ for (let i = 0; i < formIds.length; i++) {
+ const form = ((rec.sheet || {}).forms || []).find(function (f) { return String(f && f.id) === String(formIds[i]); });
+ if (onProgress) onProgress(i + 1, formIds.length, form ? form.name : formIds[i]);
+ // The image save reads the active form from the stored record, so persist the switch first.
+ await _libPersistSheet(rec, Object.assign({}, rec.sheet, { active_form_id: String(formIds[i]) }));
+ await _libEnsureAppearancePrompts(rec);
+ await _charAutoGenerateConsistentImages(rec, style);
+ }
+ } finally {
+ await _libPersistSheet(rec, Object.assign({}, rec.sheet, { active_form_id: previous })).catch(function () {});
+ }
+}
+
async function _charDetailPage(rec, allChars, opts) {
if (typeof window.studioRememberCharacter === 'function') window.studioRememberCharacter(rec.id);
opts = opts || {};
@@ -1262,6 +1387,30 @@ async function _charDetailPage(rec, allChars, opts) {
}).join('')
+ ''
) : '';
+ const formsManageHtml = ' One character, one voice — a form only changes how they look (a disguise, a transformation). '
+ + 'Select a form to edit its Physical / Clothing text below; the images are generated per form. Appearance forms (' + characterForms.length + ')
'
+ + '' + characterForms.map(function (form) {
+ const id = String(form?.id || '');
+ const state = _libFormPairState(baseSheet, form);
+ return '
' : '')
+ + '