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 `
${title} @@ -243,6 +269,7 @@ export async function showConvHistoryModal() { ? `
${preview}
` : `
No messages yet
` } +
${meta.join('·')}
`; }) .join(""); diff --git a/frontend/chat_stream.js b/frontend/chat_stream.js index d583b9f..87c3552 100644 --- a/frontend/chat_stream.js +++ b/frontend/chat_stream.js @@ -30,6 +30,9 @@ import { import { clearInspectedMessage } from "./chat_messages.js"; import { _mergeWorkflowRejections } from "./chat_workflow.js"; import { refreshCharacters } from "./library.js"; +// Imported directly rather than via settings.js to avoid an import cycle +// (settings.js → chat.js → this module), as chat_conversations.js does. +import { ensurePersonaPinned } from "./settings_personas.js"; import { S } from "./state.js"; import { $, @@ -742,6 +745,9 @@ export async function sendMessage() { } } await afterStream(); + // Any send in an unpinned chat pins the effective persona to it (no-op once + // pinned), so legacy and freshly-unpinned chats regain an author on send. + await ensurePersonaPinned(); } // ── Regenerate diff --git a/frontend/css/chat.css b/frontend/css/chat.css index 3b36f7c..e4b4f13 100644 --- a/frontend/css/chat.css +++ b/frontend/css/chat.css @@ -583,6 +583,14 @@ transition: background .1s; } +.burger-icon { + flex: 0 0 auto; + width: 18px; + text-align: center; + font-size: 14px; + line-height: 1; +} + .burger-menu-item:hover { background: var(--bg-hover); color: var(--text-primary); @@ -702,6 +710,19 @@ color: var(--text-primary); } +.conv-history-info { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + font-size: 10px; + color: var(--text-muted); +} + +.conv-history-info-sep { + color: var(--border); +} + /* Empty state */ .empty-state { display: flex; diff --git a/frontend/css/modals.css b/frontend/css/modals.css index a7e53eb..2cafc4a 100644 --- a/frontend/css/modals.css +++ b/frontend/css/modals.css @@ -320,6 +320,19 @@ border-color: var(--accent-dim); } +/* Neutral status note: which persona is pinned to the open chat/character. */ +.persona-lock-warning { + margin: 0 0 10px; + padding: 8px 12px; + font-size: 12px; + line-height: 1.4; + color: var(--text-secondary); + background: var(--bg-surface); + border: 1px solid var(--border); + border-left: 3px solid var(--accent-dim); + border-radius: var(--radius); +} + .persona-avatar { width: 32px; height: 32px; @@ -366,6 +379,42 @@ white-space: nowrap; } +/* ── Persona lock buttons (persona selector modal) ── */ +.persona-lock-btns { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.persona-lock-btn { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 5px 10px; + font-size: 13px; + line-height: 1; + cursor: pointer; + opacity: .55; + transition: opacity 0.1s, border-color 0.1s, background 0.1s; +} + +.persona-lock-btn:hover { + opacity: 1; + background: var(--bg-hover); +} + +.persona-lock-btn.locked { + opacity: 1; + border-color: var(--accent-dim); + background: var(--accent-glow); +} + +.persona-lock-btn:disabled { + opacity: .2; + cursor: default; + background: none; +} + /* ── Phrase Bank ── */ .phrase-bank-list { margin-bottom: 20px; diff --git a/frontend/index.html b/frontend/index.html index 87a95c5..bd10d50 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -105,11 +105,11 @@
-
✚ New conversation
-
📋 Conversations
-
📦 Compress History
-
🔖 Create Checkpoint
-
🖼️ Attach Image
+
New conversation
+
📋Conversations
+
📦Compress History
+
🔖Create Checkpoint
+
🖼️Attach Image
diff --git a/frontend/library.js b/frontend/library.js index fd34fe8..6221fba 100644 --- a/frontend/library.js +++ b/frontend/library.js @@ -9,7 +9,7 @@ import { loadConversations, resetChatUI } from "./chat.js"; import { loadWorlds } from "./lorebooks.js"; import { closeModal, showConfirmModal, showCropModal, showModal } from "./modal.js"; import { S } from "./state.js"; -import { $, avatarUrl, esc, toast } from "./utils.js"; +import { $, avatarCell, avatarUrl, CHAT_AVATAR_ICON, esc, NO_AVATAR_ICON, toast } from "./utils.js"; import { validate } from "./validate.js"; export { @@ -119,9 +119,7 @@ export function renderCharacters() { $("char-list").innerHTML = S.characters .map((c) => { const bust = _avatarBust.has(c.id) ? `?v=${_avatarBust.get(c.id)}` : ""; - const av = c.has_avatar - ? `` - : "👤"; + const av = avatarCell(c.has_avatar ? avatarUrl(c.id) + bust : ""); const meta = esc(c.creator_notes || (c.tags || []).slice(0, 2).join(", ") || c.source_format || ""); const isActive = S.activeCharId === c.id; return `
@@ -303,7 +301,7 @@ export function showCharCreateModal() { showModal(`
@@ -448,7 +446,7 @@ export async function showCharEditModal(idOrData) { av = ``; } else { const bust = _avatarBust.has(c.id) ? `?v=${_avatarBust.get(c.id)}` : ""; - av = c.has_avatar ? `` : "👤"; + av = avatarCell(c.has_avatar ? avatarUrl(c.id) + bust : ""); } if (isNew) { @@ -577,8 +575,7 @@ export async function saveCharEdit(id, exportAfter = false) { _avatarBust.set(id, Date.now()); if (S.activeCharId === id) { const av = document.getElementById("chat-avatar"); - if (av) - av.innerHTML = ``; + if (av) av.innerHTML = avatarCell(`${avatarUrl(id)}?v=${_avatarBust.get(id)}`, { icon: CHAT_AVATAR_ICON }); } } closeModal(); diff --git a/frontend/library_browser.js b/frontend/library_browser.js index 82cb4e9..5691f06 100644 --- a/frontend/library_browser.js +++ b/frontend/library_browser.js @@ -7,7 +7,7 @@ import { api } from "./api.js"; import { _avatarBust, showCharEditModal } from "./library.js"; import { closeModal, setModalCloseCallback, showModal } from "./modal.js"; import { S } from "./state.js"; -import { $, avatarUrl, esc, escAttr, escHandlerArg, formatRelativeDate, toast } from "./utils.js"; +import { $, avatarCell, avatarUrl, esc, escAttr, escHandlerArg, formatRelativeDate, toast } from "./utils.js"; import { validate } from "./validate.js"; // Character browser modal state @@ -291,9 +291,7 @@ function charItemMatchAttrs(c) { function renderCharBrowserCard(c) { const bust = _avatarBust.has(c.id) ? `?v=${_avatarBust.get(c.id)}` : ""; - const av = c.has_avatar - ? `` - : "👤"; + const av = avatarCell(c.has_avatar ? avatarUrl(c.id) + bust : "", { attrs: 'loading="lazy"' }); return `
${av}
@@ -303,9 +301,7 @@ function renderCharBrowserCard(c) { function renderCharBrowserListItem(c) { const bust = _avatarBust.has(c.id) ? `?v=${_avatarBust.get(c.id)}` : ""; - const av = c.has_avatar - ? `` - : "👤"; + const av = avatarCell(c.has_avatar ? avatarUrl(c.id) + bust : "", { attrs: 'loading="lazy"' }); const notes = c.creator_notes || (c.tags && c.tags.length ? c.tags.slice(0, 6).join(", ") : ""); const tags = notes ? `
${esc(notes)}
` : ""; return ` @@ -356,9 +352,7 @@ function renderInternetResultsBody() { } function renderInternetResultCard(item) { - const av = item.avatar_url - ? `` - : "👤"; + const av = avatarCell(item.avatar_url ? escAttr(item.avatar_url) : "", { attrs: 'loading="lazy" decoding="async"' }); const fullPath = escHandlerArg(item.full_path || ""); const topics = (item.topics || []).slice(0, 12); const updated = item.date_updated ? "Updated: " + formatRelativeDate(item.date_updated) : ""; diff --git a/frontend/settings.js b/frontend/settings.js index 9215f25..78b67e8 100644 --- a/frontend/settings.js +++ b/frontend/settings.js @@ -32,6 +32,8 @@ export { loadPersonas, savePersona, saveUserProfile, + setPersonaCharacterLock, + setPersonaConversationLock, showPersonaEditModal, showUserModal, updateUserBtn, diff --git a/frontend/settings_personas.js b/frontend/settings_personas.js index 4c41ce9..8c78a94 100644 --- a/frontend/settings_personas.js +++ b/frontend/settings_personas.js @@ -4,7 +4,7 @@ import { api } from "./api.js"; import { closeModal, showConfirmModal, showModal } from "./modal.js"; import { S } from "./state.js"; -import { $, esc, toast } from "./utils.js"; +import { $, effectivePersonaId, esc, escAttr, toast } from "./utils.js"; import { validate } from "./validate.js"; export async function loadPersonas() { @@ -16,20 +16,48 @@ export async function loadPersonas() { } } +// Persona-pin glyphs. The user button shows how the displayed persona is +// pinned (conversation pin wins over character pin); 💬/💏 also label the +// per-scope pin buttons and modal subtitle below. +const PERSONA_ICON = "👤"; +const CONV_LOCK_ICON = "💬"; +const CHAR_LOCK_ICON = "💏"; + // ── User Profile export function updateUserBtn() { + // Show the persona generation will actually use: conv pin → char pin → + // global default, matching backend resolve_persona_id. + const personaId = effectivePersonaId(); let displayName = "User"; - if (S.activePersonaId && S.personas.length) { - const activePersona = S.personas.find((p) => p.id === S.activePersonaId); - if (activePersona) displayName = activePersona.name; + if (personaId && S.personas.length) { + const persona = S.personas.find((p) => p.id === personaId); + if (persona) displayName = persona.name; } - const label = "👤 " + displayName; + const { conv, card } = activeLockContext(); + const glyph = conv?.persona_lock_id ? CONV_LOCK_ICON : card?.persona_lock_id ? CHAR_LOCK_ICON : PERSONA_ICON; + const label = glyph + " " + displayName; $("user-profile-btn").textContent = label; const mobileBtn = $("mobile-user-profile-btn"); if (mobileBtn) mobileBtn.textContent = label; } +// The active conversation / character card a persona lock would attach to. +// The card lookup goes through S.allCharacters: S.characters is the +// recent-filtered subset and may not contain the active card. +export function activeLockContext() { + const conv = S.conversations.find((c) => c.id === S.activeConvId); + const card = conv?.character_card_id ? (S.allCharacters || []).find((c) => c.id === conv.character_card_id) : null; + const charName = conv?.character_name || card?.name || ""; + return { conv, card, charName }; +} + export function showUserModal() { + const { conv, card, charName } = activeLockContext(); + // A pin on the open conversation or character decides who speaks here; the + // global default (Default badge) only seeds new, unpinned chats. Nothing is + // gated: selecting another persona while pinned simply re-pins the chat. + const pinned = !!(conv?.persona_lock_id || card?.persona_lock_id); + const effectiveId = effectivePersonaId(); const personaItems = S.personas .map((p) => { const isActive = p.id === S.activePersonaId; @@ -37,38 +65,77 @@ export function showUserModal() { const avatarTextColor = isActive ? "var(--accent)" : "#085041"; const avatarBg = isActive ? "var(--accent-glow)" : avatarColor; const initials = p.name.charAt(0).toUpperCase(); + const convLocked = !!conv && conv.persona_lock_id === p.id; + const charLocked = !!card && card.persona_lock_id === p.id; + const convTitle = conv + ? convLocked + ? "Unpin from this conversation" + : "Pin to this conversation" + : "Open a conversation to enable"; + const charTitle = card + ? charLocked + ? `Unpin from ${escAttr(charName)}` + : `Pin to ${escAttr(charName)}` + : "Only available for saved characters"; return `
${initials}
${esc(p.name)} - ${isActive ? 'Active' : ""} + ${isActive ? 'Default' : ""} + ${pinned && p.id === effectiveId ? 'This chat' : ""}
${esc(p.description || "")}
+
+ + +
`; }) .join(""); + const note = pinned + ? `

${CONV_LOCK_ICON} ${esc(pinnedStatusText(conv, card, charName))}

` + : ""; + showModal(` + ${note}
${personaItems.length ? personaItems : ''}
`); } +// Human-readable summary of which persona is pinned where, for the modal's +// neutral status note. Caller escapes the result before injecting it. +function pinnedStatusText(conv, card, charName) { + const named = (id) => `"${S.personas.find((p) => p.id === id)?.name || "A persona"}"`; + const convId = conv?.persona_lock_id || null; + const cardId = card?.persona_lock_id || null; + const where = charName || "this character"; + let scope; + if (convId && cardId && convId === cardId) scope = `${named(convId)} is pinned to this chat and ${where}`; + else if (convId && cardId) scope = `${named(convId)} is pinned to this chat and ${named(cardId)} to ${where}`; + else if (convId) scope = `${named(convId)} is pinned to this chat`; + else scope = `${named(cardId)} is pinned to ${where}`; + return `${scope} — selecting another persona re-pins this chat.`; +} + export async function saveUserProfile() { const name = $("user-name-input").value.trim(); const desc = $("user-desc-input").value.trim(); @@ -102,7 +169,7 @@ export function showPersonaEditModal(personaId) {