diff --git a/backend/database/migrations/0026_persona_locking.py b/backend/database/migrations/0026_persona_locking.py
new file mode 100644
index 0000000..4993ac8
--- /dev/null
+++ b/backend/database/migrations/0026_persona_locking.py
@@ -0,0 +1,28 @@
+"""
+0026_persona_locking — add the persona_lock_id mirror column to the
+conversations and character_cards tables for databases created before
+persona locking existed.
+
+A locked persona overrides the global settings.active_persona_id within a
+scope: a conversation lock binds to a single conversation, a character lock
+binds to a character card (and thus all conversations using it). Each column
+is a plain INTEGER pointing at user_personas(id); resolution priority is
+conversation lock → character lock → global active persona.
+
+NOTE: an ALTER-added column cannot carry a REFERENCES clause whose ON DELETE
+action is reliably enforced on already-migrated SQLite databases, so the FK
+action is omitted here. Dangling locks are cleared explicitly in
+delete_user_persona() instead of relying on ON DELETE SET NULL.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+
+
+def migrate(conn: sqlite3.Connection) -> None:
+ for table in ("conversations", "character_cards"):
+ columns = [row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()]
+ if "persona_lock_id" not in columns:
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN persona_lock_id INTEGER")
+ print(f"[migrations] 0026: added persona_lock_id column to {table}")
diff --git a/backend/database/models.py b/backend/database/models.py
index 68d806a..4a1d2ee 100644
--- a/backend/database/models.py
+++ b/backend/database/models.py
@@ -154,6 +154,7 @@ class ConversationRow(TypedDict):
updated_at: str | None
active_leaf_id: int | None
workflow_state: str | None
+ persona_lock_id: int | None
class ConversationListRow(ConversationRow, total=False):
@@ -428,4 +429,5 @@ class CharacterCardRow(TypedDict, total=False):
created_at: str
updated_at: str
workflow_state: str | None
+ persona_lock_id: int | None
has_avatar: bool
diff --git a/backend/database/queries/character_cards.py b/backend/database/queries/character_cards.py
index 2c5061d..5ca71d5 100644
--- a/backend/database/queries/character_cards.py
+++ b/backend/database/queries/character_cards.py
@@ -22,7 +22,7 @@ async def list_character_cards() -> list[CharacterCardRow]:
async with get_db() as db:
rows = list(
await db.execute_fetchall(
- "SELECT id, name, creator_notes, tags, creator, source_format, created_at, updated_at, avatar_mime, world_id FROM character_cards ORDER BY updated_at DESC"
+ "SELECT id, name, creator_notes, tags, creator, source_format, created_at, updated_at, avatar_mime, world_id, persona_lock_id FROM character_cards ORDER BY updated_at DESC"
)
)
result: list[CharacterCardRow] = []
@@ -43,7 +43,7 @@ async def get_character_card(card_id: str, include_avatar: bool = False) -> Char
else (
"id, name, description, personality, scenario, first_mes, mes_example, "
"creator_notes, system_prompt, post_history_instructions, tags, creator, "
- "character_version, alternate_greetings, avatar_mime, source_format, world_id, created_at, updated_at"
+ "character_version, alternate_greetings, avatar_mime, source_format, world_id, persona_lock_id, created_at, updated_at"
)
)
rows = list(
@@ -143,6 +143,7 @@ async def update_character_card(card_id: str, data: dict) -> CharacterCardRow |
"creator",
"character_version",
"world_id",
+ "persona_lock_id",
]
sets, vals = _build_set_clause(allowed, data)
# JSON fields
diff --git a/backend/database/queries/conversations.py b/backend/database/queries/conversations.py
index 96761c7..08cd3fb 100644
--- a/backend/database/queries/conversations.py
+++ b/backend/database/queries/conversations.py
@@ -41,14 +41,15 @@ async def create_conversation(
char_scenario: str,
post_history_instructions: str = "",
character_card_id: str | None = None,
+ persona_lock_id: int | None = None,
) -> ConversationRow:
async with get_db() as db:
now = datetime.now(timezone.utc).isoformat()
await db.execute(
"""INSERT INTO conversations
(id, title, character_card_id, character_name, character_scenario,
- post_history_instructions, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ post_history_instructions, persona_lock_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
cid,
title,
@@ -56,6 +57,7 @@ async def create_conversation(
char_name,
char_scenario,
post_history_instructions,
+ persona_lock_id,
now,
now,
),
@@ -74,8 +76,9 @@ async def fork_conversation(source: ConversationRow, new_title: str) -> str:
"""Create an empty conversation seeded from ``source``'s character framing.
Carries the title-independent identity fields (character name/scenario,
- post-history instructions, card id) that both the Compress History and
- Checkpoint flows start a fork with, and returns the new conversation id.
+ post-history instructions, card id, persona pin) that both the Compress
+ History and Checkpoint flows start a fork with, and returns the new
+ conversation id.
Messages, branches, director state and logs are *not* copied -- the caller
appends whatever slice of the source it intends to carry.
"""
@@ -87,6 +90,7 @@ async def fork_conversation(source: ConversationRow, new_title: str) -> str:
char_scenario=source.get("character_scenario", "") or "",
post_history_instructions=source.get("post_history_instructions", "") or "",
character_card_id=source.get("character_card_id"),
+ persona_lock_id=source.get("persona_lock_id"),
)
return new_cid
@@ -109,11 +113,15 @@ async def touch_conversation(cid: str) -> bool:
async def update_conversation(cid: str, data: dict) -> ConversationRow | None:
async with get_db() as db:
- allowed = ["title"]
+ allowed = ["title", "persona_lock_id"]
sets, vals = _build_set_clause(allowed, data)
if sets:
- sets.append("updated_at = ?")
- vals.append(datetime.now(timezone.utc).isoformat())
+ # updated_at is the conversation's "last activity" date (shown in the
+ # history modal). Pinning/changing a persona is metadata, not chat
+ # activity, so a persona_lock_id-only update must not bump it.
+ if any(k in data for k in allowed if k != "persona_lock_id"):
+ sets.append("updated_at = ?")
+ vals.append(datetime.now(timezone.utc).isoformat())
vals.append(cid)
await db.execute(
f"UPDATE conversations SET {', '.join(sets)} WHERE id = ?",
diff --git a/backend/database/queries/user_personas.py b/backend/database/queries/user_personas.py
index 78032d5..6bef6b4 100644
--- a/backend/database/queries/user_personas.py
+++ b/backend/database/queries/user_personas.py
@@ -67,6 +67,10 @@ async def update_user_persona(persona_id: int, data: dict) -> UserPersonaRow | N
async def delete_user_persona(persona_id: int) -> bool:
async with get_db() as db:
+ # Clear dangling locks explicitly: an ALTER-added persona_lock_id column
+ # can't rely on ON DELETE SET NULL on already-migrated SQLite DBs.
+ await db.execute("UPDATE conversations SET persona_lock_id = NULL WHERE persona_lock_id = ?", (persona_id,))
+ await db.execute("UPDATE character_cards SET persona_lock_id = NULL WHERE persona_lock_id = ?", (persona_id,))
cur = await db.execute("DELETE FROM user_personas WHERE id = ?", (persona_id,))
await db.commit()
return cur.rowcount > 0
diff --git a/backend/database/schema.py b/backend/database/schema.py
index 15ca321..1ebd5dc 100644
--- a/backend/database/schema.py
+++ b/backend/database/schema.py
@@ -60,7 +60,8 @@
created_at TEXT NOT NULL,
updated_at TEXT,
active_leaf_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
- workflow_state TEXT DEFAULT NULL
+ workflow_state TEXT DEFAULT NULL,
+ persona_lock_id INTEGER REFERENCES user_personas(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS character_cards (
@@ -84,7 +85,8 @@
world_id TEXT DEFAULT NULL REFERENCES worlds(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
- workflow_state TEXT DEFAULT NULL
+ workflow_state TEXT DEFAULT NULL,
+ persona_lock_id INTEGER REFERENCES user_personas(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS messages (
diff --git a/backend/main.py b/backend/main.py
index 298f301..aafe908 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -132,6 +132,7 @@
handle_regenerate,
handle_super_regenerate,
handle_magic_rewrite,
+ resolve_persona_id,
agent_enabled,
)
from .llm_client import AbortToken, LLMClient
@@ -392,6 +393,9 @@ class ConversationCreate(BaseModel):
class ConversationUpdate(BaseModel):
title: Optional[str] = None
+ # Persona lock for this conversation; an explicit null clears it (the route
+ # uses model_dump(exclude_unset=True), so absence leaves it untouched).
+ persona_lock_id: Optional[int] = None
class SummarizeRequest(BaseModel):
@@ -471,6 +475,9 @@ def name_must_not_be_blank(cls, v: Optional[str]) -> Optional[str]:
avatar_b64: Optional[str] = None
avatar_mime: Optional[str] = None
world_id: Optional[str] = None
+ # Persona lock for this character card; an explicit null clears it (handled
+ # via model_fields_set in api_update_character since the route drops Nones).
+ persona_lock_id: Optional[int] = None
class AttachmentIn(BaseModel):
@@ -1018,7 +1025,12 @@ async def api_update_conversation(cid: str, data: ConversationUpdate):
conv = await get_conversation(cid)
if not conv:
raise HTTPException(status_code=404, detail="Conversation not found")
- result = await update_conversation(cid, data.model_dump(exclude_unset=True))
+ update_data = data.model_dump(exclude_unset=True)
+ # Migrated DBs carry no FK on the ALTER-added persona_lock_id column, so
+ # the API is the only guard against locking to a nonexistent persona.
+ if update_data.get("persona_lock_id") is not None and not await get_user_persona(update_data["persona_lock_id"]):
+ raise HTTPException(status_code=400, detail="Persona not found")
+ result = await update_conversation(cid, update_data)
return result
@@ -1040,9 +1052,13 @@ async def api_summarize_conversation(cid: str, data: SummarizeRequest, request:
settings = await get_settings()
char_name = conv.get("character_name", "Character") or "Character"
- active_persona_id = settings.get("active_persona_id")
+ # Resolve the same effective persona the chat would use (conversation/character
+ # lock overrides the global active persona) so a summary stays consistent.
+ card_id = conv.get("character_card_id")
+ card = await get_character_card(card_id) if card_id else None
+ active_persona_id = resolve_persona_id(conv, card, settings)
active_persona = await get_user_persona(active_persona_id) if active_persona_id else None
- system_prompt, char_persona, mes_example = await resolve_char_context(conv, settings)
+ system_prompt, char_persona, mes_example = await resolve_char_context(conv, settings, card=card)
macros = Macros.from_settings(settings, char_name, active_persona)
user_description = active_persona.get("description", "") if active_persona else settings.get("user_description", "")
@@ -1341,6 +1357,13 @@ async def api_update_character(card_id: str, data: CharacterCardUpdate):
# world_id can be explicitly set to None to unlink; preserve it via model_fields_set
if "world_id" in data.model_fields_set:
update_data["world_id"] = data.world_id
+ # persona_lock_id likewise: an explicit null clears the character lock
+ if "persona_lock_id" in data.model_fields_set:
+ # Migrated DBs carry no FK on the ALTER-added persona_lock_id column,
+ # so the API is the only guard against locking to a missing persona.
+ if data.persona_lock_id is not None and not await get_user_persona(data.persona_lock_id):
+ raise HTTPException(status_code=400, detail="Persona not found")
+ update_data["persona_lock_id"] = data.persona_lock_id
result = await update_character_card(card_id, update_data)
if not result:
raise HTTPException(status_code=404, detail="Character card not found")
@@ -1843,14 +1866,18 @@ async def api_get_context_size(cid: str):
mood_frags = [f for f in await get_mood_fragments() if f.get("enabled", True)]
lorebook_entries = await get_active_lorebook_entries()
- # Resolve persona
- persona_id = settings.get("active_persona_id")
+ # Resolve the same effective persona generation would use (conversation/
+ # character lock overrides the global active persona) so the size
+ # breakdown matches the prompt that is actually sent.
+ card_id = conv.get("character_card_id")
+ card = await get_character_card(card_id) if card_id else None
+ persona_id = resolve_persona_id(conv, card, settings)
active_persona = await get_user_persona(persona_id) if persona_id else None
macros = Macros.from_settings(settings, conv["character_name"], active_persona)
user_desc = active_persona.get("description", "") if active_persona else settings.get("user_description", "")
# Resolve character context
- system_prompt, char_persona, mes_example = await resolve_char_context(conv, settings)
+ system_prompt, char_persona, mes_example = await resolve_char_context(conv, settings, card=card)
# Measure each component individually
sys_text = system_prompt or ""
diff --git a/backend/orchestrator.py b/backend/orchestrator.py
index 4582629..5102089 100644
--- a/backend/orchestrator.py
+++ b/backend/orchestrator.py
@@ -1030,6 +1030,19 @@ class PipelineContext:
agent_system_prompt: Optional[str]
+def resolve_persona_id(
+ conv: Mapping[str, Any],
+ card: Mapping[str, Any] | None,
+ settings: Mapping[str, Any],
+) -> int | None:
+ """Resolve the effective persona id for a turn.
+
+ A locked persona overrides the global active persona within its scope.
+ Priority: conversation lock → character-card lock → global active persona.
+ """
+ return conv.get("persona_lock_id") or (card.get("persona_lock_id") if card else None) or settings.get("active_persona_id")
+
+
async def _load_pipeline_context(conversation_id: str, *, abort_token: AbortToken | None = None) -> PipelineContext | None:
"""Load everything the pipeline needs: settings, conversation, director,
mood_fragments, phrase_bank, and an LLMClient.
@@ -1072,9 +1085,9 @@ async def _load_pipeline_context(conversation_id: str, *, abort_token: AbortToke
card = await db.get_character_card(card_id) if card_id else None
system_prompt, char_persona, mes_example = await db.resolve_char_context(conv, settings, card=card)
- # Load active persona if set
+ # Load the effective persona (conversation/character lock overrides global)
active_persona = None
- active_persona_id = settings.get("active_persona_id")
+ active_persona_id = resolve_persona_id(conv, card, settings)
if active_persona_id:
active_persona = await db.get_user_persona(active_persona_id)
diff --git a/frontend/app.js b/frontend/app.js
index cc4cc6b..b3f6157 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -73,8 +73,8 @@ import {
refreshCharacters,
renderCharacters,
saveCharEdit,
- saveInteractiveFragment,
saveImportedChar,
+ saveInteractiveFragment,
saveMoodFragment,
searchInternet,
setCharBrowserSort,
@@ -154,6 +154,8 @@ import {
saveSetting,
saveUserProfile,
setAgentEnabled,
+ setPersonaCharacterLock,
+ setPersonaConversationLock,
showAddPhraseGroupModal,
showPersonaEditModal,
showPhraseBankModal,
@@ -214,6 +216,8 @@ Object.assign(window, {
deletePersona,
editPersona,
activatePersona,
+ setPersonaConversationLock,
+ setPersonaCharacterLock,
// tools
toggleToolsPanel,
setAgentEnabled,
diff --git a/frontend/chat_conversations.js b/frontend/chat_conversations.js
index 5dcc5ab..eaa2129 100644
--- a/frontend/chat_conversations.js
+++ b/frontend/chat_conversations.js
@@ -11,8 +11,21 @@ import { resetWorkflowViewportState } from "./chat_workflow.js";
import { loadCharacters, refreshCharacters, renderCharacters } from "./library.js";
import { activateAndPrioritizeWorld, deactivateWorld } from "./lorebooks.js";
import { closeModal, showConfirmModal, showModal } from "./modal.js";
+// Imported from settings_personas.js directly: going through settings.js would
+// close an import cycle (settings.js → chat.js → this module).
+import { updateUserBtn } from "./settings_personas.js";
import { S } from "./state.js";
-import { $, avatarUrl, convUrl, esc, formatRelativeDate, scrollToBottom, toast } from "./utils.js";
+import {
+ $,
+ avatarCell,
+ avatarUrl,
+ CHAT_AVATAR_ICON,
+ convUrl,
+ esc,
+ formatRelativeDate,
+ scrollToBottom,
+ toast,
+} from "./utils.js";
import { validate } from "./validate.js";
import { clearTextEffect } from "./workflow_text_effects.js";
@@ -31,11 +44,12 @@ export function resetChatUI() {
S.inspectedMsgId = null;
S.inspectedDirectorData = null;
$("chat-title-text").textContent = "Select a character";
- $("chat-avatar").textContent = "📜";
+ $("chat-avatar").textContent = CHAT_AVATAR_ICON;
$("chat-input").disabled = true;
$("send-btn").disabled = true;
renderMessages();
renderInspector();
+ updateUserBtn(); // no active character → drop any locked-to-character icon
}
export async function selectChar(id, source = "recent") {
@@ -123,12 +137,18 @@ export async function selectConversation(id) {
S.activeCharId = conv.character_card_id;
renderCharacters();
}
+ // The user button shows the persona in force here (pin → default); opening a
+ // pinned conversation never mutates the global default.
+ updateUserBtn();
$("chat-title-text").textContent = conv ? conv.title || conv.character_name : "";
const av = $("chat-avatar");
if (conv?.character_card_id) {
- av.innerHTML = ``;
+ av.innerHTML = avatarCell(`${avatarUrl(conv.character_card_id)}?t=${Date.now()}`, {
+ icon: CHAT_AVATAR_ICON,
+ attrs: 'onclick="showAvatarPopup()" style="cursor:pointer"',
+ });
} else {
- av.textContent = "📜";
+ av.textContent = CHAT_AVATAR_ICON;
}
$("chat-input").disabled = false;
$("send-btn").disabled = false;
@@ -232,6 +252,12 @@ export async function showConvHistoryModal() {
const preview = esc((c.last_message_preview || "").substring(0, 80));
const title = esc(c.title || c.character_name || "Untitled");
const ts = c.updated_at || c.created_at;
+ const count = c.message_count ?? 0;
+ const pinnedPersona = c.persona_lock_id
+ ? (S.personas || []).find((p) => p.id === c.persona_lock_id)?.name || null
+ : null;
+ const meta = [`${count} message${count !== 1 ? "s" : ""}`];
+ if (pinnedPersona) meta.push(`💬 ${esc(pinnedPersona)}`);
return `
${CONV_LOCK_ICON} ${esc(pinnedStatusText(conv, card, charName))}
` + : ""; + showModal(`Click a persona to activate it.
+${CONV_LOCK_ICON} pin to conversation, ${CHAR_LOCK_ICON} to character — pins override the default persona.
No personas yet. Create one to get started.
'}