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
7 changes: 7 additions & 0 deletions jupyter-config/server_config/jupyter_ai_tutor.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"ServerApp": {
"jpserver_extensions": {
"jupyter_ai_tutor": true
}
}
}
10 changes: 10 additions & 0 deletions jupyter_ai_tutor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
108 changes: 108 additions & 0 deletions jupyter_ai_tutor/handlers.py
Original file line number Diff line number Diff line change
@@ -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 = """
<instructions>

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.

</instructions>
""".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)
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 3 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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"
Expand Down
64 changes: 64 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -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<string, void, undefined> {
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();
}
}
11 changes: 11 additions & 0 deletions src/icons.ts
Original file line number Diff line number Diff line change
@@ -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}`;
85 changes: 49 additions & 36 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -22,58 +26,67 @@ const plugin: JupyterFrontEndPlugin<void> = {
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: {
Expand All @@ -87,7 +100,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
settingRegistry
.load(plugin.id)
.then(_settings => {
// Settings loaded — add any config-driven initialization here.
// Settings loaded.
})
.catch(reason => {
console.error(
Expand Down
Loading
Loading