diff --git a/src/chat-handler.ts b/src/chat-handler.ts index 4d10fe5..ba2a9d3 100644 --- a/src/chat-handler.ts +++ b/src/chat-handler.ts @@ -39,12 +39,12 @@ 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. `; export type ConnectionMessage = { 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 06e870d..e43cdfe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ 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, @@ -110,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(); + }); +} diff --git a/style/base.css b/style/base.css index c4925ee..7e6a60f 100644 --- a/style/base.css +++ b/style/base.css @@ -43,3 +43,99 @@ 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; +}