@jupyter/chat 0.11.0 → 0.12.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/model.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Copyright (c) Jupyter Development Team.
3
3
  * Distributed under the terms of the Modified BSD License.
4
4
  */
5
+ import { ArrayExt } from '@lumino/algorithm';
5
6
  import { Signal } from '@lumino/signaling';
6
7
  import { InputModel } from './input-model';
7
8
  import { replaceMentionToSpan } from './utils';
@@ -23,11 +24,14 @@ export class AbstractChatModel {
23
24
  this._name = '';
24
25
  this._isDisposed = false;
25
26
  this._notificationId = null;
27
+ this._writers = [];
28
+ this._messageEditions = new Map();
26
29
  this._messagesUpdated = new Signal(this);
27
30
  this._configChanged = new Signal(this);
28
31
  this._unreadChanged = new Signal(this);
29
32
  this._viewportChanged = new Signal(this);
30
33
  this._writersChanged = new Signal(this);
34
+ this._messageEditionAdded = new Signal(this);
31
35
  if (options.id) {
32
36
  this.id = options.id;
33
37
  }
@@ -83,6 +87,12 @@ export class AbstractChatModel {
83
87
  get input() {
84
88
  return this._inputModel;
85
89
  }
90
+ /**
91
+ * The current writer list.
92
+ */
93
+ get writers() {
94
+ return this._writers;
95
+ }
86
96
  /**
87
97
  * Get the active cell manager.
88
98
  */
@@ -223,6 +233,12 @@ export class AbstractChatModel {
223
233
  get writersChanged() {
224
234
  return this._writersChanged;
225
235
  }
236
+ /**
237
+ * A signal emitting when the message edition input changed change.
238
+ */
239
+ get messageEditionAdded() {
240
+ return this._messageEditionAdded;
241
+ }
226
242
  /**
227
243
  * Clear the message list.
228
244
  */
@@ -327,7 +343,34 @@ export class AbstractChatModel {
327
343
  * This implementation only propagate the list via a signal.
328
344
  */
329
345
  updateWriters(writers) {
330
- this._writersChanged.emit(writers);
346
+ const compareWriters = (a, b) => {
347
+ return (a.user.username === b.user.username &&
348
+ a.user.display_name === b.user.display_name &&
349
+ a.messageID === b.messageID);
350
+ };
351
+ if (!ArrayExt.shallowEqual(this._writers, writers, compareWriters)) {
352
+ this._writers = writers;
353
+ this._writersChanged.emit(writers);
354
+ }
355
+ }
356
+ /**
357
+ * Get the input model of the edited message, given its id.
358
+ */
359
+ getEditionModel(messageID) {
360
+ return this._messageEditions.get(messageID);
361
+ }
362
+ /**
363
+ * Add an input model of the edited message.
364
+ */
365
+ addEditionModel(messageID, inputModel) {
366
+ var _a;
367
+ // Dispose of an hypothetic previous model for this message.
368
+ (_a = this.getEditionModel(messageID)) === null || _a === void 0 ? void 0 : _a.dispose();
369
+ this._messageEditions.set(messageID, inputModel);
370
+ this._messageEditionAdded.emit({ id: messageID, model: inputModel });
371
+ inputModel.onDisposed.connect(() => {
372
+ this._messageEditions.delete(messageID);
373
+ });
331
374
  }
332
375
  /**
333
376
  * Add unread messages to the list.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter/chat",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "A package that provides UI components that can be used to create a chat in a Jupyterlab extension.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -54,6 +54,7 @@
54
54
  "@jupyterlab/notebook": "^4.2.0",
55
55
  "@jupyterlab/rendermime": "^4.2.0",
56
56
  "@jupyterlab/ui-components": "^4.2.0",
57
+ "@lumino/algorithm": "^2.0.0",
57
58
  "@lumino/commands": "^2.0.0",
58
59
  "@lumino/coreutils": "^2.0.0",
59
60
  "@lumino/disposable": "^2.0.0",
@@ -19,8 +19,9 @@ import React, { useEffect, useState, useRef, forwardRef } from 'react';
19
19
  import { AttachmentPreviewList } from './attachments';
20
20
  import { ChatInput } from './chat-input';
21
21
  import { IInputToolbarRegistry } from './input';
22
- import { MarkdownRenderer } from './markdown-renderer';
23
22
  import { MessageFooter } from './messages/footer';
23
+ import { MessageRenderer } from './messages/message-renderer';
24
+ import { WelcomeMessage } from './messages/welcome';
24
25
  import { ScrollContainer } from './scroll-container';
25
26
  import { IChatCommandRegistry } from '../chat-commands';
26
27
  import { IMessageFooterRegistry } from '../footers';
@@ -64,6 +65,10 @@ type BaseMessageProps = {
64
65
  * The footer registry.
65
66
  */
66
67
  messageFooterRegistry?: IMessageFooterRegistry;
68
+ /**
69
+ * The welcome message.
70
+ */
71
+ welcomeMessage?: string;
67
72
  };
68
73
 
69
74
  /**
@@ -106,8 +111,8 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element {
106
111
  setMessages([...model.messages]);
107
112
  }
108
113
 
109
- function handleWritersChange(_: IChatModel, writers: IUser[]) {
110
- setCurrentWriters(writers);
114
+ function handleWritersChange(_: IChatModel, writers: IChatModel.IWriter[]) {
115
+ setCurrentWriters(writers.map(writer => writer.user));
111
116
  }
112
117
 
113
118
  model.messagesUpdated.connect(handleChatEvents);
@@ -184,6 +189,12 @@ export function ChatMessages(props: BaseMessageProps): JSX.Element {
184
189
  return (
185
190
  <>
186
191
  <ScrollContainer sx={{ flexGrow: 1 }}>
192
+ {props.welcomeMessage && (
193
+ <WelcomeMessage
194
+ rmRegistry={props.rmRegistry}
195
+ content={props.welcomeMessage}
196
+ />
197
+ )}
187
198
  <Box ref={refMsgBox} className={clsx(MESSAGES_BOX_CLASS)}>
188
199
  {messages.map((message, i) => {
189
200
  renderedPromise.current[i] = new PromiseDelegate();
@@ -370,10 +381,10 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
370
381
  const [deleted, setDeleted] = useState<boolean>(false);
371
382
  const [canEdit, setCanEdit] = useState<boolean>(false);
372
383
  const [canDelete, setCanDelete] = useState<boolean>(false);
373
- const [inputModel, setInputModel] = useState<IInputModel | null>(null);
374
384
 
375
385
  // Look if the message can be deleted or edited.
376
386
  useEffect(() => {
387
+ // Init canDelete and canEdit state.
377
388
  setDeleted(message.deleted ?? false);
378
389
  if (model.user !== undefined && !message.deleted) {
379
390
  if (model.user.username === message.sender.username) {
@@ -391,36 +402,36 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
391
402
  }, [model, message]);
392
403
 
393
404
  // Create an input model only if the message is edited.
394
- useEffect(() => {
395
- if (edit && canEdit) {
396
- setInputModel(() => {
397
- let body = message.body;
398
- message.mentions?.forEach(user => {
399
- body = replaceSpanToMention(body, user);
400
- });
401
- return new InputModel({
402
- chatContext: model.createChatContext(),
403
- onSend: (input: string, model?: IInputModel) =>
404
- updateMessage(message.id, input, model),
405
- onCancel: () => cancelEdition(),
406
- value: body,
407
- activeCellManager: model.activeCellManager,
408
- selectionWatcher: model.selectionWatcher,
409
- documentManager: model.documentManager,
410
- config: {
411
- sendWithShiftEnter: model.config.sendWithShiftEnter
412
- },
413
- attachments: message.attachments,
414
- mentions: message.mentions
415
- });
416
- });
417
- } else {
418
- setInputModel(null);
405
+ const startEdition = (): void => {
406
+ if (!canEdit) {
407
+ return;
419
408
  }
420
- }, [edit]);
409
+ let body = message.body;
410
+ message.mentions?.forEach(user => {
411
+ body = replaceSpanToMention(body, user);
412
+ });
413
+ const inputModel = new InputModel({
414
+ chatContext: model.createChatContext(),
415
+ onSend: (input: string, model?: IInputModel) =>
416
+ updateMessage(message.id, input, model),
417
+ onCancel: () => cancelEdition(),
418
+ value: body,
419
+ activeCellManager: model.activeCellManager,
420
+ selectionWatcher: model.selectionWatcher,
421
+ documentManager: model.documentManager,
422
+ config: {
423
+ sendWithShiftEnter: model.config.sendWithShiftEnter
424
+ },
425
+ attachments: message.attachments,
426
+ mentions: message.mentions
427
+ });
428
+ model.addEditionModel(message.id, inputModel);
429
+ setEdit(true);
430
+ };
421
431
 
422
432
  // Cancel the current edition of the message.
423
433
  const cancelEdition = (): void => {
434
+ model.getEditionModel(message.id)?.dispose();
424
435
  setEdit(false);
425
436
  };
426
437
 
@@ -439,6 +450,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
439
450
  updatedMessage.attachments = inputModel.attachments;
440
451
  updatedMessage.mentions = inputModel.mentions;
441
452
  model.updateMessage!(id, updatedMessage);
453
+ model.getEditionModel(message.id)?.dispose();
442
454
  setEdit(false);
443
455
  };
444
456
 
@@ -455,19 +467,19 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
455
467
  <div ref={ref} data-index={props.index}></div>
456
468
  ) : (
457
469
  <div ref={ref} data-index={props.index}>
458
- {edit && canEdit && inputModel ? (
470
+ {edit && canEdit && model.getEditionModel(message.id) ? (
459
471
  <ChatInput
460
472
  onCancel={() => cancelEdition()}
461
- model={inputModel}
473
+ model={model.getEditionModel(message.id)!}
462
474
  chatCommandRegistry={props.chatCommandRegistry}
463
475
  toolbarRegistry={props.inputToolbarRegistry}
464
476
  />
465
477
  ) : (
466
- <MarkdownRenderer
478
+ <MessageRenderer
467
479
  rmRegistry={rmRegistry}
468
480
  markdownStr={message.body}
469
481
  model={model}
470
- edit={canEdit ? () => setEdit(true) : undefined}
482
+ edit={canEdit ? startEdition : undefined}
471
483
  delete={canDelete ? () => deleteMessage(message.id) : undefined}
472
484
  rendered={props.renderedPromise}
473
485
  />
@@ -613,7 +625,10 @@ export function Navigation(props: NavigationProps): JSX.Element {
613
625
  // in viewport.
614
626
  useEffect(() => {
615
627
  const viewportChanged = (model: IChatModel, viewport: number[]) => {
616
- setLastInViewport(viewport.includes(model.messages.length - 1));
628
+ setLastInViewport(
629
+ model.messages.length === 0 ||
630
+ viewport.includes(model.messages.length - 1)
631
+ );
617
632
  };
618
633
 
619
634
  model.viewportChanged?.connect(viewportChanged);
@@ -36,6 +36,7 @@ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element {
36
36
  chatCommandRegistry={props.chatCommandRegistry}
37
37
  inputToolbarRegistry={inputToolbarRegistry}
38
38
  messageFooterRegistry={props.messageFooterRegistry}
39
+ welcomeMessage={props.welcomeMessage}
39
40
  />
40
41
  <ChatInput
41
42
  sx={{
@@ -131,6 +132,10 @@ export namespace Chat {
131
132
  * The footer registry.
132
133
  */
133
134
  messageFooterRegistry?: IMessageFooterRegistry;
135
+ /**
136
+ * The welcome message.
137
+ */
138
+ welcomeMessage?: string;
134
139
  }
135
140
 
136
141
  /**
@@ -9,7 +9,6 @@ export * from './chat-messages';
9
9
  export * from './code-blocks';
10
10
  export * from './input';
11
11
  export * from './jl-theme-provider';
12
- export * from './markdown-renderer';
13
12
  export * from './mui-extras';
14
13
  export * from './scroll-container';
15
14
  export * from './toolbar';
@@ -8,14 +8,15 @@ import { PromiseDelegate } from '@lumino/coreutils';
8
8
  import React, { useState, useEffect } from 'react';
9
9
  import { createPortal } from 'react-dom';
10
10
 
11
- import { CodeToolbar, CodeToolbarProps } from './code-blocks/code-toolbar';
12
- import { MessageToolbar } from './toolbar';
13
- import { IChatModel } from '../model';
11
+ import { CodeToolbar, CodeToolbarProps } from '../code-blocks/code-toolbar';
12
+ import { MessageToolbar } from '../toolbar';
13
+ import { MarkdownRenderer, MD_RENDERED_CLASS } from '../../markdown-renderer';
14
+ import { IChatModel } from '../../model';
14
15
 
15
- const MD_MIME_TYPE = 'text/markdown';
16
- const MD_RENDERED_CLASS = 'jp-chat-rendered-markdown';
17
-
18
- type MarkdownRendererProps = {
16
+ /**
17
+ * The type of the props for the MessageRenderer component.
18
+ */
19
+ type MessageRendererProps = {
19
20
  /**
20
21
  * The string to render.
21
22
  */
@@ -47,22 +48,10 @@ type MarkdownRendererProps = {
47
48
  };
48
49
 
49
50
  /**
50
- * Escapes backslashes in LaTeX delimiters such that they appear in the DOM
51
- * after the initial MarkDown render. For example, this function takes '\(` and
52
- * returns `\\(`.
53
- *
54
- * Required for proper rendering of MarkDown + LaTeX markup in the chat by
55
- * `ILatexTypesetter`.
51
+ * The message renderer base component.
56
52
  */
57
- function escapeLatexDelimiters(text: string) {
58
- return text
59
- .replace(/\\\(/g, '\\\\(')
60
- .replace(/\\\)/g, '\\\\)')
61
- .replace(/\\\[/g, '\\\\[')
62
- .replace(/\\\]/g, '\\\\]');
63
- }
64
-
65
- function MarkdownRendererBase(props: MarkdownRendererProps): JSX.Element {
53
+ function MessageRendererBase(props: MessageRendererProps): JSX.Element {
54
+ const { markdownStr, rmRegistry } = props;
66
55
  const appendContent = props.appendContent || false;
67
56
  const [renderedContent, setRenderedContent] = useState<HTMLElement | null>(
68
57
  null
@@ -75,26 +64,11 @@ function MarkdownRendererBase(props: MarkdownRendererProps): JSX.Element {
75
64
 
76
65
  useEffect(() => {
77
66
  const renderContent = async () => {
78
- // initialize mime model
79
- const mdStr = escapeLatexDelimiters(props.markdownStr);
80
- const model = props.rmRegistry.createModel({
81
- data: { [MD_MIME_TYPE]: mdStr }
67
+ const renderer = await MarkdownRenderer.renderContent({
68
+ content: markdownStr,
69
+ rmRegistry
82
70
  });
83
71
 
84
- const renderer = props.rmRegistry.createRenderer(MD_MIME_TYPE);
85
-
86
- // step 1: render markdown
87
- await renderer.renderModel(model);
88
- props.rmRegistry.latexTypesetter?.typeset(renderer.node);
89
- if (!renderer.node) {
90
- throw new Error(
91
- 'Rendermime was unable to render Markdown content within a chat message. Please report this upstream to Jupyter chat on GitHub.'
92
- );
93
- }
94
-
95
- // step 2: render LaTeX via MathJax.
96
- props.rmRegistry.latexTypesetter?.typeset(renderer.node);
97
-
98
72
  const newCodeToolbarDefns: [HTMLDivElement, CodeToolbarProps][] = [];
99
73
 
100
74
  // Attach CodeToolbar root element to each <pre> block
@@ -119,7 +93,7 @@ function MarkdownRendererBase(props: MarkdownRendererProps): JSX.Element {
119
93
  };
120
94
 
121
95
  renderContent();
122
- }, [props.markdownStr, props.rmRegistry]);
96
+ }, [markdownStr, rmRegistry]);
123
97
 
124
98
  return (
125
99
  <div className={MD_RENDERED_CLASS}>
@@ -146,4 +120,4 @@ function MarkdownRendererBase(props: MarkdownRendererProps): JSX.Element {
146
120
  );
147
121
  }
148
122
 
149
- export const MarkdownRenderer = React.memo(MarkdownRendererBase);
123
+ export const MessageRenderer = React.memo(MessageRendererBase);
@@ -0,0 +1,52 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { classes } from '@jupyterlab/ui-components';
7
+ import React, { useEffect, useRef } from 'react';
8
+ import { MarkdownRenderer, MD_RENDERED_CLASS } from '../../markdown-renderer';
9
+
10
+ const WELCOME_MESSAGE_CLASS = 'jp-chat-welcome-message';
11
+
12
+ /**
13
+ * The welcome message component.
14
+ * This message is displayed on top of the chat messages, and is rendered using a
15
+ * markdown renderer.
16
+ */
17
+ export function WelcomeMessage(props: MarkdownRenderer.IOptions): JSX.Element {
18
+ const { rmRegistry } = props;
19
+ const content = props.content + '\n----\n';
20
+
21
+ // ref that tracks the content container to store the rendermime node in
22
+ const renderingContainer = useRef<HTMLDivElement | null>(null);
23
+ // ref that tracks whether the rendermime node has already been inserted
24
+ const renderingInserted = useRef<boolean>(false);
25
+
26
+ /**
27
+ * Effect: use Rendermime to render `props.markdownStr` into an HTML element,
28
+ * and insert it into `renderingContainer` if not yet inserted.
29
+ */
30
+ useEffect(() => {
31
+ const renderContent = async () => {
32
+ const renderer = await MarkdownRenderer.renderContent({
33
+ content,
34
+ rmRegistry
35
+ });
36
+
37
+ // insert the rendering into renderingContainer if not yet inserted
38
+ if (renderingContainer.current !== null && !renderingInserted.current) {
39
+ renderingContainer.current.appendChild(renderer.node);
40
+ renderingInserted.current = true;
41
+ }
42
+ };
43
+
44
+ renderContent();
45
+ }, [content]);
46
+
47
+ return (
48
+ <div className={classes(MD_RENDERED_CLASS, WELCOME_MESSAGE_CLASS)}>
49
+ <div ref={renderingContainer} />
50
+ </div>
51
+ );
52
+ }
@@ -43,7 +43,7 @@ export function ScrollContainer(props: ScrollContainerProps): JSX.Element {
43
43
  ...props.sx
44
44
  }}
45
45
  >
46
- <Box sx={{ minHeight: '100.01%' }}>{props.children}</Box>
46
+ <Box>{props.children}</Box>
47
47
  <Box sx={{ overflowAnchor: 'auto', height: '1px' }} />
48
48
  </Box>
49
49
  );
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from './components';
9
9
  export * from './footers';
10
10
  export * from './icons';
11
11
  export * from './input-model';
12
+ export * from './markdown-renderer';
12
13
  export * from './model';
13
14
  export * from './registry';
14
15
  export * from './selection-watcher';
@@ -147,6 +147,11 @@ export interface IInputModel extends IDisposable {
147
147
  * Clear mentions list.
148
148
  */
149
149
  clearMentions(): void;
150
+
151
+ /**
152
+ * A signal emitting when disposing of the model.
153
+ */
154
+ readonly onDisposed: ISignal<InputModel, void>;
150
155
  }
151
156
 
152
157
  /**
@@ -411,9 +416,17 @@ export class InputModel implements IInputModel {
411
416
  if (this.isDisposed) {
412
417
  return;
413
418
  }
419
+ this._onDisposed.emit();
414
420
  this._isDisposed = true;
415
421
  }
416
422
 
423
+ /**
424
+ * A signal emitting when disposing of the model.
425
+ */
426
+ get onDisposed(): ISignal<InputModel, void> {
427
+ return this._onDisposed;
428
+ }
429
+
417
430
  /**
418
431
  * Whether the input model is disposed.
419
432
  */
@@ -438,6 +451,7 @@ export class InputModel implements IInputModel {
438
451
  private _configChanged = new Signal<IInputModel, InputModel.IConfig>(this);
439
452
  private _focusInputSignal = new Signal<InputModel, void>(this);
440
453
  private _attachmentsChanged = new Signal<InputModel, IAttachment[]>(this);
454
+ private _onDisposed = new Signal<InputModel, void>(this);
441
455
  private _isDisposed = false;
442
456
  }
443
457
 
@@ -0,0 +1,78 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ import { IRenderMime, IRenderMimeRegistry } from '@jupyterlab/rendermime';
7
+
8
+ export const MD_RENDERED_CLASS = 'jp-chat-rendered-markdown';
9
+ export const MD_MIME_TYPE = 'text/markdown';
10
+
11
+ /**
12
+ * A namespace for the MarkdownRenderer.
13
+ */
14
+ export namespace MarkdownRenderer {
15
+ /**
16
+ * The options for the MarkdownRenderer.
17
+ */
18
+ export interface IOptions {
19
+ /**
20
+ * The rendermime registry.
21
+ */
22
+ rmRegistry: IRenderMimeRegistry;
23
+ /**
24
+ * The markdown content.
25
+ */
26
+ content: string;
27
+ }
28
+
29
+ /**
30
+ * A generic function to render a markdown string into a DOM element.
31
+ *
32
+ * @param content - the markdown content.
33
+ * @param rmRegistry - the rendermime registry.
34
+ * @returns a promise that resolves to the renderer.
35
+ */
36
+ export async function renderContent(
37
+ options: IOptions
38
+ ): Promise<IRenderMime.IRenderer> {
39
+ const { rmRegistry, content } = options;
40
+
41
+ // initialize mime model
42
+ const mdStr = escapeLatexDelimiters(content);
43
+ const model = rmRegistry.createModel({
44
+ data: { [MD_MIME_TYPE]: mdStr }
45
+ });
46
+
47
+ const renderer = rmRegistry.createRenderer(MD_MIME_TYPE);
48
+
49
+ // step 1: render markdown
50
+ await renderer.renderModel(model);
51
+ if (!renderer.node) {
52
+ throw new Error(
53
+ 'Rendermime was unable to render Markdown content within a chat message. Please report this upstream to Jupyter chat on GitHub.'
54
+ );
55
+ }
56
+
57
+ // step 2: render LaTeX via MathJax.
58
+ rmRegistry.latexTypesetter?.typeset(renderer.node);
59
+
60
+ return renderer;
61
+ }
62
+
63
+ /**
64
+ * Escapes backslashes in LaTeX delimiters such that they appear in the DOM
65
+ * after the initial MarkDown render. For example, this function takes '\(` and
66
+ * returns `\\(`.
67
+ *
68
+ * Required for proper rendering of MarkDown + LaTeX markup in the chat by
69
+ * `ILatexTypesetter`.
70
+ */
71
+ export function escapeLatexDelimiters(text: string) {
72
+ return text
73
+ .replace(/\\\(/g, '\\\\(')
74
+ .replace(/\\\)/g, '\\\\)')
75
+ .replace(/\\\[/g, '\\\\[')
76
+ .replace(/\\\]/g, '\\\\]');
77
+ }
78
+ }