From 79caf202bd815551cdba2cc16f3a44b5bcd8087f Mon Sep 17 00:00:00 2001 From: Nakul Date: Fri, 4 Jul 2025 17:39:10 +0530 Subject: [PATCH 1/5] feat:-Adding command --- src/chat-handler.ts | 140 +++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 7 ++- style/base.css | 98 +++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 4 deletions(-) diff --git a/src/chat-handler.ts b/src/chat-handler.ts index 4d10fe5..acb522d 100644 --- a/src/chat-handler.ts +++ b/src/chat-handler.ts @@ -27,6 +27,7 @@ import { DEFAULT_CHAT_SYSTEM_PROMPT } from './default-prompts'; import { jupyternautLiteIcon } from './icons'; import { IAIProviderRegistry } from './tokens'; import { AIChatModel } from './types/ai-model'; +import { ContentsManager } from '@jupyterlab/services'; /** * The base64 encoded SVG string of the jupyternaut lite icon. @@ -39,12 +40,14 @@ export const welcomeMessage = (providers: string[]) => ` #### Ask JupyterLite AI -The provider to use can be set in the , by selecting it from -the _AI provider_ settings. +The provider to use can be set in the , by selecting it from +the _AI providers_ settings. The current providers that are available are _${providers.sort().join('_, _')}_. -To clear the chat, you can use the \`/clear\` command from the chat input. +- To clear the chat, you can use the \`/clear\` command from the chat input. + +- To insert file contents into the chat, use the \`/file\` command. `; export type ConnectionMessage = { @@ -252,6 +255,137 @@ export namespace ChatHandler { } } +export class FileCommandProvider implements IChatCommandProvider { + public id: string = '@jupyterlite/ai:file-commands'; + private _contents = new ContentsManager(); + + private _slash_commands: ChatCommand[] = [ + { + name: '/file', + providerId: this.id, + replaceWith: '/file', + description: 'Include contents of a selected file' + } + ]; + + async listCommandCompletions(inputModel: IInputModel) { + const match = inputModel.currentWord?.match(/^\/\w*/)?.[0]; + return match + ? this._slash_commands.filter(cmd => cmd.name.startsWith(match)) + : []; + } + + async onSubmit(inputModel: IInputModel): Promise { + const inputText = inputModel.value?.trim() ?? ''; + + const fileMentioned = inputText.match(/\/file\s+`[^`]+`/); + const hasFollowUp = inputText.replace(fileMentioned?.[0] || '', '').trim(); + + if (inputText.startsWith('/file') && !fileMentioned) { + await this._showFileBrowser(inputModel); + } else { + return; + } + + if (fileMentioned && hasFollowUp) { + console.log(inputText); + } else { + console.log('Waiting for follow-up text.'); + throw new Error('Incomplete /file command'); + } + } + + private async _showFileBrowser(inputModel: IInputModel): Promise { + return new Promise(resolve => { + const modal = document.createElement('div'); + modal.className = 'file-browser-modal'; + modal.innerHTML = ` +
+

Select a File

+
    +
    + + +
    +
    + `; + document.body.appendChild(modal); + + const fileList = modal.querySelector('.file-list')!; + const closeBtn = modal.querySelector('.close-btn') as HTMLButtonElement; + const backBtn = modal.querySelector('.back-btn') as HTMLButtonElement; + let currentPath = ''; + + const listDir = async (path = '') => { + try { + const dir = await this._contents.get(path, { content: true }); + + fileList.innerHTML = ''; + + for (const item of dir.content) { + const li = document.createElement('li'); + if (item.type === 'directory') { + li.textContent = `${item.name}/`; + li.className = 'directory'; + } else if (item.type === 'file' || item.type === 'notebook') { + li.textContent = item.name; + li.className = 'file'; + } + + fileList.appendChild(li); + + li.onclick = async () => { + try { + if (item.type === 'directory') { + currentPath = item.path; + await listDir(item.path); + } else if (item.type === 'file' || item.type === 'notebook') { + const existingText = inputModel.value?.trim(); + const updatedText = + existingText === '/file' + ? `/file \`${item.path}\` ` + : `${existingText} \`${item.path}\``; + + inputModel.value = updatedText.trim(); + li.style.backgroundColor = '#d2f8d2'; + + document.body.removeChild(modal); + resolve(); + } + } catch (error) { + console.error(error); + document.body.removeChild(modal); + resolve(); + } + }; + + fileList.appendChild(li); + } + } catch (err) { + console.error(err); + } + }; + + closeBtn.onclick = () => { + document.body.removeChild(modal); + resolve(); + }; + backBtn.onclick = () => { + if (!currentPath || currentPath === '') { + return; + } + + const parts = currentPath.split('/'); + parts.pop(); + currentPath = parts.join('/'); + listDir(currentPath); + }; + + listDir(); + }); + } +} + namespace Private { /** * Return the current timestamp in milliseconds. diff --git a/src/index.ts b/src/index.ts index 06e870d..4679521 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,11 @@ import { IFormRendererRegistry } from '@jupyterlab/ui-components'; import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; import { ISecretsManager, SecretsManager } from 'jupyter-secrets-manager'; -import { ChatHandler, welcomeMessage } from './chat-handler'; +import { + ChatHandler, + welcomeMessage, + FileCommandProvider +} from './chat-handler'; import { CompletionProvider } from './completion-provider'; import { defaultProviderPlugins } from './default-providers'; import { AIProviderRegistry } from './provider'; @@ -37,6 +41,7 @@ const chatCommandRegistryPlugin: JupyterFrontEndPlugin = { activate: () => { const registry = new ChatCommandRegistry(); registry.addProvider(new ChatHandler.ClearCommandProvider()); + registry.addProvider(new FileCommandProvider()); return registry; } }; diff --git a/style/base.css b/style/base.css index c4925ee..9bcdea6 100644 --- a/style/base.css +++ b/style/base.css @@ -43,3 +43,101 @@ border-color: var(--jp-brand-color1); color: var(--jp-brand-color1); } + +.file-browser-modal { + position: fixed; + top: 20%; + left: 30%; + width: 40%; + background: rgb(255, 255, 255); + border: 1px solid #414040; + padding: 1em; + z-index: 9999; + box-shadow: 0 0 10px rgba(0,0,0,0.2); + border-radius: 8px; + font-family: sans-serif; +} + +.file-browser-panel { + display: flex; + flex-direction: column; +} + +.file-list { + list-style: none; + padding-left: 0; + max-height: 200px; + overflow-y: auto; + margin: 1em 0; +} + +.file-list li { + padding: 6px 8px; + cursor: pointer; + border-bottom: 1px solid #eee; +} + +.file-list li:hover { + background-color: #f5f5f5; +} + +.file-browser-panel .button-row { + display: flex; + justify-content: space-between; + margin-top: 1rem; + gap: 0.5rem; +} + +.back-btn, +.close-btn { + background: #f9f9f9; + border: 1px solid #888; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + color: #333; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +.back-btn:hover, +.close-btn:hover { + background-color: #e6e6e6; + color: #111; + border-color: #666; +} + +.close-btn { + color: #a00; + border-color: #a00; +} + +.close-btn:hover { + background-color: #fcecec; + color: #700; + border-color: #700; +} + +.file { + color: #145196; + font-weight: bold; + position: relative; +} + +.file::after { + content: " —— File"; + font-weight: normal; + color: #867f7fda; +} + +.directory { + color: #0f9145; + font-weight: bold; + position: relative; +} + +.directory::after { + content: " —— Directory"; + font-weight: normal; + color: #867f7fda; +} From b49e5fb7173082f4330f8a95b7336176bc3cafe9 Mon Sep 17 00:00:00 2001 From: Nakul Date: Mon, 7 Jul 2025 19:37:11 +0530 Subject: [PATCH 2/5] lint fix --- style/base.css | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/style/base.css b/style/base.css index 9bcdea6..7e6a60f 100644 --- a/style/base.css +++ b/style/base.css @@ -49,11 +49,11 @@ top: 20%; left: 30%; width: 40%; - background: rgb(255, 255, 255); + background: #fff; border: 1px solid #414040; padding: 1em; z-index: 9999; - box-shadow: 0 0 10px rgba(0,0,0,0.2); + box-shadow: 0 0 10px #0003; border-radius: 8px; font-family: sans-serif; } @@ -97,11 +97,9 @@ cursor: pointer; font-size: 14px; color: #333; - transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; } -.back-btn:hover, -.close-btn:hover { +.back-btn:hover { background-color: #e6e6e6; color: #111; border-color: #666; @@ -125,7 +123,7 @@ } .file::after { - content: " —— File"; + content: ' —— File'; font-weight: normal; color: #867f7fda; } @@ -137,7 +135,7 @@ } .directory::after { - content: " —— Directory"; + content: ' —— Directory'; font-weight: normal; color: #867f7fda; } From f8650fc0de3ea4a9e2cc687b30efba5ab13e7983 Mon Sep 17 00:00:00 2001 From: Nakul Date: Fri, 18 Jul 2025 16:45:20 +0530 Subject: [PATCH 3/5] Adding Attach Button --- src/chat-handler.ts | 134 -------------------------------------------- src/index.ts | 17 +++--- 2 files changed, 8 insertions(+), 143 deletions(-) diff --git a/src/chat-handler.ts b/src/chat-handler.ts index acb522d..ba2a9d3 100644 --- a/src/chat-handler.ts +++ b/src/chat-handler.ts @@ -27,7 +27,6 @@ import { DEFAULT_CHAT_SYSTEM_PROMPT } from './default-prompts'; import { jupyternautLiteIcon } from './icons'; import { IAIProviderRegistry } from './tokens'; import { AIChatModel } from './types/ai-model'; -import { ContentsManager } from '@jupyterlab/services'; /** * The base64 encoded SVG string of the jupyternaut lite icon. @@ -46,8 +45,6 @@ the _AI providers_ settings. The current providers that are available are _${providers.sort().join('_, _')}_. - To clear the chat, you can use the \`/clear\` command from the chat input. - -- To insert file contents into the chat, use the \`/file\` command. `; export type ConnectionMessage = { @@ -255,137 +252,6 @@ export namespace ChatHandler { } } -export class FileCommandProvider implements IChatCommandProvider { - public id: string = '@jupyterlite/ai:file-commands'; - private _contents = new ContentsManager(); - - private _slash_commands: ChatCommand[] = [ - { - name: '/file', - providerId: this.id, - replaceWith: '/file', - description: 'Include contents of a selected file' - } - ]; - - async listCommandCompletions(inputModel: IInputModel) { - const match = inputModel.currentWord?.match(/^\/\w*/)?.[0]; - return match - ? this._slash_commands.filter(cmd => cmd.name.startsWith(match)) - : []; - } - - async onSubmit(inputModel: IInputModel): Promise { - const inputText = inputModel.value?.trim() ?? ''; - - const fileMentioned = inputText.match(/\/file\s+`[^`]+`/); - const hasFollowUp = inputText.replace(fileMentioned?.[0] || '', '').trim(); - - if (inputText.startsWith('/file') && !fileMentioned) { - await this._showFileBrowser(inputModel); - } else { - return; - } - - if (fileMentioned && hasFollowUp) { - console.log(inputText); - } else { - console.log('Waiting for follow-up text.'); - throw new Error('Incomplete /file command'); - } - } - - private async _showFileBrowser(inputModel: IInputModel): Promise { - return new Promise(resolve => { - const modal = document.createElement('div'); - modal.className = 'file-browser-modal'; - modal.innerHTML = ` -
    -

    Select a File

    -
      -
      - - -
      -
      - `; - document.body.appendChild(modal); - - const fileList = modal.querySelector('.file-list')!; - const closeBtn = modal.querySelector('.close-btn') as HTMLButtonElement; - const backBtn = modal.querySelector('.back-btn') as HTMLButtonElement; - let currentPath = ''; - - const listDir = async (path = '') => { - try { - const dir = await this._contents.get(path, { content: true }); - - fileList.innerHTML = ''; - - for (const item of dir.content) { - const li = document.createElement('li'); - if (item.type === 'directory') { - li.textContent = `${item.name}/`; - li.className = 'directory'; - } else if (item.type === 'file' || item.type === 'notebook') { - li.textContent = item.name; - li.className = 'file'; - } - - fileList.appendChild(li); - - li.onclick = async () => { - try { - if (item.type === 'directory') { - currentPath = item.path; - await listDir(item.path); - } else if (item.type === 'file' || item.type === 'notebook') { - const existingText = inputModel.value?.trim(); - const updatedText = - existingText === '/file' - ? `/file \`${item.path}\` ` - : `${existingText} \`${item.path}\``; - - inputModel.value = updatedText.trim(); - li.style.backgroundColor = '#d2f8d2'; - - document.body.removeChild(modal); - resolve(); - } - } catch (error) { - console.error(error); - document.body.removeChild(modal); - resolve(); - } - }; - - fileList.appendChild(li); - } - } catch (err) { - console.error(err); - } - }; - - closeBtn.onclick = () => { - document.body.removeChild(modal); - resolve(); - }; - backBtn.onclick = () => { - if (!currentPath || currentPath === '') { - return; - } - - const parts = currentPath.split('/'); - parts.pop(); - currentPath = parts.join('/'); - listDir(currentPath); - }; - - listDir(); - }); - } -} - namespace Private { /** * Return the current timestamp in milliseconds. diff --git a/src/index.ts b/src/index.ts index 4679521..2511331 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,12 +20,9 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { IFormRendererRegistry } from '@jupyterlab/ui-components'; import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; import { ISecretsManager, SecretsManager } from 'jupyter-secrets-manager'; +import { IDocumentManager } from '@jupyterlab/docmanager'; -import { - ChatHandler, - welcomeMessage, - FileCommandProvider -} from './chat-handler'; +import { ChatHandler, welcomeMessage } from './chat-handler'; import { CompletionProvider } from './completion-provider'; import { defaultProviderPlugins } from './default-providers'; import { AIProviderRegistry } from './provider'; @@ -41,7 +38,6 @@ const chatCommandRegistryPlugin: JupyterFrontEndPlugin = { activate: () => { const registry = new ChatCommandRegistry(); registry.addProvider(new ChatHandler.ClearCommandProvider()); - registry.addProvider(new FileCommandProvider()); return registry; } }; @@ -55,7 +51,8 @@ const chatPlugin: JupyterFrontEndPlugin = { INotebookTracker, ISettingRegistry, IThemeManager, - ILayoutRestorer + ILayoutRestorer, + IDocumentManager ], activate: async ( app: JupyterFrontEnd, @@ -65,7 +62,8 @@ const chatPlugin: JupyterFrontEndPlugin = { notebookTracker: INotebookTracker | null, settingsRegistry: ISettingRegistry | null, themeManager: IThemeManager | null, - restorer: ILayoutRestorer | null + restorer: ILayoutRestorer | null, + docManager: IDocumentManager | null ) => { let activeCellManager: IActiveCellManager | null = null; if (notebookTracker) { @@ -77,7 +75,8 @@ const chatPlugin: JupyterFrontEndPlugin = { const chatHandler = new ChatHandler({ providerRegistry, - activeCellManager + activeCellManager, + documentManager: docManager ?? undefined }); let sendWithShiftEnter = false; From 40d45f021067e67f0b4146a65aed77ff7b615c5e Mon Sep 17 00:00:00 2001 From: Nakul Date: Fri, 18 Jul 2025 16:55:36 +0530 Subject: [PATCH 4/5] removing filebrowser ui --- style/base.css | 96 -------------------------------------------------- 1 file changed, 96 deletions(-) diff --git a/style/base.css b/style/base.css index 7e6a60f..c4925ee 100644 --- a/style/base.css +++ b/style/base.css @@ -43,99 +43,3 @@ border-color: var(--jp-brand-color1); color: var(--jp-brand-color1); } - -.file-browser-modal { - position: fixed; - top: 20%; - left: 30%; - width: 40%; - background: #fff; - border: 1px solid #414040; - padding: 1em; - z-index: 9999; - box-shadow: 0 0 10px #0003; - border-radius: 8px; - font-family: sans-serif; -} - -.file-browser-panel { - display: flex; - flex-direction: column; -} - -.file-list { - list-style: none; - padding-left: 0; - max-height: 200px; - overflow-y: auto; - margin: 1em 0; -} - -.file-list li { - padding: 6px 8px; - cursor: pointer; - border-bottom: 1px solid #eee; -} - -.file-list li:hover { - background-color: #f5f5f5; -} - -.file-browser-panel .button-row { - display: flex; - justify-content: space-between; - margin-top: 1rem; - gap: 0.5rem; -} - -.back-btn, -.close-btn { - background: #f9f9f9; - border: 1px solid #888; - padding: 6px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - color: #333; -} - -.back-btn:hover { - background-color: #e6e6e6; - color: #111; - border-color: #666; -} - -.close-btn { - color: #a00; - border-color: #a00; -} - -.close-btn:hover { - background-color: #fcecec; - color: #700; - border-color: #700; -} - -.file { - color: #145196; - font-weight: bold; - position: relative; -} - -.file::after { - content: ' —— File'; - font-weight: normal; - color: #867f7fda; -} - -.directory { - color: #0f9145; - font-weight: bold; - position: relative; -} - -.directory::after { - content: ' —— Directory'; - font-weight: normal; - color: #867f7fda; -} From f3a4b0041b3a2aedf04e43207510947c91c96187 Mon Sep 17 00:00:00 2001 From: Nakul Date: Thu, 24 Jul 2025 17:32:13 +0530 Subject: [PATCH 5/5] Enable and Disable Attach button support --- schema/chat.json | 6 ++++++ src/index.ts | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/schema/chat.json b/schema/chat.json index 0e79f64..e96a484 100644 --- a/schema/chat.json +++ b/schema/chat.json @@ -22,6 +22,12 @@ "title": "AI persona name", "description": "The name of the AI persona", "default": "Jupyternaut" + }, + "enableFileAttachment": { + "type": "boolean", + "title": "Enable file attachment support", + "default": false, + "description": "This allow you to sending attached files along with your message. ⚠️ This may send large content to the LLM, consuming many tokens." } }, "additionalProperties": false diff --git a/src/index.ts b/src/index.ts index 2511331..8fe94b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,17 +81,27 @@ const chatPlugin: JupyterFrontEndPlugin = { let sendWithShiftEnter = false; let enableCodeToolbar = true; + let enableFileAttachment = false; let personaName = 'AI'; function loadSetting(setting: ISettingRegistry.ISettings): void { sendWithShiftEnter = setting.get('sendWithShiftEnter') - .composite as boolean; - enableCodeToolbar = setting.get('enableCodeToolbar').composite as boolean; - personaName = setting.get('personaName').composite as string; + ?.composite as boolean; + enableCodeToolbar = setting.get('enableCodeToolbar') + ?.composite as boolean; + personaName = setting.get('personaName')?.composite as string; + enableFileAttachment = setting.get('enableFileAttachment') + ?.composite as boolean; // set the properties chatHandler.config = { sendWithShiftEnter, enableCodeToolbar }; chatHandler.personaName = personaName; + + if (enableFileAttachment) { + inputToolbarRegistry.show('attach'); + } else { + inputToolbarRegistry.hide('attach'); + } } Promise.all([app.restored, settingsRegistry?.load(chatPlugin.id)])