Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions backend/database/migrations/0026_persona_locking.py
Original file line number Diff line number Diff line change
@@ -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}")
2 changes: 2 additions & 0 deletions backend/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
5 changes: 3 additions & 2 deletions backend/database/queries/character_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions backend/database/queries/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,23 @@ 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,
character_card_id,
char_name,
char_scenario,
post_history_instructions,
persona_lock_id,
now,
now,
),
Expand All @@ -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.
"""
Expand All @@ -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

Expand All @@ -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 = ?",
Expand Down
4 changes: 4 additions & 0 deletions backend/database/queries/user_personas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 4 additions & 2 deletions backend/database/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 (
Expand Down
39 changes: 33 additions & 6 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
handle_regenerate,
handle_super_regenerate,
handle_magic_rewrite,
resolve_persona_id,
agent_enabled,
)
from .llm_client import AbortToken, LLMClient
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand All @@ -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", "")

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 ""
Expand Down
17 changes: 15 additions & 2 deletions backend/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
6 changes: 5 additions & 1 deletion frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ import {
refreshCharacters,
renderCharacters,
saveCharEdit,
saveInteractiveFragment,
saveImportedChar,
saveInteractiveFragment,
saveMoodFragment,
searchInternet,
setCharBrowserSort,
Expand Down Expand Up @@ -154,6 +154,8 @@ import {
saveSetting,
saveUserProfile,
setAgentEnabled,
setPersonaCharacterLock,
setPersonaConversationLock,
showAddPhraseGroupModal,
showPersonaEditModal,
showPhraseBankModal,
Expand Down Expand Up @@ -214,6 +216,8 @@ Object.assign(window, {
deletePersona,
editPersona,
activatePersona,
setPersonaConversationLock,
setPersonaCharacterLock,
// tools
toggleToolsPanel,
setAgentEnabled,
Expand Down
35 changes: 31 additions & 4 deletions frontend/chat_conversations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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") {
Expand Down Expand Up @@ -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 = `<img src="${avatarUrl(conv.character_card_id)}?t=${Date.now()}" onerror="this.parentElement.textContent='📜'" onclick="showAvatarPopup()" style="cursor:pointer">`;
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;
Expand Down Expand Up @@ -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 `<div class="conv-history-item${isActive ? " active-conv" : ""}" onclick="closeModal();selectConversation('${c.id}')">
<div class="conv-history-meta">
<span class="conv-history-title">${title}</span>
Expand All @@ -243,6 +269,7 @@ export async function showConvHistoryModal() {
? `<div class="conv-history-preview">${preview}</div>`
: `<div class="conv-history-preview" style="color:var(--text-muted);font-style:italic">No messages yet</div>`
}
<div class="conv-history-info">${meta.join('<span class="conv-history-info-sep">·</span>')}</div>
</div>`;
})
.join("");
Expand Down
Loading
Loading