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