@jupyter/chat 0.1.0 → 0.2.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.
@@ -9,57 +9,20 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
9
9
  import SettingsIcon from '@mui/icons-material/Settings';
10
10
  import { IconButton } from '@mui/material';
11
11
  import { Box } from '@mui/system';
12
- import React, { useState, useEffect } from 'react';
12
+ import React, { useState } from 'react';
13
13
 
14
14
  import { JlThemeProvider } from './jl-theme-provider';
15
15
  import { ChatMessages } from './chat-messages';
16
16
  import { ChatInput } from './chat-input';
17
- import { ScrollContainer } from './scroll-container';
18
17
  import { IChatModel } from '../model';
19
- import { IChatMessage } from '../types';
20
-
21
- type ChatBodyProps = {
22
- model: IChatModel;
23
- rmRegistry: IRenderMimeRegistry;
24
- };
25
-
26
- function ChatBody({
27
- model,
28
- rmRegistry: renderMimeRegistry
29
- }: ChatBodyProps): JSX.Element {
30
- const [messages, setMessages] = useState<IChatMessage[]>([]);
31
-
32
- /**
33
- * Effect: fetch history and config on initial render
34
- */
35
- useEffect(() => {
36
- async function fetchHistory() {
37
- if (!model.getHistory) {
38
- return;
39
- }
40
- model
41
- .getHistory()
42
- .then(history => setMessages(history.messages))
43
- .catch(e => console.error(e));
44
- }
45
-
46
- fetchHistory();
47
- }, [model]);
48
-
49
- /**
50
- * Effect: listen to chat messages
51
- */
52
- useEffect(() => {
53
- function handleChatEvents(_: IChatModel) {
54
- setMessages([...model.messages]);
55
- }
56
-
57
- model.messagesUpdated.connect(handleChatEvents);
58
- return function cleanup() {
59
- model.messagesUpdated.disconnect(handleChatEvents);
60
- };
61
- }, [model]);
18
+ import { IAutocompletionRegistry } from '../registry';
62
19
 
20
+ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element {
21
+ const {
22
+ model,
23
+ rmRegistry: renderMimeRegistry,
24
+ autocompletionRegistry
25
+ } = props;
63
26
  // no need to append to messageGroups imperatively here. all of that is
64
27
  // handled by the listeners registered in the effect hooks above.
65
28
  const onSend = async (input: string) => {
@@ -69,13 +32,7 @@ function ChatBody({
69
32
 
70
33
  return (
71
34
  <>
72
- <ScrollContainer sx={{ flexGrow: 1 }}>
73
- <ChatMessages
74
- messages={messages}
75
- rmRegistry={renderMimeRegistry}
76
- model={model}
77
- />
78
- </ScrollContainer>
35
+ <ChatMessages rmRegistry={renderMimeRegistry} model={model} />
79
36
  <ChatInput
80
37
  onSend={onSend}
81
38
  sx={{
@@ -86,15 +43,14 @@ function ChatBody({
86
43
  borderTop: '1px solid var(--jp-border-color1)'
87
44
  }}
88
45
  sendWithShiftEnter={model.config.sendWithShiftEnter ?? false}
46
+ autocompletionRegistry={autocompletionRegistry}
89
47
  />
90
48
  </>
91
49
  );
92
50
  }
93
51
 
94
52
  export function Chat(props: Chat.IOptions): JSX.Element {
95
- const [view, setView] = useState<Chat.ChatView>(
96
- props.chatView || Chat.ChatView.Chat
97
- );
53
+ const [view, setView] = useState<Chat.View>(props.chatView || Chat.View.chat);
98
54
  return (
99
55
  <JlThemeProvider themeManager={props.themeManager ?? null}>
100
56
  <Box
@@ -111,15 +67,15 @@ export function Chat(props: Chat.IOptions): JSX.Element {
111
67
  >
112
68
  {/* top bar */}
113
69
  <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
114
- {view !== Chat.ChatView.Chat ? (
115
- <IconButton onClick={() => setView(Chat.ChatView.Chat)}>
70
+ {view !== Chat.View.chat ? (
71
+ <IconButton onClick={() => setView(Chat.View.chat)}>
116
72
  <ArrowBackIcon />
117
73
  </IconButton>
118
74
  ) : (
119
75
  <Box />
120
76
  )}
121
- {view === Chat.ChatView.Chat && props.settingsPanel ? (
122
- <IconButton onClick={() => setView(Chat.ChatView.Settings)}>
77
+ {view !== Chat.View.settings && props.settingsPanel ? (
78
+ <IconButton onClick={() => setView(Chat.View.settings)}>
123
79
  <SettingsIcon />
124
80
  </IconButton>
125
81
  ) : (
@@ -127,10 +83,14 @@ export function Chat(props: Chat.IOptions): JSX.Element {
127
83
  )}
128
84
  </Box>
129
85
  {/* body */}
130
- {view === Chat.ChatView.Chat && (
131
- <ChatBody model={props.model} rmRegistry={props.rmRegistry} />
86
+ {view === Chat.View.chat && (
87
+ <ChatBody
88
+ model={props.model}
89
+ rmRegistry={props.rmRegistry}
90
+ autocompletionRegistry={props.autocompletionRegistry}
91
+ />
132
92
  )}
133
- {view === Chat.ChatView.Settings && props.settingsPanel && (
93
+ {view === Chat.View.settings && props.settingsPanel && (
134
94
  <props.settingsPanel />
135
95
  )}
136
96
  </Box>
@@ -143,9 +103,9 @@ export function Chat(props: Chat.IOptions): JSX.Element {
143
103
  */
144
104
  export namespace Chat {
145
105
  /**
146
- * The options to build the Chat UI.
106
+ * The props for the chat body component.
147
107
  */
148
- export interface IOptions {
108
+ export interface IChatBodyProps {
149
109
  /**
150
110
  * The chat model.
151
111
  */
@@ -154,6 +114,20 @@ export namespace Chat {
154
114
  * The rendermime registry.
155
115
  */
156
116
  rmRegistry: IRenderMimeRegistry;
117
+ /**
118
+ * Autocompletion registry.
119
+ */
120
+ autocompletionRegistry?: IAutocompletionRegistry;
121
+ /**
122
+ * Autocompletion name.
123
+ */
124
+ autocompletionName?: string;
125
+ }
126
+
127
+ /**
128
+ * The options to build the Chat UI.
129
+ */
130
+ export interface IOptions extends IChatBodyProps {
157
131
  /**
158
132
  * The theme manager.
159
133
  */
@@ -161,7 +135,7 @@ export namespace Chat {
161
135
  /**
162
136
  * The view to render.
163
137
  */
164
- chatView?: ChatView;
138
+ chatView?: View;
165
139
  /**
166
140
  * A settings panel that can be used for dedicated settings (e.g. jupyter-ai)
167
141
  */
@@ -172,8 +146,8 @@ export namespace Chat {
172
146
  * The view to render.
173
147
  * The settings view is available only if the settings panel is provided in options.
174
148
  */
175
- export enum ChatView {
176
- Chat,
177
- Settings
149
+ export enum View {
150
+ chat,
151
+ settings
178
152
  }
179
153
  }
@@ -3,7 +3,7 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
 
6
- import React, { useEffect, useMemo } from 'react';
6
+ import React, { useMemo } from 'react';
7
7
  import { Box, SxProps, Theme } from '@mui/material';
8
8
 
9
9
  type ScrollContainerProps = {
@@ -32,30 +32,6 @@ export function ScrollContainer(props: ScrollContainerProps): JSX.Element {
32
32
  []
33
33
  );
34
34
 
35
- /**
36
- * Effect: Scroll the container to the bottom as soon as it is visible.
37
- */
38
- useEffect(() => {
39
- const el = document.querySelector<HTMLElement>(`#${id}`);
40
- if (!el) {
41
- return;
42
- }
43
-
44
- const observer = new IntersectionObserver(
45
- entries => {
46
- entries.forEach(entry => {
47
- if (entry.isIntersecting) {
48
- el.scroll({ top: 999999999 });
49
- }
50
- });
51
- },
52
- { threshold: 1.0 }
53
- );
54
-
55
- observer.observe(el);
56
- return () => observer.disconnect();
57
- }, []);
58
-
59
35
  return (
60
36
  <Box
61
37
  id={id}
package/src/icons.ts CHANGED
@@ -8,8 +8,14 @@
8
8
  import { LabIcon } from '@jupyterlab/ui-components';
9
9
 
10
10
  import chatSvgStr from '../style/icons/chat.svg';
11
+ import readSvgStr from '../style/icons/read.svg';
11
12
 
12
13
  export const chatIcon = new LabIcon({
13
14
  name: 'jupyter-chat::chat',
14
15
  svgstr: chatSvgStr
15
16
  });
17
+
18
+ export const readIcon = new LabIcon({
19
+ name: 'jupyter-chat::read',
20
+ svgstr: readSvgStr
21
+ });
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  export * from './icons';
7
7
  export * from './model';
8
+ export * from './registry';
8
9
  export * from './types';
9
10
  export * from './widgets/chat-error';
10
11
  export * from './widgets/chat-sidebar';
package/src/model.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
5
 
6
+ import { CommandRegistry } from '@lumino/commands';
6
7
  import { IDisposable } from '@lumino/disposable';
7
8
  import { ISignal, Signal } from '@lumino/signaling';
8
9
 
@@ -19,15 +20,25 @@ import {
19
20
  */
20
21
  export interface IChatModel extends IDisposable {
21
22
  /**
22
- * The chat model ID.
23
+ * The chat model name.
23
24
  */
24
- id: string;
25
+ name: string;
25
26
 
26
27
  /**
27
28
  * The configuration for the chat panel.
28
29
  */
29
30
  config: IConfig;
30
31
 
32
+ /**
33
+ * The indexes list of the unread messages.
34
+ */
35
+ unreadMessages: number[];
36
+
37
+ /**
38
+ * The indexes list of the messages currently in the viewport.
39
+ */
40
+ messagesInViewport?: number[];
41
+
31
42
  /**
32
43
  * The user connected to the chat panel.
33
44
  */
@@ -39,10 +50,20 @@ export interface IChatModel extends IDisposable {
39
50
  readonly messages: IChatMessage[];
40
51
 
41
52
  /**
42
- * The signal emitted when the messages list is updated.
53
+ * A signal emitting when the messages list is updated.
43
54
  */
44
55
  readonly messagesUpdated: ISignal<IChatModel, void>;
45
56
 
57
+ /**
58
+ * A signal emitting when unread messages change.
59
+ */
60
+ readonly unreadChanged?: ISignal<IChatModel, number[]>;
61
+
62
+ /**
63
+ * A signal emitting when the viewport change.
64
+ */
65
+ readonly viewportChanged?: ISignal<IChatModel, number[]>;
66
+
46
67
  /**
47
68
  * Send a message, to be defined depending on the chosen technology.
48
69
  * Default to no-op.
@@ -119,7 +140,12 @@ export class ChatModel implements IChatModel {
119
140
  * Create a new chat model.
120
141
  */
121
142
  constructor(options: ChatModel.IOptions = {}) {
122
- this._config = options.config ?? {};
143
+ const config = options.config ?? {};
144
+
145
+ // Stack consecutive messages from the same user by default.
146
+ this._config = { stackMessages: true, ...config };
147
+
148
+ this._commands = options.commands;
123
149
  }
124
150
 
125
151
  /**
@@ -130,15 +156,48 @@ export class ChatModel implements IChatModel {
130
156
  }
131
157
 
132
158
  /**
133
- * The chat model ID.
159
+ * The chat model id.
134
160
  */
135
- get id(): string {
161
+ get id(): string | undefined {
136
162
  return this._id;
137
163
  }
138
- set id(value: string) {
164
+ set id(value: string | undefined) {
139
165
  this._id = value;
140
166
  }
141
167
 
168
+ /**
169
+ * The chat model name.
170
+ */
171
+ get name(): string {
172
+ return this._name;
173
+ }
174
+ set name(value: string) {
175
+ this._name = value;
176
+ }
177
+
178
+ /**
179
+ * Timestamp of the last read message in local storage.
180
+ */
181
+ get lastRead(): number {
182
+ if (this._id === undefined) {
183
+ return 0;
184
+ }
185
+ const storage = JSON.parse(
186
+ localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}'
187
+ );
188
+ return storage.lastRead ?? 0;
189
+ }
190
+ set lastRead(value: number) {
191
+ if (this._id === undefined) {
192
+ return;
193
+ }
194
+ const storage = JSON.parse(
195
+ localStorage.getItem(`@jupyter/chat:${this._id}`) || '{}'
196
+ );
197
+ storage.lastRead = value;
198
+ localStorage.setItem(`@jupyter/chat:${this._id}`, JSON.stringify(storage));
199
+ }
200
+
142
201
  /**
143
202
  * The chat settings.
144
203
  */
@@ -146,16 +205,103 @@ export class ChatModel implements IChatModel {
146
205
  return this._config;
147
206
  }
148
207
  set config(value: Partial<IConfig>) {
208
+ const stackMessagesChanged =
209
+ 'stackMessages' in value &&
210
+ this._config.stackMessages !== value.stackMessages;
211
+
212
+ const unreadNotificationsChanged =
213
+ 'unreadNotifications' in value &&
214
+ this._config.unreadNotifications !== value.unreadNotifications;
215
+
149
216
  this._config = { ...this._config, ...value };
217
+
218
+ if (stackMessagesChanged) {
219
+ if (this._config.stackMessages) {
220
+ this._messages.slice(1).forEach((message, idx) => {
221
+ const previousUser = this._messages[idx].sender.username;
222
+ message.stacked = previousUser === message.sender.username;
223
+ });
224
+ } else {
225
+ this._messages.forEach(message => {
226
+ delete message.stacked;
227
+ });
228
+ }
229
+ this._messagesUpdated.emit();
230
+ }
231
+
232
+ // remove existing notifications if they are not required anymore.
233
+ if (unreadNotificationsChanged && !this._config.unreadNotifications) {
234
+ this._notify(0);
235
+ }
236
+ }
237
+
238
+ /**
239
+ * The indexes list of the unread messages.
240
+ */
241
+ get unreadMessages(): number[] {
242
+ return this._unreadMessages;
243
+ }
244
+ set unreadMessages(unread: number[]) {
245
+ const recentlyRead = this._unreadMessages.filter(
246
+ elem => !unread.includes(elem)
247
+ );
248
+ const unreadCountDiff = unread.length - this._unreadMessages.length;
249
+
250
+ this._unreadMessages = unread;
251
+ this._unreadChanged.emit(this._unreadMessages);
252
+
253
+ // Notify the change.
254
+ this._notify(unread.length, unreadCountDiff > 0);
255
+
256
+ // Save the last read to the local storage.
257
+ if (this._id !== undefined && recentlyRead.length) {
258
+ let lastReadChanged = false;
259
+ let lastRead = this.lastRead ?? this.messages[recentlyRead[0]].time;
260
+ recentlyRead.forEach(index => {
261
+ if (this.messages[index].time > lastRead) {
262
+ lastRead = this.messages[index].time;
263
+ lastReadChanged = true;
264
+ }
265
+ });
266
+
267
+ if (lastReadChanged) {
268
+ this.lastRead = lastRead;
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * The indexes list of the messages currently in the viewport.
275
+ */
276
+ get messagesInViewport(): number[] {
277
+ return this._messagesInViewport;
278
+ }
279
+ set messagesInViewport(values: number[]) {
280
+ this._messagesInViewport = values;
281
+ this._viewportChanged.emit(values);
150
282
  }
151
283
 
152
284
  /**
153
- * The signal emitted when the messages list is updated.
285
+ * A signal emitting when the messages list is updated.
154
286
  */
155
287
  get messagesUpdated(): ISignal<IChatModel, void> {
156
288
  return this._messagesUpdated;
157
289
  }
158
290
 
291
+ /**
292
+ * A signal emitting when unread messages change.
293
+ */
294
+ get unreadChanged(): ISignal<IChatModel, number[]> {
295
+ return this._unreadChanged;
296
+ }
297
+
298
+ /**
299
+ * A signal emitting when the viewport change.
300
+ */
301
+ get viewportChanged(): ISignal<IChatModel, number[]> {
302
+ return this._viewportChanged;
303
+ }
304
+
159
305
  /**
160
306
  * Send a message, to be defined depending on the chosen technology.
161
307
  * Default to no-op.
@@ -165,17 +311,6 @@ export class ChatModel implements IChatModel {
165
311
  */
166
312
  addMessage(message: INewMessage): Promise<boolean | void> | boolean | void {}
167
313
 
168
- /**
169
- * Optional, to update a message from the chat panel.
170
- *
171
- * @param id - the unique ID of the message.
172
- * @param message - the message to update.
173
- */
174
- updateMessage?(
175
- id: string,
176
- message: INewMessage
177
- ): Promise<boolean | void> | boolean | void;
178
-
179
314
  /**
180
315
  * Dispose the chat model.
181
316
  */
@@ -231,10 +366,35 @@ export class ChatModel implements IChatModel {
231
366
  */
232
367
  messagesInserted(index: number, messages: IChatMessage[]): void {
233
368
  const formattedMessages: IChatMessage[] = [];
234
- messages.forEach(message => {
369
+ const unreadIndexes: number[] = [];
370
+
371
+ const lastRead = this.lastRead ?? 0;
372
+
373
+ // Format the messages.
374
+ messages.forEach((message, idx) => {
235
375
  formattedMessages.push(this.formatChatMessage(message));
376
+ if (message.time > lastRead) {
377
+ unreadIndexes.push(index + idx);
378
+ }
236
379
  });
380
+
381
+ // Insert the messages.
237
382
  this._messages.splice(index, 0, ...formattedMessages);
383
+
384
+ if (this._config.stackMessages) {
385
+ // Check if some messages should be stacked by comparing each message' sender
386
+ // with the previous one.
387
+ const lastIdx = index + formattedMessages.length - 1;
388
+ const start = index === 0 ? 1 : index;
389
+ const end = this._messages.length > lastIdx + 1 ? lastIdx + 1 : lastIdx;
390
+ for (let idx = start; idx <= end; idx++) {
391
+ const message = this._messages[idx];
392
+ const previousUser = this._messages[idx - 1].sender.username;
393
+ message.stacked = previousUser === message.sender.username;
394
+ }
395
+ }
396
+
397
+ this._addUnreadMessages(unreadIndexes);
238
398
  this._messagesUpdated.emit();
239
399
  }
240
400
 
@@ -249,11 +409,67 @@ export class ChatModel implements IChatModel {
249
409
  this._messagesUpdated.emit();
250
410
  }
251
411
 
412
+ /**
413
+ * Add unread messages to the list.
414
+ * @param indexes - list of new indexes.
415
+ */
416
+ private _addUnreadMessages(indexes: number[]) {
417
+ const unread = new Set(this._unreadMessages);
418
+ indexes.forEach(index => unread.add(index));
419
+ this.unreadMessages = Array.from(unread.values());
420
+ }
421
+
422
+ /**
423
+ * Notifications on unread messages.
424
+ *
425
+ * @param unreadCount - number of unread messages.
426
+ * If the value is 0, existing notification will be deleted.
427
+ * @param canCreate - whether to create a notification if it does not exist.
428
+ * Usually it is used when there are new unread messages, and not when the
429
+ * unread messages count decrease.
430
+ */
431
+ private _notify(unreadCount: number, canCreate: boolean = false) {
432
+ if (this._commands) {
433
+ if (unreadCount && this._config.unreadNotifications) {
434
+ // Update the notification if exist.
435
+ this._commands
436
+ .execute('apputils:update-notification', {
437
+ id: this._notificationId,
438
+ message: `${unreadCount} incoming message(s) ${this._name ? 'in ' + this._name : ''}`
439
+ })
440
+ .then(success => {
441
+ // Create a new notification only if messages are added.
442
+ if (!success && canCreate) {
443
+ this._commands!.execute('apputils:notify', {
444
+ type: 'info',
445
+ message: `${unreadCount} incoming message(s) in ${this._name}`
446
+ }).then(id => {
447
+ this._notificationId = id;
448
+ });
449
+ }
450
+ });
451
+ } else if (this._notificationId) {
452
+ // Delete notification if there is no more unread messages.
453
+ this._commands.execute('apputils:dismiss-notification', {
454
+ id: this._notificationId
455
+ });
456
+ this._notificationId = null;
457
+ }
458
+ }
459
+ }
460
+
252
461
  private _messages: IChatMessage[] = [];
253
- private _id: string = '';
462
+ private _unreadMessages: number[] = [];
463
+ private _messagesInViewport: number[] = [];
464
+ private _id: string | undefined;
465
+ private _name: string = '';
254
466
  private _config: IConfig;
255
467
  private _isDisposed = false;
468
+ private _commands?: CommandRegistry;
469
+ private _notificationId: string | null = null;
256
470
  private _messagesUpdated = new Signal<IChatModel, void>(this);
471
+ private _unreadChanged = new Signal<IChatModel, number[]>(this);
472
+ private _viewportChanged = new Signal<IChatModel, number[]>(this);
257
473
  }
258
474
 
259
475
  /**
@@ -268,5 +484,10 @@ export namespace ChatModel {
268
484
  * Initial config for the chat widget.
269
485
  */
270
486
  config?: IConfig;
487
+
488
+ /**
489
+ * Commands registry.
490
+ */
491
+ commands?: CommandRegistry;
271
492
  }
272
493
  }