diff --git a/docs/jupyter-chat-example/src/index.ts b/docs/jupyter-chat-example/src/index.ts index e1166322..963bdcd0 100644 --- a/docs/jupyter-chat-example/src/index.ts +++ b/docs/jupyter-chat-example/src/index.ts @@ -5,8 +5,10 @@ import { ActiveCellManager, + AttachmentOpenerRegistry, buildChatSidebar, ChatModel, + IAttachment, IChatMessage, INewMessage, SelectionWatcher @@ -16,6 +18,7 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { IThemeManager } from '@jupyterlab/apputils'; +import { IDefaultFileBrowser } from '@jupyterlab/filebrowser'; import { INotebookTracker } from '@jupyterlab/notebook'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; @@ -25,15 +28,16 @@ class MyChatModel extends ChatModel { sendMessage( newMessage: INewMessage ): Promise | boolean | void { - console.log(`New Message:\n${newMessage.body}`); const message: IChatMessage = { body: newMessage.body, id: newMessage.id ?? UUID.uuid4(), type: 'msg', time: Date.now() / 1000, - sender: { username: 'me' } + sender: { username: 'me' }, + attachments: this.inputAttachments }; this.messageAdded(message); + this.clearAttachments(); } } @@ -45,10 +49,16 @@ const plugin: JupyterFrontEndPlugin = { description: 'The chat panel widget.', autoStart: true, requires: [IRenderMimeRegistry], - optional: [INotebookTracker, ISettingRegistry, IThemeManager], + optional: [ + IDefaultFileBrowser, + INotebookTracker, + ISettingRegistry, + IThemeManager + ], activate: ( app: JupyterFrontEnd, rmRegistry: IRenderMimeRegistry, + filebrowser: IDefaultFileBrowser | null, notebookTracker: INotebookTracker | null, settingRegistry: ISettingRegistry | null, themeManager: IThemeManager | null @@ -102,7 +112,19 @@ const plugin: JupyterFrontEndPlugin = { }); } - const panel = buildChatSidebar({ model, rmRegistry, themeManager }); + // Create the attachment opener registry. + const attachmentOpenerRegistry = new AttachmentOpenerRegistry(); + attachmentOpenerRegistry.set('file', (attachment: IAttachment) => { + app.commands.execute('docmanager:open', { path: attachment.value }); + }); + + const panel = buildChatSidebar({ + model, + rmRegistry, + themeManager, + documentManager: filebrowser?.model.manager, + attachmentOpenerRegistry + }); app.shell.add(panel, 'left'); } }; diff --git a/docs/source/README.ipynb b/docs/source/README.ipynb index 68af6b6e..546f92c5 100644 --- a/docs/source/README.ipynb +++ b/docs/source/README.ipynb @@ -91,6 +91,25 @@ " " ] }, + { + "cell_type": "markdown", + "id": "9cd5f2a6-25d1-4b5c-b774-1527d724c829", + "metadata": {}, + "source": [ + "## Attachments" + ] + }, + { + "cell_type": "markdown", + "id": "93642e43-c4da-4260-84b8-d6fdab556e8a", + "metadata": {}, + "source": [ + "Files can be attached to the messages using the clip icon next to the send icon in the input.\n", + "It opens a Dialog allowing to select and atach files.\n", + "\n", + "Attachments can then be opened by clicking on the preview icon." + ] + }, { "cell_type": "markdown", "id": "40240824-8b1e-4da7-bfd7-40e87ff47383", diff --git a/docs/source/developers/developing_extensions/extension-providing-chat.md b/docs/source/developers/developing_extensions/extension-providing-chat.md index fc62d112..83a50654 100644 --- a/docs/source/developers/developing_extensions/extension-providing-chat.md +++ b/docs/source/developers/developing_extensions/extension-providing-chat.md @@ -79,7 +79,7 @@ The rendermime registry is required to display the messages using markdown synta This registry is provided by jupyterlab with a token, and must be required by the extension. -### A full example +### A minimal full extension The example below adds a new chat to the right panel. @@ -346,6 +346,8 @@ the [Material UI API](https://mui.com/material-ui/api/autocomplete/). Here is a simple example using a commands list (commands list copied from _jupyter-ai_): +{emphasize-lines="2,6,12,13,14,15,16,17,18,25,26,32"} + ```typescript import { AutocompletionRegistry, @@ -385,3 +387,79 @@ const myChatExtension: JupyterFrontEndPlugin = { } }; ``` + +(attachment-opener-registry)= + +### attachmentOpenerRegistry + +The `attachmentOpenerRegistry` provides a way to open attachments for a given type. +A simple example is to handle the attached files, by opening them using a command. + +```{tip} +To be able to attach files from the chat, you must provide a `IDocumentManager` that will +be used to select the files to attach. +By default the `IDefaultFileBrowser.model.manager` can be used. +``` + +The default registry is not much than a `Map void>`, allowing setting a +specific function for an attachment type. + +{emphasize-lines="2,5,9,23,26,34,38,40,41,42,43,49,50"} + +```typescript +import { + AttachmentOpenerRegistry, + ChatModel, + ChatWidget, + IAttachment, + IChatMessage, + INewMessage +} from '@jupyter/chat'; +import { IDefaultFileBrowser } from '@jupyterlab/filebrowser'; + +... + +class MyModel extends ChatModel { + sendMessage( + newMessage: INewMessage + ): Promise | boolean | void { + const message: IChatMessage = { + body: newMessage.body, + id: newMessage.id ?? UUID.uuid4(), + type: 'msg', + time: Date.now() / 1000, + sender: { username: 'me' }, + attachments: this.inputAttachments + }; + this.messageAdded(message); + this.clearAttachments(); + } +} + +const myChatExtension: JupyterFrontEndPlugin = { + id: 'myExtension:plugin', + autoStart: true, + requires: [IRenderMimeRegistry], + optional: [IDefaultFileBrowser], + activate: ( + app: JupyterFrontEnd, + rmRegistry: IRenderMimeRegistry, + filebrowser: IDefaultFileBrowser | null + ): void => { + const attachmentOpenerRegistry = new AttachmentOpenerRegistry(); + attachmentOpenerRegistry.set('file', (attachment: IAttachment) => { + app.commands.execute('docmanager:open', { path: attachment.value }); + }); + + const model = new MyModel(); + const widget = new ChatWidget({ + model, + rmRegistry, + documentManager: filebrowser?.model.manager, + attachmentOpenerRegistry + }); + + app.shell.add(widget, 'right'); + } +}; +``` diff --git a/docs/source/users/index.md b/docs/source/users/index.md index 31ede42d..252a7824 100644 --- a/docs/source/users/index.md +++ b/docs/source/users/index.md @@ -98,6 +98,13 @@ available: (chat-settings)= +### Attachments + +Files can be attached to the messages using the clip icon next to the send icon in the input. +It opens a Dialog allowing to select and atach files. + +Attachments can then be opened by clicking on the preview icon. + ## Chat settings Some jupyterlab settings are available for the chats in the setting panel diff --git a/packages/jupyter-chat/package.json b/packages/jupyter-chat/package.json index fc4ed30e..a5a5a291 100644 --- a/packages/jupyter-chat/package.json +++ b/packages/jupyter-chat/package.json @@ -48,6 +48,8 @@ "@jupyter/react-components": "^0.15.2", "@jupyterlab/application": "^4.2.0", "@jupyterlab/apputils": "^4.3.0", + "@jupyterlab/docmanager": "^4.2.0", + "@jupyterlab/filebrowser": "^4.2.0", "@jupyterlab/fileeditor": "^4.2.0", "@jupyterlab/notebook": "^4.2.0", "@jupyterlab/rendermime": "^4.2.0", diff --git a/packages/jupyter-chat/src/components/attachments.tsx b/packages/jupyter-chat/src/components/attachments.tsx new file mode 100644 index 00000000..27a17d29 --- /dev/null +++ b/packages/jupyter-chat/src/components/attachments.tsx @@ -0,0 +1,91 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +// import { IDocumentManager } from '@jupyterlab/docmanager'; +import CloseIcon from '@mui/icons-material/Close'; +import { Box } from '@mui/material'; +import React, { useContext } from 'react'; + +import { TooltippedButton } from './mui-extras/tooltipped-button'; +import { IAttachment } from '../types'; +import { AttachmentOpenerContext } from '../context'; + +const ATTACHMENTS_CLASS = 'jp-chat-attachments'; +const ATTACHMENT_CLASS = 'jp-chat-attachment'; +const ATTACHMENT_CLICKABLE_CLASS = 'jp-chat-attachment-clickable'; +const REMOVE_BUTTON_CLASS = 'jp-chat-attachment-remove'; + +/** + * The attachments props. + */ +export type AttachmentsProps = { + attachments: IAttachment[]; + onRemove?: (attachment: IAttachment) => void; +}; + +/** + * The Attachments component. + */ +export function AttachmentPreviewList(props: AttachmentsProps): JSX.Element { + return ( + + {props.attachments.map(attachment => ( + + ))} + + ); +} + +/** + * The attachment props. + */ +export type AttachmentProps = AttachmentsProps & { + attachment: IAttachment; +}; + +/** + * The Attachment component. + */ +export function AttachmentPreview(props: AttachmentProps): JSX.Element { + const remove_tooltip = 'Remove attachment'; + const attachmentOpenerRegistry = useContext(AttachmentOpenerContext); + + return ( + + + attachmentOpenerRegistry?.get(props.attachment.type)?.( + props.attachment + ) + } + > + {props.attachment.value} + + {props.onRemove && ( + props.onRemove!(props.attachment)} + tooltip={remove_tooltip} + buttonProps={{ + size: 'small', + title: remove_tooltip, + className: REMOVE_BUTTON_CLASS + }} + sx={{ + minWidth: 'unset', + padding: '0', + color: 'inherit' + }} + > + + + )} + + ); +} diff --git a/packages/jupyter-chat/src/components/chat-input.tsx b/packages/jupyter-chat/src/components/chat-input.tsx index 4a43332c..85e1d410 100644 --- a/packages/jupyter-chat/src/components/chat-input.tsx +++ b/packages/jupyter-chat/src/components/chat-input.tsx @@ -3,8 +3,7 @@ * Distributed under the terms of the Modified BSD License. */ -import React, { useEffect, useRef, useState } from 'react'; - +import { IDocumentManager } from '@jupyterlab/docmanager'; import { Autocomplete, Box, @@ -14,19 +13,20 @@ import { Theme } from '@mui/material'; import clsx from 'clsx'; +import React, { useEffect, useRef, useState } from 'react'; -import { CancelButton } from './input/cancel-button'; -import { SendButton } from './input/send-button'; +import { AttachmentPreviewList } from './attachments'; +import { AttachButton, CancelButton, SendButton } from './input'; import { IChatModel } from '../model'; import { IAutocompletionRegistry } from '../registry'; -import { IConfig, Selection } from '../types'; +import { IAttachment, IConfig, Selection } from '../types'; import { useChatCommands } from './input/use-chat-commands'; import { IChatCommandRegistry } from '../chat-commands'; const INPUT_BOX_CLASS = 'jp-chat-input-container'; export function ChatInput(props: ChatInput.IProps): JSX.Element { - const { model } = props; + const { documentManager, model } = props; const [input, setInput] = useState(props.value || ''); const inputRef = useRef(); @@ -43,6 +43,7 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element { const [typingNotification, setTypingNotification] = useState( model.config.sendTypingNotification ?? false ); + const [attachments, setAttachments] = useState([]); // Display the include selection menu if it is not explicitly hidden, and if at least // one of the tool to check for text or cell selection is enabled. @@ -65,9 +66,15 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element { }; model.focusInputSignal?.connect(focusInputElement); + const attachmentChanged = (_: IChatModel, attachments: IAttachment[]) => { + setAttachments([...attachments]); + }; + model.inputAttachmentsChanged?.connect(attachmentChanged); + return () => { model.configChanged?.disconnect(configChanged); model.focusInputSignal?.disconnect(focusInputElement); + model.inputAttachmentsChanged?.disconnect(attachmentChanged); }; }, [model]); @@ -177,6 +184,10 @@ ${selection.source} return ( + + {documentManager && model.addAttachment && ( + + )} {props.onCancel && } 0} onSend={onSend} hideIncludeSelection={hideIncludeSelection} hasButtonOnLeft={!!props.onCancel} @@ -272,6 +290,10 @@ export namespace ChatInput { * Custom mui/material styles. */ sx?: SxProps; + /** + * The document manager. + */ + documentManager?: IDocumentManager; /** * Autocompletion properties. */ diff --git a/packages/jupyter-chat/src/components/chat-messages.tsx b/packages/jupyter-chat/src/components/chat-messages.tsx index 109581cb..5c4a4449 100644 --- a/packages/jupyter-chat/src/components/chat-messages.tsx +++ b/packages/jupyter-chat/src/components/chat-messages.tsx @@ -21,6 +21,7 @@ import { MarkdownRenderer } from './markdown-renderer'; import { ScrollContainer } from './scroll-container'; import { IChatModel } from '../model'; import { IChatMessage, IUser } from '../types'; +import { AttachmentPreviewList } from './attachments'; const MESSAGES_BOX_CLASS = 'jp-chat-messages-container'; const MESSAGE_CLASS = 'jp-chat-message'; @@ -400,6 +401,9 @@ export const ChatMessage = forwardRef( rendered={props.renderedPromise} /> )} + {message.attachments && ( + + )} ); } diff --git a/packages/jupyter-chat/src/components/chat.tsx b/packages/jupyter-chat/src/components/chat.tsx index 256c552c..d9c5eda0 100644 --- a/packages/jupyter-chat/src/components/chat.tsx +++ b/packages/jupyter-chat/src/components/chat.tsx @@ -4,6 +4,7 @@ */ import { IThemeManager } from '@jupyterlab/apputils'; +import { IDocumentManager } from '@jupyterlab/docmanager'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import SettingsIcon from '@mui/icons-material/Settings'; @@ -12,42 +13,41 @@ import { Box } from '@mui/system'; import React, { useState } from 'react'; import { JlThemeProvider } from './jl-theme-provider'; +import { IChatCommandRegistry } from '../chat-commands'; import { ChatMessages } from './chat-messages'; import { ChatInput } from './chat-input'; +import { AttachmentOpenerContext } from '../context'; import { IChatModel } from '../model'; -import { IAutocompletionRegistry } from '../registry'; -import { IChatCommandRegistry } from '../chat-commands'; +import { + IAttachmentOpenerRegistry, + IAutocompletionRegistry +} from '../registry'; export function ChatBody(props: Chat.IChatBodyProps): JSX.Element { - const { - model, - rmRegistry: renderMimeRegistry, - autocompletionRegistry - } = props; - // no need to append to messageGroups imperatively here. all of that is - // handled by the listeners registered in the effect hooks above. + const { model } = props; const onSend = async (input: string) => { // send message to backend model.sendMessage({ body: input }); }; return ( - <> - + + - + ); } @@ -92,8 +92,10 @@ export function Chat(props: Chat.IOptions): JSX.Element { )} {view === Chat.View.settings && props.settingsPanel && ( @@ -120,6 +122,10 @@ export namespace Chat { * The rendermime registry. */ rmRegistry: IRenderMimeRegistry; + /** + * The document manager. + */ + documentManager?: IDocumentManager; /** * Autocompletion registry. */ @@ -132,6 +138,10 @@ export namespace Chat { * Chat command registry. */ chatCommandRegistry?: IChatCommandRegistry; + /** + * Attachment opener registry. + */ + attachmentOpenerRegistry?: IAttachmentOpenerRegistry; } /** diff --git a/packages/jupyter-chat/src/components/input/attach-button.tsx b/packages/jupyter-chat/src/components/input/attach-button.tsx new file mode 100644 index 00000000..b7769c94 --- /dev/null +++ b/packages/jupyter-chat/src/components/input/attach-button.tsx @@ -0,0 +1,68 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { IDocumentManager } from '@jupyterlab/docmanager'; +import { FileDialog } from '@jupyterlab/filebrowser'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import React from 'react'; + +import { TooltippedButton } from '../mui-extras/tooltipped-button'; +import { IAttachment } from '../../types'; + +const ATTACH_BUTTON_CLASS = 'jp-chat-attach-button'; + +/** + * The attach button props. + */ +export type AttachButtonProps = { + documentManager: IDocumentManager; + onAttach: (attachment: IAttachment) => void; +}; + +/** + * The attach button. + */ +export function AttachButton(props: AttachButtonProps): JSX.Element { + const tooltip = 'Add attachment'; + + const onclick = async () => { + try { + const files = await FileDialog.getOpenFiles({ + title: 'Select files to attach', + manager: props.documentManager + }); + if (files.value) { + files.value.forEach(file => { + if (file.type !== 'directory') { + props.onAttach({ type: 'file', value: file.path }); + } + }); + } + } catch (e) { + console.warn('Error selecting files to attach', e); + } + }; + + return ( + + + + ); +} diff --git a/packages/jupyter-chat/src/components/input/cancel-button.tsx b/packages/jupyter-chat/src/components/input/cancel-button.tsx index 22fd806d..d5a6419a 100644 --- a/packages/jupyter-chat/src/components/input/cancel-button.tsx +++ b/packages/jupyter-chat/src/components/input/cancel-button.tsx @@ -5,6 +5,7 @@ import CancelIcon from '@mui/icons-material/Cancel'; import React from 'react'; + import { TooltippedButton } from '../mui-extras/tooltipped-button'; const CANCEL_BUTTON_CLASS = 'jp-chat-cancel-button'; diff --git a/packages/jupyter-chat/src/components/input/index.ts b/packages/jupyter-chat/src/components/input/index.ts index 3d58f6d3..98578736 100644 --- a/packages/jupyter-chat/src/components/input/index.ts +++ b/packages/jupyter-chat/src/components/input/index.ts @@ -3,5 +3,6 @@ * Distributed under the terms of the Modified BSD License. */ +export * from './attach-button'; export * from './cancel-button'; export * from './send-button'; diff --git a/packages/jupyter-chat/src/context.ts b/packages/jupyter-chat/src/context.ts new file mode 100644 index 00000000..81925e85 --- /dev/null +++ b/packages/jupyter-chat/src/context.ts @@ -0,0 +1,10 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ +import { createContext } from 'react'; +import { IAttachmentOpenerRegistry } from './registry'; + +export const AttachmentOpenerContext = createContext< + IAttachmentOpenerRegistry | undefined +>(undefined); diff --git a/packages/jupyter-chat/src/model.ts b/packages/jupyter-chat/src/model.ts index d97aff14..14256165 100644 --- a/packages/jupyter-chat/src/model.ts +++ b/packages/jupyter-chat/src/model.ts @@ -12,7 +12,8 @@ import { INewMessage, IChatMessage, IConfig, - IUser + IUser, + IAttachment } from './types'; import { IActiveCellManager } from './active-cell-manager'; import { ISelectionWatcher } from './selection-watcher'; @@ -91,6 +92,11 @@ export interface IChatModel extends IDisposable { */ readonly focusInputSignal?: ISignal; + /** + * A signal emitting when the input attachments changed. + */ + readonly inputAttachmentsChanged?: ISignal; + /** * Send a message, to be defined depending on the chosen technology. * Default to no-op. @@ -166,6 +172,21 @@ export interface IChatModel extends IDisposable { */ focusInput(): void; + /** + * Add attachment to the next message to send. + */ + addAttachment?(attachment: IAttachment): void; + + /** + * Remove attachment to the next message to send. + */ + removeAttachment?(attachment: IAttachment): void; + + /** + * Clear the attachment list. + */ + clearAttachments?(): void; + /** * Function called by the input on key pressed. */ @@ -393,6 +414,13 @@ export class ChatModel implements IChatModel { return this._focusInputSignal; } + /** + * A signal emitting when the input attachments changed. + */ + get inputAttachmentsChanged(): ISignal { + return this._inputAttachmentsChanged; + } + /** * Send a message, to be defined depending on the chosen technology. * Default to no-op. @@ -520,6 +548,44 @@ export class ChatModel implements IChatModel { */ inputChanged?(input?: string): void {} + /** + * Add attachment to send with next message. + */ + addAttachment = (attachment: IAttachment): void => { + const duplicateAttachment = this.inputAttachments.find( + att => att.type === attachment.type && att.value === attachment.value + ); + if (duplicateAttachment) { + return; + } + + this.inputAttachments.push(attachment); + this._inputAttachmentsChanged.emit([...this.inputAttachments]); + }; + + /** + * Remove attachment to be sent. + */ + removeAttachment = (attachment: IAttachment): void => { + const attachmentIndex = this.inputAttachments.findIndex( + att => att.type === attachment.type && att.value === attachment.value + ); + if (attachmentIndex === -1) { + return; + } + + this.inputAttachments.splice(attachmentIndex, 1); + this._inputAttachmentsChanged.emit([...this.inputAttachments]); + }; + + /** + * Update attachments. + */ + clearAttachments = (): void => { + this.inputAttachments = []; + this._inputAttachmentsChanged.emit([]); + }; + /** * Add unread messages to the list. * @param indexes - list of new indexes. @@ -569,6 +635,7 @@ export class ChatModel implements IChatModel { } } + protected inputAttachments: IAttachment[] = []; private _messages: IChatMessage[] = []; private _unreadMessages: number[] = []; private _messagesInViewport: number[] = []; @@ -586,6 +653,7 @@ export class ChatModel implements IChatModel { private _viewportChanged = new Signal(this); private _writersChanged = new Signal(this); private _focusInputSignal = new Signal(this); + private _inputAttachmentsChanged = new Signal(this); } /** diff --git a/packages/jupyter-chat/src/registry.ts b/packages/jupyter-chat/src/registry.ts index 344b456e..a23b2fc7 100644 --- a/packages/jupyter-chat/src/registry.ts +++ b/packages/jupyter-chat/src/registry.ts @@ -3,7 +3,7 @@ * Distributed under the terms of the Modified BSD License. */ import { Token } from '@lumino/coreutils'; -import { IAutocompletionCommandsProps } from './types'; +import { IAttachment, IAutocompletionCommandsProps } from './types'; /** * The token for the autocomplete registry, which can be provided by an extension @@ -127,3 +127,32 @@ export class AutocompletionRegistry implements IAutocompletionRegistry { private _default: string | null = null; private _autocompletions = new Map(); } + +/** + * The token for the attachments opener registry, which can be provided by an extension + * using @jupyter/chat package. + */ +export const IAttachmentOpenerRegistry = new Token( + '@jupyter/chat:IAttachmentOpenerRegistry' +); + +/** + * The interface of a registry to provide attachments opener. + */ +export interface IAttachmentOpenerRegistry { + /** + * Get the function opening an attachment for a given type. + */ + get(type: string): ((attachment: IAttachment) => void) | undefined; + /** + * Register a function to open an attachment type. + */ + set(type: string, opener: (attachment: IAttachment) => void): void; +} + +/** + * The default registry, a Map object. + */ +export class AttachmentOpenerRegistry + extends Map void> + implements IAttachmentOpenerRegistry {} diff --git a/packages/jupyter-chat/src/types.ts b/packages/jupyter-chat/src/types.ts index 86774356..165a8688 100644 --- a/packages/jupyter-chat/src/types.ts +++ b/packages/jupyter-chat/src/types.ts @@ -44,12 +44,13 @@ export interface IConfig { /** * The chat message description. */ -export interface IChatMessage { +export interface IChatMessage { type: 'msg'; body: string; id: string; time: number; sender: T; + attachments?: U[]; raw_time?: boolean; deleted?: boolean; edited?: boolean; @@ -71,6 +72,24 @@ export interface INewMessage { id?: string; } +/** + * The attachment interface. + */ +export interface IAttachment { + /** + * The type of the attachment (basically 'file', 'variable', 'image') + */ + type: string; + /** + * The value, i.e. the file path, the variable name or image content. + */ + value: string; + /** + * The mimetype of the attachment, optional. + */ + mimetype?: string; +} + /** * An empty interface to describe optional settings that could be fetched from server. */ diff --git a/packages/jupyter-chat/style/chat.css b/packages/jupyter-chat/style/chat.css index 633be0da..cb65195c 100644 --- a/packages/jupyter-chat/style/chat.css +++ b/packages/jupyter-chat/style/chat.css @@ -112,3 +112,21 @@ .jp-chat-navigation-bottom { bottom: 100px; } + +.jp-chat-attachments { + display: flex; + min-height: 1.5em; +} + +.jp-chat-attachment { + border: solid 1px; + border-radius: 10px; + margin: 0 0.2em; + padding: 0 0.3em; + align-content: center; + background-color: var(--jp-border-color3); +} + +.jp-chat-attachment .jp-chat-attachment-clickable:hover { + cursor: pointer; +} diff --git a/packages/jupyterlab-chat/package.json b/packages/jupyterlab-chat/package.json index 296beeae..a82d9b7a 100644 --- a/packages/jupyterlab-chat/package.json +++ b/packages/jupyterlab-chat/package.json @@ -48,6 +48,7 @@ "@jupyterlab/application": "^4.2.0", "@jupyterlab/apputils": "^4.3.0", "@jupyterlab/coreutils": "^6.2.0", + "@jupyterlab/docmanager": "^4.2.0", "@jupyterlab/docregistry": "^4.2.0", "@jupyterlab/launcher": "^4.2.0", "@jupyterlab/notebook": "^4.2.0", diff --git a/packages/jupyterlab-chat/src/factory.ts b/packages/jupyterlab-chat/src/factory.ts index 19289ff3..c5063861 100644 --- a/packages/jupyterlab-chat/src/factory.ts +++ b/packages/jupyterlab-chat/src/factory.ts @@ -6,11 +6,13 @@ import { ChatWidget, IActiveCellManager, + IAttachmentOpenerRegistry, IAutocompletionRegistry, IChatCommandRegistry, ISelectionWatcher } from '@jupyter/chat'; import { IThemeManager } from '@jupyterlab/apputils'; +import { IDocumentManager } from '@jupyterlab/docmanager'; import { ABCWidgetFactory, DocumentRegistry } from '@jupyterlab/docregistry'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { Contents, User } from '@jupyterlab/services'; @@ -75,8 +77,10 @@ export class ChatWidgetFactory extends ABCWidgetFactory< super(options); this._themeManager = options.themeManager; this._rmRegistry = options.rmRegistry; + this._documentManager = options.documentManager; this._autocompletionRegistry = options.autocompletionRegistry; this._chatCommandRegistry = options.chatCommandRegistry; + this._attachmentOpenerRegistry = options.attachmentOpenerRegistry; } /** @@ -88,8 +92,10 @@ export class ChatWidgetFactory extends ABCWidgetFactory< protected createNewWidget(context: ChatWidgetFactory.IContext): LabChatPanel { context.rmRegistry = this._rmRegistry; context.themeManager = this._themeManager; + context.documentManager = this._documentManager; context.autocompletionRegistry = this._autocompletionRegistry; context.chatCommandRegistry = this._chatCommandRegistry; + context.attachmentOpenerRegistry = this._attachmentOpenerRegistry; return new LabChatPanel({ context, content: new ChatWidget(context) @@ -98,24 +104,30 @@ export class ChatWidgetFactory extends ABCWidgetFactory< private _themeManager: IThemeManager | null; private _rmRegistry: IRenderMimeRegistry; + private _documentManager?: IDocumentManager; private _autocompletionRegistry?: IAutocompletionRegistry; private _chatCommandRegistry?: IChatCommandRegistry; + private _attachmentOpenerRegistry?: IAttachmentOpenerRegistry; } export namespace ChatWidgetFactory { export interface IContext extends DocumentRegistry.IContext { themeManager: IThemeManager | null; rmRegistry: IRenderMimeRegistry; + documentManager?: IDocumentManager; autocompletionRegistry?: IAutocompletionRegistry; chatCommandRegistry?: IChatCommandRegistry; + attachmentOpenerRegistry?: IAttachmentOpenerRegistry; } export interface IOptions extends DocumentRegistry.IWidgetFactoryOptions { themeManager: IThemeManager | null; rmRegistry: IRenderMimeRegistry; + documentManager?: IDocumentManager; autocompletionRegistry?: IAutocompletionRegistry; chatCommandRegistry?: IChatCommandRegistry; + attachmentOpenerRegistry?: IAttachmentOpenerRegistry; } } diff --git a/packages/jupyterlab-chat/src/model.ts b/packages/jupyterlab-chat/src/model.ts index 4b25b412..5d0b555c 100644 --- a/packages/jupyterlab-chat/src/model.ts +++ b/packages/jupyterlab-chat/src/model.ts @@ -3,7 +3,13 @@ * Distributed under the terms of the Modified BSD License. */ -import { ChatModel, IChatMessage, INewMessage, IUser } from '@jupyter/chat'; +import { + ChatModel, + IAttachment, + IChatMessage, + INewMessage, + IUser +} from '@jupyter/chat'; import { IChangedArgs } from '@jupyterlab/coreutils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { User } from '@jupyterlab/services'; @@ -137,6 +143,7 @@ export class LabChatModel extends ChatModel implements DocumentRegistry.IModel { if (this._timeoutWriting !== null) { window.clearTimeout(this._timeoutWriting); } + const msg: IYmessage = { type: 'msg', id: UUID.uuid4(), @@ -150,6 +157,16 @@ export class LabChatModel extends ChatModel implements DocumentRegistry.IModel { if (!(this.sharedModel.getUser(this._user.username) === this._user)) { this.sharedModel.setUser(this._user); } + + // Add the attachments to the message. + if (this.inputAttachments.length) { + const attachmentIds = this.inputAttachments.map(attachment => + this.sharedModel.setAttachment(attachment) + ); + msg.attachments = attachmentIds; + this.clearAttachments(); + } + this.sharedModel.addMessage(msg); } @@ -242,13 +259,34 @@ export class LabChatModel extends ChatModel implements DocumentRegistry.IModel { index += delta.retain; } else if (delta.insert) { const messages = delta.insert.map(ymessage => { + const { + sender, + attachments: attachmentIds, + ...baseMessage + } = ymessage; + + // Build the base message with sender. const msg: IChatMessage = { - ...ymessage, - sender: this.sharedModel.getUser(ymessage.sender) || { + ...baseMessage, + sender: this.sharedModel.getUser(sender) || { username: 'User undefined' } }; + // Add attachments. + if (attachmentIds) { + const attachments: IAttachment[] = []; + attachmentIds.forEach(attachmentId => { + const attachment = this.sharedModel.getAttachment(attachmentId); + if (attachment) { + attachments.push(attachment); + } + }); + if (attachments.length) { + msg.attachments = attachments; + } + } + return msg; }); this.messagesInserted(index, messages); diff --git a/packages/jupyterlab-chat/src/widget.tsx b/packages/jupyterlab-chat/src/widget.tsx index 3c61c6dd..cc4d4f5b 100644 --- a/packages/jupyterlab-chat/src/widget.tsx +++ b/packages/jupyterlab-chat/src/widget.tsx @@ -5,6 +5,7 @@ import { ChatWidget, + IAttachmentOpenerRegistry, IAutocompletionRegistry, IChatCommandRegistry, IChatModel, @@ -34,6 +35,7 @@ import React, { useState } from 'react'; import { LabChatModel } from './model'; import { CommandIDs, chatFileType } from './token'; +import { IDocumentManager } from '@jupyterlab/docmanager'; const MAIN_PANEL_CLASS = 'jp-lab-chat-main-panel'; const TITLE_UNREAD_CLASS = 'jp-lab-chat-title-unread'; @@ -103,8 +105,10 @@ export class ChatPanel extends SidePanel { this._rmRegistry = options.rmRegistry; this._themeManager = options.themeManager; this._defaultDirectory = options.defaultDirectory; + this._documentManager = options.documentManager; this._autocompletionRegistry = options.autocompletionRegistry; this._chatCommandRegistry = options.chatCommandRegistry; + this._attachmentOpenerRegistry = options.attachmentOpenerRegistry; const addChat = new CommandToolbarButton({ commands: this._commands, @@ -166,8 +170,10 @@ export class ChatPanel extends SidePanel { model: model, rmRegistry: this._rmRegistry, themeManager: this._themeManager, + documentManager: this._documentManager, autocompletionRegistry: this._autocompletionRegistry, - chatCommandRegistry: this._chatCommandRegistry + chatCommandRegistry: this._chatCommandRegistry, + attachmentOpenerRegistry: this._attachmentOpenerRegistry }); this.addWidget( @@ -286,8 +292,10 @@ export class ChatPanel extends SidePanel { private _openChat: ReactWidget; private _rmRegistry: IRenderMimeRegistry; private _themeManager: IThemeManager | null; + private _documentManager?: IDocumentManager; private _autocompletionRegistry?: IAutocompletionRegistry; private _chatCommandRegistry?: IChatCommandRegistry; + private _attachmentOpenerRegistry?: IAttachmentOpenerRegistry; } /** @@ -303,8 +311,10 @@ export namespace ChatPanel { rmRegistry: IRenderMimeRegistry; themeManager: IThemeManager | null; defaultDirectory: string; + documentManager?: IDocumentManager; autocompletionRegistry?: IAutocompletionRegistry; chatCommandRegistry?: IChatCommandRegistry; + attachmentOpenerRegistry?: IAttachmentOpenerRegistry; } } diff --git a/packages/jupyterlab-chat/src/ychat.ts b/packages/jupyterlab-chat/src/ychat.ts index 22aa68eb..b295e624 100644 --- a/packages/jupyterlab-chat/src/ychat.ts +++ b/packages/jupyterlab-chat/src/ychat.ts @@ -3,15 +3,15 @@ * Distributed under the terms of the Modified BSD License. */ -import { IChatMessage, IUser } from '@jupyter/chat'; +import { IAttachment, IChatMessage, IUser } from '@jupyter/chat'; import { Delta, DocumentChange, IMapChange, YDocument } from '@jupyter/ydoc'; -import { JSONExt, JSONObject, PartialJSONValue } from '@lumino/coreutils'; +import { JSONExt, JSONObject, PartialJSONValue, UUID } from '@lumino/coreutils'; import * as Y from 'yjs'; /** * The type for a YMessage. */ -export type IYmessage = IChatMessage; +export type IYmessage = IChatMessage; /** * The type for a YMessage. @@ -30,6 +30,10 @@ export interface IChatChanges extends DocumentChange { * Changes in users. */ userChanges?: UserChange[]; + /** + * Changes in attachments. + */ + attachmentChanges?: AttachmentChange[]; /** * Changes in metadata. */ @@ -46,6 +50,11 @@ export type MessageChange = Delta; */ export type UserChange = IMapChange; +/** + * The attachment change type. + */ +export type AttachmentChange = IMapChange; + /** * The metadata change type. */ @@ -66,6 +75,9 @@ export class YChat extends YDocument { this._messages = this.ydoc.getArray('messages'); this._messages.observe(this._messagesObserver); + this._attachments = this.ydoc.getMap('attachments'); + this._attachments.observe(this._attachmentsObserver); + this._metadata = this.ydoc.getMap('metadata'); this._metadata.observe(this._metadataObserver); } @@ -96,6 +108,10 @@ export class YChat extends YDocument { return JSONExt.deepCopy(this._messages.toJSON()); } + get attachments(): JSONObject { + return JSONExt.deepCopy(this._attachments.toJSON()); + } + getSource(): JSONObject { const users = this._users.toJSON(); const messages = this._messages.toJSON(); @@ -168,28 +184,46 @@ export class YChat extends YDocument { }); } + getAttachment(id: string): IAttachment | undefined { + return this._attachments.get(id); + } + + setAttachment(attachment: IAttachment): string { + // Search if the attachment already exist to update it, otherwise add it. + const id = + Array.from(this._attachments.entries()).find( + ([_, att]) => + att.type === attachment.type && att.value === attachment.value + )?.[0] || UUID.uuid4(); + + this.transact(() => { + this._attachments.set(id, attachment); + }); + return id; + } + private _usersObserver = (event: Y.YMapEvent): void => { - const userChange = new Array(); + const userChanges = new Array(); event.keysChanged.forEach(key => { const change = event.changes.keys.get(key); if (change) { switch (change.action) { case 'add': - userChange.push({ + userChanges.push({ key, newValue: this._users.get(key), type: 'add' }); break; case 'delete': - userChange.push({ + userChanges.push({ key, oldValue: change.oldValue, type: 'remove' }); break; case 'update': - userChange.push({ + userChanges.push({ key: key, oldValue: change.oldValue, newValue: this._users.get(key), @@ -200,7 +234,7 @@ export class YChat extends YDocument { } }); - this._changed.emit({ userChange: userChange } as Partial); + this._changed.emit({ userChanges } as Partial); }; private _messagesObserver = (event: Y.YArrayEvent): void => { @@ -210,26 +244,61 @@ export class YChat extends YDocument { } as Partial); }; + private _attachmentsObserver = (event: Y.YMapEvent): void => { + const attachmentChanges = new Array(); + event.keysChanged.forEach(key => { + const change = event.changes.keys.get(key); + if (change) { + switch (change.action) { + case 'add': + attachmentChanges.push({ + key, + newValue: this._attachments.get(key), + type: 'add' + }); + break; + case 'delete': + attachmentChanges.push({ + key, + oldValue: change.oldValue, + type: 'remove' + }); + break; + case 'update': + attachmentChanges.push({ + key: key, + oldValue: change.oldValue, + newValue: this._attachments.get(key), + type: 'change' + }); + break; + } + } + }); + + this._changed.emit({ attachmentChanges } as Partial); + }; + private _metadataObserver = (event: Y.YMapEvent): void => { - const metadataChange = new Array(); + const metadataChanges = new Array(); event.changes.keys.forEach((change, key) => { switch (change.action) { case 'add': - metadataChange.push({ + metadataChanges.push({ key, newValue: this._metadata.get(key), type: 'add' }); break; case 'delete': - metadataChange.push({ + metadataChanges.push({ key, oldValue: change.oldValue, type: 'remove' }); break; case 'update': - metadataChange.push({ + metadataChanges.push({ key: key, oldValue: change.oldValue, newValue: this._metadata.get(key), @@ -239,12 +308,11 @@ export class YChat extends YDocument { } }); - this._changed.emit({ - metadataChanges: metadataChange - } as Partial); + this._changed.emit({ metadataChanges } as Partial); }; private _users: Y.Map; private _messages: Y.Array; + private _attachments: Y.Map; private _metadata: Y.Map; } diff --git a/python/jupyterlab-chat/jupyterlab_chat/models.py b/python/jupyterlab-chat/jupyterlab_chat/models.py index 6cb71972..43a5927d 100644 --- a/python/jupyterlab-chat/jupyterlab_chat/models.py +++ b/python/jupyterlab-chat/jupyterlab_chat/models.py @@ -68,3 +68,20 @@ class NewMessage: @dataclass class User(JupyterUser): """ Object representing a user (same as Jupyter User ) """ + + +@dataclass +class Attachment: + """ Object representing an attachment """ + + type: str + """ The type of attachment (i.e. "file", "variable", "image") """ + + value: str + """ The value (i.e. a path, a variable name, an image content) """ + + mimetype: Optional[str] = None + """ + The mime type of the attachment + Default to None. + """ diff --git a/python/jupyterlab-chat/jupyterlab_chat/ychat.py b/python/jupyterlab-chat/jupyterlab_chat/ychat.py index d80679d3..99f66f6f 100644 --- a/python/jupyterlab-chat/jupyterlab_chat/ychat.py +++ b/python/jupyterlab-chat/jupyterlab_chat/ychat.py @@ -13,7 +13,7 @@ from uuid import uuid4 from pycrdt import Array, ArrayEvent, Map, MapEvent -from .models import message_asdict_factory, Message, NewMessage, User +from .models import message_asdict_factory, Attachment, Message, NewMessage, User class YChat(YBaseDoc): @@ -23,6 +23,7 @@ def __init__(self, *args, **kwargs): self.dirty = True self._ydoc["users"] = self._yusers = Map() # type:ignore[var-annotated] self._ydoc["messages"] = self._ymessages = Array() # type:ignore[var-annotated] + self._ydoc["attachments"] = self._yattachments = Map() # type:ignore[var-annotated] self._ydoc["metadata"] = self._ymetadata = Map() # type:ignore[var-annotated] self._ymessages.observe(self._on_messages_change) @@ -54,6 +55,10 @@ def ymessages(self) -> Array: def yusers(self) -> Map: return self._yusers + @property + def yattachments(self) -> Map: + return self._yattachments + @property def ymetadata(self) -> Map: return self._ymetadata @@ -152,6 +157,25 @@ def update_message(self, message: Message, append: bool = False): message.body = initial_message["body"] + message.body # type:ignore[index] self._ymessages[index] = asdict(message, dict_factory=message_asdict_factory) + def get_attachments(self) -> dict[str, Attachment]: + """ + Returns the attachments of the document. + """ + return self._yattachments.to_py() or {} + + def set_attachment(self, attachment: Attachment): + """ + Add or modify an attachments of the document. + """ + attachment_id = str(uuid4()) + with self._ydoc.transaction(): + # Search if the attachment already exist to update it, otherwise add it. + for id, att in self.get_attachments().items(): + if att.type == attachment.type and att.value == attachment.value: + attachment_id = id + break + self._yattachments.update({attachment_id: asdict(attachment)}) + def get_metadata(self) -> dict[str, Any]: """ Returns the metadata of the document. @@ -195,6 +219,7 @@ def get(self) -> str: { "messages": self._get_messages(), "users": self._get_users(), + "attachments": self.get_attachments(), "metadata": self.get_metadata() }, indent=2 @@ -215,6 +240,7 @@ def set(self, value: str) -> None: with self._ydoc.transaction(): self._yusers.clear() self._ymessages.clear() + self._yattachments.clear() self._ymetadata.clear() for key in [k for k in self._ystate.keys() if k not in ("dirty", "path")]: del self._ystate[key] @@ -223,6 +249,10 @@ def set(self, value: str) -> None: for k, v in contents["users"].items(): self._yusers.update({k: v}) + if "attachments" in contents.keys(): + for k, v in contents["attachments"].items(): + self._yattachments.update({k: v}) + if "messages" in contents.keys(): self._ymessages.extend(contents["messages"]) @@ -240,6 +270,9 @@ def observe(self, callback: Callable[[str, Any], None]) -> None: partial(callback, "messages") ) self._subscriptions[self._yusers] = self._yusers.observe(partial(callback, "users")) + self._subscriptions[self._yattachments] = self._yattachments.observe( + partial(callback, "attachments") + ) def _initialize(self, event: MapEvent) -> None: """ diff --git a/python/jupyterlab-chat/package.json b/python/jupyterlab-chat/package.json index 4a663c69..1ed8f612 100644 --- a/python/jupyterlab-chat/package.json +++ b/python/jupyterlab-chat/package.json @@ -55,6 +55,7 @@ "@jupyterlab/apputils": "^4.3.0", "@jupyterlab/coreutils": "^6.2.0", "@jupyterlab/docregistry": "^4.2.0", + "@jupyterlab/filebrowser": "^4.2.0", "@jupyterlab/launcher": "^4.2.0", "@jupyterlab/notebook": "^4.2.0", "@jupyterlab/rendermime": "^4.2.0", diff --git a/python/jupyterlab-chat/src/index.ts b/python/jupyterlab-chat/src/index.ts index 0cb85155..e81faaca 100644 --- a/python/jupyterlab-chat/src/index.ts +++ b/python/jupyterlab-chat/src/index.ts @@ -6,9 +6,12 @@ import { NotebookShell } from '@jupyter-notebook/application'; import { ActiveCellManager, + AttachmentOpenerRegistry, AutocompletionRegistry, ChatWidget, IActiveCellManager, + IAttachment, + IAttachmentOpenerRegistry, IAutocompletionRegistry, IChatCommandRegistry, ISelectionWatcher, @@ -36,6 +39,7 @@ import { } from '@jupyterlab/apputils'; import { PathExt } from '@jupyterlab/coreutils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { IDefaultFileBrowser } from '@jupyterlab/filebrowser'; import { ILauncher } from '@jupyterlab/launcher'; import { INotebookTracker } from '@jupyterlab/notebook'; import { IObservableList } from '@jupyterlab/observables'; @@ -67,6 +71,7 @@ const FACTORY = 'Chat'; const pluginIds = { activeCellManager: 'jupyterlab-chat-extension:activeCellManager', + attachmentOpenerRegistry: 'jupyterlab-chat-extension:attachmentOpener', autocompletionRegistry: 'jupyterlab-chat-extension:autocompletionRegistry', chatCommands: 'jupyterlab-chat-extension:commands', chatPanel: 'jupyterlab-chat-extension:chat-panel', @@ -87,6 +92,25 @@ const autocompletionPlugin: JupyterFrontEndPlugin = { } }; +/** + * Extension providing the attachment opener registry. + */ +const attachmentOpeners: JupyterFrontEndPlugin = { + id: pluginIds.attachmentOpenerRegistry, + description: 'The attachment opener registry.', + autoStart: true, + provides: IAttachmentOpenerRegistry, + activate: (app: JupyterFrontEnd): IAttachmentOpenerRegistry => { + const attachmentOpenerRegistry = new AttachmentOpenerRegistry(); + + attachmentOpenerRegistry.set('file', (attachment: IAttachment) => { + app.commands.execute('docmanager:open', { path: attachment.value }); + }); + + return attachmentOpenerRegistry; + } +}; + /** * Extension registering the chat file type. */ @@ -97,9 +121,11 @@ const docFactories: JupyterFrontEndPlugin = { requires: [IRenderMimeRegistry], optional: [ IActiveCellManagerToken, + IAttachmentOpenerRegistry, IAutocompletionRegistry, IChatCommandRegistry, ICollaborativeDrive, + IDefaultFileBrowser, ILayoutRestorer, ISelectionWatcherToken, ISettingRegistry, @@ -112,9 +138,11 @@ const docFactories: JupyterFrontEndPlugin = { app: JupyterFrontEnd, rmRegistry: IRenderMimeRegistry, activeCellManager: IActiveCellManager | null, + attachmentOpenerRegistry: IAttachmentOpenerRegistry, autocompletionRegistry: IAutocompletionRegistry, chatCommandRegistry: IChatCommandRegistry, drive: ICollaborativeDrive | null, + filebrowser: IDefaultFileBrowser | null, restorer: ILayoutRestorer | null, selectionWatcher: ISelectionWatcher | null, settingRegistry: ISettingRegistry | null, @@ -282,8 +310,10 @@ const docFactories: JupyterFrontEndPlugin = { rmRegistry, toolbarFactory, translator, + documentManager: filebrowser?.model.manager, autocompletionRegistry, - chatCommandRegistry + chatCommandRegistry, + attachmentOpenerRegistry }); // Add the widget to the tracker when it's created @@ -575,7 +605,7 @@ const chatCommands: JupyterFrontEndPlugin = { user, sharedModel, widgetConfig, - commands: app.commands, + commands, activeCellManager, selectionWatcher }); @@ -640,8 +670,10 @@ const chatPanel: JupyterFrontEndPlugin = { provides: IChatPanel, requires: [IChatFactory, ICollaborativeDrive, IRenderMimeRegistry], optional: [ + IAttachmentOpenerRegistry, IAutocompletionRegistry, IChatCommandRegistry, + IDefaultFileBrowser, ILayoutRestorer, IThemeManager ], @@ -650,8 +682,10 @@ const chatPanel: JupyterFrontEndPlugin = { factory: IChatFactory, drive: ICollaborativeDrive, rmRegistry: IRenderMimeRegistry, + attachmentOpenerRegistry: IAttachmentOpenerRegistry, autocompletionRegistry: IAutocompletionRegistry, chatCommandRegistry: IChatCommandRegistry, + filebrowser: IDefaultFileBrowser | null, restorer: ILayoutRestorer | null, themeManager: IThemeManager | null ): ChatPanel => { @@ -668,8 +702,10 @@ const chatPanel: JupyterFrontEndPlugin = { rmRegistry, themeManager, defaultDirectory, + documentManager: filebrowser?.model.manager, autocompletionRegistry, - chatCommandRegistry + chatCommandRegistry, + attachmentOpenerRegistry }); chatPanel.id = 'JupyterlabChat:sidepanel'; chatPanel.title.icon = chatIcon; @@ -779,6 +815,7 @@ const selectionWatcher: JupyterFrontEndPlugin = { export default [ activeCellManager, + attachmentOpeners, autocompletionPlugin, chatCommands, chatPanel, diff --git a/ui-tests/tests/attachments.spec.ts b/ui-tests/tests/attachments.spec.ts new file mode 100644 index 00000000..6d78b934 --- /dev/null +++ b/ui-tests/tests/attachments.spec.ts @@ -0,0 +1,200 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ +import { PathExt } from '@jupyterlab/coreutils'; +import { expect, test } from '@jupyterlab/galata'; + +import { createChat, exposeDepsJs, getPlugin, openChat } from './test-utils'; + +const CHAT = 'attachments.chat'; +const NOTEBOOK = 'test.ipynb'; + +test.describe('#attachments', () => { + let chatPath: string; + test.beforeEach(async ({ page, tmpPath }) => { + chatPath = PathExt.join(tmpPath, CHAT); + // Create a chat, a notebook and a markdown file. + await createChat(page, chatPath); + await page.menu.clickMenuItem('File>New>Markdown File'); + await page.notebook.createNew(NOTEBOOK); + + // Wait for the notebook to be ready before closing it to avoid popup + await page.waitForCondition( + async () => (await page.locator('li.jp-mod-dirty').count()) === 1 + ); + await page.waitForCondition( + async () => (await page.locator('li.jp-mod-dirty').count()) === 0 + ); + await page.activity.closeAll(); + }); + + test('Should have a button to attach a file', async ({ page }) => { + const chatPanel = await openChat(page, chatPath); + const button = chatPanel.locator( + '.jp-chat-input-container .jp-chat-attach-button' + ); + await expect(button).toBeVisible(); + await expect(button).toBeEnabled(); + }); + + test('Should open a dialog on click', async ({ page }) => { + const chatPanel = await openChat(page, chatPath); + const button = chatPanel.locator( + '.jp-chat-input-container .jp-chat-attach-button' + ); + await button.click(); + await page.waitForSelector('.jp-Dialog'); + await expect(page.locator('.jp-Dialog .jp-Dialog-header')).toHaveText( + 'Select files to attach' + ); + }); + + test('Should add attachments to input and open it', async ({ + page, + tmpPath + }) => { + const chatPanel = await openChat(page, chatPath); + const input = chatPanel.locator('.jp-chat-input-container'); + + // Open the attachment dialog. + await input.locator('.jp-chat-attach-button').click(); + await page.waitForSelector('.jp-Dialog'); + const items = page.locator('.jp-Dialog .jp-DirListing-item'); + + // Open the temp directory in dialog, select the files and validate. + await items.first().locator('.jp-DirListing-itemName').dblclick(); + await page.waitForCondition( + async () => + (await items + .first() + .locator('.jp-DirListing-itemName') + .textContent()) !== tmpPath + ); + for (let i = 1; i < 3; i++) { + const checkbox = items.nth(i).locator('input[type="checkbox"]'); + const box = await checkbox.boundingBox(); + await page.mouse.move(box!.x + 5, box!.y + 5); + await items.nth(i).locator('input[type="checkbox"]').click(); + } + await page.locator('.jp-Dialog button.jp-mod-accept').click(); + + // Should have attachment in input + const attachments = input.locator('.jp-chat-attachment'); + await expect(attachments).toHaveCount(2); + await expect(attachments.nth(0)).toHaveText( + PathExt.join(tmpPath, NOTEBOOK) + ); + await expect(attachments.nth(1)).toHaveText( + PathExt.join(tmpPath, 'untitled.md') + ); + + // Should open attachment file from input + await attachments.nth(1).locator('.jp-chat-attachment-clickable').click(); + await page.waitForCondition( + async () => await page.activity.isTabActive('untitled.md') + ); + }); + + test('Should add attachments to message and open it', async ({ + page, + tmpPath + }) => { + const chatPanel = await openChat(page, chatPath); + const input = chatPanel.locator('.jp-chat-input-container'); + + // Open the attachment dialog. + await input.locator('.jp-chat-attach-button').click(); + await page.waitForSelector('.jp-Dialog'); + const items = page.locator('.jp-Dialog .jp-DirListing-item'); + + // Open the temp directory in dialog, select the files and validate. + await items.first().locator('.jp-DirListing-itemName').dblclick(); + await page.waitForCondition( + async () => + (await items + .first() + .locator('.jp-DirListing-itemName') + .textContent()) !== tmpPath + ); + for (let i = 1; i < 3; i++) { + const checkbox = items.nth(i).locator('input[type="checkbox"]'); + const box = await checkbox.boundingBox(); + await page.mouse.move(box!.x + 5, box!.y + 5); + await items.nth(i).locator('input[type="checkbox"]').click(); + } + await page.locator('.jp-Dialog button.jp-mod-accept').click(); + + // Send the message + await input.locator('.jp-chat-send-button').click(); + + // Should have attachment in message + const message = chatPanel + .locator('.jp-chat-messages-container .jp-chat-message') + .first(); + const attachments = message.locator('.jp-chat-attachment'); + await expect(attachments).toHaveCount(2); + await expect(attachments.nth(0)).toHaveText( + PathExt.join(tmpPath, NOTEBOOK) + ); + await expect(attachments.nth(1)).toHaveText( + PathExt.join(tmpPath, 'untitled.md') + ); + + // Should open attachment file from input + await attachments.nth(1).locator('.jp-chat-attachment-clickable').click(); + await page.waitForCondition( + async () => await page.activity.isTabActive('untitled.md') + ); + }); +}); + +test.describe('#attachmentOpenerRegistry', () => { + test.use({ autoGoto: false }); + + test('Should have change the callback', async ({ page, tmpPath }) => { + const logs: string[] = []; + + page.on('console', message => { + logs.push(message.text()); + }); + + await page.goto(); + + // Expose a function to get a plugin. + await page.evaluate(exposeDepsJs({ getPlugin })); + + // Modify the behavior on attachment click. + await page.evaluate(async () => { + // change the registered callback on attachment click. + const registry = await window.getPlugin( + 'jupyterlab-chat-extension:attachmentOpener' + ); + registry.set('file', attachment => + console.log(`Attached file: ${attachment.value}`) + ); + }); + + const chatPath = PathExt.join(tmpPath, CHAT); + await createChat(page, chatPath); + const chatPanel = await openChat(page, chatPath); + + const input = chatPanel.locator('.jp-chat-input-container'); + + // Open the attachment dialog. + await input.locator('.jp-chat-attach-button').click(); + await page.waitForSelector('.jp-Dialog'); + // Open the temp directory in dialog, select the file and validate. + await page.locator('.jp-Dialog .jp-DirListing-itemName').first().dblclick(); + await page.locator('.jp-Dialog .jp-DirListing-itemName').last().click(); + await page.locator('.jp-Dialog button.jp-mod-accept').click(); + + // Click on the attachment and expect a log. + const attachment = input.locator('.jp-chat-attachment').first(); + await attachment.locator('.jp-chat-attachment-clickable').click(); + expect(logs.filter(s => s === `Attached file: ${chatPath}`)).toHaveLength( + 1 + ); + await page.pause(); + }); +}); diff --git a/ui-tests/tests/notifications.spec.ts b/ui-tests/tests/notifications.spec.ts index 2c998f57..92f539f4 100644 --- a/ui-tests/tests/notifications.spec.ts +++ b/ui-tests/tests/notifications.spec.ts @@ -19,7 +19,7 @@ const FILENAME = 'my-chat.chat'; const MSG_CONTENT = 'Hello World!'; const USERNAME = USER.identity.username; -test.describe('#notifications', () => { +test.skip('#notifications', () => { const baseTime = 1714116341; const messagesCount = 15; const messagesList: any[] = []; diff --git a/ui-tests/tests/test-utils.ts b/ui-tests/tests/test-utils.ts index b4854bcb..7f44fd76 100644 --- a/ui-tests/tests/test-utils.ts +++ b/ui-tests/tests/test-utils.ts @@ -170,3 +170,46 @@ export const openSidePanel = async ( } return panel.first(); }; + +// Workaround to expose a function using 'window' in the browser context. +// Copied from https://github.com/puppeteer/puppeteer/issues/724#issuecomment-896755822 +export const exposeDepsJs = ( + deps: Record any> +): string => { + return Object.keys(deps) + .map(key => { + return `window["${key}"] = ${deps[key]};`; + }) + .join('\n'); +}; + +/** + * The function running in browser context to get a plugin. + * + * This function does the same as the equivalent in InPage galata helper, without the + * constraint on the plugin id. + */ +export const getPlugin = (pluginId: string): Promise => { + return new Promise((resolve, reject) => { + const app = window.jupyterapp as any; + const hasPlugin = app.hasPlugin(pluginId); + + if (hasPlugin) { + try { + // Compatibility with jupyterlab 4.3 + const plugin: any = app._plugins + ? app._plugins.get(pluginId) + : app.pluginRegistry._plugins.get(pluginId); + if (plugin.activated) { + resolve(plugin.service); + } else { + void app.activatePlugin(pluginId).then(response => { + resolve(plugin.service); + }); + } + } catch (error) { + console.error('Failed to get plugin', error); + } + } + }); +}; diff --git a/ui-tests/tests/unread.spec.ts-snapshots/navigation-top-linux.png b/ui-tests/tests/unread.spec.ts-snapshots/navigation-top-linux.png index d781c504..0f5f5573 100644 Binary files a/ui-tests/tests/unread.spec.ts-snapshots/navigation-top-linux.png and b/ui-tests/tests/unread.spec.ts-snapshots/navigation-top-linux.png differ diff --git a/yarn.lock b/yarn.lock index 31bae3d4..4769d331 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2465,6 +2465,8 @@ __metadata: "@jupyter/react-components": ^0.15.2 "@jupyterlab/application": ^4.2.0 "@jupyterlab/apputils": ^4.3.0 + "@jupyterlab/docmanager": ^4.2.0 + "@jupyterlab/filebrowser": ^4.2.0 "@jupyterlab/fileeditor": ^4.2.0 "@jupyterlab/notebook": ^4.2.0 "@jupyterlab/rendermime": ^4.2.0 @@ -2813,7 +2815,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/docmanager@npm:^4.2.3": +"@jupyterlab/docmanager@npm:^4.2.0, @jupyterlab/docmanager@npm:^4.2.3": version: 4.2.3 resolution: "@jupyterlab/docmanager@npm:4.2.3" dependencies: @@ -2883,7 +2885,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/filebrowser@npm:^4.2.3": +"@jupyterlab/filebrowser@npm:^4.2.0, @jupyterlab/filebrowser@npm:^4.2.3": version: 4.2.3 resolution: "@jupyterlab/filebrowser@npm:4.2.3" dependencies: @@ -10072,6 +10074,7 @@ __metadata: "@jupyterlab/builder": ^4.2.0 "@jupyterlab/coreutils": ^6.2.0 "@jupyterlab/docregistry": ^4.2.0 + "@jupyterlab/filebrowser": ^4.2.0 "@jupyterlab/launcher": ^4.2.0 "@jupyterlab/notebook": ^4.2.0 "@jupyterlab/rendermime": ^4.2.0 @@ -10110,6 +10113,7 @@ __metadata: "@jupyterlab/application": ^4.2.0 "@jupyterlab/apputils": ^4.3.0 "@jupyterlab/coreutils": ^6.2.0 + "@jupyterlab/docmanager": ^4.2.0 "@jupyterlab/docregistry": ^4.2.0 "@jupyterlab/launcher": ^4.2.0 "@jupyterlab/notebook": ^4.2.0