Skip to content

Commit fac6f66

Browse files
committed
Add avatar path support for personas
This adds the ability for personas to specify an avatar image file directly in their package instead of needing to implement their own static file handler. Personas now set `avatar_path` to an absolute path pointing to an image file (SVG, PNG, JPG). The server dynamically looks up and serves these files through `/api/ai/avatars/{url_encoded_id}`. - Uses persona.id (URL-encoded) for avatar URLs to ensure uniqueness - File size validation (5MB max) - Proper content-type headers based on file extension - Module-level cache for O(1) avatar path lookups (built at init, rebuilt on refresh) Fixes #6
1 parent df09c6c commit fac6f66

File tree

5 files changed

+115
-85
lines changed

5 files changed

+115
-85
lines changed

jupyter_ai_persona_manager/base_persona.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class PersonaDefaults(BaseModel):
3434
################################################
3535
name: str # e.g. "Jupyternaut"
3636
description: str # e.g. "..."
37-
avatar_path: str # e.g. "/path/to/package/avatars/jupyternaut.svg" - absolute path to avatar file
37+
avatar_path: str # Absolute filesystem path to avatar image file (SVG, PNG, or JPG)
3838
system_prompt: str # e.g. "You are a language model named..."
3939

4040
################################################
@@ -171,16 +171,22 @@ def name(self) -> str:
171171
@property
172172
def avatar_path(self) -> str:
173173
"""
174-
Returns the URL route that serves the avatar shown on messages from this
175-
persona in the chat. This sets the `avatar_url` field in the data model
176-
returned by `self.as_user()`. Provided by `BasePersona`.
174+
Returns the API URL route that serves the avatar for this persona.
177175
178-
NOTE/TODO: This currently just returns the value set in `self.defaults`.
179-
This is set here because we may require this field to be configurable
180-
for all personas in the future.
176+
The avatar is served at `/api/ai/avatars/{id}` where the ID is the
177+
unique persona identifier. This ensures that each persona has a unique
178+
avatar URL without exposing filesystem paths.
179+
180+
The actual avatar file path is specified in `defaults.avatar_path` as an
181+
absolute filesystem path to an image file (SVG, PNG, or JPG) within the
182+
persona's package or module.
183+
184+
This sets the `avatar_url` field in the data model returned by
185+
`self.as_user()`. Provided by `BasePersona`.
181186
"""
182-
filename = os.path.basename(self.defaults.avatar_path)
183-
return f"/api/ai/avatars/{filename}"
187+
# URL-encode the persona ID to handle special characters
188+
from urllib.parse import quote
189+
return f"/api/ai/avatars/{quote(self.id, safe='')}"
184190

185191
@property
186192
def system_prompt(self) -> str:

jupyter_ai_persona_manager/extension.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from traitlets import Type
1212
from traitlets.config import Config
1313

14-
from jupyter_ai_persona_manager.handlers import RouteHandler, AvatarHandler
14+
from jupyter_ai_persona_manager.handlers import AvatarHandler, build_avatar_cache
1515

1616
from .persona_manager import PersonaManager
1717

@@ -31,7 +31,6 @@ class PersonaManagerExtension(ExtensionApp):
3131

3232
name = "jupyter_ai_persona_manager"
3333
handlers = [
34-
(r"jupyter-ai-persona-manager/health/?", RouteHandler),
3534
(r"/api/ai/avatars/(.*)", AvatarHandler),
3635
]
3736

@@ -130,6 +129,9 @@ def _on_router_chat_init(self, room_id: str, ychat: "YChat") -> None:
130129
persona_managers_by_room = self.serverapp.web_app.settings['jupyter-ai']['persona-managers']
131130
persona_managers_by_room[room_id] = persona_manager
132131

132+
# Rebuild avatar cache to include the new personas
133+
build_avatar_cache(persona_managers_by_room)
134+
133135
# Register persona manager callbacks with router
134136
self.router.observe_chat_msg(room_id, persona_manager.on_chat_message)
135137

Lines changed: 60 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,76 @@
11
import json
22
import mimetypes
33
import os
4-
from functools import lru_cache
5-
from typing import Optional
4+
from typing import Dict, Optional
5+
from urllib.parse import unquote
66

77
from jupyter_server.base.handlers import JupyterHandler
88
import tornado
99

1010

11-
class RouteHandler(JupyterHandler):
12-
# The following decorator should be present on all verb methods (head, get, post,
13-
# patch, put, delete, options) to ensure only authorized user can request the
14-
# Jupyter server
15-
@tornado.web.authenticated
16-
def get(self):
17-
self.set_header("Content-Type", "application/json")
18-
self.finish(json.dumps({
19-
"data": "This is /jupyter-ai-persona-manager/get-example endpoint!"
20-
}))
11+
# Maximum avatar file size (5MB)
12+
MAX_AVATAR_SIZE = 5 * 1024 * 1024
13+
14+
# Module-level cache: {persona_id: avatar_path}
15+
# This is populated when personas are initialized/refreshed
16+
_avatar_cache: Dict[str, str] = {}
17+
18+
19+
def build_avatar_cache(persona_managers: dict) -> None:
20+
"""
21+
Build the avatar cache from all persona managers.
22+
23+
This should be called when personas are initialized or refreshed.
24+
"""
25+
global _avatar_cache
26+
_avatar_cache = {}
27+
28+
for room_id, persona_manager in persona_managers.items():
29+
for persona in persona_manager.personas.values():
30+
try:
31+
avatar_path = persona.defaults.avatar_path
32+
if avatar_path and os.path.exists(avatar_path):
33+
_avatar_cache[persona.id] = avatar_path
34+
except Exception:
35+
# Skip personas with invalid avatar paths
36+
continue
37+
38+
39+
def clear_avatar_cache() -> None:
40+
"""Clear the avatar cache. Called during persona refresh."""
41+
global _avatar_cache
42+
_avatar_cache = {}
2143

2244

2345
class AvatarHandler(JupyterHandler):
2446
"""
2547
Handler for serving persona avatar files.
2648
27-
Looks up avatar files through the PersonaManager to find the correct file path,
28-
then serves the file with appropriate content-type headers.
49+
Looks up avatar files by persona ID and serves the image file
50+
with appropriate content-type headers.
2951
"""
3052

3153
@tornado.web.authenticated
32-
async def get(self, filename: str):
33-
"""Serve an avatar file by filename."""
34-
# Get the avatar file path from persona managers
35-
avatar_path = self._find_avatar_file(filename)
54+
async def get(self, persona_id: str):
55+
"""Serve an avatar file by persona ID."""
56+
# URL-decode the persona ID
57+
persona_id = unquote(persona_id)
58+
59+
# Get the avatar file path
60+
avatar_path = self._find_avatar_file(persona_id)
3661

3762
if avatar_path is None:
38-
raise tornado.web.HTTPError(404, f"Avatar file not found: {filename}")
63+
raise tornado.web.HTTPError(404, f"Avatar not found for persona")
64+
65+
# Check file size
66+
try:
67+
file_size = os.path.getsize(avatar_path)
68+
if file_size > MAX_AVATAR_SIZE:
69+
self.log.error(f"Avatar file too large: {file_size} bytes (max: {MAX_AVATAR_SIZE})")
70+
raise tornado.web.HTTPError(413, "Avatar file too large")
71+
except OSError as e:
72+
self.log.error(f"Error checking avatar file size: {e}")
73+
raise tornado.web.HTTPError(500, "Error accessing avatar file")
3974

4075
# Serve the file
4176
try:
@@ -51,32 +86,14 @@ async def get(self, filename: str):
5186

5287
await self.finish()
5388
except Exception as e:
54-
self.log.error(f"Error serving avatar file {filename}: {e}")
89+
self.log.error(f"Error serving avatar file: {e}")
5590
raise tornado.web.HTTPError(500, f"Error serving avatar file: {str(e)}")
5691

57-
@lru_cache(maxsize=128)
58-
def _find_avatar_file(self, filename: str) -> Optional[str]:
92+
def _find_avatar_file(self, persona_id: str) -> Optional[str]:
5993
"""
60-
Find the avatar file path by searching through all persona managers.
94+
Find the avatar file path by persona ID using the module-level cache.
6195
62-
Uses LRU cache to avoid repeated lookups for the same filename.
96+
The cache is built when personas are initialized or refreshed,
97+
so this is an O(1) lookup instead of iterating all personas.
6398
"""
64-
# Get all persona managers from settings
65-
persona_managers = self.settings.get('jupyter-ai', {}).get('persona-managers', {})
66-
67-
for room_id, persona_manager in persona_managers.items():
68-
# Check each persona's avatar path
69-
for persona in persona_manager.personas.values():
70-
try:
71-
avatar_path = persona.defaults.avatar_path
72-
if avatar_path and os.path.basename(avatar_path) == filename:
73-
# Found a match, return the absolute path
74-
if os.path.exists(avatar_path):
75-
return avatar_path
76-
else:
77-
self.log.warning(f"Avatar file not found at path: {avatar_path}")
78-
except Exception as e:
79-
self.log.warning(f"Error checking avatar for persona {persona.name}: {e}")
80-
continue
81-
82-
return None
99+
return _avatar_cache.get(persona_id)

jupyter_ai_persona_manager/persona_manager.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from .base_persona import BasePersona
2121
from .directories import find_dot_dir, find_workspace_dir
22+
from .handlers import build_avatar_cache
2223

2324
if TYPE_CHECKING:
2425
from asyncio import AbstractEventLoop
@@ -433,6 +434,16 @@ async def refresh_personas(self):
433434
self._init_local_persona_classes()
434435
self._personas = self._init_personas()
435436

437+
# Rebuild avatar cache after reloading personas
438+
# Get all persona managers from parent (extension) settings
439+
try:
440+
# Access all persona managers through the parent extension's settings
441+
# Note: self.parent is the PersonaManagerExtension instance
442+
persona_managers = self.parent.serverapp.web_app.settings.get('jupyter-ai', {}).get('persona-managers', {})
443+
build_avatar_cache(persona_managers)
444+
except Exception as e:
445+
self.log.error(f"Error rebuilding avatar cache: {e}")
446+
436447
# Write success message to chat & logs
437448
self.send_system_message("Refreshed all AI personas in this chat.")
438449
self.log.info(f"Refreshed all AI personas in chat '{self.room_id}'.")

jupyter_ai_persona_manager/tests/test_handlers.py

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,16 @@
33
import tempfile
44
from pathlib import Path
55
from unittest.mock import Mock
6+
from urllib.parse import quote
67

78
import pytest
89

9-
10-
async def test_health(jp_fetch):
11-
# When
12-
response = await jp_fetch("jupyter-ai-persona-manager", "health")
13-
14-
# Then
15-
assert response.code == 200
16-
payload = json.loads(response.body)
17-
assert payload == {
18-
"data": "This is /jupyter-ai-persona-manager/get-example endpoint!"
19-
}
20-
21-
22-
@pytest.fixture
23-
def mock_persona_with_avatar(tmp_path):
24-
"""Create a mock persona with an avatar file."""
25-
# Create avatar file
26-
avatar_file = tmp_path / "test_avatar.svg"
27-
avatar_file.write_text('<svg><circle r="10"/></svg>')
28-
29-
# Create mock persona
30-
mock_persona = Mock()
31-
mock_persona.defaults.avatar_path = str(avatar_file)
32-
mock_persona.name = "TestPersona"
33-
34-
return mock_persona, str(avatar_file)
10+
from jupyter_ai_persona_manager.handlers import build_avatar_cache
3511

3612

3713
async def test_avatar_handler_serves_file(jp_fetch, jp_serverapp, tmp_path):
3814
"""Test that the avatar handler can serve avatar files."""
15+
3916
# Create avatar file
4017
avatar_file = tmp_path / "test.svg"
4118
avatar_file.write_text('<svg><circle r="10"/></svg>')
@@ -44,6 +21,7 @@ async def test_avatar_handler_serves_file(jp_fetch, jp_serverapp, tmp_path):
4421
mock_persona = Mock()
4522
mock_persona.defaults.avatar_path = str(avatar_file)
4623
mock_persona.name = "TestPersona"
24+
mock_persona.id = "jupyter-ai-personas::test::TestPersona"
4725

4826
# Create mock persona manager
4927
mock_pm = Mock()
@@ -56,8 +34,12 @@ async def test_avatar_handler_serves_file(jp_fetch, jp_serverapp, tmp_path):
5634
'room1': mock_pm
5735
}
5836

59-
# Fetch the avatar
60-
response = await jp_fetch("api", "ai", "avatars", "test.svg")
37+
# Build the avatar cache
38+
build_avatar_cache(jp_serverapp.web_app.settings['jupyter-ai']['persona-managers'])
39+
40+
# Fetch the avatar using URL-encoded persona ID
41+
encoded_id = quote(mock_persona.id, safe='')
42+
response = await jp_fetch("api", "ai", "avatars", encoded_id)
6143

6244
# Verify response
6345
assert response.code == 200
@@ -67,6 +49,7 @@ async def test_avatar_handler_serves_file(jp_fetch, jp_serverapp, tmp_path):
6749

6850
async def test_avatar_handler_404_for_missing_file(jp_fetch, jp_serverapp):
6951
"""Test that the avatar handler returns 404 for missing files."""
52+
7053
# Create mock persona manager with no matching avatar
7154
mock_pm = Mock()
7255
mock_pm.personas = {}
@@ -78,16 +61,20 @@ async def test_avatar_handler_404_for_missing_file(jp_fetch, jp_serverapp):
7861
'room1': mock_pm
7962
}
8063

64+
# Build the avatar cache (will be empty)
65+
build_avatar_cache(jp_serverapp.web_app.settings['jupyter-ai']['persona-managers'])
66+
8167
# Try to fetch a non-existent avatar
8268
with pytest.raises(Exception) as exc_info:
83-
await jp_fetch("api", "ai", "avatars", "nonexistent.svg")
69+
await jp_fetch("api", "ai", "avatars", "nonexistent-id")
8470

8571
# Verify 404 response
8672
assert '404' in str(exc_info.value) or 'Not Found' in str(exc_info.value)
8773

8874

8975
async def test_avatar_handler_serves_png(jp_fetch, jp_serverapp, tmp_path):
9076
"""Test that the avatar handler can serve PNG files."""
77+
9178
# Create PNG file
9279
avatar_file = tmp_path / "test.png"
9380
avatar_file.write_bytes(b'\x89PNG\r\n\x1a\n')
@@ -96,6 +83,7 @@ async def test_avatar_handler_serves_png(jp_fetch, jp_serverapp, tmp_path):
9683
mock_persona = Mock()
9784
mock_persona.defaults.avatar_path = str(avatar_file)
9885
mock_persona.name = "TestPersona"
86+
mock_persona.id = "jupyter-ai-personas::test::AnotherPersona"
9987

10088
# Create mock persona manager
10189
mock_pm = Mock()
@@ -108,10 +96,16 @@ async def test_avatar_handler_serves_png(jp_fetch, jp_serverapp, tmp_path):
10896
'room1': mock_pm
10997
}
11098

111-
# Fetch the avatar
112-
response = await jp_fetch("api", "ai", "avatars", "test.png")
99+
# Build the avatar cache
100+
build_avatar_cache(jp_serverapp.web_app.settings['jupyter-ai']['persona-managers'])
101+
102+
# Fetch the avatar using URL-encoded persona ID
103+
encoded_id = quote(mock_persona.id, safe='')
104+
response = await jp_fetch("api", "ai", "avatars", encoded_id)
113105

114106
# Verify response
115107
assert response.code == 200
116108
assert response.body.startswith(b'\x89PNG')
117109
assert 'image/png' in response.headers.get('Content-Type', '')
110+
111+

0 commit comments

Comments
 (0)