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