Skip to content

Commit a6db21d

Browse files
dlqqq3coins
andauthored
Add Jupyternaut (#3)
* WIP: migrate Jupyternaut code into this package * WIP: add Jupyternaut as an entry point * WIP: serve Jupyternaut avatar to make it accessible from UI * Integrate with `jupyter-ai-persona-manager` * fix lint * revert deletion of jupyter server hidden files config --------- Co-authored-by: Piyush Jain <[email protected]>
1 parent e27be18 commit a6db21d

File tree

8 files changed

+212
-7
lines changed

8 files changed

+212
-7
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,6 @@ dmypy.json
123123

124124
# Yarn cache
125125
.yarn/
126+
127+
# For local testing
128+
playground/

jupyter_ai_jupyternaut/extension_app.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,30 @@
22
from asyncio import get_event_loop_policy
33
from jupyter_server.extension.application import ExtensionApp
44
from jupyter_server.serverapp import ServerApp
5+
import os
6+
from tornado.web import StaticFileHandler
57
from traitlets import List, Unicode, Dict
68
from traitlets.config import Config
79
from typing import TYPE_CHECKING
810

911
from .config import ConfigManager, ConfigRestAPI
1012
from .handlers import RouteHandler
13+
from .jupyternaut import JupyternautPersona
1114
from .models import ChatModelsRestAPI, ModelParametersRestAPI
1215
from .secrets import EnvSecretsManager, SecretsRestAPI
1316

1417
if TYPE_CHECKING:
1518
from asyncio import AbstractEventLoop
1619

20+
JUPYTERNAUT_AVATAR_PATH = str(
21+
os.path.join(os.path.dirname(__file__), "static", "jupyternaut.svg")
22+
)
23+
24+
1725
class JupyternautExtension(ExtensionApp):
1826
"""
1927
The Jupyternaut server extension.
20-
28+
2129
This serves several REST APIs under the `/api/jupyternaut` route. Currently,
2230
for the sake of simplicity, they are hard-coded into the Jupyternaut server
2331
extension to allow users to configure the chat model & add API keys.
@@ -33,6 +41,11 @@ class JupyternautExtension(ExtensionApp):
3341
(r"api/jupyternaut/models/chat/?", ChatModelsRestAPI),
3442
(r"api/jupyternaut/model-parameters/?", ModelParametersRestAPI),
3543
(r"api/jupyternaut/secrets/?", SecretsRestAPI),
44+
(
45+
r"api/jupyternaut/static/jupyternaut.svg()/?",
46+
StaticFileHandler,
47+
{"path": JUPYTERNAUT_AVATAR_PATH},
48+
),
3649
]
3750

3851
allowed_providers = List(
@@ -176,7 +189,7 @@ def initialize_settings(self):
176189
}
177190

178191
# Initialize ConfigManager
179-
self.settings["jupyternaut.config_manager"] = ConfigManager(
192+
config_manager = ConfigManager(
180193
config=self.config,
181194
log=self.log,
182195
allowed_providers=self.allowed_providers,
@@ -186,9 +199,16 @@ def initialize_settings(self):
186199
defaults=defaults,
187200
)
188201

189-
# Initialize SecretsManager
202+
# Bind ConfigManager instance to global settings dictionary
203+
self.settings["jupyternaut.config_manager"] = config_manager
204+
205+
# Bind ConfigManager instance to Jupyternaut as a class variable
206+
JupyternautPersona.config_manager = config_manager
207+
208+
# Initialize SecretsManager and bind it to global settings dictionary
190209
self.settings["jupyternaut.secrets_manager"] = EnvSecretsManager(parent=self)
191-
210+
211+
192212
def _link_jupyter_server_extension(self, server_app: ServerApp):
193213
"""Setup custom config needed by this extension."""
194214
c = Config()
@@ -210,4 +230,3 @@ def _link_jupyter_server_extension(self, server_app: ServerApp):
210230
]
211231
server_app.update_config(c)
212232
super()._link_jupyter_server_extension(server_app)
213-
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .jupyternaut import JupyternautPersona
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from typing import Any, Optional
2+
3+
from jupyterlab_chat.models import Message
4+
from litellm import acompletion
5+
6+
from jupyter_ai_persona_manager import BasePersona, PersonaDefaults
7+
from jupyter_ai_persona_manager.persona_manager import SYSTEM_USERNAME
8+
from .prompt_template import (
9+
JUPYTERNAUT_SYSTEM_PROMPT_TEMPLATE,
10+
JupyternautSystemPromptArgs,
11+
)
12+
13+
14+
class JupyternautPersona(BasePersona):
15+
"""
16+
The Jupyternaut persona, the main persona provided by Jupyter AI.
17+
"""
18+
19+
def __init__(self, *args, **kwargs):
20+
super().__init__(*args, **kwargs)
21+
22+
@property
23+
def defaults(self):
24+
return PersonaDefaults(
25+
name="Jupyternaut",
26+
avatar_path="/api/jupyternaut/static/jupyternaut.svg",
27+
description="The standard agent provided by JupyterLab. Currently has no tools.",
28+
system_prompt="...",
29+
)
30+
31+
async def process_message(self, message: Message) -> None:
32+
if not hasattr(self, 'config_manager'):
33+
self.send_message(
34+
"Jupyternaut requires the `jupyter_ai_jupyternaut` server extension package.\n\n",
35+
"Please make sure to first install that package in your environment & restart the server."
36+
)
37+
if not self.config_manager.chat_model:
38+
self.send_message(
39+
"No chat model is configured.\n\n"
40+
"You must set one first in the Jupyter AI settings, found in 'Settings > AI Settings' from the menu bar."
41+
)
42+
return
43+
44+
model_id = self.config_manager.chat_model
45+
model_args = self.config_manager.chat_model_args
46+
context_as_messages = self.get_context_as_messages(model_id, message)
47+
response_aiter = await acompletion(
48+
**model_args,
49+
model=model_id,
50+
messages=[
51+
*context_as_messages,
52+
{
53+
"role": "user",
54+
"content": message.body,
55+
},
56+
],
57+
stream=True,
58+
)
59+
60+
await self.stream_message(response_aiter)
61+
62+
def get_context_as_messages(
63+
self, model_id: str, message: Message
64+
) -> list[dict[str, Any]]:
65+
"""
66+
Returns the current context, including attachments and recent messages,
67+
as a list of messages accepted by `litellm.acompletion()`.
68+
"""
69+
system_msg_args = JupyternautSystemPromptArgs(
70+
model_id=model_id,
71+
persona_name=self.name,
72+
context=self.process_attachments(message),
73+
).model_dump()
74+
75+
system_msg = {
76+
"role": "system",
77+
"content": JUPYTERNAUT_SYSTEM_PROMPT_TEMPLATE.render(**system_msg_args),
78+
}
79+
80+
context_as_messages = [system_msg, *self._get_history_as_messages()]
81+
return context_as_messages
82+
83+
def _get_history_as_messages(self, k: Optional[int] = 2) -> list[dict[str, Any]]:
84+
"""
85+
Returns the current history as a list of messages accepted by
86+
`litellm.acompletion()`.
87+
"""
88+
# TODO: consider bounding history based on message size (e.g. total
89+
# char/token count) instead of message count.
90+
all_messages = self.ychat.get_messages()
91+
92+
# gather last k * 2 messages and return
93+
# we exclude the last message since that is the human message just
94+
# submitted by a user.
95+
start_idx = 0 if k is None else -2 * k - 1
96+
recent_messages: list[Message] = all_messages[start_idx:-1]
97+
98+
history: list[dict[str, Any]] = []
99+
for msg in recent_messages:
100+
role = (
101+
"assistant"
102+
if msg.sender.startswith("jupyter-ai-personas::")
103+
else "system" if msg.sender == SYSTEM_USERNAME else "user"
104+
)
105+
history.append({"role": role, "content": msg.body})
106+
107+
return history
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from typing import Optional
2+
3+
from jinja2 import Template
4+
from pydantic import BaseModel
5+
6+
_JUPYTERNAUT_SYSTEM_PROMPT_FORMAT = """
7+
<instructions>
8+
9+
You are {{persona_name}}, an AI agent provided in JupyterLab through the 'Jupyter AI' extension.
10+
11+
Jupyter AI is an installable software package listed on PyPI and Conda Forge as `jupyter-ai`.
12+
13+
When installed, Jupyter AI adds a chat experience in JupyterLab that allows multiple users to collaborate with one or more agents like yourself.
14+
15+
You are not a language model, but rather an AI agent powered by a foundation model `{{model_id}}`.
16+
17+
You are receiving a request from a user in JupyterLab. Your goal is to fulfill this request to the best of your ability.
18+
19+
If you do not know the answer to a question, answer truthfully by responding that you do not know.
20+
21+
You should use Markdown to format your response.
22+
23+
Any code in your response must be enclosed in Markdown fenced code blocks (with triple backticks before and after).
24+
25+
Any mathematical notation in your response must be expressed in LaTeX markup and enclosed in LaTeX delimiters.
26+
27+
- Example of a correct response: The area of a circle is \\(\\pi * r^2\\).
28+
29+
All dollar quantities (of USD) must be formatted in LaTeX, with the `$` symbol escaped by a single backslash `\\`.
30+
31+
- Example of a correct response: `You have \\(\\$80\\) remaining.`
32+
33+
You will receive any provided context and a relevant portion of the chat history.
34+
35+
The user's request is located at the last message. Please fulfill the user's request to the best of your ability.
36+
</instructions>
37+
38+
<context>
39+
{% if context %}The user has shared the following context:
40+
41+
{{context}}
42+
{% else %}The user did not share any additional context.{% endif %}
43+
</context>
44+
""".strip()
45+
46+
47+
JUPYTERNAUT_SYSTEM_PROMPT_TEMPLATE: Template = Template(
48+
_JUPYTERNAUT_SYSTEM_PROMPT_FORMAT
49+
)
50+
51+
52+
class JupyternautSystemPromptArgs(BaseModel):
53+
persona_name: str
54+
model_id: str
55+
context: Optional[str] = None
Lines changed: 9 additions & 0 deletions
Loading

pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ dependencies = [
4141
"litellm>=1.73,<2",
4242
"jinja2>=3.0,<4",
4343
"python_dotenv>=1,<2",
44+
"jupyter_ai_persona_manager>=0.0.1",
4445
]
4546
dynamic = ["version", "description", "authors", "urls", "keywords"]
4647

@@ -103,3 +104,13 @@ before-build-python = ["jlpm clean:all"]
103104

104105
[tool.check-wheel-contents]
105106
ignore = ["W002"]
107+
108+
###############################################################################
109+
# Provide Jupyternaut on the personas entry point to `jupyter_ai_persona_manager`.
110+
# This adds Jupyternaut to JupyterLab.
111+
# See: https://jupyter-ai.readthedocs.io/en/v3/developers/entry_points_api/personas_group.html
112+
# See also: https://packaging.python.org/en/latest/specifications/entry-points/
113+
114+
[project.entry-points."jupyter_ai.personas"]
115+
jupyternaut = "jupyter_ai_jupyternaut.jupyternaut.jupyternaut:JupyternautPersona"
116+
###############################################################################

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
1616
import { SingletonLayout, Widget } from '@lumino/widgets';
1717

1818
import { StopButton } from './components/message-footer/stop-button';
19-
import { completionPlugin } from './completions';
19+
//import { completionPlugin } from './completions';
2020
import { buildErrorWidget } from './widgets/chat-error';
2121
import { buildAiSettings } from './widgets/settings-widget';
2222
import { statusItemPlugin } from './status';
@@ -145,6 +145,6 @@ export default [
145145
jupyternautSettingsPlugin,
146146
// webComponentsPlugin,
147147
stopButtonPlugin,
148-
completionPlugin,
148+
// completionPlugin,
149149
statusItemPlugin
150150
];

0 commit comments

Comments
 (0)