@jupyter/chat 0.14.0 → 0.16.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/components/input/chat-input.js +1 -1
- package/lib/components/input/use-chat-commands.js +11 -3
- package/lib/input-model.js +55 -5
- package/lib/types.d.ts +73 -8
- package/lib/utils.js +8 -6
- 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/components/input/chat-input.tsx +1 -1
- package/src/components/input/use-chat-commands.tsx +11 -3
- package/src/input-model.ts +71 -7
- package/src/types.ts +77 -8
- package/src/utils.ts +8 -6
- package/src/widgets/chat-widget.tsx +279 -0
- package/style/chat.css +9 -0
- package/style/input.css +44 -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 })));
|
|
@@ -118,7 +118,7 @@ export function ChatInput(props) {
|
|
|
118
118
|
(!sendWithShiftEnter && !event.shiftKey)) {
|
|
119
119
|
// Run all command providers
|
|
120
120
|
await ((_a = props.chatCommandRegistry) === null || _a === void 0 ? void 0 : _a.onSubmit(model));
|
|
121
|
-
model.send(
|
|
121
|
+
model.send(model.value);
|
|
122
122
|
event.stopPropagation();
|
|
123
123
|
event.preventDefault();
|
|
124
124
|
}
|
|
@@ -14,7 +14,9 @@ import React, { useEffect, useState } from 'react';
|
|
|
14
14
|
export function useChatCommands(inputModel, chatCommandRegistry) {
|
|
15
15
|
// whether an option is highlighted in the chat commands menu
|
|
16
16
|
const [highlighted, setHighlighted] = useState(false);
|
|
17
|
-
// whether the chat commands menu is open
|
|
17
|
+
// whether the chat commands menu is open.
|
|
18
|
+
// NOTE: every `setOpen(false)` call should be followed by a
|
|
19
|
+
// `setHighlighted(false)` call.
|
|
18
20
|
const [open, setOpen] = useState(false);
|
|
19
21
|
// current list of chat commands matched by the current word.
|
|
20
22
|
// the current word is the space-separated word at the user's cursor.
|
|
@@ -55,7 +57,13 @@ export function useChatCommands(inputModel, chatCommandRegistry) {
|
|
|
55
57
|
}
|
|
56
58
|
// Otherwise, open/close the menu based on the presence of command
|
|
57
59
|
// completions and set the menu entries.
|
|
58
|
-
|
|
60
|
+
if (commandCompletions.length) {
|
|
61
|
+
setOpen(true);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
setOpen(false);
|
|
65
|
+
setHighlighted(false);
|
|
66
|
+
}
|
|
59
67
|
setCommands(commandCompletions);
|
|
60
68
|
}
|
|
61
69
|
inputModel.currentWordChanged.connect(getCommands);
|
|
@@ -91,7 +99,7 @@ export function useChatCommands(inputModel, chatCommandRegistry) {
|
|
|
91
99
|
autocompleteProps: {
|
|
92
100
|
open,
|
|
93
101
|
options: commands,
|
|
94
|
-
getOptionLabel: (command) => command.name,
|
|
102
|
+
getOptionLabel: (command) => typeof command === 'string' ? '' : command.name,
|
|
95
103
|
renderOption: (defaultProps, command, __, ___) => {
|
|
96
104
|
const { key, ...listItemProps } = defaultProps;
|
|
97
105
|
const commandIcon = React.isValidElement(command.icon) ? (command.icon) : (React.createElement("span", null, command.icon instanceof LabIcon ? (React.createElement(command.icon.react, null)) : (command.icon)));
|
package/lib/input-model.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
5
|
import { Signal } from '@lumino/signaling';
|
|
6
|
-
const WHITESPACE = new Set([' ', '\n', '\t']);
|
|
7
6
|
/**
|
|
8
7
|
* The input model.
|
|
9
8
|
*/
|
|
@@ -21,10 +20,31 @@ export class InputModel {
|
|
|
21
20
|
* Add attachment to send with next message.
|
|
22
21
|
*/
|
|
23
22
|
this.addAttachment = (attachment) => {
|
|
24
|
-
|
|
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);
|
|
25
26
|
if (duplicateAttachment) {
|
|
26
27
|
return;
|
|
27
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
|
+
}
|
|
28
48
|
this._attachments.push(attachment);
|
|
29
49
|
this._attachmentsChanged.emit([...this._attachments]);
|
|
30
50
|
};
|
|
@@ -32,7 +52,9 @@ export class InputModel {
|
|
|
32
52
|
* Remove attachment to be sent.
|
|
33
53
|
*/
|
|
34
54
|
this.removeAttachment = (attachment) => {
|
|
35
|
-
|
|
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);
|
|
36
58
|
if (attachmentIndex === -1) {
|
|
37
59
|
return;
|
|
38
60
|
}
|
|
@@ -254,14 +276,42 @@ export class InputModel {
|
|
|
254
276
|
}
|
|
255
277
|
var Private;
|
|
256
278
|
(function (Private) {
|
|
279
|
+
const WHITESPACE = new Set([' ', '\n', '\t']);
|
|
280
|
+
/**
|
|
281
|
+
* Returns the start index (inclusive) & end index (exclusive) that contain
|
|
282
|
+
* the current word. The start & end index can be passed to `String.slice()`
|
|
283
|
+
* to extract the current word. The returned range never includes any
|
|
284
|
+
* whitespace character unless it is escaped by a backslash `\`.
|
|
285
|
+
*
|
|
286
|
+
* NOTE: the escape sequence handling here is naive and non-recursive. This
|
|
287
|
+
* function considers the space in "`\\ `" as escaped, even though "`\\ `"
|
|
288
|
+
* defines a backslash followed by an _unescaped_ space in most languages.
|
|
289
|
+
*/
|
|
257
290
|
function getCurrentWordBoundaries(input, cursorIndex) {
|
|
258
291
|
let start = cursorIndex;
|
|
259
292
|
let end = cursorIndex;
|
|
260
293
|
const n = input.length;
|
|
261
|
-
while (
|
|
294
|
+
while (
|
|
295
|
+
// terminate when `input[start - 1]` is whitespace
|
|
296
|
+
// i.e. `input[start]` is never whitespace after exiting
|
|
297
|
+
(start - 1 >= 0 && !WHITESPACE.has(input[start - 1])) ||
|
|
298
|
+
// unless it is preceded by a backslash
|
|
299
|
+
(start - 2 >= 0 &&
|
|
300
|
+
input[start - 2] === '\\' &&
|
|
301
|
+
WHITESPACE.has(input[start - 1]))) {
|
|
262
302
|
start--;
|
|
263
303
|
}
|
|
264
|
-
|
|
304
|
+
// `end` is an exclusive index unlike `start`, hence the different `while`
|
|
305
|
+
// condition here
|
|
306
|
+
while (
|
|
307
|
+
// terminate when `input[end]` is whitespace
|
|
308
|
+
// i.e. `input[end]` may be whitespace after exiting
|
|
309
|
+
(end < n && !WHITESPACE.has(input[end])) ||
|
|
310
|
+
// unless it is preceded by a backslash
|
|
311
|
+
(end < n &&
|
|
312
|
+
end - 1 >= 0 &&
|
|
313
|
+
input[end - 1] === '\\' &&
|
|
314
|
+
WHITESPACE.has(input[end]))) {
|
|
265
315
|
end++;
|
|
266
316
|
}
|
|
267
317
|
return [start, end];
|
package/lib/types.d.ts
CHANGED
|
@@ -9,7 +9,12 @@ 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.
|
|
12
|
+
* The string to use to mention a user in the chat. This should be computed
|
|
13
|
+
* via the following procedure:
|
|
14
|
+
*
|
|
15
|
+
* 1. Let `mention_name = user.display_name || user.name || user.username`.
|
|
16
|
+
*
|
|
17
|
+
* 2. Replace each ' ' character with '-' in `mention_name`.
|
|
13
18
|
*/
|
|
14
19
|
mention_name?: string;
|
|
15
20
|
/**
|
|
@@ -72,21 +77,81 @@ export interface INewMessage {
|
|
|
72
77
|
id?: string;
|
|
73
78
|
}
|
|
74
79
|
/**
|
|
75
|
-
* 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.
|
|
76
88
|
*/
|
|
77
|
-
export
|
|
89
|
+
export type IAttachment = IFileAttachment | INotebookAttachment;
|
|
90
|
+
export interface IFileAttachment {
|
|
91
|
+
type: 'file';
|
|
78
92
|
/**
|
|
79
|
-
* The
|
|
93
|
+
* The path to the file, relative to `ContentsManager.root_dir`.
|
|
80
94
|
*/
|
|
81
|
-
|
|
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';
|
|
82
120
|
/**
|
|
83
|
-
*
|
|
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`.
|
|
84
135
|
*/
|
|
85
136
|
value: string;
|
|
86
137
|
/**
|
|
87
|
-
*
|
|
138
|
+
* (optional) A list of cells in the notebook.
|
|
88
139
|
*/
|
|
89
|
-
|
|
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;
|
|
90
155
|
}
|
|
91
156
|
/**
|
|
92
157
|
* An empty interface to describe optional settings that could be fetched from server.
|
package/lib/utils.js
CHANGED
|
@@ -46,9 +46,10 @@ export function replaceMentionToSpan(content, user) {
|
|
|
46
46
|
if (!user.mention_name) {
|
|
47
47
|
return content;
|
|
48
48
|
}
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
49
|
+
const mention = '@' + user.mention_name;
|
|
50
|
+
const regex = new RegExp(mention, 'g');
|
|
51
|
+
const mentionEl = `<span class="${MENTION_CLASS}">${mention}</span>`;
|
|
52
|
+
return content.replace(regex, mentionEl);
|
|
52
53
|
}
|
|
53
54
|
/**
|
|
54
55
|
* Replace a span to a mentioned to user string (@someone).
|
|
@@ -60,7 +61,8 @@ export function replaceSpanToMention(content, user) {
|
|
|
60
61
|
if (!user.mention_name) {
|
|
61
62
|
return content;
|
|
62
63
|
}
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
64
|
+
const mention = '@' + user.mention_name;
|
|
65
|
+
const mentionEl = `<span class="${MENTION_CLASS}">${mention}</span>`;
|
|
66
|
+
const regex = new RegExp(mentionEl, 'g');
|
|
67
|
+
return content.replace(regex, mention);
|
|
66
68
|
}
|
|
@@ -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
|
}
|