From 3bf22dec17d4879ce15512696ffa509b6e23ad35 Mon Sep 17 00:00:00 2001 From: Nakul Date: Fri, 4 Jul 2025 17:39:10 +0530 Subject: [PATCH 1/3] 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 bbd17eb4b774c6b175a4b76db68c7ef3663bd3ae Mon Sep 17 00:00:00 2001 From: Nakul Date: Mon, 7 Jul 2025 19:37:11 +0530 Subject: [PATCH 2/3] 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 d3cad0a4903e84dfcf76aa88d8ea97d0159aac34 Mon Sep 17 00:00:00 2001 From: Nakul Date: Fri, 11 Jul 2025 15:37:05 +0530 Subject: [PATCH 3/3] Adding Attach button --- src/chat-handler.ts | 134 ------------------------------- src/components/attach-button.tsx | 69 ++++++++++++++++ src/index.ts | 10 +-- src/utils/show-file-browser.ts | 95 ++++++++++++++++++++++ 4 files changed, 168 insertions(+), 140 deletions(-) create mode 100644 src/components/attach-button.tsx create mode 100644 src/utils/show-file-browser.ts 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/components/attach-button.tsx b/src/components/attach-button.tsx new file mode 100644 index 0000000..c43751d --- /dev/null +++ b/src/components/attach-button.tsx @@ -0,0 +1,69 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { InputToolbarRegistry, TooltippedButton } from '@jupyter/chat'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import React from 'react'; +import { showFileBrowserModal } from '../utils/show-file-browser'; + +/** + * Properties of the attach button. + */ +export interface IAttachButtonProps + extends InputToolbarRegistry.IToolbarItemProps {} + +/** + The attach button. + */ +export function AttachButton( + props: InputToolbarRegistry.IToolbarItemProps +): JSX.Element { + const tooltip = 'Attach a file'; + + const handleClick = async () => { + const inputModel = props.model; + if (!inputModel) { + console.warn('No input model found.'); + return; + } + + try { + await showFileBrowserModal(inputModel); + } catch (e) { + console.error('Error opening file modal:', e); + } + }; + + return ( + + + + ); +} + +/** + * Factory returning the toolbar item. + * This function creates an attach button that allows users to attach files. + * + * @returns An InputToolbarRegistry.IToolbarItem for the attach button. + */ + +export function attachItem(): InputToolbarRegistry.IToolbarItem { + return { + element: (props: InputToolbarRegistry.IToolbarItemProps) => { + return ; + }, + position: 40, + hidden: false + }; +} diff --git a/src/index.ts b/src/index.ts index 4679521..e43cdfe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,17 +21,14 @@ import { IFormRendererRegistry } from '@jupyterlab/ui-components'; import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; import { ISecretsManager, SecretsManager } from 'jupyter-secrets-manager'; -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'; import { aiSettingsRenderer, textArea } from './settings'; import { IAIProviderRegistry, PLUGIN_IDS } from './tokens'; import { stopItem } from './components/stop-button'; +import { attachItem } from './components/attach-button'; const chatCommandRegistryPlugin: JupyterFrontEndPlugin = { id: PLUGIN_IDS.chatCommandRegistry, @@ -41,7 +38,6 @@ const chatCommandRegistryPlugin: JupyterFrontEndPlugin = { activate: () => { const registry = new ChatCommandRegistry(); registry.addProvider(new ChatHandler.ClearCommandProvider()); - registry.addProvider(new FileCommandProvider()); return registry; } }; @@ -115,6 +111,8 @@ const chatPlugin: JupyterFrontEndPlugin = { let chatWidget: ReactWidget | null = null; const inputToolbarRegistry = InputToolbarRegistry.defaultToolbarRegistry(); + const attachButton = attachItem(); + inputToolbarRegistry.addItem('_attach', attachButton); const stopButton = stopItem(() => chatHandler.stopStreaming()); inputToolbarRegistry.addItem('stop', stopButton); diff --git a/src/utils/show-file-browser.ts b/src/utils/show-file-browser.ts new file mode 100644 index 0000000..b3d79c9 --- /dev/null +++ b/src/utils/show-file-browser.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { IInputModel } from '@jupyter/chat'; +import { ContentsManager } from '@jupyterlab/services'; + +/** + * Opens a modal file browser and updates the inputModel with the selected file path. + */ +export async function showFileBrowserModal( + inputModel: IInputModel +): Promise { + const contents = new ContentsManager(); + + 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 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 { + const existingText = inputModel.value?.trim(); + inputModel.value = `${existingText} \`${item.path}\``.trim(); + li.style.backgroundColor = '#d2f8d2'; + document.body.removeChild(modal); + resolve(); + } + } catch (error) { + console.error(error); + document.body.removeChild(modal); + resolve(); + } + }; + } + } 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(); + }); +}