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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion backend/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import os
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 3 additions & 18 deletions backend/api/routes/characters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
19 changes: 17 additions & 2 deletions backend/api/routes/personas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions backend/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import base64
import json
import re
from typing import Any, Literal
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions backend/database/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions backend/database/migrations/0057_persona_avatars.py
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 4 additions & 1 deletion backend/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions backend/database/queries/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 30 additions & 10 deletions backend/database/queries/user_personas.py
Original file line number Diff line number Diff line change
@@ -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,
),
Expand All @@ -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 = ?")
Expand All @@ -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,))
Expand Down
3 changes: 3 additions & 0 deletions backend/database/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
);
Expand Down
1 change: 1 addition & 0 deletions backend/database/seeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions docs/features/persona-pinning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading