@jupyter/chat 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/attachments.d.ts +0 -3
- package/lib/components/attachments.js +28 -3
- package/lib/components/chat.js +1 -1
- package/lib/input-model.js +25 -2
- package/lib/model.d.ts +5 -0
- package/lib/model.js +4 -0
- package/lib/types.d.ts +70 -10
- package/lib/widgets/chat-widget.d.ts +42 -0
- package/lib/widgets/chat-widget.js +231 -0
- package/package.json +1 -1
- package/src/components/attachments.tsx +35 -4
- package/src/components/chat.tsx +1 -1
- package/src/input-model.ts +38 -3
- package/src/model.ts +8 -0
- package/src/types.ts +74 -10
- package/src/widgets/chat-widget.tsx +279 -0
- package/style/chat.css +9 -2
- package/style/input.css +42 -0
|
@@ -2,21 +2,46 @@
|
|
|
2
2
|
* Copyright (c) Jupyter Development Team.
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
|
-
// import { IDocumentManager } from '@jupyterlab/docmanager';
|
|
6
5
|
import CloseIcon from '@mui/icons-material/Close';
|
|
7
6
|
import { Box } from '@mui/material';
|
|
8
7
|
import React, { useContext } from 'react';
|
|
8
|
+
import { PathExt } from '@jupyterlab/coreutils';
|
|
9
|
+
import { UUID } from '@lumino/coreutils';
|
|
9
10
|
import { TooltippedButton } from './mui-extras/tooltipped-button';
|
|
10
11
|
import { AttachmentOpenerContext } from '../context';
|
|
11
12
|
const ATTACHMENTS_CLASS = 'jp-chat-attachments';
|
|
12
13
|
const ATTACHMENT_CLASS = 'jp-chat-attachment';
|
|
13
14
|
const ATTACHMENT_CLICKABLE_CLASS = 'jp-chat-attachment-clickable';
|
|
14
15
|
const REMOVE_BUTTON_CLASS = 'jp-chat-attachment-remove';
|
|
16
|
+
/**
|
|
17
|
+
* Generate a user-friendly display name for an attachment
|
|
18
|
+
*/
|
|
19
|
+
function getAttachmentDisplayName(attachment) {
|
|
20
|
+
var _a;
|
|
21
|
+
if (attachment.type === 'notebook') {
|
|
22
|
+
// Extract notebook filename with extension
|
|
23
|
+
const notebookName = PathExt.basename(attachment.value) || 'Unknown notebook';
|
|
24
|
+
// Show info about attached cells if there are any
|
|
25
|
+
if (((_a = attachment.cells) === null || _a === void 0 ? void 0 : _a.length) === 1) {
|
|
26
|
+
return `${notebookName}: ${attachment.cells[0].input_type} cell`;
|
|
27
|
+
}
|
|
28
|
+
else if (attachment.cells && attachment.cells.length > 1) {
|
|
29
|
+
return `${notebookName}: ${attachment.cells.length} cells`;
|
|
30
|
+
}
|
|
31
|
+
return notebookName;
|
|
32
|
+
}
|
|
33
|
+
if (attachment.type === 'file') {
|
|
34
|
+
// Extract filename with extension
|
|
35
|
+
const fileName = PathExt.basename(attachment.value) || 'Unknown file';
|
|
36
|
+
return fileName;
|
|
37
|
+
}
|
|
38
|
+
return attachment.value || 'Unknown attachment';
|
|
39
|
+
}
|
|
15
40
|
/**
|
|
16
41
|
* The Attachments component.
|
|
17
42
|
*/
|
|
18
43
|
export function AttachmentPreviewList(props) {
|
|
19
|
-
return (React.createElement(Box, { className: ATTACHMENTS_CLASS }, props.attachments.map(attachment => (React.createElement(AttachmentPreview, { ...props, attachment: attachment })))));
|
|
44
|
+
return (React.createElement(Box, { className: ATTACHMENTS_CLASS }, props.attachments.map(attachment => (React.createElement(AttachmentPreview, { key: `${PathExt.basename(attachment.value)}-${UUID.uuid4()}`, ...props, attachment: attachment })))));
|
|
20
45
|
}
|
|
21
46
|
/**
|
|
22
47
|
* The Attachment component.
|
|
@@ -30,7 +55,7 @@ export function AttachmentPreview(props) {
|
|
|
30
55
|
: '', onClick: () => {
|
|
31
56
|
var _a;
|
|
32
57
|
return (_a = attachmentOpenerRegistry === null || attachmentOpenerRegistry === void 0 ? void 0 : attachmentOpenerRegistry.get(props.attachment.type)) === null || _a === void 0 ? void 0 : _a(props.attachment);
|
|
33
|
-
} }, props.attachment
|
|
58
|
+
} }, getAttachmentDisplayName(props.attachment)),
|
|
34
59
|
props.onRemove && (React.createElement(TooltippedButton, { onClick: () => props.onRemove(props.attachment), tooltip: remove_tooltip, buttonProps: {
|
|
35
60
|
size: 'small',
|
|
36
61
|
title: remove_tooltip,
|
package/lib/components/chat.js
CHANGED
|
@@ -22,7 +22,7 @@ export function ChatBody(props) {
|
|
|
22
22
|
React.createElement(ChatInput, { sx: {
|
|
23
23
|
paddingLeft: 4,
|
|
24
24
|
paddingRight: 4,
|
|
25
|
-
paddingTop:
|
|
25
|
+
paddingTop: 0,
|
|
26
26
|
paddingBottom: 0,
|
|
27
27
|
borderTop: '1px solid var(--jp-border-color1)'
|
|
28
28
|
}, model: model.input, chatCommandRegistry: props.chatCommandRegistry, toolbarRegistry: inputToolbarRegistry })));
|
package/lib/input-model.js
CHANGED
|
@@ -20,10 +20,31 @@ export class InputModel {
|
|
|
20
20
|
* Add attachment to send with next message.
|
|
21
21
|
*/
|
|
22
22
|
this.addAttachment = (attachment) => {
|
|
23
|
-
|
|
23
|
+
// Use JSON comparison to detect duplicates (same as in YChat.setAttachment)
|
|
24
|
+
const attachmentJson = JSON.stringify(attachment);
|
|
25
|
+
const duplicateAttachment = this._attachments.find(att => JSON.stringify(att) === attachmentJson);
|
|
24
26
|
if (duplicateAttachment) {
|
|
25
27
|
return;
|
|
26
28
|
}
|
|
29
|
+
// Merge cells from same notebook into the same attachment
|
|
30
|
+
if (attachment.type === 'notebook' && attachment.cells) {
|
|
31
|
+
const existingNotebookIndex = this._attachments.findIndex(att => att.type === 'notebook' && att.value === attachment.value);
|
|
32
|
+
if (existingNotebookIndex !== -1) {
|
|
33
|
+
const existingAttachment = this._attachments[existingNotebookIndex];
|
|
34
|
+
const existingCells = existingAttachment.cells || [];
|
|
35
|
+
// Filter out duplicate cells
|
|
36
|
+
const newCells = attachment.cells.filter(newCell => !existingCells.some(existingCell => existingCell.id === newCell.id));
|
|
37
|
+
if (!newCells.length) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this._attachments[existingNotebookIndex] = {
|
|
41
|
+
...existingAttachment,
|
|
42
|
+
cells: [...existingCells, ...newCells]
|
|
43
|
+
};
|
|
44
|
+
this._attachmentsChanged.emit([...this._attachments]);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
27
48
|
this._attachments.push(attachment);
|
|
28
49
|
this._attachmentsChanged.emit([...this._attachments]);
|
|
29
50
|
};
|
|
@@ -31,7 +52,9 @@ export class InputModel {
|
|
|
31
52
|
* Remove attachment to be sent.
|
|
32
53
|
*/
|
|
33
54
|
this.removeAttachment = (attachment) => {
|
|
34
|
-
|
|
55
|
+
// Use JSON comparison to detect duplicates (same as in YChat.setAttachment)
|
|
56
|
+
const attachmentJson = JSON.stringify(attachment);
|
|
57
|
+
const attachmentIndex = this._attachments.findIndex(att => JSON.stringify(att) === attachmentJson);
|
|
35
58
|
if (attachmentIndex === -1) {
|
|
36
59
|
return;
|
|
37
60
|
}
|
package/lib/model.d.ts
CHANGED
|
@@ -419,6 +419,10 @@ export interface IChatContext {
|
|
|
419
419
|
* A list of all users who have connected to this chat.
|
|
420
420
|
*/
|
|
421
421
|
readonly users: IUser[];
|
|
422
|
+
/**
|
|
423
|
+
* Current user connected with the chat panel
|
|
424
|
+
*/
|
|
425
|
+
readonly user: IUser | undefined;
|
|
422
426
|
}
|
|
423
427
|
/**
|
|
424
428
|
* An abstract base class implementing `IChatContext`. This can be extended into
|
|
@@ -430,6 +434,7 @@ export declare abstract class AbstractChatContext implements IChatContext {
|
|
|
430
434
|
});
|
|
431
435
|
get name(): string;
|
|
432
436
|
get messages(): IChatMessage[];
|
|
437
|
+
get user(): IUser | undefined;
|
|
433
438
|
/**
|
|
434
439
|
* ABSTRACT: Should return a list of users who have connected to this chat.
|
|
435
440
|
*/
|
package/lib/model.js
CHANGED
package/lib/types.d.ts
CHANGED
|
@@ -9,14 +9,14 @@ export interface IUser {
|
|
|
9
9
|
color?: string;
|
|
10
10
|
avatar_url?: string;
|
|
11
11
|
/**
|
|
12
|
-
* The string to use to mention a user in the chat. This
|
|
13
|
-
* following procedure:
|
|
12
|
+
* The string to use to mention a user in the chat. This should be computed
|
|
13
|
+
* via the following procedure:
|
|
14
14
|
*
|
|
15
15
|
* 1. Let `mention_name = user.display_name || user.name || user.username`.
|
|
16
16
|
*
|
|
17
17
|
* 2. Replace each ' ' character with '-' in `mention_name`.
|
|
18
18
|
*/
|
|
19
|
-
mention_name
|
|
19
|
+
mention_name?: string;
|
|
20
20
|
/**
|
|
21
21
|
* Boolean identifying if user is a bot.
|
|
22
22
|
*/
|
|
@@ -77,21 +77,81 @@ export interface INewMessage {
|
|
|
77
77
|
id?: string;
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
|
-
* The attachment
|
|
80
|
+
* The attachment type. Jupyter Chat allows for two types of attachments
|
|
81
|
+
* currently:
|
|
82
|
+
*
|
|
83
|
+
* 1. File attachments (`IFileAttachment`)
|
|
84
|
+
* 2. Notebook attachments (`INotebookAttachment`)
|
|
85
|
+
*
|
|
86
|
+
* The `type` field is always defined on every attachment, so it can be used to
|
|
87
|
+
* distinguish different attachment types.
|
|
81
88
|
*/
|
|
82
|
-
export
|
|
89
|
+
export type IAttachment = IFileAttachment | INotebookAttachment;
|
|
90
|
+
export interface IFileAttachment {
|
|
91
|
+
type: 'file';
|
|
83
92
|
/**
|
|
84
|
-
* The
|
|
93
|
+
* The path to the file, relative to `ContentsManager.root_dir`.
|
|
85
94
|
*/
|
|
86
|
-
|
|
95
|
+
value: string;
|
|
96
|
+
/**
|
|
97
|
+
* (optional) The MIME type of the attachment.
|
|
98
|
+
*/
|
|
99
|
+
mimetype?: string;
|
|
100
|
+
/**
|
|
101
|
+
* (optional) A selection range within the file. See `IAttachmentSelection`
|
|
102
|
+
* for more info.
|
|
103
|
+
*/
|
|
104
|
+
selection?: IAttachmentSelection;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Model of a single cell within a notebook attachment.
|
|
108
|
+
*
|
|
109
|
+
* The corresponding backend model is `NotebookCell`.
|
|
110
|
+
*/
|
|
111
|
+
export interface INotebookAttachmentCell {
|
|
112
|
+
/**
|
|
113
|
+
* The ID of the cell within the notebook.
|
|
114
|
+
*/
|
|
115
|
+
id: string;
|
|
116
|
+
/**
|
|
117
|
+
* The type of the cell.
|
|
118
|
+
*/
|
|
119
|
+
input_type: 'raw' | 'markdown' | 'code';
|
|
87
120
|
/**
|
|
88
|
-
*
|
|
121
|
+
* (optional) A selection range within the cell. See `IAttachmentSelection` for
|
|
122
|
+
* more info.
|
|
123
|
+
*/
|
|
124
|
+
selection?: IAttachmentSelection;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Model of a notebook attachment.
|
|
128
|
+
*
|
|
129
|
+
* The corresponding backend model is `NotebookAttachment`.
|
|
130
|
+
*/
|
|
131
|
+
export interface INotebookAttachment {
|
|
132
|
+
type: 'notebook';
|
|
133
|
+
/**
|
|
134
|
+
* The local path of the notebook, relative to `ContentsManager.root_dir`.
|
|
89
135
|
*/
|
|
90
136
|
value: string;
|
|
91
137
|
/**
|
|
92
|
-
*
|
|
138
|
+
* (optional) A list of cells in the notebook.
|
|
93
139
|
*/
|
|
94
|
-
|
|
140
|
+
cells?: INotebookAttachmentCell[];
|
|
141
|
+
}
|
|
142
|
+
export interface IAttachmentSelection {
|
|
143
|
+
/**
|
|
144
|
+
* The line number & column number of where the selection begins (inclusive).
|
|
145
|
+
*/
|
|
146
|
+
start: [number, number];
|
|
147
|
+
/**
|
|
148
|
+
* The line number & column number of where the selection ends (inclusive).
|
|
149
|
+
*/
|
|
150
|
+
end: [number, number];
|
|
151
|
+
/**
|
|
152
|
+
* The initial content of the selection.
|
|
153
|
+
*/
|
|
154
|
+
content: string;
|
|
95
155
|
}
|
|
96
156
|
/**
|
|
97
157
|
* An empty interface to describe optional settings that could be fetched from server.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ReactWidget } from '@jupyterlab/apputils';
|
|
2
2
|
import React from 'react';
|
|
3
|
+
import { Message } from '@lumino/messaging';
|
|
3
4
|
import { Chat, IInputToolbarRegistry } from '../components';
|
|
4
5
|
import { IChatModel } from '../model';
|
|
5
6
|
export declare class ChatWidget extends ReactWidget {
|
|
@@ -13,5 +14,46 @@ export declare class ChatWidget extends ReactWidget {
|
|
|
13
14
|
*/
|
|
14
15
|
get inputToolbarRegistry(): IInputToolbarRegistry | undefined;
|
|
15
16
|
render(): React.JSX.Element;
|
|
17
|
+
/**
|
|
18
|
+
* Handle DOM events for drag and drop
|
|
19
|
+
*/
|
|
20
|
+
handleEvent(event: Event): void;
|
|
21
|
+
/**
|
|
22
|
+
* A message handler invoked on an `'after-attach'` message.
|
|
23
|
+
*/
|
|
24
|
+
protected onAfterAttach(msg: Message): void;
|
|
25
|
+
/**
|
|
26
|
+
* A message handler invoked on a `'before-detach'` message.
|
|
27
|
+
*/
|
|
28
|
+
protected onBeforeDetach(msg: Message): void;
|
|
29
|
+
/**
|
|
30
|
+
* Handle drag over events
|
|
31
|
+
*/
|
|
32
|
+
private _handleDrag;
|
|
33
|
+
/**
|
|
34
|
+
* Check if we can handle the drop
|
|
35
|
+
*/
|
|
36
|
+
private _canHandleDrop;
|
|
37
|
+
/**
|
|
38
|
+
* Handle drop events
|
|
39
|
+
*/
|
|
40
|
+
private _handleDrop;
|
|
41
|
+
/**
|
|
42
|
+
* Process dropped files
|
|
43
|
+
*/
|
|
44
|
+
private _processFileDrop;
|
|
45
|
+
/**
|
|
46
|
+
* Process dropped cells
|
|
47
|
+
*/
|
|
48
|
+
private _processCellDrop;
|
|
49
|
+
/**
|
|
50
|
+
* Find the notebook path for a cell by searching through active and open notebooks
|
|
51
|
+
*/
|
|
52
|
+
private _findNotebookPath;
|
|
53
|
+
/**
|
|
54
|
+
* Remove drag hover class
|
|
55
|
+
*/
|
|
56
|
+
private _removeDragHoverClass;
|
|
16
57
|
private _chatOptions;
|
|
58
|
+
private _dragTarget;
|
|
17
59
|
}
|
|
@@ -3,12 +3,22 @@
|
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
5
|
import { ReactWidget } from '@jupyterlab/apputils';
|
|
6
|
+
import { isCode, isMarkdown, isRaw } from '@jupyterlab/nbformat';
|
|
6
7
|
import React from 'react';
|
|
8
|
+
import { Drag } from '@lumino/dragdrop';
|
|
7
9
|
import { Chat } from '../components';
|
|
8
10
|
import { chatIcon } from '../icons';
|
|
11
|
+
// MIME type constant for file browser drag events
|
|
12
|
+
const FILE_BROWSER_MIME = 'application/x-jupyter-icontentsrich';
|
|
13
|
+
// MIME type constant for Notebook cell drag events
|
|
14
|
+
const NOTEBOOK_CELL_MIME = 'application/vnd.jupyter.cells';
|
|
15
|
+
// CSS class constants
|
|
16
|
+
const INPUT_CONTAINER_CLASS = 'jp-chat-input-container';
|
|
17
|
+
const DRAG_HOVER_CLASS = 'jp-chat-drag-hover';
|
|
9
18
|
export class ChatWidget extends ReactWidget {
|
|
10
19
|
constructor(options) {
|
|
11
20
|
super();
|
|
21
|
+
this._dragTarget = null;
|
|
12
22
|
this.title.icon = chatIcon;
|
|
13
23
|
this.title.caption = 'Jupyter Chat'; // TODO: i18n
|
|
14
24
|
this._chatOptions = options;
|
|
@@ -32,4 +42,225 @@ export class ChatWidget extends ReactWidget {
|
|
|
32
42
|
// the case of collaborative document.
|
|
33
43
|
return React.createElement(Chat, { ...this._chatOptions, model: this._chatOptions.model });
|
|
34
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Handle DOM events for drag and drop
|
|
47
|
+
*/
|
|
48
|
+
handleEvent(event) {
|
|
49
|
+
// only handle drag-and-drop events
|
|
50
|
+
if (!(event instanceof Drag.Event)) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
switch (event.type) {
|
|
54
|
+
case 'lm-dragenter':
|
|
55
|
+
// see `Drag.Event` documentation for context
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
event.stopPropagation();
|
|
58
|
+
break;
|
|
59
|
+
case 'lm-dragover':
|
|
60
|
+
this._handleDrag(event);
|
|
61
|
+
break;
|
|
62
|
+
case 'lm-drop':
|
|
63
|
+
this._handleDrop(event);
|
|
64
|
+
break;
|
|
65
|
+
case 'lm-dragleave': {
|
|
66
|
+
// Remove hover class on leaving the widget
|
|
67
|
+
const targetElement = event.relatedTarget;
|
|
68
|
+
if (!targetElement || !this.node.contains(targetElement)) {
|
|
69
|
+
this._removeDragHoverClass();
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A message handler invoked on an `'after-attach'` message.
|
|
77
|
+
*/
|
|
78
|
+
onAfterAttach(msg) {
|
|
79
|
+
super.onAfterAttach(msg);
|
|
80
|
+
this.node.addEventListener('lm-dragover', this, true);
|
|
81
|
+
this.node.addEventListener('lm-dragenter', this, true);
|
|
82
|
+
this.node.addEventListener('lm-drop', this, true);
|
|
83
|
+
this.node.addEventListener('lm-dragleave', this, true);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A message handler invoked on a `'before-detach'` message.
|
|
87
|
+
*/
|
|
88
|
+
onBeforeDetach(msg) {
|
|
89
|
+
this.node.removeEventListener('lm-dragover', this, true);
|
|
90
|
+
this.node.removeEventListener('lm-dragenter', this, true);
|
|
91
|
+
this.node.removeEventListener('lm-drop', this, true);
|
|
92
|
+
this.node.removeEventListener('lm-dragleave', this, true);
|
|
93
|
+
super.onBeforeDetach(msg);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Handle drag over events
|
|
97
|
+
*/
|
|
98
|
+
_handleDrag(event) {
|
|
99
|
+
const inputContainer = this.node.querySelector(`.${INPUT_CONTAINER_CLASS}`);
|
|
100
|
+
const target = event.target;
|
|
101
|
+
const isOverInput = (inputContainer === null || inputContainer === void 0 ? void 0 : inputContainer.contains(target)) || inputContainer === target;
|
|
102
|
+
if (!isOverInput) {
|
|
103
|
+
this._removeDragHoverClass();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!this._canHandleDrop(event)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
event.stopPropagation();
|
|
111
|
+
event.dropAction = 'move';
|
|
112
|
+
if (inputContainer &&
|
|
113
|
+
!inputContainer.classList.contains(DRAG_HOVER_CLASS)) {
|
|
114
|
+
inputContainer.classList.add(DRAG_HOVER_CLASS);
|
|
115
|
+
this._dragTarget = inputContainer;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Check if we can handle the drop
|
|
120
|
+
*/
|
|
121
|
+
_canHandleDrop(event) {
|
|
122
|
+
const types = event.mimeData.types();
|
|
123
|
+
return (types.includes(NOTEBOOK_CELL_MIME) || types.includes(FILE_BROWSER_MIME));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Handle drop events
|
|
127
|
+
*/
|
|
128
|
+
_handleDrop(event) {
|
|
129
|
+
if (!this._canHandleDrop(event)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
event.stopPropagation();
|
|
134
|
+
event.dropAction = 'move';
|
|
135
|
+
this._removeDragHoverClass();
|
|
136
|
+
try {
|
|
137
|
+
if (event.mimeData.hasData(NOTEBOOK_CELL_MIME)) {
|
|
138
|
+
this._processCellDrop(event);
|
|
139
|
+
}
|
|
140
|
+
else if (event.mimeData.hasData(FILE_BROWSER_MIME)) {
|
|
141
|
+
this._processFileDrop(event);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
console.error('Error processing drop:', error);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Process dropped files
|
|
150
|
+
*/
|
|
151
|
+
_processFileDrop(event) {
|
|
152
|
+
var _a, _b, _c;
|
|
153
|
+
const data = event.mimeData.getData(FILE_BROWSER_MIME);
|
|
154
|
+
if (!((_a = data === null || data === void 0 ? void 0 : data.model) === null || _a === void 0 ? void 0 : _a.path)) {
|
|
155
|
+
console.warn('Invalid file browser data in drop event');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const attachment = {
|
|
159
|
+
type: 'file',
|
|
160
|
+
value: data.model.path,
|
|
161
|
+
mimetype: data.model.mimetype
|
|
162
|
+
};
|
|
163
|
+
(_c = (_b = this.model.input).addAttachment) === null || _c === void 0 ? void 0 : _c.call(_b, attachment);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Process dropped cells
|
|
167
|
+
*/
|
|
168
|
+
_processCellDrop(event) {
|
|
169
|
+
var _a, _b, _c;
|
|
170
|
+
try {
|
|
171
|
+
const cellData = event.mimeData.getData(NOTEBOOK_CELL_MIME);
|
|
172
|
+
// Cells might come as array or single object
|
|
173
|
+
const cells = Array.isArray(cellData) ? cellData : [cellData];
|
|
174
|
+
// Get path from first cell as all cells come from same notebook as users can only select or drag cells from one notebook at a time
|
|
175
|
+
if (!((_a = cells[0]) === null || _a === void 0 ? void 0 : _a.id)) {
|
|
176
|
+
console.warn('No valid cells to process');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const notebookPath = this._findNotebookPath(String(cells[0].id));
|
|
180
|
+
if (!notebookPath) {
|
|
181
|
+
console.warn(`Cannot find notebook for dragged cells from ${cells[0].id}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const validCells = [];
|
|
185
|
+
for (const cell of cells) {
|
|
186
|
+
if (!cell.id) {
|
|
187
|
+
console.warn('Dropped cell missing required ID, skipping');
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
// Use type guards to validate cell type
|
|
191
|
+
let cellType;
|
|
192
|
+
if (isCode(cell)) {
|
|
193
|
+
cellType = 'code';
|
|
194
|
+
}
|
|
195
|
+
else if (isMarkdown(cell)) {
|
|
196
|
+
cellType = 'markdown';
|
|
197
|
+
}
|
|
198
|
+
else if (isRaw(cell)) {
|
|
199
|
+
cellType = 'raw';
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
console.warn(`Unknown cell type: ${cell.cell_type}, skipping`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const notebookCell = {
|
|
206
|
+
id: cell.id,
|
|
207
|
+
input_type: cellType
|
|
208
|
+
};
|
|
209
|
+
validCells.push(notebookCell);
|
|
210
|
+
}
|
|
211
|
+
// Create single attachment with all cells from the notebook
|
|
212
|
+
if (validCells.length) {
|
|
213
|
+
const attachment = {
|
|
214
|
+
type: 'notebook',
|
|
215
|
+
value: notebookPath,
|
|
216
|
+
cells: validCells
|
|
217
|
+
};
|
|
218
|
+
(_c = (_b = this.model.input).addAttachment) === null || _c === void 0 ? void 0 : _c.call(_b, attachment);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
console.error('Failed to process cell drop: ', error);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Find the notebook path for a cell by searching through active and open notebooks
|
|
227
|
+
*/
|
|
228
|
+
_findNotebookPath(cellId) {
|
|
229
|
+
if (!this.model.input.activeCellManager) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
const activeCellManager = this.model.input
|
|
233
|
+
.activeCellManager;
|
|
234
|
+
const notebookTracker = activeCellManager._notebookTracker;
|
|
235
|
+
if (!notebookTracker) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (notebookTracker.currentWidget) {
|
|
239
|
+
const currentNotebook = notebookTracker.currentWidget;
|
|
240
|
+
const cells = currentNotebook.content.widgets;
|
|
241
|
+
const cellWidget = cells.find((c) => c.model.id === cellId);
|
|
242
|
+
if (cellWidget) {
|
|
243
|
+
return currentNotebook.context.path;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// If not in current notebook, check all open notebooks
|
|
247
|
+
const widgets = notebookTracker.widgets || [];
|
|
248
|
+
for (const notebook of widgets) {
|
|
249
|
+
const cells = notebook.content.widgets;
|
|
250
|
+
const cellWidget = cells.find((c) => c.model.id === cellId);
|
|
251
|
+
if (cellWidget) {
|
|
252
|
+
return notebook.context.path;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Remove drag hover class
|
|
259
|
+
*/
|
|
260
|
+
_removeDragHoverClass() {
|
|
261
|
+
if (this._dragTarget) {
|
|
262
|
+
this._dragTarget.classList.remove(DRAG_HOVER_CLASS);
|
|
263
|
+
this._dragTarget = null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
35
266
|
}
|
package/package.json
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
// import { IDocumentManager } from '@jupyterlab/docmanager';
|
|
7
6
|
import CloseIcon from '@mui/icons-material/Close';
|
|
8
7
|
import { Box } from '@mui/material';
|
|
9
8
|
import React, { useContext } from 'react';
|
|
9
|
+
import { PathExt } from '@jupyterlab/coreutils';
|
|
10
|
+
import { UUID } from '@lumino/coreutils';
|
|
10
11
|
|
|
11
12
|
import { TooltippedButton } from './mui-extras/tooltipped-button';
|
|
12
13
|
import { IAttachment } from '../types';
|
|
@@ -18,8 +19,34 @@ const ATTACHMENT_CLICKABLE_CLASS = 'jp-chat-attachment-clickable';
|
|
|
18
19
|
const REMOVE_BUTTON_CLASS = 'jp-chat-attachment-remove';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
*
|
|
22
|
+
* Generate a user-friendly display name for an attachment
|
|
22
23
|
*/
|
|
24
|
+
function getAttachmentDisplayName(attachment: IAttachment): string {
|
|
25
|
+
if (attachment.type === 'notebook') {
|
|
26
|
+
// Extract notebook filename with extension
|
|
27
|
+
const notebookName =
|
|
28
|
+
PathExt.basename(attachment.value) || 'Unknown notebook';
|
|
29
|
+
|
|
30
|
+
// Show info about attached cells if there are any
|
|
31
|
+
if (attachment.cells?.length === 1) {
|
|
32
|
+
return `${notebookName}: ${attachment.cells[0].input_type} cell`;
|
|
33
|
+
} else if (attachment.cells && attachment.cells.length > 1) {
|
|
34
|
+
return `${notebookName}: ${attachment.cells.length} cells`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return notebookName;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (attachment.type === 'file') {
|
|
41
|
+
// Extract filename with extension
|
|
42
|
+
const fileName = PathExt.basename(attachment.value) || 'Unknown file';
|
|
43
|
+
|
|
44
|
+
return fileName;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (attachment as any).value || 'Unknown attachment';
|
|
48
|
+
}
|
|
49
|
+
|
|
23
50
|
export type AttachmentsProps = {
|
|
24
51
|
attachments: IAttachment[];
|
|
25
52
|
onRemove?: (attachment: IAttachment) => void;
|
|
@@ -32,7 +59,11 @@ export function AttachmentPreviewList(props: AttachmentsProps): JSX.Element {
|
|
|
32
59
|
return (
|
|
33
60
|
<Box className={ATTACHMENTS_CLASS}>
|
|
34
61
|
{props.attachments.map(attachment => (
|
|
35
|
-
<AttachmentPreview
|
|
62
|
+
<AttachmentPreview
|
|
63
|
+
key={`${PathExt.basename(attachment.value)}-${UUID.uuid4()}`}
|
|
64
|
+
{...props}
|
|
65
|
+
attachment={attachment}
|
|
66
|
+
/>
|
|
36
67
|
))}
|
|
37
68
|
</Box>
|
|
38
69
|
);
|
|
@@ -66,7 +97,7 @@ export function AttachmentPreview(props: AttachmentProps): JSX.Element {
|
|
|
66
97
|
)
|
|
67
98
|
}
|
|
68
99
|
>
|
|
69
|
-
{props.attachment
|
|
100
|
+
{getAttachmentDisplayName(props.attachment)}
|
|
70
101
|
</span>
|
|
71
102
|
{props.onRemove && (
|
|
72
103
|
<TooltippedButton
|
package/src/components/chat.tsx
CHANGED
package/src/input-model.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { ISignal, Signal } from '@lumino/signaling';
|
|
|
9
9
|
import { IActiveCellManager } from './active-cell-manager';
|
|
10
10
|
import { ISelectionWatcher } from './selection-watcher';
|
|
11
11
|
import { IChatContext } from './model';
|
|
12
|
-
import { IAttachment, IUser } from './types';
|
|
12
|
+
import { IAttachment, INotebookAttachment, IUser } from './types';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* The chat input interface.
|
|
@@ -318,13 +318,46 @@ export class InputModel implements IInputModel {
|
|
|
318
318
|
* Add attachment to send with next message.
|
|
319
319
|
*/
|
|
320
320
|
addAttachment = (attachment: IAttachment): void => {
|
|
321
|
+
// Use JSON comparison to detect duplicates (same as in YChat.setAttachment)
|
|
322
|
+
const attachmentJson = JSON.stringify(attachment);
|
|
321
323
|
const duplicateAttachment = this._attachments.find(
|
|
322
|
-
att =>
|
|
324
|
+
att => JSON.stringify(att) === attachmentJson
|
|
323
325
|
);
|
|
324
326
|
if (duplicateAttachment) {
|
|
325
327
|
return;
|
|
326
328
|
}
|
|
327
329
|
|
|
330
|
+
// Merge cells from same notebook into the same attachment
|
|
331
|
+
if (attachment.type === 'notebook' && attachment.cells) {
|
|
332
|
+
const existingNotebookIndex = this._attachments.findIndex(
|
|
333
|
+
att => att.type === 'notebook' && att.value === attachment.value
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
if (existingNotebookIndex !== -1) {
|
|
337
|
+
const existingAttachment = this._attachments[
|
|
338
|
+
existingNotebookIndex
|
|
339
|
+
] as INotebookAttachment;
|
|
340
|
+
const existingCells = existingAttachment.cells || [];
|
|
341
|
+
|
|
342
|
+
// Filter out duplicate cells
|
|
343
|
+
const newCells = attachment.cells.filter(
|
|
344
|
+
newCell =>
|
|
345
|
+
!existingCells.some(existingCell => existingCell.id === newCell.id)
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
if (!newCells.length) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this._attachments[existingNotebookIndex] = {
|
|
353
|
+
...existingAttachment,
|
|
354
|
+
cells: [...existingCells, ...newCells]
|
|
355
|
+
};
|
|
356
|
+
this._attachmentsChanged.emit([...this._attachments]);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
328
361
|
this._attachments.push(attachment);
|
|
329
362
|
this._attachmentsChanged.emit([...this._attachments]);
|
|
330
363
|
};
|
|
@@ -333,8 +366,10 @@ export class InputModel implements IInputModel {
|
|
|
333
366
|
* Remove attachment to be sent.
|
|
334
367
|
*/
|
|
335
368
|
removeAttachment = (attachment: IAttachment): void => {
|
|
369
|
+
// Use JSON comparison to detect duplicates (same as in YChat.setAttachment)
|
|
370
|
+
const attachmentJson = JSON.stringify(attachment);
|
|
336
371
|
const attachmentIndex = this._attachments.findIndex(
|
|
337
|
-
att =>
|
|
372
|
+
att => JSON.stringify(att) === attachmentJson
|
|
338
373
|
);
|
|
339
374
|
if (attachmentIndex === -1) {
|
|
340
375
|
return;
|
package/src/model.ts
CHANGED
|
@@ -784,6 +784,10 @@ export interface IChatContext {
|
|
|
784
784
|
* A list of all users who have connected to this chat.
|
|
785
785
|
*/
|
|
786
786
|
readonly users: IUser[];
|
|
787
|
+
/**
|
|
788
|
+
* Current user connected with the chat panel
|
|
789
|
+
*/
|
|
790
|
+
readonly user: IUser | undefined;
|
|
787
791
|
}
|
|
788
792
|
|
|
789
793
|
/**
|
|
@@ -803,6 +807,10 @@ export abstract class AbstractChatContext implements IChatContext {
|
|
|
803
807
|
return [...this._model.messages];
|
|
804
808
|
}
|
|
805
809
|
|
|
810
|
+
get user(): IUser | undefined {
|
|
811
|
+
return this._model?.user;
|
|
812
|
+
}
|
|
813
|
+
|
|
806
814
|
/**
|
|
807
815
|
* ABSTRACT: Should return a list of users who have connected to this chat.
|
|
808
816
|
*/
|
package/src/types.ts
CHANGED
|
@@ -14,14 +14,14 @@ export interface IUser {
|
|
|
14
14
|
color?: string;
|
|
15
15
|
avatar_url?: string;
|
|
16
16
|
/**
|
|
17
|
-
* The string to use to mention a user in the chat. This
|
|
18
|
-
* following procedure:
|
|
17
|
+
* The string to use to mention a user in the chat. This should be computed
|
|
18
|
+
* via the following procedure:
|
|
19
19
|
*
|
|
20
20
|
* 1. Let `mention_name = user.display_name || user.name || user.username`.
|
|
21
21
|
*
|
|
22
22
|
* 2. Replace each ' ' character with '-' in `mention_name`.
|
|
23
23
|
*/
|
|
24
|
-
mention_name
|
|
24
|
+
mention_name?: string;
|
|
25
25
|
/**
|
|
26
26
|
* Boolean identifying if user is a bot.
|
|
27
27
|
*/
|
|
@@ -87,21 +87,85 @@ export interface INewMessage {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* The attachment
|
|
90
|
+
* The attachment type. Jupyter Chat allows for two types of attachments
|
|
91
|
+
* currently:
|
|
92
|
+
*
|
|
93
|
+
* 1. File attachments (`IFileAttachment`)
|
|
94
|
+
* 2. Notebook attachments (`INotebookAttachment`)
|
|
95
|
+
*
|
|
96
|
+
* The `type` field is always defined on every attachment, so it can be used to
|
|
97
|
+
* distinguish different attachment types.
|
|
91
98
|
*/
|
|
92
|
-
export
|
|
99
|
+
export type IAttachment = IFileAttachment | INotebookAttachment;
|
|
100
|
+
|
|
101
|
+
export interface IFileAttachment {
|
|
102
|
+
type: 'file';
|
|
103
|
+
/**
|
|
104
|
+
* The path to the file, relative to `ContentsManager.root_dir`.
|
|
105
|
+
*/
|
|
106
|
+
value: string;
|
|
107
|
+
/**
|
|
108
|
+
* (optional) The MIME type of the attachment.
|
|
109
|
+
*/
|
|
110
|
+
mimetype?: string;
|
|
111
|
+
/**
|
|
112
|
+
* (optional) A selection range within the file. See `IAttachmentSelection`
|
|
113
|
+
* for more info.
|
|
114
|
+
*/
|
|
115
|
+
selection?: IAttachmentSelection;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Model of a single cell within a notebook attachment.
|
|
120
|
+
*
|
|
121
|
+
* The corresponding backend model is `NotebookCell`.
|
|
122
|
+
*/
|
|
123
|
+
export interface INotebookAttachmentCell {
|
|
124
|
+
/**
|
|
125
|
+
* The ID of the cell within the notebook.
|
|
126
|
+
*/
|
|
127
|
+
id: string;
|
|
93
128
|
/**
|
|
94
|
-
* The type of the
|
|
129
|
+
* The type of the cell.
|
|
95
130
|
*/
|
|
96
|
-
|
|
131
|
+
input_type: 'raw' | 'markdown' | 'code';
|
|
97
132
|
/**
|
|
98
|
-
*
|
|
133
|
+
* (optional) A selection range within the cell. See `IAttachmentSelection` for
|
|
134
|
+
* more info.
|
|
135
|
+
*/
|
|
136
|
+
selection?: IAttachmentSelection;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Model of a notebook attachment.
|
|
141
|
+
*
|
|
142
|
+
* The corresponding backend model is `NotebookAttachment`.
|
|
143
|
+
*/
|
|
144
|
+
export interface INotebookAttachment {
|
|
145
|
+
type: 'notebook';
|
|
146
|
+
/**
|
|
147
|
+
* The local path of the notebook, relative to `ContentsManager.root_dir`.
|
|
99
148
|
*/
|
|
100
149
|
value: string;
|
|
101
150
|
/**
|
|
102
|
-
*
|
|
151
|
+
* (optional) A list of cells in the notebook.
|
|
103
152
|
*/
|
|
104
|
-
|
|
153
|
+
cells?: INotebookAttachmentCell[];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface IAttachmentSelection {
|
|
157
|
+
/**
|
|
158
|
+
* The line number & column number of where the selection begins (inclusive).
|
|
159
|
+
*/
|
|
160
|
+
start: [number, number];
|
|
161
|
+
/**
|
|
162
|
+
* The line number & column number of where the selection ends (inclusive).
|
|
163
|
+
*/
|
|
164
|
+
end: [number, number];
|
|
165
|
+
/**
|
|
166
|
+
* The initial content of the selection.
|
|
167
|
+
*/
|
|
168
|
+
content: string;
|
|
105
169
|
}
|
|
106
170
|
|
|
107
171
|
/**
|
|
@@ -4,11 +4,32 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { ReactWidget } from '@jupyterlab/apputils';
|
|
7
|
+
import { Cell } from '@jupyterlab/cells';
|
|
8
|
+
import { DirListing } from '@jupyterlab/filebrowser';
|
|
9
|
+
import { ICell, isCode, isMarkdown, isRaw } from '@jupyterlab/nbformat';
|
|
7
10
|
import React from 'react';
|
|
11
|
+
import { Message } from '@lumino/messaging';
|
|
12
|
+
import { Drag } from '@lumino/dragdrop';
|
|
8
13
|
|
|
9
14
|
import { Chat, IInputToolbarRegistry } from '../components';
|
|
10
15
|
import { chatIcon } from '../icons';
|
|
11
16
|
import { IChatModel } from '../model';
|
|
17
|
+
import {
|
|
18
|
+
IFileAttachment,
|
|
19
|
+
INotebookAttachment,
|
|
20
|
+
INotebookAttachmentCell
|
|
21
|
+
} from '../types';
|
|
22
|
+
import { ActiveCellManager } from '../active-cell-manager';
|
|
23
|
+
|
|
24
|
+
// MIME type constant for file browser drag events
|
|
25
|
+
const FILE_BROWSER_MIME = 'application/x-jupyter-icontentsrich';
|
|
26
|
+
|
|
27
|
+
// MIME type constant for Notebook cell drag events
|
|
28
|
+
const NOTEBOOK_CELL_MIME = 'application/vnd.jupyter.cells';
|
|
29
|
+
|
|
30
|
+
// CSS class constants
|
|
31
|
+
const INPUT_CONTAINER_CLASS = 'jp-chat-input-container';
|
|
32
|
+
const DRAG_HOVER_CLASS = 'jp-chat-drag-hover';
|
|
12
33
|
|
|
13
34
|
export class ChatWidget extends ReactWidget {
|
|
14
35
|
constructor(options: Chat.IOptions) {
|
|
@@ -42,5 +63,263 @@ export class ChatWidget extends ReactWidget {
|
|
|
42
63
|
return <Chat {...this._chatOptions} model={this._chatOptions.model} />;
|
|
43
64
|
}
|
|
44
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Handle DOM events for drag and drop
|
|
68
|
+
*/
|
|
69
|
+
handleEvent(event: Event): void {
|
|
70
|
+
// only handle drag-and-drop events
|
|
71
|
+
if (!(event instanceof Drag.Event)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
switch (event.type) {
|
|
76
|
+
case 'lm-dragenter':
|
|
77
|
+
// see `Drag.Event` documentation for context
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
event.stopPropagation();
|
|
80
|
+
break;
|
|
81
|
+
case 'lm-dragover':
|
|
82
|
+
this._handleDrag(event);
|
|
83
|
+
break;
|
|
84
|
+
case 'lm-drop':
|
|
85
|
+
this._handleDrop(event);
|
|
86
|
+
break;
|
|
87
|
+
case 'lm-dragleave': {
|
|
88
|
+
// Remove hover class on leaving the widget
|
|
89
|
+
const targetElement = event.relatedTarget;
|
|
90
|
+
if (!targetElement || !this.node.contains(targetElement as Node)) {
|
|
91
|
+
this._removeDragHoverClass();
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A message handler invoked on an `'after-attach'` message.
|
|
100
|
+
*/
|
|
101
|
+
protected onAfterAttach(msg: Message): void {
|
|
102
|
+
super.onAfterAttach(msg);
|
|
103
|
+
this.node.addEventListener('lm-dragover', this, true);
|
|
104
|
+
this.node.addEventListener('lm-dragenter', this, true);
|
|
105
|
+
this.node.addEventListener('lm-drop', this, true);
|
|
106
|
+
this.node.addEventListener('lm-dragleave', this, true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A message handler invoked on a `'before-detach'` message.
|
|
111
|
+
*/
|
|
112
|
+
protected onBeforeDetach(msg: Message): void {
|
|
113
|
+
this.node.removeEventListener('lm-dragover', this, true);
|
|
114
|
+
this.node.removeEventListener('lm-dragenter', this, true);
|
|
115
|
+
this.node.removeEventListener('lm-drop', this, true);
|
|
116
|
+
this.node.removeEventListener('lm-dragleave', this, true);
|
|
117
|
+
super.onBeforeDetach(msg);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Handle drag over events
|
|
122
|
+
*/
|
|
123
|
+
private _handleDrag(event: Drag.Event): void {
|
|
124
|
+
const inputContainer = this.node.querySelector(`.${INPUT_CONTAINER_CLASS}`);
|
|
125
|
+
const target = event.target as HTMLElement;
|
|
126
|
+
const isOverInput =
|
|
127
|
+
inputContainer?.contains(target) || inputContainer === target;
|
|
128
|
+
|
|
129
|
+
if (!isOverInput) {
|
|
130
|
+
this._removeDragHoverClass();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!this._canHandleDrop(event)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
event.stopPropagation();
|
|
140
|
+
event.dropAction = 'move';
|
|
141
|
+
|
|
142
|
+
if (
|
|
143
|
+
inputContainer &&
|
|
144
|
+
!inputContainer.classList.contains(DRAG_HOVER_CLASS)
|
|
145
|
+
) {
|
|
146
|
+
inputContainer.classList.add(DRAG_HOVER_CLASS);
|
|
147
|
+
this._dragTarget = inputContainer as HTMLElement;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if we can handle the drop
|
|
153
|
+
*/
|
|
154
|
+
private _canHandleDrop(event: Drag.Event): boolean {
|
|
155
|
+
const types = event.mimeData.types();
|
|
156
|
+
return (
|
|
157
|
+
types.includes(NOTEBOOK_CELL_MIME) || types.includes(FILE_BROWSER_MIME)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Handle drop events
|
|
163
|
+
*/
|
|
164
|
+
private _handleDrop(event: Drag.Event): void {
|
|
165
|
+
if (!this._canHandleDrop(event)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
event.stopPropagation();
|
|
171
|
+
event.dropAction = 'move';
|
|
172
|
+
|
|
173
|
+
this._removeDragHoverClass();
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
if (event.mimeData.hasData(NOTEBOOK_CELL_MIME)) {
|
|
177
|
+
this._processCellDrop(event);
|
|
178
|
+
} else if (event.mimeData.hasData(FILE_BROWSER_MIME)) {
|
|
179
|
+
this._processFileDrop(event);
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.error('Error processing drop:', error);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Process dropped files
|
|
188
|
+
*/
|
|
189
|
+
private _processFileDrop(event: Drag.Event): void {
|
|
190
|
+
const data = event.mimeData.getData(
|
|
191
|
+
FILE_BROWSER_MIME
|
|
192
|
+
) as DirListing.IContentsThunk;
|
|
193
|
+
|
|
194
|
+
if (!data?.model?.path) {
|
|
195
|
+
console.warn('Invalid file browser data in drop event');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const attachment: IFileAttachment = {
|
|
200
|
+
type: 'file',
|
|
201
|
+
value: data.model.path,
|
|
202
|
+
mimetype: data.model.mimetype
|
|
203
|
+
};
|
|
204
|
+
this.model.input.addAttachment?.(attachment);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Process dropped cells
|
|
209
|
+
*/
|
|
210
|
+
private _processCellDrop(event: Drag.Event): void {
|
|
211
|
+
try {
|
|
212
|
+
const cellData = event.mimeData.getData(NOTEBOOK_CELL_MIME) as ICell[];
|
|
213
|
+
|
|
214
|
+
// Cells might come as array or single object
|
|
215
|
+
const cells = Array.isArray(cellData) ? cellData : [cellData];
|
|
216
|
+
|
|
217
|
+
// Get path from first cell as all cells come from same notebook as users can only select or drag cells from one notebook at a time
|
|
218
|
+
if (!cells[0]?.id) {
|
|
219
|
+
console.warn('No valid cells to process');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const notebookPath = this._findNotebookPath(String(cells[0].id));
|
|
224
|
+
if (!notebookPath) {
|
|
225
|
+
console.warn(
|
|
226
|
+
`Cannot find notebook for dragged cells from ${cells[0].id}`
|
|
227
|
+
);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const validCells: INotebookAttachmentCell[] = [];
|
|
232
|
+
|
|
233
|
+
for (const cell of cells) {
|
|
234
|
+
if (!cell.id) {
|
|
235
|
+
console.warn('Dropped cell missing required ID, skipping');
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Use type guards to validate cell type
|
|
240
|
+
let cellType: 'code' | 'markdown' | 'raw';
|
|
241
|
+
if (isCode(cell)) {
|
|
242
|
+
cellType = 'code';
|
|
243
|
+
} else if (isMarkdown(cell)) {
|
|
244
|
+
cellType = 'markdown';
|
|
245
|
+
} else if (isRaw(cell)) {
|
|
246
|
+
cellType = 'raw';
|
|
247
|
+
} else {
|
|
248
|
+
console.warn(`Unknown cell type: ${cell.cell_type}, skipping`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const notebookCell: INotebookAttachmentCell = {
|
|
253
|
+
id: cell.id,
|
|
254
|
+
input_type: cellType
|
|
255
|
+
};
|
|
256
|
+
validCells.push(notebookCell);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Create single attachment with all cells from the notebook
|
|
260
|
+
if (validCells.length) {
|
|
261
|
+
const attachment: INotebookAttachment = {
|
|
262
|
+
type: 'notebook',
|
|
263
|
+
value: notebookPath,
|
|
264
|
+
cells: validCells
|
|
265
|
+
};
|
|
266
|
+
this.model.input.addAttachment?.(attachment);
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error('Failed to process cell drop: ', error);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Find the notebook path for a cell by searching through active and open notebooks
|
|
275
|
+
*/
|
|
276
|
+
private _findNotebookPath(cellId: string): string | null {
|
|
277
|
+
if (!this.model.input.activeCellManager) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const activeCellManager = this.model.input
|
|
282
|
+
.activeCellManager as ActiveCellManager;
|
|
283
|
+
const notebookTracker = (activeCellManager as any)._notebookTracker;
|
|
284
|
+
|
|
285
|
+
if (!notebookTracker) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (notebookTracker.currentWidget) {
|
|
290
|
+
const currentNotebook = notebookTracker.currentWidget;
|
|
291
|
+
const cells = currentNotebook.content.widgets;
|
|
292
|
+
const cellWidget = cells.find((c: Cell) => c.model.id === cellId);
|
|
293
|
+
|
|
294
|
+
if (cellWidget) {
|
|
295
|
+
return currentNotebook.context.path;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// If not in current notebook, check all open notebooks
|
|
300
|
+
const widgets = notebookTracker.widgets || [];
|
|
301
|
+
for (const notebook of widgets) {
|
|
302
|
+
const cells = notebook.content.widgets;
|
|
303
|
+
const cellWidget = cells.find((c: Cell) => c.model.id === cellId);
|
|
304
|
+
|
|
305
|
+
if (cellWidget) {
|
|
306
|
+
return notebook.context.path;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Remove drag hover class
|
|
315
|
+
*/
|
|
316
|
+
private _removeDragHoverClass(): void {
|
|
317
|
+
if (this._dragTarget) {
|
|
318
|
+
this._dragTarget.classList.remove(DRAG_HOVER_CLASS);
|
|
319
|
+
this._dragTarget = null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
45
323
|
private _chatOptions: Chat.IOptions;
|
|
324
|
+
private _dragTarget: HTMLElement | null = null;
|
|
46
325
|
}
|
package/style/chat.css
CHANGED
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/*
|
|
22
|
-
*
|
|
23
22
|
* Selectors must be nested in `.jp-ThemedContainer` to have a higher
|
|
24
23
|
* specificity than selectors in rules provided by JupyterLab.
|
|
25
24
|
*
|
|
@@ -147,7 +146,6 @@
|
|
|
147
146
|
}
|
|
148
147
|
|
|
149
148
|
/* Keyframe animations */
|
|
150
|
-
|
|
151
149
|
@keyframes jp-chat-typing-bounce {
|
|
152
150
|
0%,
|
|
153
151
|
80%,
|
|
@@ -201,6 +199,14 @@
|
|
|
201
199
|
|
|
202
200
|
.jp-chat-attachments {
|
|
203
201
|
display: flex;
|
|
202
|
+
gap: 4px;
|
|
203
|
+
flex-wrap: wrap;
|
|
204
|
+
min-height: 1.5em;
|
|
205
|
+
padding: 8px 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.jp-chat-attachments:empty {
|
|
209
|
+
padding: 8px 0;
|
|
204
210
|
min-height: 1.5em;
|
|
205
211
|
}
|
|
206
212
|
|
|
@@ -211,6 +217,7 @@
|
|
|
211
217
|
padding: 0 0.3em;
|
|
212
218
|
align-content: center;
|
|
213
219
|
background-color: var(--jp-border-color3);
|
|
220
|
+
flex-shrink: 0;
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
.jp-chat-attachment .jp-chat-attachment-clickable:hover {
|
package/style/input.css
CHANGED
|
@@ -30,3 +30,45 @@
|
|
|
30
30
|
.jp-chat-input-toolbar .jp-chat-send-include-opener {
|
|
31
31
|
padding: 4px 0;
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
.jp-chat-input-container {
|
|
35
|
+
position: relative;
|
|
36
|
+
transition: all 150ms ease;
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
min-height: 56px;
|
|
41
|
+
padding: 8px 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* stylelint-disable-next-line selector-class-pattern */
|
|
45
|
+
.jp-chat-input-container .MuiTextField-root {
|
|
46
|
+
padding-bottom: 8px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.jp-chat-input-container.jp-chat-drag-hover::after {
|
|
50
|
+
content: '';
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset: 0;
|
|
53
|
+
background: rgb(33 150 243 / 10%);
|
|
54
|
+
border: 2px dashed var(--jp-brand-color1);
|
|
55
|
+
border-radius: 4px;
|
|
56
|
+
pointer-events: none;
|
|
57
|
+
z-index: 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.jp-chat-input-container.jp-chat-drag-hover::before {
|
|
61
|
+
content: 'Drop files or cells here';
|
|
62
|
+
position: absolute;
|
|
63
|
+
top: -24px;
|
|
64
|
+
left: 50%;
|
|
65
|
+
transform: translateX(-50%);
|
|
66
|
+
color: var(--jp-brand-color1);
|
|
67
|
+
font-size: 12px;
|
|
68
|
+
font-weight: 500;
|
|
69
|
+
pointer-events: none;
|
|
70
|
+
background: var(--jp-layout-color0);
|
|
71
|
+
padding: 2px 8px;
|
|
72
|
+
border-radius: 3px;
|
|
73
|
+
white-space: nowrap;
|
|
74
|
+
}
|