diff --git a/backend/api/deps.py b/backend/api/deps.py index f2c2057e..f936d994 100644 --- a/backend/api/deps.py +++ b/backend/api/deps.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import hashlib import json import logging import os @@ -13,7 +14,7 @@ import httpx from fastapi import Depends, HTTPException, Request -from fastapi.responses import StreamingResponse +from fastapi.responses import Response, StreamingResponse from ..database import ( get_conversation, @@ -328,6 +329,15 @@ def _pipeline_sse_response( _PROFILE_UPSTREAM = "The model endpoint did not answer the profile request." +def cached_image_response(image_bytes: bytes, mime: str | None, request: Request) -> Response: + """Return a privately cacheable image response with ETag support.""" + etag = '"' + hashlib.md5(image_bytes, usedforsecurity=False).hexdigest() + '"' + cache_headers = {"Cache-Control": "private, max-age=300", "ETag": etag} + if request.headers.get("if-none-match") == etag: + return Response(status_code=304, headers=cache_headers) + return Response(content=image_bytes, media_type=mime or "image/png", headers=cache_headers) + + async def require_conversation(cid: str) -> ConversationRow: """404 guard shared by the ``/api/conversations/{cid}/...`` routes.""" conv = await get_conversation(cid) diff --git a/backend/api/routes/characters.py b/backend/api/routes/characters.py index 27bfbf20..c4d20582 100644 --- a/backend/api/routes/characters.py +++ b/backend/api/routes/characters.py @@ -44,6 +44,7 @@ from ...inference import agent_lane_from_settings, client_from_settings from ..deps import ( _normalise_lorebook_entry, + cached_image_response, lorebook_to_book, profile_draft_failures, project_lorebook_view, @@ -240,17 +241,7 @@ async def api_get_avatar(card_id: str, request: Request): if not result: raise HTTPException(status_code=404, detail="No avatar found") image_bytes, mime_type = result - # Avatars are large (a card's full PNG) and change only on edit. Let the - # browser cache them so the library grid doesn't re-download every avatar on - # each re-render/search/sort. The frontend already busts the URL (?v=) when - # an avatar is edited in-session; the ETag corrects cross-session edits once - # max-age lapses via a cheap conditional GET. usedforsecurity=False: this is - # a cache validator, not a security hash. - etag = '"' + hashlib.md5(image_bytes, usedforsecurity=False).hexdigest() + '"' - cache_headers = {"Cache-Control": "private, max-age=300", "ETag": etag} - if request.headers.get("if-none-match") == etag: - return Response(status_code=304, headers=cache_headers) - return Response(content=image_bytes, media_type=mime_type or "image/png", headers=cache_headers) + return cached_image_response(image_bytes, mime_type, request) @router.get("/api/characters/{card_id}/export") @@ -328,13 +319,7 @@ async def api_get_expression(card_id: str, label: str, request: Request): if not result: raise HTTPException(status_code=404, detail="No expression found") image_bytes, mime = result - # Same private-cache + conditional-GET block as avatars: expressions change - # only on re-upload, and the popup swaps src on label change without a buster. - etag = '"' + hashlib.md5(image_bytes, usedforsecurity=False).hexdigest() + '"' - cache_headers = {"Cache-Control": "private, max-age=300", "ETag": etag} - if request.headers.get("if-none-match") == etag: - return Response(status_code=304, headers=cache_headers) - return Response(content=image_bytes, media_type=mime or "image/png", headers=cache_headers) + return cached_image_response(image_bytes, mime, request) @router.delete("/api/characters/{card_id}/expressions") diff --git a/backend/api/routes/personas.py b/backend/api/routes/personas.py index c8500171..be3a16ea 100644 --- a/backend/api/routes/personas.py +++ b/backend/api/routes/personas.py @@ -2,14 +2,16 @@ from __future__ import annotations -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from ...database import ( create_user_persona, delete_user_persona, + get_persona_avatar, get_user_personas, update_user_persona, ) +from ..deps import cached_image_response from ..schemas import UserPersonaCreate, UserPersonaUpdate router = APIRouter() @@ -27,12 +29,25 @@ async def api_create_user_persona(data: UserPersonaCreate): @router.put("/api/user-personas/{persona_id}") async def api_update_user_persona(persona_id: int, data: UserPersonaUpdate): - result = await update_user_persona(persona_id, data.model_dump(exclude_none=True)) + update_data = data.model_dump(exclude_none=True) + update_data.update( + {field: getattr(data, field) for field in ("avatar_b64", "avatar_mime") if field in data.model_fields_set} + ) + result = await update_user_persona(persona_id, update_data) if not result: raise HTTPException(status_code=404, detail="User persona not found") return result +@router.get("/api/user-personas/{persona_id}/avatar") +async def api_get_persona_avatar(persona_id: int, request: Request): + result = await get_persona_avatar(persona_id) + if not result: + raise HTTPException(status_code=404, detail="No avatar found") + image_bytes, mime_type = result + return cached_image_response(image_bytes, mime_type, request) + + @router.delete("/api/user-personas/{persona_id}") async def api_delete_user_persona(persona_id: int): success = await delete_user_persona(persona_id) diff --git a/backend/api/schemas.py b/backend/api/schemas.py index bbf836dd..23c72464 100644 --- a/backend/api/schemas.py +++ b/backend/api/schemas.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import json import re from typing import Any, Literal @@ -42,6 +43,7 @@ class SettingsUpdate(BaseModel): character_library_sort: str | None = None active_endpoint_id: int | None = None show_editor_diff: bool | None = None + show_chat_avatars: bool | None = None editor_audit_toggles: dict | None = None # Document-mode Output Auditor (doc-owned columns; deliberately not shared # with editor_audit_toggles so a doc-mode save can't perturb chat scanners). @@ -676,16 +678,46 @@ class PhraseGroupUpdate(BaseModel): pattern: str = "" +# Keep avatar blobs bounded before they reach SQLite. +MAX_PERSONA_AVATAR_BYTES = 2 * 1024 * 1024 + + +def _validate_persona_avatar_b64(v: str | None) -> str | None: + if v is None: + return v + try: + raw = base64.b64decode(v, validate=True) + except Exception: + raise ValueError("Invalid base64 string") from None + if len(raw) > MAX_PERSONA_AVATAR_BYTES: + raise ValueError("Avatar exceeds 2 MB limit") + return v + + class UserPersonaCreate(BaseModel): name: str description: str = "" avatar_color: str | None = None + avatar_b64: str | None = None + avatar_mime: str | None = None + + @field_validator("avatar_b64") + @classmethod + def validate_avatar_b64(cls, v): + return _validate_persona_avatar_b64(v) class UserPersonaUpdate(BaseModel): name: str | None = None description: str | None = None avatar_color: str | None = None + avatar_b64: str | None = None + avatar_mime: str | None = None + + @field_validator("avatar_b64") + @classmethod + def validate_avatar_b64(cls, v): + return _validate_persona_avatar_b64(v) class ResetConfirm(BaseModel): diff --git a/backend/database/__init__.py b/backend/database/__init__.py index e8cd5be9..62d97b2c 100644 --- a/backend/database/__init__.py +++ b/backend/database/__init__.py @@ -159,6 +159,7 @@ from .queries.user_personas import ( create_user_persona, delete_user_persona, + get_persona_avatar, get_user_persona, get_user_personas, update_user_persona, @@ -314,6 +315,7 @@ "get_sheet_proposals", "get_speaker_names", "get_user_attachments_for_message", + "get_persona_avatar", "get_user_persona", "get_user_personas", "get_workflow_attachment_by_id", diff --git a/backend/database/migrations/0057_persona_avatars.py b/backend/database/migrations/0057_persona_avatars.py new file mode 100644 index 00000000..3f7808f5 --- /dev/null +++ b/backend/database/migrations/0057_persona_avatars.py @@ -0,0 +1,27 @@ +"""Add persona avatars and the chat-avatar setting.""" + +from __future__ import annotations + +import sqlite3 + + +def _columns(conn: sqlite3.Connection, table: str) -> set[str]: + if conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone() is None: + return set() + return {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} # nosec B608 -- literal table names + + +def migrate(conn: sqlite3.Connection) -> None: + persona_cols = _columns(conn, "user_personas") + if persona_cols: + if "avatar_b64" not in persona_cols: + conn.execute("ALTER TABLE user_personas ADD COLUMN avatar_b64 TEXT DEFAULT NULL") + print("[migrations] 0057: added avatar_b64 column to user_personas") + if "avatar_mime" not in persona_cols: + conn.execute("ALTER TABLE user_personas ADD COLUMN avatar_mime TEXT DEFAULT NULL") + print("[migrations] 0057: added avatar_mime column to user_personas") + + settings_cols = _columns(conn, "settings") + if settings_cols and "show_chat_avatars" not in settings_cols: + conn.execute("ALTER TABLE settings ADD COLUMN show_chat_avatars INTEGER NOT NULL DEFAULT 0") + print("[migrations] 0057: added show_chat_avatars column to settings") diff --git a/backend/database/models.py b/backend/database/models.py index cc418faa..0f20a2f1 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -85,6 +85,7 @@ class _SettingsBase(TypedDict): character_library_view: str character_library_sort: str show_editor_diff: int + show_chat_avatars: int editor_audit_toggles: dict # decoded to its in-memory shape by get_settings() document_audit_enabled: int document_audit_autopatch: int @@ -494,12 +495,14 @@ class ActiveLorebookEntryRow(LorebookEntryRow): class UserPersonaRow(TypedDict): - """A row from ``user_personas`` (the queries select these six columns).""" + """A user persona without avatar bytes.""" id: int name: str description: str avatar_color: str | None + avatar_mime: str | None + has_avatar: bool created_at: str updated_at: str diff --git a/backend/database/queries/settings.py b/backend/database/queries/settings.py index 3294080a..de35b3bb 100644 --- a/backend/database/queries/settings.py +++ b/backend/database/queries/settings.py @@ -292,6 +292,7 @@ async def update_settings(data: dict) -> SettingsRow: "character_library_sort", "active_endpoint_id", "show_editor_diff", + "show_chat_avatars", "editor_audit_toggles", "document_audit_enabled", "document_audit_autopatch", diff --git a/backend/database/queries/user_personas.py b/backend/database/queries/user_personas.py index 2fae5bfa..3ede1032 100644 --- a/backend/database/queries/user_personas.py +++ b/backend/database/queries/user_personas.py @@ -1,42 +1,64 @@ from __future__ import annotations +import base64 from datetime import UTC, datetime -from typing import cast +from typing import Any, cast from ..connection import _build_set_clause, get_db from ..models import UserPersonaRow +_PERSONA_SELECT = "SELECT id, name, description, avatar_color, avatar_mime, created_at, updated_at FROM user_personas" + + +def _project(row: Any) -> UserPersonaRow: + d = dict(row) + d["has_avatar"] = d["avatar_mime"] is not None + return cast(UserPersonaRow, d) + async def get_user_personas() -> list[UserPersonaRow]: + async with get_db() as db: + rows = list(await db.execute_fetchall(_PERSONA_SELECT + " ORDER BY name ASC")) + return [_project(r) for r in rows] + + +async def get_user_persona(persona_id: int) -> UserPersonaRow | None: async with get_db() as db: rows = list( await db.execute_fetchall( - "SELECT id, name, description, avatar_color, created_at, updated_at FROM user_personas ORDER BY name ASC" + _PERSONA_SELECT + " WHERE id = ?", + (persona_id,), ) ) - return [cast(UserPersonaRow, dict(r)) for r in rows] + return _project(rows[0]) if rows else None -async def get_user_persona(persona_id: int) -> UserPersonaRow | None: +async def get_persona_avatar(persona_id: int) -> tuple[bytes, str] | None: + """Return decoded avatar bytes and MIME type, if present.""" async with get_db() as db: rows = list( await db.execute_fetchall( - "SELECT id, name, description, avatar_color, created_at, updated_at FROM user_personas WHERE id = ?", + "SELECT avatar_b64, avatar_mime FROM user_personas WHERE id = ?", (persona_id,), ) ) - return cast(UserPersonaRow, dict(rows[0])) if rows else None + if not rows or not rows[0]["avatar_b64"]: + return None + return base64.b64decode(rows[0]["avatar_b64"]), rows[0]["avatar_mime"] async def create_user_persona(data: dict) -> UserPersonaRow: async with get_db() as db: now = datetime.now(UTC).isoformat() cur = await db.execute( - "INSERT INTO user_personas (name, description, avatar_color, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO user_personas (name, description, avatar_color, avatar_b64, avatar_mime, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", ( data["name"], data.get("description", ""), data.get("avatar_color"), + data.get("avatar_b64"), + data.get("avatar_mime"), now, now, ), @@ -51,7 +73,7 @@ async def create_user_persona(data: dict) -> UserPersonaRow: async def update_user_persona(persona_id: int, data: dict) -> UserPersonaRow | None: async with get_db() as db: - allowed = ["name", "description", "avatar_color"] + allowed = ["name", "description", "avatar_color", "avatar_b64", "avatar_mime"] sets, vals = _build_set_clause(allowed, data) if sets: sets.append("updated_at = ?") @@ -67,8 +89,6 @@ 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,)) diff --git a/backend/database/schema.py b/backend/database/schema.py index b36905fe..4dcfd87b 100644 --- a/backend/database/schema.py +++ b/backend/database/schema.py @@ -32,6 +32,7 @@ character_library_view TEXT NOT NULL DEFAULT 'grid', character_library_sort TEXT NOT NULL DEFAULT 'time-added', show_editor_diff INTEGER NOT NULL DEFAULT 1, + show_chat_avatars INTEGER NOT NULL DEFAULT 0, editor_audit_toggles TEXT NOT NULL DEFAULT '{"banned_phrases":true,"repetitive_openers":true,"repetitive_templates":true,"contrastive_negation":true,"phrase_repetition":true,"structural_repetition":true,"anti_echo":true}', document_audit_enabled INTEGER NOT NULL DEFAULT 1, document_audit_autopatch INTEGER NOT NULL DEFAULT 0, @@ -214,6 +215,8 @@ name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', avatar_color TEXT, + avatar_b64 TEXT DEFAULT NULL, + avatar_mime TEXT DEFAULT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); diff --git a/backend/database/seeds.py b/backend/database/seeds.py index 0f393828..8fbfb95d 100644 --- a/backend/database/seeds.py +++ b/backend/database/seeds.py @@ -202,6 +202,7 @@ "character_library_view": "grid", "character_library_sort": "time-added", "show_editor_diff": 1, + "show_chat_avatars": 0, "editor_audit_toggles": { "banned_phrases": True, "repetitive_openers": True, diff --git a/docs/features/persona-pinning.md b/docs/features/persona-pinning.md index 3fc768c8..aa3756f8 100644 --- a/docs/features/persona-pinning.md +++ b/docs/features/persona-pinning.md @@ -13,6 +13,18 @@ Open the user menu with the **👤** button. Each persona can be pinned to: The conversation option requires an open conversation. The character option requires a saved character. +## Persona avatars + +Edit a persona to give it a picture. **Choose image** opens the same crop editor +character avatars use; **Remove** drops back to the coloured circle holding the +persona's initial. The picture appears beside the persona in the user menu, and +in the chat gutter when avatars are turned on. + +Turn the gutter on under **Settings -> Show avatars in chat**. It is off by +default. With it on, every message carries a portrait on the left: the speaking +character's for a reply, and the persona in force for your own messages -- so +switching or pinning a persona changes what your messages show. + ## Which persona is used Orb resolves the persona in this order: diff --git a/docs/getting-started.md b/docs/getting-started.md index 3c6c3ef9..25cc075a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -75,12 +75,12 @@ repository root. | `worlds/*.json` | Worlds, with their global enabled state | | `chats/**/*.jsonl` | Conversations and their original dates | | Swipes | Message branches, including the selected branch | -| Personas | Personas and descriptions | +| Personas | Personas, descriptions and avatar images | | Groups and group chats | Group scenes and speaker attribution | Orb does not import prompts, context templates, instruct sequences, generation -presets, endpoints or API keys, themes, backgrounds, persona avatars, reasoning -traces, token counts, author's notes, or SillyTavern's tag list. Chats whose +presets, endpoints or API keys, themes, backgrounds, reasoning traces, token +counts, author's notes, or SillyTavern's tag list. Chats whose character card was deleted are skipped unless you add `--include-orphans`. Use `--help` to see options such as `--only`, `--db`, and `--limit`. diff --git a/frontend/app.js b/frontend/app.js index d88c29d5..ea17076c 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -202,6 +202,7 @@ import { toggleLengthGuard, toggleLengthGuardEnforce, togglePreventPromptOverrides, + toggleShowChatAvatars, toggleShowEditorDiff, toggleToolEnabled, toggleToolsPanel, @@ -302,6 +303,7 @@ Object.assign(window, { toggleAuditType, toggleHideUntilBaked, togglePreventPromptOverrides, + toggleShowChatAvatars, toggleWorkflowsGlobal, toggleWorkflowEnabled, scoreSlop, diff --git a/frontend/chat_core.js b/frontend/chat_core.js index 8b39e3e9..d7d55073 100644 --- a/frontend/chat_core.js +++ b/frontend/chat_core.js @@ -6,7 +6,7 @@ import { _renderWorkflowRejection, } from "./chat_workflow.js"; import { reconcileChildren } from "./dom_reconcile.js"; -import { sceneEmptyStateHtml, speakerLabel } from "./group_cast.js"; +import { sceneEmptyStateHtml, speakerAvatarCell, speakerLabel } from "./group_cast.js"; import { CHEVRON_LEFT_ICON, CHEVRON_RIGHT_ICON, EDIT_ICON_PATHS } from "./icons.js"; import { preserveScrollDistance } from "./scroll_follow.js"; import { effectiveWorkflowEnabled, S, subscribe } from "./state.js"; @@ -296,7 +296,7 @@ export function swipeNavHtml(m) { `; } -function _messageHtml(m, childByParent) { +function _messageHtml(m, childByParent, avatars) { const isForkEditing = S.forkEditMsgId !== null && S.forkEditMsgId === m.id; const isEditing = (S.editingMsgId !== null && S.editingMsgId === m.id) || (!m.id && S.editingPendingUserMsg) || isForkEditing; @@ -332,11 +332,21 @@ function _messageHtml(m, childByParent) { ? `Rewriting prose…` : ""; return `
-
${esc(speakerLabel(m))} ${branchHtml}${rewritingHtml}
+ ${avatars ? speakerAvatarCell(m) : ""}
${esc(speakerLabel(m))} ${branchHtml}${rewritingHtml}
${body}${attachmentsHtml}${workflowArtifactsHtml}${rejectionHtml}${proposalsHtml}${toolbar}
`; } +function syncStreamingAvatar(el, avatars) { + const cell = el.querySelector(":scope > .msg-avatar"); + if (avatars && !cell) { + const msg = { role: "assistant", speaker_member_id: S.currentSpeaker?.member_id ?? null }; + el.insertAdjacentHTML("afterbegin", speakerAvatarCell(msg)); + } else if (!avatars && cell) { + cell.remove(); + } +} + // content-visibility hides an off-screen bubble's real height, so a node has to // be forced visible before it can be measured. Batch the whole set: add the // class to every node, read every height, then write. Interleaving a read and a @@ -356,6 +366,8 @@ function _measureIntrinsicSizes(nodes) { export function renderMessages(forceBottom = false) { const ct = $("chat-messages"); let renderedMsgs = null; + const avatars = S.showChatAvatars; + ct.dataset.avatars = avatars ? "on" : "off"; preserveScrollDistance( () => ct, 50, @@ -395,7 +407,7 @@ export function renderMessages(forceBottom = false) { ct, // An aborted turn can leave two id-less rows in the list (the pending user // message and the unpersisted reply), so they key by position, not by role. - msgs.map((m, i) => ({ key: m.id ? `m${m.id}` : `p${i}`, html: _messageHtml(m, childByParent) })), + msgs.map((m, i) => ({ key: m.id ? `m${m.id}` : `p${i}`, html: _messageHtml(m, childByParent, avatars) })), "msg-swap", ); // Seed the new bubbles' intrinsic sizes before the scroll math below @@ -404,7 +416,10 @@ export function renderMessages(forceBottom = false) { _measureIntrinsicSizes(fresh); } if (badgeEl) ct.appendChild(badgeEl); - if (streamingEl && !S.hideStreamingBox && !S.hideUntilBaked) ct.appendChild(streamingEl); + if (streamingEl && !S.hideStreamingBox && !S.hideUntilBaked) { + syncStreamingAvatar(streamingEl, avatars); + ct.appendChild(streamingEl); + } renderTurnError(ct); }, { forceBottom }, diff --git a/frontend/chat_stream.js b/frontend/chat_stream.js index 07790fb2..e7fb5c4d 100644 --- a/frontend/chat_stream.js +++ b/frontend/chat_stream.js @@ -32,7 +32,7 @@ import { optimisticDropDirectionNotesFrom, renderDirectionNotesPanel, } from "./direction_notes_panel.js"; -import { restNotice, unansweredHint } from "./group_cast.js"; +import { restNotice, speakerAvatarCell, unansweredHint } from "./group_cast.js"; import { consumeSpeakerOverride, refreshSheetProposals, renderGroupCast } from "./group_setup.js"; import { refreshCharacters } from "./library.js"; import { isUtilityPanelOpen } from "./panels.js"; @@ -164,10 +164,11 @@ export function stopGeneration() { } } -export function createStreamingDiv(name = null) { +export function createStreamingDiv(name = null, memberId = null) { const div = document.createElement("div"); div.className = "message assistant"; - div.innerHTML = `
${esc(name || getCharName())}
+ const avatar = S.showChatAvatars ? speakerAvatarCell({ role: "assistant", speaker_member_id: memberId }) : ""; + div.innerHTML = `${avatar}
${esc(name || getCharName())}
@@ -405,7 +406,7 @@ export async function processSSEStream(resp, container, holder, signal) { S.currentExchangeId = parsed.exchange_id; S.currentSpeaker = parsed; resetSpeakerTurnState(); - holder.el = createStreamingDiv(parsed.name); + holder.el = createStreamingDiv(parsed.name, parsed.member_id); if (!S.hideUntilBaked) container.appendChild(holder.el); onTurnStart(); renderGroupCast(); diff --git a/frontend/css/chat.css b/frontend/css/chat.css index 81d34381..9556d600 100644 --- a/frontend/css/chat.css +++ b/frontend/css/chat.css @@ -1084,3 +1084,48 @@ color: var(--text-muted); margin-top: 2px; } + +/* Message avatar gutter. */ + +#chat-messages[data-avatars="on"] .message { + display: grid; + grid-template-columns: var(--msg-avatar-size, 34px) 1fr; + column-gap: 10px; +} + +#chat-messages[data-avatars="on"] .message > .msg-avatar { + grid-column: 1; + grid-row: 1 / span 10; + align-self: start; +} + +#chat-messages[data-avatars="on"] .message > *:not(.msg-avatar) { + grid-column: 2; +} + +#chat-messages[data-avatars="on"] .message.user { margin-left: 0; } +#chat-messages[data-avatars="on"] .message.user .msg-role { justify-content: flex-start; } +#chat-messages[data-avatars="on"] .message.user .msg-toolbar { justify-content: flex-start; } + +.msg-avatar { + width: var(--msg-avatar-size, 34px); + height: var(--msg-avatar-size, 34px); + border-radius: 50%; + background: var(--bg-elevated); + border: 1px solid var(--border); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + font-weight: 600; + color: var(--text-muted); + flex-shrink: 0; +} + +.msg-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} diff --git a/frontend/css/modals.css b/frontend/css/modals.css index ad07dd3b..dd07b9dd 100644 --- a/frontend/css/modals.css +++ b/frontend/css/modals.css @@ -410,6 +410,29 @@ font-weight: 600; font-size: 13px; flex-shrink: 0; + overflow: hidden; +} + +.persona-avatar img { width: 100%; height: 100%; object-fit: cover; } + +.persona-edit-avatar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.persona-avatar-lg { + width: 64px; + height: 64px; + font-size: 24px; + background: var(--bg-elevated); + border: 1px solid var(--border); +} + +.persona-avatar-actions { + display: flex; + gap: 6px; } .persona-info { diff --git a/frontend/group_cast.js b/frontend/group_cast.js index bb928127..99e6f084 100644 --- a/frontend/group_cast.js +++ b/frontend/group_cast.js @@ -1,5 +1,15 @@ import { S } from "./state.js"; -import { avatarCell, avatarUrl, esc, escAttr } from "./utils.js"; +import { + avatarCell, + avatarUrl, + effectivePersonaId, + esc, + escAttr, + escHandlerArg, + personaAvatarSrc, + readableInk, + safePersonaColour, +} from "./utils.js"; export const TURN_MODES = { director: { label: "Auto", hint: "Director chooses" }, @@ -148,6 +158,44 @@ export function speakerLabel(msg) { return S.groupCast.speakerNames?.get(msg.speaker_member_id) || "Unknown speaker"; } +const AVATAR_IMG_ATTRS = 'loading="lazy" decoding="async" alt=""'; + +const NARRATOR_AVATAR = "\u2712\ufe0f"; +const GENERIC_AVATAR = "\ud83d\udc64"; + +/** Return a message's avatar markup. */ +function messagePersona() { + return S.personas?.find((p) => p.id === effectivePersonaId()) || null; +} + +export function speakerAvatar(msg) { + if (msg?.role === "user") { + const persona = messagePersona(); + const src = personaAvatarSrc(persona); + const initial = persona?.name?.charAt(0).toUpperCase() || ""; + if (src) + return avatarCell(escAttr(src), { icon: escHandlerArg(initial) || GENERIC_AVATAR, attrs: AVATAR_IMG_ATTRS }); + return initial ? esc(initial) : GENERIC_AVATAR; + } + const cardId = S.groupCast + ? S.groupCast.members?.find((m) => m.id === msg?.speaker_member_id)?.character_card_id + : S.conversations?.find((c) => c.id === S.activeConvId)?.character_card_id; + if (cardId) return avatarCell(escAttr(avatarUrl(cardId)), { icon: GENERIC_AVATAR, attrs: AVATAR_IMG_ATTRS }); + const member = S.groupCast?.members?.find((m) => m.id === msg?.speaker_member_id); + return member?.member_kind === "narrator" ? NARRATOR_AVATAR : GENERIC_AVATAR; +} + +/** Wrap a message avatar in its gutter cell. */ +export function speakerAvatarCell(msg) { + const inner = speakerAvatar(msg); + let style = ""; + if (msg?.role === "user" && !inner.startsWith("${inner}`; +} + export function eligibleMembers() { return (S.groupCast?.members || []).filter((member) => !member.muted); } diff --git a/frontend/mobile.css b/frontend/mobile.css index 165ea49b..4e7f9142 100644 --- a/frontend/mobile.css +++ b/frontend/mobile.css @@ -192,6 +192,10 @@ body { } @media (max-width: 700px) { + #chat-messages[data-avatars="on"] { + --msg-avatar-size: 26px; + } + .modal { width: 100%; max-width: calc(100vw - 16px); diff --git a/frontend/settings.js b/frontend/settings.js index 5a4e7e54..1dd9b32d 100644 --- a/frontend/settings.js +++ b/frontend/settings.js @@ -101,6 +101,9 @@ export async function loadSettings() { if (typeof S.settings.show_editor_diff === "number") S.showEditorDiff = S.settings.show_editor_diff !== 0; else if (typeof S.settings.show_editor_diff === "boolean") S.showEditorDiff = S.settings.show_editor_diff; + if (typeof S.settings.show_chat_avatars === "number") S.showChatAvatars = S.settings.show_chat_avatars !== 0; + else if (typeof S.settings.show_chat_avatars === "boolean") S.showChatAvatars = S.settings.show_chat_avatars; + if (S.settings.editor_audit_toggles && typeof S.settings.editor_audit_toggles === "object") S.editorAuditToggles = { ...S.editorAuditToggles, ...S.settings.editor_audit_toggles }; @@ -149,18 +152,28 @@ export function renderSettings() {
Hide until baked -
Hide replies until completion.
+
+
+ Show avatars in chat + +
+
Show the speaker's portrait beside each message.
+
Prevent prompt overrides -
@@ -175,9 +188,28 @@ export function renderSettings() {
`; $("cleanup-btn").addEventListener("click", showCleanupModal); + wireSettingsToggles($("settings-form")); loadLocalMLSection(); } +const SETTING_TOGGLES = { + hideUntilBaked: toggleHideUntilBaked, + showChatAvatars: toggleShowChatAvatars, + preventPromptOverrides: togglePreventPromptOverrides, +}; + +function wireSettingsToggles(el) { + if (el.dataset.togglesWired) return; + el.dataset.togglesWired = "1"; + el.addEventListener("click", (ev) => { + if (ev.target.closest("[data-setting-stop]")) ev.stopPropagation(); + }); + el.addEventListener("change", (ev) => { + const input = ev.target.closest("[data-setting-toggle]"); + if (input) SETTING_TOGGLES[input.dataset.settingToggle]?.(input.checked); + }); +} + const LOCAL_ML_LABELS = { autocomplete: "Input Autocomplete", slop_classifier: "AI-Slop Classifier", @@ -628,6 +660,13 @@ export async function toggleHideUntilBaked(on) { await persistSettings({ hide_streaming_until_baked: on }); } +export async function toggleShowChatAvatars(on) { + S.showChatAvatars = on; + renderMessages(); + renderSettings(); + await persistSettings({ show_chat_avatars: on }); +} + export async function togglePreventPromptOverrides(on) { S.preventPromptOverrides = on; renderSettings(); diff --git a/frontend/settings_personas.js b/frontend/settings_personas.js index 3e39f768..a5940caf 100644 --- a/frontend/settings_personas.js +++ b/frontend/settings_personas.js @@ -1,7 +1,19 @@ import { api } from "./api.js"; -import { closeModal, confirmDelete, showModal } from "./modal.js"; +import { renderMessages } from "./chat_core.js"; +import { EDIT_ICON } from "./icons.js"; +import { closeModal, confirmDelete, showCropModal, showModal } from "./modal.js"; import { charactersView, S } from "./state.js"; -import { $, effectivePersonaId, esc, escAttr, toast } from "./utils.js"; +import { + $, + avatarCell, + effectivePersonaId, + esc, + escAttr, + escHandlerArg, + personaAvatarSrc, + safePersonaColour, + toast, +} from "./utils.js"; import { validate } from "./validate.js"; export async function loadPersonas() { @@ -11,8 +23,19 @@ export async function loadPersonas() { console.error("Failed to load personas:", e); S.personas = []; } + repaintUserAvatars(); } +/** Repaint the chat gutter when persona state changes. */ +export function repaintUserAvatars() { + if (S.showChatAvatars) renderMessages(); +} + +// The image chosen in the crop modal, held until savePersona() posts it. +// `null` means "leave whatever is stored alone"; `REMOVE_AVATAR` clears it. +const REMOVE_AVATAR = Symbol("remove-avatar"); +let _pendingPersonaAvatar = null; + const PERSONA_ICON = "👤"; const CONV_LOCK_ICON = "💬"; const CHAR_LOCK_ICON = "💏"; @@ -52,10 +75,12 @@ export function showUserModal() { const personaItems = S.personas .map((p) => { const isActive = p.id === S.activePersonaId; - const avatarColor = p.avatar_color || "#E1F5EE"; + const avatarColor = safePersonaColour(p.avatar_color) || "#E1F5EE"; const avatarTextColor = isActive ? "var(--accent)" : "#085041"; const avatarBg = isActive ? "var(--accent-glow)" : avatarColor; const initials = p.name.charAt(0).toUpperCase(); + const avatarSrc = personaAvatarSrc(p); + const avatarInner = avatarSrc ? avatarCell(escAttr(avatarSrc), { icon: escHandlerArg(initials) }) : esc(initials); const convLocked = !!conv && conv.persona_lock_id === p.id; const charLocked = !!card && card.persona_lock_id === p.id; const convTitle = conv @@ -70,7 +95,7 @@ export function showUserModal() { : "Only available for saved characters"; return `
-
${initials}
+
${avatarInner}
${esc(p.name)} @@ -86,7 +111,7 @@ export function showUserModal() { title="${charTitle}" aria-label="${charTitle}" aria-pressed="${charLocked}" onclick="event.stopPropagation();setPersonaCharacterLock(${p.id}, ${!charLocked})">${CHAR_LOCK_ICON} + onclick="event.stopPropagation();editPersona(${p.id})">${EDIT_ICON}
`; @@ -151,8 +176,16 @@ export async function saveUserProfile() { export function showPersonaEditModal(personaId) { const persona = personaId ? S.personas.find((p) => p.id === personaId) : null; const isEdit = persona !== null; + _pendingPersonaAvatar = null; showModal(`

${isEdit ? "Edit persona" : "New persona"}

+
+
${personaPreviewHtml(persona)}
+
+ + +
+
@@ -172,6 +205,41 @@ export function showPersonaEditModal(personaId) {
`); + wirePersonaAvatarControls(persona); +} + +function personaPreviewHtml(persona) { + if (_pendingPersonaAvatar && _pendingPersonaAvatar !== REMOVE_AVATAR) { + const { b64, mime } = _pendingPersonaAvatar; + return ``; + } + if (_pendingPersonaAvatar === REMOVE_AVATAR) return PERSONA_ICON; + const src = personaAvatarSrc(persona); + return src ? avatarCell(escAttr(src), { icon: PERSONA_ICON }) : PERSONA_ICON; +} + +/** Wire the avatar controls. */ +function wirePersonaAvatarControls(persona) { + const box = $("persona-avatar-controls"); + if (!box || box.dataset.wired) return; + box.dataset.wired = "1"; + box.addEventListener("click", (e) => { + const action = e.target.closest("[data-action]")?.dataset.action; + if (action === "choose") { + showCropModal(({ b64, mime }) => { + _pendingPersonaAvatar = { b64, mime }; + const preview = $("persona-avatar-preview"); + if (preview) preview.innerHTML = personaPreviewHtml(persona); + const removeBtn = box.querySelector('[data-action="remove"]'); + if (removeBtn) removeBtn.disabled = false; + }, 1); + } else if (action === "remove") { + _pendingPersonaAvatar = REMOVE_AVATAR; + const preview = $("persona-avatar-preview"); + if (preview) preview.innerHTML = personaPreviewHtml(persona); + e.target.closest("[data-action]").disabled = true; + } + }); } export async function savePersona(personaId) { @@ -183,21 +251,34 @@ export async function savePersona(personaId) { toast(validation.error, true); return; } + const payload = { name, description }; + if (_pendingPersonaAvatar === REMOVE_AVATAR) { + payload.avatar_b64 = null; + payload.avatar_mime = null; + } else if (_pendingPersonaAvatar) { + payload.avatar_b64 = _pendingPersonaAvatar.b64; + payload.avatar_mime = _pendingPersonaAvatar.mime; + } + const pending = _pendingPersonaAvatar; + const avatarChanged = pending !== null; + _pendingPersonaAvatar = null; try { let newId; if (personaId && personaId !== "null") { - await api.put(`/user-personas/${personaId}`, { name, description }); + await api.put(`/user-personas/${personaId}`, payload); newId = parseInt(personaId, 10); } else { - const result = await api.post("/user-personas", { name, description }); + const result = await api.post("/user-personas", payload); newId = result.id; } + if (avatarChanged) S.personaAvatarVersion++; await loadPersonas(); if (setActive) await activatePersona(newId); updateUserBtn(); showUserModal(); toast("Persona saved"); } catch (e) { + _pendingPersonaAvatar = pending; toast(`Failed: ${e.message}`, true); } } @@ -235,6 +316,7 @@ export async function activatePersona(personaId) { toast(`Re-pinned this chat to "${name}"`); } updateUserBtn(); + repaintUserAvatars(); showUserModal(); } catch (e) { toast(`Failed: ${e.message}`, true); @@ -254,6 +336,7 @@ export async function setPersonaConversationLock(personaId, locked) { await api.put(`/conversations/${conv.id}`, { persona_lock_id: val }); conv.persona_lock_id = val; updateUserBtn(); + repaintUserAvatars(); toast( locked ? (replacing ? "Re-pinned this chat" : "Pinned to this conversation") : "Unpinned from this conversation", ); @@ -285,6 +368,7 @@ export async function setPersonaCharacterLock(personaId, locked) { await api.put(`/characters/${card.id}`, { persona_lock_id: val }); card.persona_lock_id = val; updateUserBtn(); + repaintUserAvatars(); toast( locked ? (replacing ? "Re-pinned this character" : "Pinned to this character") : "Unpinned from this character", ); diff --git a/frontend/state.js b/frontend/state.js index 7c786293..deef2f31 100644 --- a/frontend/state.js +++ b/frontend/state.js @@ -40,6 +40,8 @@ export const S = { hideUntilBaked: false, // keep the streaming reply out of the DOM until final preventPromptOverrides: false, // ignore character-card prompt overrides showEditorDiff: true, // show editor-pass diff highlights + showChatAvatars: false, // portrait gutter on chat messages + personaAvatarVersion: 0, // bumped on a persona avatar save; busts the image URL reasoningEnabled: { director: false, writer: false, editor: false, scripter: false }, reasoningPrefill: { director: "", writer: "", editor: "" }, editorAuditToggles: { diff --git a/frontend/themes/apricot.css b/frontend/themes/apricot.css index 43943eb3..654c8a6d 100644 --- a/frontend/themes/apricot.css +++ b/frontend/themes/apricot.css @@ -96,10 +96,12 @@ body { border-radius: 16px !important; box-shadow: var(--shadow-card) !important; } - #chat-header, #doc-header { margin-bottom: 12px; } - #chat-input-area, #doc-footer { margin-top: 12px; } + #doc-header { margin-bottom: 12px; } + #doc-footer { margin-top: 12px; } + #chat-header { margin-bottom: 4px; } + #chat-input-area { margin-top: 4px; } - #chat-messages { padding: 10px 12px 14px !important; } + #chat-messages { padding: 2px 12px 6px !important; } } .btn { @@ -155,7 +157,7 @@ body { #burger-btn:hover { background: var(--bg-hover) !important; color: var(--ink) !important; } .chat-avatar, .char-avatar-sm, .char-avatar-lg, .card-pick-avatar, -.cast-avatar, .cast-pick-avatar, .persona-avatar, .world-avatar, +.cast-avatar, .cast-pick-avatar, .persona-avatar, .msg-avatar, .world-avatar, .char-browser-avatar, .char-browser-list-avatar, .stat-fav-avatar { border: 2px solid var(--ink) !important; background: var(--bg-elevated) !important; diff --git a/frontend/themes/frutiger_aero.css b/frontend/themes/frutiger_aero.css index 6d025122..a8aba52f 100644 --- a/frontend/themes/frutiger_aero.css +++ b/frontend/themes/frutiger_aero.css @@ -214,7 +214,7 @@ body { } .chat-avatar, .char-avatar-sm, .char-avatar-lg, .card-pick-avatar, -.cast-avatar, .cast-pick-avatar, .persona-avatar, .world-avatar, +.cast-avatar, .cast-pick-avatar, .persona-avatar, .msg-avatar, .world-avatar, .char-browser-avatar, .char-browser-list-avatar, .stat-fav-avatar { border-color: #65b7dc !important; box-shadow: inset 0 1px 0 #ffffff, 0 1px 2px rgba(30,90,135,0.22) !important; diff --git a/frontend/utils.js b/frontend/utils.js index 5a76078e..d9e1db7d 100644 --- a/frontend/utils.js +++ b/frontend/utils.js @@ -99,6 +99,37 @@ export function avatarUrl(charId) { return `/api/characters/${charId}/avatar`; } +export function personaAvatarUrl(personaId) { + return `/api/user-personas/${personaId}/avatar`; +} + +/** Return a versioned persona portrait URL, or "" when absent. */ +export function personaAvatarSrc(persona) { + return persona?.has_avatar ? `${personaAvatarUrl(persona.id)}?v=${S.personaAvatarVersion}` : ""; +} + +const HEX_COLOUR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; + +/** Return a safe literal hex colour, or "". */ +export function safePersonaColour(colour) { + return typeof colour === "string" && HEX_COLOUR.test(colour) ? colour : ""; +} + +/** Return readable ink for a validated hex colour. */ +export function readableInk(hex) { + const h = + hex.length === 4 + ? hex + .slice(1) + .split("") + .map((c) => c + c) + .join("") + : hex.slice(1); + const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255); + const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) > 0.36 ? "#14201c" : "#f2f5f4"; +} + export function convActivity(c) { return [c.last_accessed_at, c.updated_at, c.created_at].reduce((a, b) => (b && b > a ? b : a), ""); } diff --git a/scripts/migrate_sillytavern.py b/scripts/migrate_sillytavern.py index 3d6b2d26..fd040ac6 100644 --- a/scripts/migrate_sillytavern.py +++ b/scripts/migrate_sillytavern.py @@ -19,7 +19,6 @@ Director/Writer/Editor pipeline with a cache-stable prefix * endpoints and API keys (secrets.json) -- configure those in Orb * UI chrome (themes/, backgrounds/, movingUI/, assets/, thumbnails/, backups/) - * persona avatars -- user_personas stores a colour, not an image * per-message generation metadata (reasoning traces, token counts, gen ids) * author's notes, and the ST tag taxonomy * ST-only lorebook knobs: recursion, probability, sticky/cooldown/delay, @@ -75,7 +74,7 @@ "character_cards": ("extensions", "world_id", "avatar_b64", "avatar_mime", "source_format"), "lorebook_entries": ("entry_layer", "overlay_action", "use_regex", "selective", "secondary_keys"), "group_members": ("speaker_key", "card_sheet_override", "public_profile_override", "member_kind"), - "user_personas": ("name", "description", "avatar_color"), + "user_personas": ("name", "description", "avatar_color", "avatar_b64", "avatar_mime"), "worlds": ("content_revision", "dynamic_enabled"), "character_expressions": ("character_card_id", "label", "data_b64", "mime"), "director_state": ("conversation_id", "active_moods", "keywords"), @@ -152,6 +151,38 @@ def persona_color(name: str) -> str: return f"#{digest[0]:02x}{digest[1]:02x}{digest[2]:02x}" +_PERSONA_AVATAR_MIMES = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", +} + + +MAX_PERSONA_AVATAR_BYTES = 2 * 1024 * 1024 + + +def read_persona_avatar(paths: STPaths, avatar: str) -> tuple[str, str] | None: + """Read a supported persona avatar without allowing path traversal.""" + name = str(avatar or "").strip() + if not name: + return None + mime = _PERSONA_AVATAR_MIMES.get(Path(name).suffix.lower()) + if mime is None: + return None + try: + root = paths.user_avatars.resolve() + path = (root / name).resolve() + if not path.is_relative_to(root) or not path.is_file(): + return None + if path.stat().st_size > MAX_PERSONA_AVATAR_BYTES: + return None + return base64.b64encode(path.read_bytes()).decode("ascii"), mime + except OSError: + return None + + # --------------------------------------------------------------------------- # # Timestamps -- SillyTavern has written five different date shapes over its life # --------------------------------------------------------------------------- # @@ -326,6 +357,7 @@ class STPaths: groups: Path group_chats: Path settings: Path + user_avatars: Path @classmethod def locate(cls, st_dir: Path, user: str) -> STPaths: @@ -340,6 +372,7 @@ def locate(cls, st_dir: Path, user: str) -> STPaths: groups=base / "groups", group_chats=base / "group chats", settings=base / "settings.json", + user_avatars=base / "User Avatars", ) @@ -799,25 +832,23 @@ def import_personas(conn: sqlite3.Connection, tx: Tx, paths: STPaths, report: Re entry = descriptions.get(avatar) if isinstance(entry, dict): description = str(entry.get("description") or "") + image = read_persona_avatar(paths, str(avatar)) + avatar_b64, avatar_mime = image or (None, None) try: tx.begin() cursor = conn.execute( - """INSERT INTO user_personas (name, description, avatar_color, created_at, updated_at) - VALUES (?, ?, ?, ?, ?)""", - (label, description, persona_color(label), now, now), + """INSERT INTO user_personas (name, description, avatar_color, avatar_b64, avatar_mime, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (label, description, persona_color(label), avatar_b64, avatar_mime, now, now), ) tx.commit() persona_id = cursor.lastrowid assert persona_id is not None by_name[label.casefold()] = persona_id - # Orb personas carry a colour, not an image, so the ST avatar PNG - # has nowhere to land. Noted once below rather than per persona. - report.add("personas", "created") + report.add("personas", "created", "with its avatar image" if image else "colour only -- no usable avatar file") except sqlite3.Error as exc: tx.rollback() report.add("personas", "failed", problem=f"persona {label}: {exc}") - if report.tally("personas", "created"): - report.notes[("personas", "created", "avatar images dropped -- Orb personas store a colour")] += 1 return by_name @@ -1166,7 +1197,7 @@ def main() -> int: description="Migrate SillyTavern characters, lorebooks, chats, personas and groups into Orb.", epilog=( "Not migrated: prompts and generation settings, endpoints and API keys, themes and " - "backgrounds, persona avatars, per-message generation metadata, author's notes, ST tags, " + "backgrounds, per-message generation metadata, author's notes, ST tags, " "and the lorebook knobs Orb has no equivalent for (recursion, probability, sticky/cooldown, " "inclusion groups, per-entry scan depth)." ), diff --git a/tests/frontend/group_cast.test.mjs b/tests/frontend/group_cast.test.mjs index 01cd9b35..76c719b9 100644 --- a/tests/frontend/group_cast.test.mjs +++ b/tests/frontend/group_cast.test.mjs @@ -41,12 +41,15 @@ import { overrideIsOneShot, recommendContextMode, sceneEmptyStateHtml, + speakerAvatar, + speakerAvatarCell, speakerLabel, speakingPlanHtml, TURN_MODES, visibleGroups, } from "../../frontend/group_cast.js"; import { S } from "../../frontend/state.js"; +import { readableInk, safePersonaColour } from "../../frontend/utils.js"; const ARTUS = { id: "m1", display_name: "Artus", character_card_id: "c1" }; const ASSISTANT = { id: "m2", display_name: "Assistant", character_card_id: "c2" }; @@ -486,3 +489,144 @@ test("search matches the group name or its cast, and ignores the cap", () => { test("search never reports a hidden remainder, so no “show all” is offered", () => { assert.equal(visibleGroups(families(GROUP_LIMIT + 4), { query: "Group" }).hidden, 0); }); + +function withPersona({ personas = [], activeId = null, conv = null } = {}) { + S.personas = personas; + S.activePersonaId = activeId; + S.conversations = conv ? [conv] : []; + S.activeConvId = conv?.id ?? null; + S.personaAvatarVersion = 0; +} + +test("a user message shows the active persona's picture", () => { + solo(); + withPersona({ personas: [{ id: 7, name: "Kai", has_avatar: true }], activeId: 7 }); + const html = speakerAvatar({ role: "user" }); + assert.match(html, / { + solo(); + withPersona({ personas: [{ id: 7, name: "Kai", has_avatar: false }], activeId: 7 }); + assert.equal(speakerAvatar({ role: "user" }), "K"); +}); + +test("a user message with no persona at all falls back to a glyph", () => { + solo(); + withPersona(); + assert.equal(speakerAvatar({ role: "user" }), "\u{1F464}"); +}); + +test("a conversation pin outranks the global default for the user's picture", () => { + solo(); + withPersona({ + personas: [ + { id: 7, name: "Kai", has_avatar: true }, + { id: 8, name: "Bex", has_avatar: true }, + ], + activeId: 7, + conv: { id: "cv1", persona_lock_id: 8 }, + }); + assert.match(speakerAvatar({ role: "user" }), /user-personas\/8\/avatar/); +}); + +test("a fresh persona save busts the portrait's cached URL", () => { + solo(); + withPersona({ personas: [{ id: 7, name: "Kai", has_avatar: true }], activeId: 7 }); + S.personaAvatarVersion = 3; + assert.match(speakerAvatar({ role: "user" }), /\?v=3"/); +}); + +test("a group reply shows its own speaker's card, not the scene's", () => { + scene(); + withPersona(); + assert.match(speakerAvatar({ role: "assistant", speaker_member_id: "m2" }), /\/api\/characters\/c2\/avatar/); +}); + +test("a narrator line and a summary fall back to their own glyphs", () => { + scene({ members: [ARTUS, NARRATOR] }); + withPersona(); + assert.equal(speakerAvatar({ role: "assistant", speaker_member_id: "m3" }), "\u2712\uFE0F"); + assert.equal(speakerAvatar({ role: "assistant", speaker_member_id: null }), "\u{1F464}"); +}); + +test("a solo reply shows the conversation's character", () => { + solo(); + withPersona({ conv: { id: "cv1", character_card_id: "c9" } }); + assert.match(speakerAvatar({ role: "assistant" }), /\/api\/characters\/c9\/avatar/); +}); + +test("a user message with no picture wears the persona's own colour", () => { + solo(); + withPersona({ personas: [{ id: 7, name: "Kai", has_avatar: false, avatar_color: "#E1F5EE" }], activeId: 7 }); + const cell = speakerAvatarCell({ role: "user" }); + assert.match(cell, /background:#E1F5EE/); + assert.match(cell, />K { + assert.equal(readableInk("#ffffff"), "#14201c"); + assert.equal(readableInk("#000000"), "#f2f5f4"); + assert.equal(readableInk("#E1F5EE"), "#14201c"); + assert.equal(readableInk("#2b1d0e"), "#f2f5f4"); + assert.equal(readableInk("#fff"), readableInk("#ffffff")); +}); + +test("a non-hex avatar_color never reaches the style attribute", () => { + solo(); + withPersona({ + personas: [{ id: 7, name: "Kai", has_avatar: false, avatar_color: "red;background-image:url(x)" }], + activeId: 7, + }); + const cell = speakerAvatarCell({ role: "user" }); + assert.ok(!cell.includes("style="), cell); + assert.ok(!cell.includes("url("), cell); +}); + +test("a picture fills the chip, so it is never tinted", () => { + solo(); + withPersona({ personas: [{ id: 7, name: "Kai", has_avatar: true, avatar_color: "#E1F5EE" }], activeId: 7 }); + assert.ok(!speakerAvatarCell({ role: "user" }).includes("style=")); +}); + +test("a character's chip is never given a persona colour", () => { + scene(); + withPersona({ personas: [{ id: 7, name: "Kai", has_avatar: false, avatar_color: "#E1F5EE" }], activeId: 7 }); + assert.ok(!speakerAvatarCell({ role: "assistant", speaker_member_id: "m1" }).includes("style=")); +}); + +test("safePersonaColour passes a literal hex colour and nothing else", () => { + for (const ok of ["#fff", "#FFF", "#2b6f4e", "#E1F5EE"]) { + assert.equal(safePersonaColour(ok), ok); + } + for (const bad of [ + '#fff">', // attribute break-out into a tag + '#fff" onmouseover="alert(1)', // attribute break-out into a handler + "red;background-image:url(https://evil.example/x)", // in-attribute CSS injection + "var(--accent)", + "#12345", + "#gggggg", + "", + null, + undefined, + 123, + {}, + ]) { + assert.equal(safePersonaColour(bad), "", `let through: ${String(bad)}`); + } +}); + +test("a break-out payload in avatar_color never reaches the rendered chip", () => { + solo(); + withPersona({ + personas: [ + { id: 7, name: "Kai", has_avatar: false, avatar_color: '#fff">' }, + ], + activeId: 7, + }); + const cell = speakerAvatarCell({ role: "user" }); + assert.ok(!cell.includes("onerror"), cell); + assert.ok(!cell.includes(" bytes: blob = open(presets._library_path(name), "rb").read() leaked_keys = [c.decode() for c in api_key_canaries if c in blob] assert leaked_keys == [], leaked_keys + + +async def test_persona_avatar_never_ships_without_the_configs_domain(client, db_path): + canary = base64.b64encode(b"LEAK-CANARY-user_personas-avatar_b64").decode().encode() + resp = await client.post( + "/api/user-personas", + json={"name": "Pictured", "avatar_b64": canary.decode(), "avatar_mime": "image/png"}, + ) + assert resp.status_code == 200 + + for domain in [d for d in presets.ALL_DOMAINS if d != "configs"]: + name = (await client.post("/api/presets/export", json={"domains": [domain], "strip_keys": False})).json()["name"] + blob = open(presets._library_path(name), "rb").read() + assert canary not in blob, domain diff --git a/tests/integration/test_settings.py b/tests/integration/test_settings.py index e7753e0e..b5c8af66 100644 --- a/tests/integration/test_settings.py +++ b/tests/integration/test_settings.py @@ -185,6 +185,23 @@ async def test_editor_audit_toggles_default_and_roundtrip(client, db): assert json.loads(row["editor_audit_toggles"]) == updated +async def test_show_chat_avatars_default_and_roundtrip(client, db): + resp = await client.get("/api/settings") + assert resp.status_code == 200 + assert resp.json()["show_chat_avatars"] == 0 + + resp = await client.put("/api/settings", json={"show_chat_avatars": True}) + assert resp.status_code == 200 + assert resp.json()["show_chat_avatars"] == 1 + + async with db.execute("SELECT show_chat_avatars FROM settings WHERE id = 1") as cur: + row = await cur.fetchone() + assert row["show_chat_avatars"] == 1 + + resp = await client.put("/api/settings", json={"show_chat_avatars": False}) + assert resp.json()["show_chat_avatars"] == 0 + + async def test_hide_streaming_until_baked_default_and_roundtrip(client, db): resp = await client.get("/api/settings") assert resp.status_code == 200 diff --git a/tests/integration/test_sillytavern_migration.py b/tests/integration/test_sillytavern_migration.py index ad45abeb..5061d32f 100644 --- a/tests/integration/test_sillytavern_migration.py +++ b/tests/integration/test_sillytavern_migration.py @@ -8,6 +8,7 @@ from __future__ import annotations +import base64 import json import sqlite3 from datetime import UTC, datetime @@ -67,6 +68,11 @@ } +PERSONA_AVATAR_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + + def _chat_line(**kwargs) -> str: return json.dumps(kwargs) @@ -74,9 +80,12 @@ def _chat_line(**kwargs) -> str: def build_st_install(base: Path) -> Path: """Write a small but representative ST data directory. Returns the ST root.""" user = base / "data" / "default-user" - for folder in ("characters", "chats", "worlds", "groups", "group chats"): + for folder in ("characters", "chats", "worlds", "groups", "group chats", "User Avatars"): (user / folder).mkdir(parents=True, exist_ok=True) + (user / "User Avatars" / "mariner.png").write_bytes(PERSONA_AVATAR_BYTES) + (user / "User Avatars" / "huge.png").write_bytes(PERSONA_AVATAR_BYTES + b"\x00" * (2 * 1024 * 1024)) + # The card's *name* and its avatar *filename* differ on purpose: ST keys # sprite folders by name and chat folders by filename stem. lamplighter = { @@ -196,7 +205,7 @@ def build_st_install(base: Path) -> Path: "user_avatar": "mariner.png", "world_info_settings": {"world_info": {"globalSelect": ["Testworld"]}}, "power_user": { - "personas": {"mariner.png": "Mariner", "quiet.png": "Quiet One"}, + "personas": {"mariner.png": "Mariner", "quiet.png": "Quiet One", "huge.png": "Huge"}, "persona_descriptions": { "mariner.png": {"description": "Sails the coast."}, "quiet.png": {"description": ""}, @@ -232,7 +241,7 @@ async def test_migrates_every_dataset(st_install: Path, db_path: Path): assert report.tally("characters", "created") == 2 assert report.tally("chats", "created") == 1 assert report.tally("groups", "created") == 1 - assert report.tally("personas", "created") == 2 + assert report.tally("personas", "created") == 3 # Testworld from the standalone file, Harbour Lore from the card's book. assert report.tally("worlds", "created") == 2 # The empty standalone world is skipped, and so is the card-less chat. @@ -347,6 +356,12 @@ async def test_personas_are_created_and_pinned_to_their_chat(st_install: Path, d personas = {p["name"]: p for p in (await client.get("/api/user-personas")).json()} assert {"Mariner", "Quiet One"} <= set(personas) assert personas["Mariner"]["description"] == "Sails the coast." + assert personas["Mariner"]["has_avatar"] is True + assert personas["Quiet One"]["has_avatar"] is False + assert personas["Huge"]["has_avatar"] is False + avatar = await client.get(f"/api/user-personas/{personas['Mariner']['id']}/avatar") + assert avatar.status_code == 200 + assert avatar.content == PERSONA_AVATAR_BYTES solo = [c for c in (await client.get("/api/conversations")).json() if c["kind"] == "solo"][0] assert solo["persona_lock_id"] == personas["Mariner"]["id"]