diff --git a/jupyter-config/server_config/jupyter_ai_tutor.json b/jupyter-config/server_config/jupyter_ai_tutor.json
new file mode 100644
index 0000000..b9e2b6c
--- /dev/null
+++ b/jupyter-config/server_config/jupyter_ai_tutor.json
@@ -0,0 +1,7 @@
+{
+ "ServerApp": {
+ "jpserver_extensions": {
+ "jupyter_ai_tutor": true
+ }
+ }
+}
diff --git a/jupyter_ai_tutor/__init__.py b/jupyter_ai_tutor/__init__.py
index 0a6bc8e..250a198 100644
--- a/jupyter_ai_tutor/__init__.py
+++ b/jupyter_ai_tutor/__init__.py
@@ -14,3 +14,13 @@ def _jupyter_labextension_paths():
"src": "labextension",
"dest": "jupyter-ai-tutor"
}]
+
+
+def _jupyter_server_extension_points():
+ return [{"module": "jupyter_ai_tutor"}]
+
+
+def _load_jupyter_server_extension(server_app):
+ from .handlers import setup_handlers
+ setup_handlers(server_app.web_app)
+ server_app.log.info("jupyter_ai_tutor: server extension loaded")
diff --git a/jupyter_ai_tutor/handlers.py b/jupyter_ai_tutor/handlers.py
new file mode 100644
index 0000000..93b70de
--- /dev/null
+++ b/jupyter_ai_tutor/handlers.py
@@ -0,0 +1,108 @@
+import json
+
+import tornado
+from jupyter_server.base.handlers import APIHandler
+from jupyter_server.utils import url_path_join
+
+TUTOR_SYSTEM_PROMPT = """
+
+
+You are an AI tutor embedded in JupyterLab. Your sole purpose is to help students
+understand code — never to write code for them.
+
+## Core Rules
+
+- **Never write code** for the student, not even a single line or a partial snippet.
+- **Never give direct solutions** to a problem, even if the student explicitly asks.
+- If a student asks you to "just write it" or "give me the answer", gently decline and
+ redirect them to think through the problem themselves.
+
+## How to Help
+
+- Ask guiding questions that lead the student toward the answer themselves.
+- Explain the underlying concept or principle at play.
+- Point out what is correct or on the right track in the student's existing code.
+- Identify the specific part that is wrong or missing, without fixing it.
+- Suggest what to search for or which documentation to read.
+- Break a complex problem into smaller steps and ask the student to tackle one at a time.
+
+## Tone
+
+- Be encouraging and patient.
+- Treat mistakes as learning opportunities, not failures.
+- Keep explanations concise — prefer one focused question or hint over a long lecture.
+
+
+""".strip()
+
+
+class ExplainHandler(APIHandler):
+ @tornado.web.authenticated
+ async def post(self):
+ body = self.get_json_body()
+ if not body or "body" not in body:
+ raise tornado.web.HTTPError(400, "Missing 'body' field in request")
+
+ message_body = body["body"]
+
+ config_manager = self.settings.get("jupyternaut.config_manager")
+ if not config_manager:
+ raise tornado.web.HTTPError(
+ 503, "Jupyternaut config manager is not available"
+ )
+ if not config_manager.chat_model:
+ raise tornado.web.HTTPError(
+ 503,
+ "No chat model is configured. Set one in 'Settings > AI Settings'.",
+ )
+
+ self.set_header("Content-Type", "text/event-stream")
+ self.set_header("Cache-Control", "no-cache")
+ self.set_header("X-Accel-Buffering", "no")
+
+ try:
+ from jupyter_ai_jupyternaut.jupyternaut.chat_models import ChatLiteLLM
+ from langchain_core.messages import HumanMessage, SystemMessage
+
+ model = ChatLiteLLM(
+ **config_manager.chat_model_args,
+ model=config_manager.chat_model,
+ streaming=True,
+ )
+
+ async for chunk in model.astream(
+ [
+ SystemMessage(content=TUTOR_SYSTEM_PROMPT),
+ HumanMessage(content=message_body),
+ ]
+ ):
+ text = (
+ chunk.content
+ if isinstance(chunk.content, str)
+ else "".join(
+ block.get("text", "")
+ for block in chunk.content
+ if isinstance(block, dict)
+ )
+ )
+ if text:
+ self.write(f"data: {json.dumps({'text': text})}\n\n")
+ self.flush()
+
+ except Exception as e:
+ self.log.exception("Error during tutor LLM call")
+ self.write(f"data: {json.dumps({'error': str(e)})}\n\n")
+ self.flush()
+
+ self.write("data: [DONE]\n\n")
+ self.flush()
+ self.finish()
+
+
+def setup_handlers(web_app):
+ host_pattern = ".*$"
+ base_url = web_app.settings["base_url"]
+ handlers = [
+ (url_path_join(base_url, "api/jupyter-ai-tutor/explain"), ExplainHandler)
+ ]
+ web_app.add_handlers(host_pattern, handlers)
diff --git a/package.json b/package.json
index f753197..b40dcce 100644
--- a/package.json
+++ b/package.json
@@ -56,10 +56,14 @@
"dependencies": {
"@jupyter/chat": "^0.22.1",
"@jupyterlab/application": "^4.0.0",
+ "@jupyterlab/coreutils": "^6.0.0",
"@jupyterlab/notebook": "^4.0.0",
+ "@jupyterlab/rendermime": "^4.0.0",
+ "@jupyterlab/services": "^7.0.0",
"@jupyterlab/settingregistry": "^4.0.0",
"@jupyterlab/translation": "^4.0.0",
- "@jupyterlab/ui-components": "^4.0.0"
+ "@jupyterlab/ui-components": "^4.0.0",
+ "@lumino/coreutils": "^2.0.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
diff --git a/pyproject.toml b/pyproject.toml
index 944b3b2..aca5db2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,11 +31,8 @@ dev = [
"jupyterlab>=4",
"jupyter-builder>=1.0.0",
]
-ai = [
- "jupyter-ai"
-]
-lite-ai = [
- "jupyterlite-ai"
+server = [
+ "jupyter_ai_jupyternaut",
]
[tool.hatch.version]
@@ -51,6 +48,7 @@ exclude = [".github", "binder"]
[tool.hatch.build.targets.wheel.shared-data]
"jupyter_ai_tutor/labextension" = "share/jupyter/labextensions/jupyter-ai-tutor"
"install.json" = "share/jupyter/labextensions/jupyter-ai-tutor/install.json"
+"jupyter-config/server_config/jupyter_ai_tutor.json" = "etc/jupyter/jupyter_server_config.d/jupyter_ai_tutor.json"
[tool.hatch.build.hooks.version]
path = "jupyter_ai_tutor/_version.py"
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 0000000..abe287a
--- /dev/null
+++ b/src/api.ts
@@ -0,0 +1,64 @@
+import { URLExt } from '@jupyterlab/coreutils';
+import { ServerConnection } from '@jupyterlab/services';
+
+/**
+ * Streams the tutor explanation for the given message body via SSE.
+ * Yields text chunks as they arrive from the backend.
+ */
+export async function* streamExplanation(
+ body: string
+): AsyncGenerator {
+ const settings = ServerConnection.makeSettings();
+ const url = URLExt.join(settings.baseUrl, 'api/jupyter-ai-tutor/explain');
+
+ const response = await ServerConnection.makeRequest(
+ url,
+ {
+ method: 'POST',
+ body: JSON.stringify({ body }),
+ headers: { 'Content-Type': 'application/json' }
+ },
+ settings
+ );
+
+ if (!response.ok) {
+ throw new ServerConnection.ResponseError(response);
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ throw new Error('Response body is not readable');
+ }
+
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+ const data = line.slice(6).trim();
+ if (data === '[DONE]') return;
+
+ let parsed: { text?: string; error?: string };
+ try {
+ parsed = JSON.parse(data) as { text?: string; error?: string };
+ } catch {
+ continue;
+ }
+
+ if (parsed.error) throw new Error(parsed.error);
+ if (parsed.text) yield parsed.text;
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+}
diff --git a/src/icons.ts b/src/icons.ts
new file mode 100644
index 0000000..c1f25b7
--- /dev/null
+++ b/src/icons.ts
@@ -0,0 +1,11 @@
+import { LabIcon } from '@jupyterlab/ui-components';
+
+import jupyternautSvg from '../style/icons/jupyternaut-lite.svg';
+
+export const jupyternautIcon = new LabIcon({
+ name: '@jupyterlite/ai:jupyternaut',
+ svgstr: jupyternautSvg
+});
+
+const AI_AVATAR_BASE64 = btoa(jupyternautIcon.svgstr);
+export const AI_AVATAR = `data:image/svg+xml;base64,${AI_AVATAR_BASE64}`;
diff --git a/src/index.ts b/src/index.ts
index 38e4d71..59265aa 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,12 +1,16 @@
-import { IChatTracker } from '@jupyter/chat';
+import { ChatWidget } from '@jupyter/chat';
import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
+import { INotebookTracker } from '@jupyterlab/notebook';
+import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
import { infoIcon } from '@jupyterlab/ui-components';
+import { TutorChatModel } from './model';
+
/**
* Command IDs used by the jupyter-ai-tutor extension.
*/
@@ -22,58 +26,67 @@ const plugin: JupyterFrontEndPlugin = {
description:
'A JupyterLab extension to add an AI-powered tutor assistant to Notebooks.',
autoStart: true,
- optional: [ISettingRegistry, IChatTracker, ITranslator],
+ requires: [IRenderMimeRegistry],
+ optional: [ISettingRegistry, INotebookTracker, ITranslator],
activate: (
app: JupyterFrontEnd,
+ rmRegistry: IRenderMimeRegistry,
settingRegistry: ISettingRegistry | null,
- chatTracker: IChatTracker | null,
+ notebookTracker: INotebookTracker | null,
translator: ITranslator | null
) => {
const { commands } = app;
const trans = (translator ?? nullTranslator).load('jupyterlab');
- // Register the command to explain code in active cell
+ const tutorModel = new TutorChatModel({
+ id: 'jupyter-ai-tutor',
+ translator: translator ?? undefined
+ });
+ const chatWidget = new ChatWidget({
+ model: tutorModel,
+ rmRegistry,
+ translator: translator ?? undefined,
+ welcomeMessage: trans.__(
+ 'Select a code cell and click **Explain Code** to get started, or type a question below.'
+ )
+ });
+ chatWidget.id = 'jupyter-ai-tutor-panel';
+ chatWidget.title.label = trans.__('Tutor');
+ chatWidget.title.caption = trans.__('Tutor');
+ chatWidget.title.closable = true;
+ app.shell.add(chatWidget, 'right');
+
+ // Keep the enabled state in sync when the active cell changes.
+ notebookTracker?.activeCellChanged.connect(() => {
+ commands.notifyCommandChanged(CommandIDs.explainCode);
+ });
+
commands.addCommand(CommandIDs.explainCode, {
label: trans.__('Explain Code'),
- caption: trans.__('Send cell content to AI chat for explanation'),
+ caption: trans.__('Send cell content to AI tutor for explanation'),
icon: infoIcon,
- isEnabled: () =>
- !!chatTracker?.currentWidget?.model?.activeCellManager?.available,
+ isEnabled: () => {
+ const cell = notebookTracker?.activeCell;
+ return !!cell && cell.model.type === 'code';
+ },
isVisible: () => true,
execute: async () => {
- if (!chatTracker) {
- return;
- }
-
- const chat = chatTracker.currentWidget;
- if (!chat) {
- console.warn('No active chat found to send message');
- return;
- }
-
- const { activeCellManager, selectionWatcher } = chat.model;
+ const cell = notebookTracker?.activeCell;
+ if (!cell || cell.model.type !== 'code') return;
- let source = '';
- let language: string | undefined;
+ const source = cell.model.sharedModel.source.trim();
+ if (!source) return;
- if (selectionWatcher?.selection) {
- source = selectionWatcher.selection.text;
- language = selectionWatcher.selection.language;
- } else if (activeCellManager?.available) {
- const content = activeCellManager.getContent(false);
- if (content) {
- source = content.source;
- language = content.language;
- }
- }
+ const language =
+ notebookTracker?.currentWidget?.model?.defaultKernelLanguage ?? '';
+ const body = `Can you explain this code?\n\n\`\`\`${language}\n${source}\n\`\`\`\n`;
- if (!source.trim()) {
- return;
+ if (!chatWidget.isAttached) {
+ app.shell.add(chatWidget, 'right');
}
+ app.shell.activateById(chatWidget.id);
- const body = `Can you explain this code?\n\n\`\`\`${language ?? ''}\n${source}\n\`\`\`\n`;
- await chat.model.sendMessage({ body });
- chat.activate();
+ await tutorModel.sendMessageToAI({ body });
},
describedBy: {
args: {
@@ -87,7 +100,7 @@ const plugin: JupyterFrontEndPlugin = {
settingRegistry
.load(plugin.id)
.then(_settings => {
- // Settings loaded — add any config-driven initialization here.
+ // Settings loaded.
})
.catch(reason => {
console.error(
diff --git a/src/model.ts b/src/model.ts
new file mode 100644
index 0000000..5696820
--- /dev/null
+++ b/src/model.ts
@@ -0,0 +1,98 @@
+import {
+ AbstractChatContext,
+ AbstractChatModel,
+ IChatContext,
+ IChatModel,
+ IMessageContent,
+ INewMessage,
+ IUser
+} from '@jupyter/chat';
+import { UUID } from '@lumino/coreutils';
+
+import { streamExplanation } from './api';
+import { AI_AVATAR } from './icons';
+
+const TUTOR_USER: IUser = {
+ username: 'tutor',
+ display_name: 'Tutor',
+ initials: 'T',
+ bot: true,
+ avatar_url: AI_AVATAR
+};
+
+class TutorChatContext extends AbstractChatContext {
+ get users(): IUser[] {
+ const users = new Map();
+ for (const msg of this._model.messages) {
+ users.set(msg.sender.username, msg.sender);
+ }
+ return Array.from(users.values());
+ }
+}
+
+/**
+ * Chat model for the AI tutor panel.
+ * Routes messages to the tutor backend and streams responses into the chat.
+ */
+export class TutorChatModel extends AbstractChatModel {
+ constructor(options?: IChatModel.IOptions) {
+ super(options);
+ this.name = 'Tutor';
+ this.setReady();
+ }
+
+ get user(): IUser | undefined {
+ return undefined;
+ }
+
+ sendMessage(message: INewMessage): void {
+ const userMsg: IMessageContent = {
+ type: 'msg',
+ id: UUID.uuid4(),
+ time: Date.now() / 1000,
+ body: message.body,
+ sender: this.user ?? { username: 'user', display_name: 'You' }
+ };
+ this.messageAdded(userMsg);
+ }
+
+ async sendMessageToAI(message: INewMessage): Promise {
+ if (!message.body.trim()) return;
+
+ this.sendMessage(message);
+
+ this.updateWriters([{ user: TUTOR_USER }]);
+
+ // Add an initial empty tutor message slightly later to preserve order.
+ const tutorMsgContent: IMessageContent = {
+ type: 'msg',
+ id: UUID.uuid4(),
+ time: Date.now() / 1000 + 0.001,
+ body: '',
+ sender: TUTOR_USER
+ };
+ this.messageAdded(tutorMsgContent);
+
+ const streamingMsg = this.messages[this.messages.length - 1];
+
+ try {
+ let accumulated = '';
+ for await (const chunk of streamExplanation(message.body)) {
+ accumulated += chunk;
+ streamingMsg.update({ body: accumulated });
+ }
+ } catch (err) {
+ const errorText = err instanceof Error ? err.message : String(err);
+ streamingMsg.update({
+ body: `Sorry, an error occurred: ${errorText}`
+ });
+ console.error('Tutor explanation failed:', err);
+ } finally {
+ this.updateWriters([]);
+ }
+ }
+
+ createChatContext(): IChatContext {
+ return new TutorChatContext({ model: this });
+ }
+}
diff --git a/src/types.d.ts b/src/types.d.ts
new file mode 100644
index 0000000..cdb2b1a
--- /dev/null
+++ b/src/types.d.ts
@@ -0,0 +1,4 @@
+declare module '*.svg' {
+ const content: string;
+ export default content;
+}
diff --git a/style/base.css b/style/base.css
index e11f457..48f55fd 100644
--- a/style/base.css
+++ b/style/base.css
@@ -3,3 +3,7 @@
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
*/
+
+#jupyter-ai-tutor-panel .jp-chat-input-container {
+ display: none;
+}
diff --git a/style/icons/jupyternaut-lite.svg b/style/icons/jupyternaut-lite.svg
new file mode 100644
index 0000000..4af41a2
--- /dev/null
+++ b/style/icons/jupyternaut-lite.svg
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index c9631f3..fcecd17 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2577,7 +2577,7 @@ __metadata:
languageName: node
linkType: hard
-"@jupyterlab/coreutils@npm:^6.5.8":
+"@jupyterlab/coreutils@npm:^6.0.0, @jupyterlab/coreutils@npm:^6.5.8":
version: 6.5.8
resolution: "@jupyterlab/coreutils@npm:6.5.8"
dependencies:
@@ -2866,7 +2866,7 @@ __metadata:
languageName: node
linkType: hard
-"@jupyterlab/rendermime@npm:^4.2.0, @jupyterlab/rendermime@npm:^4.5.8":
+"@jupyterlab/rendermime@npm:^4.0.0, @jupyterlab/rendermime@npm:^4.2.0, @jupyterlab/rendermime@npm:^4.5.8":
version: 4.5.8
resolution: "@jupyterlab/rendermime@npm:4.5.8"
dependencies:
@@ -2886,7 +2886,7 @@ __metadata:
languageName: node
linkType: hard
-"@jupyterlab/services@npm:^7.5.8":
+"@jupyterlab/services@npm:^7.0.0, @jupyterlab/services@npm:^7.5.8":
version: 7.5.8
resolution: "@jupyterlab/services@npm:7.5.8"
dependencies:
@@ -8056,11 +8056,15 @@ __metadata:
"@jupyter/chat": ^0.22.1
"@jupyter/eslint-plugin": ^0.0.5
"@jupyterlab/application": ^4.0.0
+ "@jupyterlab/coreutils": ^6.0.0
"@jupyterlab/notebook": ^4.0.0
+ "@jupyterlab/rendermime": ^4.0.0
+ "@jupyterlab/services": ^7.0.0
"@jupyterlab/settingregistry": ^4.0.0
"@jupyterlab/testutils": ^4.0.0
"@jupyterlab/translation": ^4.0.0
"@jupyterlab/ui-components": ^4.0.0
+ "@lumino/coreutils": ^2.0.0
"@types/jest": ^29.2.0
"@types/json-schema": ^7.0.11
"@types/react": ^18.0.26