diff --git a/packages/jupyter-chat/package.json b/packages/jupyter-chat/package.json index fc4ed30..a5a5a29 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 0000000..e277a14 --- /dev/null +++ b/packages/jupyter-chat/src/components/attachments.tsx @@ -0,0 +1,88 @@ +/* + * 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 from 'react'; + +import { TooltippedButton } from './mui-extras/tooltipped-button'; +import { IAttachment } from '../types'; + +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[]; + onClick?: (attachment: IAttachment) => void; + 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 onclick = () => { + if (props.onClick) { + props.onClick(props.attachment); + } + }; + + return ( + + + {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 41a15b4..4b0ab0c 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,13 +13,15 @@ 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 { AutocompleteCommand, + IAttachment, IAutocompletionCommandsProps, IConfig, Selection @@ -29,7 +30,9 @@ import { const INPUT_BOX_CLASS = 'jp-chat-input-container'; export function ChatInput(props: ChatInput.IProps): JSX.Element { - const { autocompletionName, autocompletionRegistry, model } = props; + const { autocompletionName, autocompletionRegistry, documentManager, model } = + props; + const autocompletion = useRef(); const [input, setInput] = useState(props.value || ''); const [sendWithShiftEnter, setSendWithShiftEnter] = useState( @@ -38,6 +41,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. @@ -63,9 +67,15 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element { }; model.focusInputSignal?.connect(focusInputElement); + const attachmentChanged = (_: IChatModel, attachments: IAttachment[]) => { + setAttachments([...attachments]); + }; + model.inputAttachmentsChanges?.connect(attachmentChanged); + return () => { model.configChanged?.disconnect(configChanged); model.focusInputSignal?.disconnect(focusInputElement); + model.inputAttachmentsChanges?.disconnect(attachmentChanged); }; }, [model]); @@ -195,6 +205,11 @@ ${selection.source} return ( + + {documentManager && model.addAttachment && ( + + )} {props.onCancel && } 0} onSend={onSend} hideIncludeSelection={hideIncludeSelection} hasButtonOnLeft={!!props.onCancel} @@ -319,6 +341,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 109581c..d5ab16b 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,12 @@ 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 f56d3a2..1dafa26 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'; @@ -18,13 +19,7 @@ import { IChatModel } from '../model'; import { 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 }); @@ -32,18 +27,19 @@ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element { return ( <> - + ); @@ -90,6 +86,7 @@ export function Chat(props: Chat.IOptions): JSX.Element { )} @@ -117,6 +114,10 @@ export namespace Chat { * The rendermime registry. */ rmRegistry: IRenderMimeRegistry; + /** + * The document manager. + */ + documentManager?: IDocumentManager; /** * Autocompletion registry. */ 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 0000000..b7769c9 --- /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 22fd806..d5a6419 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 3d58f6d..9857873 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/model.ts b/packages/jupyter-chat/src/model.ts index d97aff1..be21952 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 inputAttachmentsChanges?: ISignal; + /** * Send a message, to be defined depending on the chosen technology. * Default to no-op. @@ -166,6 +172,26 @@ 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; + + /** + * Open attachments. + */ + clickAttachment?(attachments: IAttachment): void; + /** * Function called by the input on key pressed. */ @@ -393,6 +419,13 @@ export class ChatModel implements IChatModel { return this._focusInputSignal; } + /** + * A signal emitting when the input attachments changed. + */ + get inputAttachmentsChanges(): ISignal { + return this._inputAttachmentsChanges; + } + /** * Send a message, to be defined depending on the chosen technology. * Default to no-op. @@ -520,6 +553,53 @@ 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._inputAttachmentsChanges.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._inputAttachmentsChanges.emit([...this.inputAttachments]); + }; + + /** + * Update attachments. + */ + clearAttachments = (): void => { + this.inputAttachments = []; + this._inputAttachmentsChanges.emit([]); + }; + + /** + * Open an attachments. + */ + clickAttachment = (attachment: IAttachment): void => { + if (attachment.type === 'file') { + this._commands?.execute('docmanager:open', { path: attachment.value }); + } + }; + /** * Add unread messages to the list. * @param indexes - list of new indexes. @@ -569,6 +649,7 @@ export class ChatModel implements IChatModel { } } + protected inputAttachments: IAttachment[] = []; private _messages: IChatMessage[] = []; private _unreadMessages: number[] = []; private _messagesInViewport: number[] = []; @@ -586,6 +667,7 @@ export class ChatModel implements IChatModel { private _viewportChanged = new Signal(this); private _writersChanged = new Signal(this); private _focusInputSignal = new Signal(this); + private _inputAttachmentsChanges = new Signal(this); } /** diff --git a/packages/jupyter-chat/src/types.ts b/packages/jupyter-chat/src/types.ts index cd226f6..7ebccf9 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 633be0d..9b834c4 100644 --- a/packages/jupyter-chat/style/chat.css +++ b/packages/jupyter-chat/style/chat.css @@ -112,3 +112,19 @@ .jp-chat-navigation-bottom { bottom: 100px; } + +.jp-chat-attachments { + display: flex; + min-height: 1.5em; +} + +.jp-chat-attachment { + border: solid 1px; + border-radius: 2px; + margin: 0 0.2em; + padding: 0 0.3em; +} + +.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 296beea..a82d9b7 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 224613d..50c0ca3 100644 --- a/packages/jupyterlab-chat/src/factory.ts +++ b/packages/jupyterlab-chat/src/factory.ts @@ -10,6 +10,7 @@ import { 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'; @@ -74,6 +75,7 @@ export class ChatWidgetFactory extends ABCWidgetFactory< super(options); this._themeManager = options.themeManager; this._rmRegistry = options.rmRegistry; + this._documentManager = options.documentManager; this._autocompletionRegistry = options.autocompletionRegistry; } @@ -86,6 +88,7 @@ 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; return new LabChatPanel({ context, @@ -95,6 +98,7 @@ export class ChatWidgetFactory extends ABCWidgetFactory< private _themeManager: IThemeManager | null; private _rmRegistry: IRenderMimeRegistry; + private _documentManager?: IDocumentManager; private _autocompletionRegistry?: IAutocompletionRegistry; } @@ -102,6 +106,7 @@ export namespace ChatWidgetFactory { export interface IContext extends DocumentRegistry.IContext { themeManager: IThemeManager | null; rmRegistry: IRenderMimeRegistry; + documentManager?: IDocumentManager; autocompletionRegistry?: IAutocompletionRegistry; } @@ -109,6 +114,7 @@ export namespace ChatWidgetFactory { extends DocumentRegistry.IWidgetFactoryOptions { themeManager: IThemeManager | null; rmRegistry: IRenderMimeRegistry; + documentManager?: IDocumentManager; autocompletionRegistry?: IAutocompletionRegistry; } } diff --git a/packages/jupyterlab-chat/src/model.ts b/packages/jupyterlab-chat/src/model.ts index 4b25b41..5d0b555 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 c2a4417..b56ec03 100644 --- a/packages/jupyterlab-chat/src/widget.tsx +++ b/packages/jupyterlab-chat/src/widget.tsx @@ -33,6 +33,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'; @@ -102,6 +103,7 @@ 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; const addChat = new CommandToolbarButton({ @@ -164,6 +166,7 @@ export class ChatPanel extends SidePanel { model: model, rmRegistry: this._rmRegistry, themeManager: this._themeManager, + documentManager: this._documentManager, autocompletionRegistry: this._autocompletionRegistry }); @@ -283,6 +286,7 @@ export class ChatPanel extends SidePanel { private _openChat: ReactWidget; private _rmRegistry: IRenderMimeRegistry; private _themeManager: IThemeManager | null; + private _documentManager?: IDocumentManager; private _autocompletionRegistry?: IAutocompletionRegistry; } @@ -299,6 +303,7 @@ export namespace ChatPanel { rmRegistry: IRenderMimeRegistry; themeManager: IThemeManager | null; defaultDirectory: string; + documentManager?: IDocumentManager; autocompletionRegistry?: IAutocompletionRegistry; } } diff --git a/packages/jupyterlab-chat/src/ychat.ts b/packages/jupyterlab-chat/src/ychat.ts index 22aa68e..b295e62 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 6cb7197..43a5927 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 d80679d..99f66f6 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 4a663c6..1ed8f61 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 0db5c4c..0c583b9 100644 --- a/python/jupyterlab-chat/src/index.ts +++ b/python/jupyterlab-chat/src/index.ts @@ -35,6 +35,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'; @@ -96,6 +97,7 @@ const docFactories: JupyterFrontEndPlugin = { IActiveCellManagerToken, IAutocompletionRegistry, ICollaborativeDrive, + IDefaultFileBrowser, ILayoutRestorer, ISelectionWatcherToken, ISettingRegistry, @@ -110,6 +112,7 @@ const docFactories: JupyterFrontEndPlugin = { activeCellManager: IActiveCellManager | null, autocompletionRegistry: IAutocompletionRegistry, drive: ICollaborativeDrive | null, + filebrowser: IDefaultFileBrowser | null, restorer: ILayoutRestorer | null, selectionWatcher: ISelectionWatcher | null, settingRegistry: ISettingRegistry | null, @@ -277,6 +280,7 @@ const docFactories: JupyterFrontEndPlugin = { rmRegistry, toolbarFactory, translator, + documentManager: filebrowser?.model.manager, autocompletionRegistry }); @@ -569,7 +573,7 @@ const chatCommands: JupyterFrontEndPlugin = { user, sharedModel, widgetConfig, - commands: app.commands, + commands, activeCellManager, selectionWatcher }); @@ -633,13 +637,19 @@ const chatPanel: JupyterFrontEndPlugin = { autoStart: true, provides: IChatPanel, requires: [IChatFactory, ICollaborativeDrive, IRenderMimeRegistry], - optional: [IAutocompletionRegistry, ILayoutRestorer, IThemeManager], + optional: [ + IAutocompletionRegistry, + IDefaultFileBrowser, + ILayoutRestorer, + IThemeManager + ], activate: ( app: JupyterFrontEnd, factory: IChatFactory, drive: ICollaborativeDrive, rmRegistry: IRenderMimeRegistry, autocompletionRegistry: IAutocompletionRegistry, + filebrowser: IDefaultFileBrowser | null, restorer: ILayoutRestorer | null, themeManager: IThemeManager | null ): ChatPanel => { @@ -656,6 +666,7 @@ const chatPanel: JupyterFrontEndPlugin = { rmRegistry, themeManager, defaultDirectory, + documentManager: filebrowser?.model.manager, autocompletionRegistry }); chatPanel.id = 'JupyterlabChat:sidepanel'; diff --git a/yarn.lock b/yarn.lock index dfaefa2..905a9ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2458,6 +2458,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 @@ -2806,7 +2808,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: @@ -2876,7 +2878,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: @@ -9995,6 +9997,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 @@ -10033,6 +10036,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