Skip to content

Commit 651d3b4

Browse files
authored
Merge pull request #10 from Zsailer/avatar-path
Add avatar path support for personas
2 parents 6813dfa + bea930c commit 651d3b4

File tree

7 files changed

+256
-49
lines changed

7 files changed

+256
-49
lines changed

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,19 @@ To create and register a custom AI persona:
2222
```python
2323
from jupyter_ai_persona_manager import BasePersona, PersonaDefaults
2424
from jupyterlab_chat.models import Message
25+
import os
26+
27+
# Path to avatar file in your package
28+
AVATAR_PATH = os.path.join(os.path.dirname(__file__), "assets", "avatar.svg")
29+
2530

2631
class MyCustomPersona(BasePersona):
2732
@property
2833
def defaults(self):
2934
return PersonaDefaults(
3035
name="MyPersona",
3136
description="A helpful custom assistant",
32-
avatar_path="/api/ai/static/custom-avatar.svg",
37+
avatar_path=AVATAR_PATH, # Absolute path to avatar file
3338
system_prompt="You are a helpful assistant specialized in...",
3439
)
3540

@@ -39,6 +44,8 @@ class MyCustomPersona(BasePersona):
3944
self.send_message(response)
4045
```
4146

47+
**Avatar Path**: The `avatar_path` should be an absolute path to an image file (SVG, PNG, or JPG) within your package. The avatar will be automatically served at `/api/ai/avatars/{filename}`. If multiple personas use the same filename, the first one found will be served.
48+
4249
### 2. Register via Entry Points
4350

4451
Add to your package's `pyproject.toml`:
@@ -85,21 +92,28 @@ For development and local customization, personas can be loaded from the `.jupyt
8592
```python
8693
from jupyter_ai_persona_manager import BasePersona, PersonaDefaults
8794
from jupyterlab_chat.models import Message
95+
import os
96+
97+
# Path to avatar file (in same directory as persona file)
98+
AVATAR_PATH = os.path.join(os.path.dirname(__file__), "avatar.svg")
99+
88100

89101
class MyLocalPersona(BasePersona):
90102
@property
91103
def defaults(self):
92104
return PersonaDefaults(
93105
name="Local Dev Assistant",
94106
description="A persona for local development",
95-
avatar_path="/api/ai/static/jupyternaut.svg",
107+
avatar_path=AVATAR_PATH,
96108
system_prompt="You help with local development tasks.",
97109
)
98110

99111
async def process_message(self, message: Message):
100112
self.send_message(f"Local persona received: {message.body}")
101113
```
102114

115+
**Note**: Place your avatar file (e.g., `avatar.svg`) in the same directory as your persona file.
116+
103117
### Refreshing Personas
104118

105119
Use the `/refresh-personas` slash command in any chat to reload personas without restarting JupyterLab:

jupyter_ai_persona_manager/base_persona.py

Lines changed: 15 additions & 8 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. /avatars/jupyternaut.svg
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,15 +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-
return self.defaults.avatar_path
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='')}"
183190

184191
@property
185192
def system_prompt(self) -> str:

jupyter_ai_persona_manager/extension.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import time
5+
from asyncio import get_event_loop_policy
56
from typing import TYPE_CHECKING
67

78
from jupyter_server.extension.application import ExtensionApp
@@ -10,7 +11,7 @@
1011
from traitlets import Type
1112
from traitlets.config import Config
1213

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

1516
from .persona_manager import PersonaManager
1617

@@ -30,8 +31,8 @@ class PersonaManagerExtension(ExtensionApp):
3031

3132
name = "jupyter_ai_persona_manager"
3233
handlers = [
33-
(r"jupyter-ai-persona-manager/health/?", RouteHandler)
34-
] # No direct HTTP handlers, works through router integration
34+
(r"/api/ai/avatars/(.*)", AvatarHandler),
35+
]
3536

3637
persona_manager_class = Type(
3738
klass=PersonaManager,
@@ -48,28 +49,27 @@ def event_loop(self) -> AbstractEventLoop:
4849
"""
4950
Returns a reference to the asyncio event loop.
5051
"""
51-
from asyncio import get_event_loop_policy
5252
return get_event_loop_policy().get_event_loop()
5353

5454
def initialize_settings(self):
5555
"""Initialize persona manager settings and router integration."""
5656
start = time.time()
57-
57+
5858
# Ensure 'jupyter-ai.persona-manager' is in `self.settings`, which gets
5959
# copied to `self.serverapp.web_app.settings` after this method returns
6060
if 'jupyter-ai' not in self.settings:
6161
self.settings['jupyter-ai'] = {}
6262
if 'persona-manager' not in self.settings['jupyter-ai']:
6363
self.settings['jupyter-ai']['persona-managers'] = {}
64-
64+
6565
# Set up router integration task
6666
self.event_loop.create_task(self._setup_router_integration())
6767

6868
# Log server extension startup time
6969
self.log.info(f"Registered {self.name} server extension")
7070
startup_time = round((time.time() - start) * 1000)
7171
self.log.info(f"Initialized Persona Manager server extension in {startup_time} ms.")
72-
72+
7373
async def _setup_router_integration(self) -> None:
7474
"""
7575
Set up integration with jupyter-ai-router.
@@ -110,7 +110,7 @@ def _on_router_chat_init(self, room_id: str, ychat: "YChat") -> None:
110110
This initializes persona manager for the new chat room.
111111
"""
112112
self.log.info(f"Router detected new chat room, initializing persona manager: {room_id}")
113-
113+
114114
# Initialize persona manager for this chat
115115
persona_manager = self._init_persona_manager(room_id, ychat)
116116
if not persona_manager:
@@ -119,7 +119,7 @@ def _on_router_chat_init(self, room_id: str, ychat: "YChat") -> None:
119119
+ "Please verify your configuration and open a new issue on GitHub if this error persists."
120120
)
121121
return
122-
122+
123123
# Cache the persona manager in server settings dictionary.
124124
#
125125
# NOTE: This must be added to `self.serverapp.web_app.settings`, not
@@ -128,7 +128,10 @@ def _on_router_chat_init(self, room_id: str, ychat: "YChat") -> None:
128128
# `self.initialize_settings` returns.
129129
persona_managers_by_room = self.serverapp.web_app.settings['jupyter-ai']['persona-managers']
130130
persona_managers_by_room[room_id] = persona_manager
131-
131+
132+
# Rebuild avatar cache to include the new personas
133+
build_avatar_cache(persona_managers_by_room)
134+
132135
# Register persona manager callbacks with router
133136
self.router.observe_chat_msg(room_id, persona_manager.on_chat_message)
134137

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,99 @@
11
import json
2+
import mimetypes
3+
import os
4+
from typing import Dict, Optional
5+
from urllib.parse import unquote
26

3-
from jupyter_server.base.handlers import APIHandler
7+
from jupyter_server.base.handlers import JupyterHandler
48
import tornado
59

6-
class RouteHandler(APIHandler):
7-
# The following decorator should be present on all verb methods (head, get, post,
8-
# patch, put, delete, options) to ensure only authorized user can request the
9-
# Jupyter server
10+
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 = {}
43+
44+
45+
class AvatarHandler(JupyterHandler):
46+
"""
47+
Handler for serving persona avatar files.
48+
49+
Looks up avatar files by persona ID and serves the image file
50+
with appropriate content-type headers.
51+
"""
52+
1053
@tornado.web.authenticated
11-
def get(self):
12-
self.finish(json.dumps({
13-
"data": "This is /jupyter-ai-persona-manager/get-example endpoint!"
14-
}))
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)
61+
62+
if avatar_path is None:
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")
74+
75+
# Serve the file
76+
try:
77+
# Set content type based on file extension
78+
content_type, _ = mimetypes.guess_type(avatar_path)
79+
if content_type:
80+
self.set_header("Content-Type", content_type)
81+
82+
# Read and serve the file
83+
with open(avatar_path, 'rb') as f:
84+
content = f.read()
85+
self.write(content)
86+
87+
await self.finish()
88+
except Exception as e:
89+
self.log.error(f"Error serving avatar file: {e}")
90+
raise tornado.web.HTTPError(500, f"Error serving avatar file: {str(e)}")
91+
92+
def _find_avatar_file(self, persona_id: str) -> Optional[str]:
93+
"""
94+
Find the avatar file path by persona ID using the module-level cache.
95+
96+
The cache is built when personas are initialized or refreshed,
97+
so this is an O(1) lookup instead of iterating all personas.
98+
"""
99+
return _avatar_cache.get(persona_id)

jupyter_ai_persona_manager/persona_manager.py

Lines changed: 12 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
@@ -284,6 +285,7 @@ def _init_personas(self) -> dict[str, BasePersona]:
284285
self.log.info(
285286
f"SUCCESS: Initialized {len(personas)} AI personas for chat room '{self.ychat.get_id()}'. Time elapsed: {elapsed_time_ms}ms."
286287
)
288+
287289
return personas
288290

289291
def _display_persona_error_message(self, persona_item: dict) -> None:
@@ -432,6 +434,16 @@ async def refresh_personas(self):
432434
self._init_local_persona_classes()
433435
self._personas = self._init_personas()
434436

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+
435447
# Write success message to chat & logs
436448
self.send_system_message("Refreshed all AI personas in this chat.")
437449
self.log.info(f"Refreshed all AI personas in chat '{self.room_id}'.")

0 commit comments

Comments
 (0)