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() {
+
@@ -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 `
};base64,${escAttr(b64)})
`;
+ }
+ 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);
+});
+
+test("the chip's ink flips with the colour's luminance, so it stays readable", () => {
+ 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"]