@jupyter/chat 0.23.0-alpha.2 → 0.23.0-alpha.4
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/__tests__/input-model.spec.d.ts +9 -0
- package/lib/__tests__/input-model.spec.js +102 -0
- package/lib/__tests__/model.spec.js +17 -3
- package/lib/__tests__/multichat-panel.spec.js +78 -27
- package/lib/components/messages/message.js +15 -8
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/input-model.d.ts +50 -2
- package/lib/input-model.js +57 -1
- package/lib/model.d.ts +14 -0
- package/lib/model.js +4 -1
- package/lib/tokens.d.ts +21 -5
- package/lib/types.d.ts +1 -4
- package/lib/utils.d.ts +11 -0
- package/lib/utils.js +42 -0
- package/lib/widgets/multichat-panel.d.ts +20 -20
- package/lib/widgets/multichat-panel.js +33 -48
- package/package.json +3 -1
- package/src/__tests__/input-model.spec.ts +129 -0
- package/src/__tests__/model.spec.ts +18 -0
- package/src/__tests__/multichat-panel.spec.ts +93 -37
- package/src/components/messages/message.tsx +21 -11
- package/src/index.ts +1 -0
- package/src/input-model.ts +106 -4
- package/src/model.ts +19 -1
- package/src/tokens.ts +21 -5
- package/src/types.ts +6 -4
- package/src/utils.ts +59 -0
- package/src/widgets/multichat-panel.tsx +54 -66
package/lib/utils.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* Copyright (c) Jupyter Development Team.
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
|
+
import { CommandToolbarButton } from '@jupyterlab/apputils';
|
|
5
6
|
import { CodeMirrorEditor } from '@jupyterlab/codemirror';
|
|
6
7
|
import { DocumentWidget } from '@jupyterlab/docregistry';
|
|
7
8
|
import { FileEditor } from '@jupyterlab/fileeditor';
|
|
8
9
|
import { Notebook } from '@jupyterlab/notebook';
|
|
10
|
+
import { ObservableList } from '@jupyterlab/observables';
|
|
9
11
|
const MENTION_CLASS = 'jp-chat-mention';
|
|
10
12
|
/**
|
|
11
13
|
* Gets the editor instance used by a document widget. Returns `null` if unable.
|
|
@@ -66,3 +68,43 @@ export function replaceSpanToMention(content, user) {
|
|
|
66
68
|
const regex = new RegExp(mentionEl, 'g');
|
|
67
69
|
return content.replace(regex, mention);
|
|
68
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Wraps a toolbar factory so that every CommandToolbarButton it creates
|
|
73
|
+
* automatically receives the panel's area as an arg.
|
|
74
|
+
* This lets commands branch on `args.area` without each plugin having to
|
|
75
|
+
* register a per-item toolbarRegistry.addFactory call.
|
|
76
|
+
*/
|
|
77
|
+
export function injectAreaArg(baseFactory, commands) {
|
|
78
|
+
return (panel) => {
|
|
79
|
+
const base = baseFactory(panel);
|
|
80
|
+
const inject = (item) => {
|
|
81
|
+
const { widget } = item;
|
|
82
|
+
if (!(widget instanceof CommandToolbarButton)) {
|
|
83
|
+
return item;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
name: item.name,
|
|
87
|
+
widget: new CommandToolbarButton({
|
|
88
|
+
commands,
|
|
89
|
+
id: widget.commandId,
|
|
90
|
+
args: { area: panel.area }
|
|
91
|
+
})
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
const wrapped = new ObservableList({
|
|
95
|
+
values: Array.from({ length: base.length }, (_, i) => inject(base.get(i)))
|
|
96
|
+
});
|
|
97
|
+
base.changed.connect((_, change) => {
|
|
98
|
+
if (change.type === 'add') {
|
|
99
|
+
wrapped.insertAll(change.newIndex, change.newValues.map(inject));
|
|
100
|
+
}
|
|
101
|
+
else if (change.type === 'remove') {
|
|
102
|
+
wrapped.removeRange(change.oldIndex, change.oldIndex + change.oldValues.length);
|
|
103
|
+
}
|
|
104
|
+
else if (change.type === 'set') {
|
|
105
|
+
change.newValues.forEach((item, i) => wrapped.set(change.newIndex + i, inject(item)));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return wrapped;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ToolbarRegistry } from '@jupyterlab/apputils';
|
|
2
|
+
import { IObservableList } from '@jupyterlab/observables';
|
|
1
3
|
import { TranslationBundle } from '@jupyterlab/translation';
|
|
2
4
|
import { PanelWithToolbar, ReactiveToolbar } from '@jupyterlab/ui-components';
|
|
3
5
|
import { Message } from '@lumino/messaging';
|
|
@@ -6,7 +8,8 @@ import { Panel, Widget } from '@lumino/widgets';
|
|
|
6
8
|
import { ChatWidget } from './chat-widget';
|
|
7
9
|
import { Chat, IInputToolbarRegistryFactory } from '../components';
|
|
8
10
|
import { IChatModel } from '../model';
|
|
9
|
-
import { IChatPlaceholderFactory } from '../tokens';
|
|
11
|
+
import { IChatPanel, IChatPlaceholderFactory } from '../tokens';
|
|
12
|
+
import { ChatArea } from '../types';
|
|
10
13
|
/**
|
|
11
14
|
* A panel widget with a reactive toolbar.
|
|
12
15
|
*/
|
|
@@ -28,9 +31,9 @@ export declare class MultiChatPanel extends PanelWithToolbar {
|
|
|
28
31
|
*/
|
|
29
32
|
get current(): SidePanelWidget | undefined;
|
|
30
33
|
/**
|
|
31
|
-
* A signal emitting when a chat
|
|
34
|
+
* A signal emitting when a chat panel is opened in the sidepanel.
|
|
32
35
|
*/
|
|
33
|
-
get chatOpened(): ISignal<MultiChatPanel,
|
|
36
|
+
get chatOpened(): ISignal<MultiChatPanel, IChatPanel>;
|
|
34
37
|
/**
|
|
35
38
|
* A signal emitting when the panel visibility changed.
|
|
36
39
|
*/
|
|
@@ -106,10 +109,10 @@ export declare class MultiChatPanel extends PanelWithToolbar {
|
|
|
106
109
|
private _chatNamesChanged;
|
|
107
110
|
private _chatOptions;
|
|
108
111
|
private _inputToolbarFactory?;
|
|
112
|
+
private _chatToolbarFactory?;
|
|
109
113
|
private _updateChatListDebouncer;
|
|
110
114
|
private _createModel?;
|
|
111
115
|
private _getChatNames?;
|
|
112
|
-
private _openInMain?;
|
|
113
116
|
private _renameChat?;
|
|
114
117
|
private _placeholderFactory?;
|
|
115
118
|
private _openChatWidget?;
|
|
@@ -132,6 +135,10 @@ export declare namespace MultiChatPanel {
|
|
|
132
135
|
* The input toolbar factory;
|
|
133
136
|
*/
|
|
134
137
|
inputToolbarFactory?: IInputToolbarRegistryFactory;
|
|
138
|
+
/**
|
|
139
|
+
* An optional toolbar factory for each opened chat.
|
|
140
|
+
*/
|
|
141
|
+
chatToolbarFactory?: (panel: IChatPanel) => IObservableList<ToolbarRegistry.IToolbarItem>;
|
|
135
142
|
/**
|
|
136
143
|
* An optional callback to create a chat model.
|
|
137
144
|
*
|
|
@@ -147,12 +154,6 @@ export declare namespace MultiChatPanel {
|
|
|
147
154
|
getChatNames?: () => Promise<{
|
|
148
155
|
[name: string]: string;
|
|
149
156
|
}>;
|
|
150
|
-
/**
|
|
151
|
-
* An optional callback to open the chat in the main area.
|
|
152
|
-
*
|
|
153
|
-
* @param name - the name of the chat to move.
|
|
154
|
-
*/
|
|
155
|
-
openInMain?: (name: string) => Promise<boolean>;
|
|
156
157
|
/**
|
|
157
158
|
* An optional callback to rename a chat.
|
|
158
159
|
*
|
|
@@ -188,10 +189,14 @@ export declare namespace MultiChatPanel {
|
|
|
188
189
|
/**
|
|
189
190
|
* A widget containing the chat and its toolbar.
|
|
190
191
|
*/
|
|
191
|
-
declare class SidePanelWidget extends ReactivePanelWithToolbar {
|
|
192
|
+
declare class SidePanelWidget extends ReactivePanelWithToolbar implements IChatPanel {
|
|
192
193
|
constructor(options: SidePanelWidget.IOptions);
|
|
193
194
|
protected onAfterAttach(msg: Message): void;
|
|
194
195
|
protected onResize(msg: Widget.ResizeMessage): void;
|
|
196
|
+
/**
|
|
197
|
+
* The area of the widget.
|
|
198
|
+
*/
|
|
199
|
+
get area(): ChatArea;
|
|
195
200
|
/**
|
|
196
201
|
* The chat widget embedded in the sidepanel widget.
|
|
197
202
|
*/
|
|
@@ -220,16 +225,11 @@ declare class SidePanelWidget extends ReactivePanelWithToolbar {
|
|
|
220
225
|
* Update the title based on the chat name.
|
|
221
226
|
*/
|
|
222
227
|
private _updateTitle;
|
|
223
|
-
/**
|
|
224
|
-
* Enable/disable unread icon.
|
|
225
|
-
*/
|
|
226
|
-
private _unreadChanged;
|
|
227
228
|
/**
|
|
228
229
|
* Trigger reactive toolbar overflow computation from rendered toolbar size.
|
|
229
230
|
*/
|
|
230
231
|
private _updateReactiveToolbar;
|
|
231
232
|
private _chatWidget;
|
|
232
|
-
private _markAsRead;
|
|
233
233
|
private _displayName;
|
|
234
234
|
private _titleWidget;
|
|
235
235
|
private _nameChanged;
|
|
@@ -254,14 +254,14 @@ declare namespace SidePanelWidget {
|
|
|
254
254
|
* The displayed name of the chat.
|
|
255
255
|
*/
|
|
256
256
|
displayName?: string;
|
|
257
|
-
/**
|
|
258
|
-
* The callback to open the chat in main area.
|
|
259
|
-
*/
|
|
260
|
-
openInMain?: (name: string) => Promise<boolean>;
|
|
261
257
|
/**
|
|
262
258
|
* The callback to rename the chat.
|
|
263
259
|
*/
|
|
264
260
|
renameChat?: boolean | ((oldName: string) => Promise<string | null>);
|
|
261
|
+
/**
|
|
262
|
+
* An optional toolbar factory.
|
|
263
|
+
*/
|
|
264
|
+
toolbarFactory?: (panel: IChatPanel) => IObservableList<ToolbarRegistry.IToolbarItem>;
|
|
265
265
|
/**
|
|
266
266
|
* The translation bundle.
|
|
267
267
|
*/
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { InputDialog } from '@jupyterlab/apputils';
|
|
10
10
|
import { nullTranslator } from '@jupyterlab/translation';
|
|
11
|
-
import { addIcon, closeIcon,
|
|
11
|
+
import { addIcon, closeIcon, PanelWithToolbar, ReactiveToolbar, ReactWidget, Spinner, Toolbar, ToolbarButton } from '@jupyterlab/ui-components';
|
|
12
12
|
import { ArrayExt } from '@lumino/algorithm';
|
|
13
13
|
import { MessageLoop } from '@lumino/messaging';
|
|
14
14
|
import { Debouncer } from '@lumino/polling';
|
|
@@ -19,7 +19,7 @@ import { ChatSelectorPopup } from './chat-selector-popup';
|
|
|
19
19
|
import { ChatWidget } from './chat-widget';
|
|
20
20
|
import { defaultPlaceholder } from './placeholder';
|
|
21
21
|
import { TRANSLATION_DOMAIN } from '../context';
|
|
22
|
-
import { chatIcon
|
|
22
|
+
import { chatIcon } from '../icons';
|
|
23
23
|
const SIDEPANEL_CLASS = 'jp-chat-sidepanel';
|
|
24
24
|
const ADD_BUTTON_CLASS = 'jp-chat-add';
|
|
25
25
|
const OPEN_SELECT_CLASS = 'jp-chat-open';
|
|
@@ -120,9 +120,9 @@ export class MultiChatPanel extends PanelWithToolbar {
|
|
|
120
120
|
this.addClass(SIDEPANEL_CLASS);
|
|
121
121
|
this._chatOptions = options;
|
|
122
122
|
this._inputToolbarFactory = options.inputToolbarFactory;
|
|
123
|
+
this._chatToolbarFactory = options.chatToolbarFactory;
|
|
123
124
|
this._getChatNames = options.getChatNames;
|
|
124
125
|
this._createModel = options.createModel;
|
|
125
|
-
this._openInMain = options.openInMain;
|
|
126
126
|
this._renameChat = options.renameChat;
|
|
127
127
|
this._placeholderFactory = options.placeholderFactory;
|
|
128
128
|
if (this._createModel) {
|
|
@@ -133,6 +133,7 @@ export class MultiChatPanel extends PanelWithToolbar {
|
|
|
133
133
|
this.open(addChatArgs);
|
|
134
134
|
},
|
|
135
135
|
icon: addIcon,
|
|
136
|
+
label: this._trans.__('New chat'),
|
|
136
137
|
tooltip: this._trans.__('Create a new chat')
|
|
137
138
|
});
|
|
138
139
|
addChat.addClass(ADD_BUTTON_CLASS);
|
|
@@ -168,7 +169,7 @@ export class MultiChatPanel extends PanelWithToolbar {
|
|
|
168
169
|
: undefined;
|
|
169
170
|
}
|
|
170
171
|
/**
|
|
171
|
-
* A signal emitting when a chat
|
|
172
|
+
* A signal emitting when a chat panel is opened in the sidepanel.
|
|
172
173
|
*/
|
|
173
174
|
get chatOpened() {
|
|
174
175
|
return this._chatOpened;
|
|
@@ -298,8 +299,8 @@ export class MultiChatPanel extends PanelWithToolbar {
|
|
|
298
299
|
const widget = new SidePanelWidget({
|
|
299
300
|
widget: chatWidget,
|
|
300
301
|
displayName: name,
|
|
301
|
-
openInMain: this._openInMain,
|
|
302
302
|
renameChat: this._renameChat,
|
|
303
|
+
toolbarFactory: this._chatToolbarFactory,
|
|
303
304
|
onClose: (name, disposeModel = true) => {
|
|
304
305
|
this.unsetLoadedModel(name, disposeModel);
|
|
305
306
|
},
|
|
@@ -314,7 +315,7 @@ export class MultiChatPanel extends PanelWithToolbar {
|
|
|
314
315
|
if (this._chatSelectorPopup) {
|
|
315
316
|
this._chatSelectorPopup.setCurrentChat(name);
|
|
316
317
|
}
|
|
317
|
-
this._chatOpened.emit(
|
|
318
|
+
this._chatOpened.emit(widget);
|
|
318
319
|
return chatWidget;
|
|
319
320
|
}
|
|
320
321
|
/**
|
|
@@ -372,14 +373,8 @@ export class MultiChatPanel extends PanelWithToolbar {
|
|
|
372
373
|
*/
|
|
373
374
|
class SidePanelWidget extends ReactivePanelWithToolbar {
|
|
374
375
|
constructor(options) {
|
|
375
|
-
var _a
|
|
376
|
+
var _a;
|
|
376
377
|
super();
|
|
377
|
-
/**
|
|
378
|
-
* Enable/disable unread icon.
|
|
379
|
-
*/
|
|
380
|
-
this._unreadChanged = (_, unread) => {
|
|
381
|
-
this._markAsRead.enabled = unread.length > 0;
|
|
382
|
-
};
|
|
383
378
|
this._nameChanged = new Signal(this);
|
|
384
379
|
this._chatWidget = options.widget;
|
|
385
380
|
this._displayName = (_a = options.displayName) !== null && _a !== void 0 ? _a : options.widget.model.name;
|
|
@@ -399,18 +394,6 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
|
|
|
399
394
|
});
|
|
400
395
|
// Add the chat widget
|
|
401
396
|
this.addWidget(this._chatWidget);
|
|
402
|
-
// Add toolbar buttons
|
|
403
|
-
this._markAsRead = new ToolbarButton({
|
|
404
|
-
icon: readIcon,
|
|
405
|
-
iconLabel: trans.__('Mark chat as read'),
|
|
406
|
-
className: 'jp-mod-styled',
|
|
407
|
-
onClick: () => {
|
|
408
|
-
if (this.model) {
|
|
409
|
-
this.model.unreadMessages = [];
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
});
|
|
413
|
-
this.toolbar.addItem('markRead', this._markAsRead);
|
|
414
397
|
if (options.renameChat) {
|
|
415
398
|
const renameButton = new ToolbarButton({
|
|
416
399
|
iconClass: 'jp-EditIcon',
|
|
@@ -447,21 +430,6 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
|
|
|
447
430
|
});
|
|
448
431
|
this.toolbar.addItem('rename', renameButton);
|
|
449
432
|
}
|
|
450
|
-
if (options.openInMain) {
|
|
451
|
-
const moveToMain = new ToolbarButton({
|
|
452
|
-
icon: launchIcon,
|
|
453
|
-
iconLabel: trans.__('Move the chat to the main area'),
|
|
454
|
-
className: 'jp-mod-styled',
|
|
455
|
-
onClick: async () => {
|
|
456
|
-
var _a;
|
|
457
|
-
const name = this.model.name;
|
|
458
|
-
if (await ((_a = options.openInMain) === null || _a === void 0 ? void 0 : _a.call(options, name))) {
|
|
459
|
-
options.onClose(this._displayName, false);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
});
|
|
463
|
-
this.toolbar.addItem('moveMain', moveToMain);
|
|
464
|
-
}
|
|
465
433
|
const closeButton = new ToolbarButton({
|
|
466
434
|
icon: closeIcon,
|
|
467
435
|
iconLabel: trans.__('Close the chat'),
|
|
@@ -471,9 +439,25 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
|
|
|
471
439
|
}
|
|
472
440
|
});
|
|
473
441
|
this.toolbar.addItem('close', closeButton);
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
442
|
+
if (options.toolbarFactory) {
|
|
443
|
+
const items = options.toolbarFactory(this);
|
|
444
|
+
for (let i = 0; i < items.length; i++) {
|
|
445
|
+
const { name, widget } = items.get(i);
|
|
446
|
+
this.toolbar.insertBefore('close', name, widget);
|
|
447
|
+
}
|
|
448
|
+
items.changed.connect((_, change) => {
|
|
449
|
+
if (change.type === 'add') {
|
|
450
|
+
for (const { name, widget } of change.newValues) {
|
|
451
|
+
this.toolbar.insertBefore('close', name, widget);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
else if (change.type === 'remove') {
|
|
455
|
+
for (const { widget } of change.oldValues) {
|
|
456
|
+
widget.dispose();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
}
|
|
477
461
|
}
|
|
478
462
|
onAfterAttach(msg) {
|
|
479
463
|
super.onAfterAttach(msg);
|
|
@@ -483,6 +467,12 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
|
|
|
483
467
|
super.onResize(msg);
|
|
484
468
|
this._updateReactiveToolbar();
|
|
485
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* The area of the widget.
|
|
472
|
+
*/
|
|
473
|
+
get area() {
|
|
474
|
+
return 'sidebar';
|
|
475
|
+
}
|
|
486
476
|
/**
|
|
487
477
|
* The chat widget embedded in the sidepanel widget.
|
|
488
478
|
*/
|
|
@@ -520,11 +510,6 @@ class SidePanelWidget extends ReactivePanelWithToolbar {
|
|
|
520
510
|
* Dispose of the resources held by the widget.
|
|
521
511
|
*/
|
|
522
512
|
dispose() {
|
|
523
|
-
var _a;
|
|
524
|
-
const model = this.model;
|
|
525
|
-
if (model) {
|
|
526
|
-
(_a = model.unreadChanged) === null || _a === void 0 ? void 0 : _a.disconnect(this._unreadChanged);
|
|
527
|
-
}
|
|
528
513
|
super.dispose();
|
|
529
514
|
}
|
|
530
515
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jupyter/chat",
|
|
3
|
-
"version": "0.23.0-alpha.
|
|
3
|
+
"version": "0.23.0-alpha.4",
|
|
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",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"@emotion/react": "^11.10.5",
|
|
47
47
|
"@emotion/styled": "^11.10.5",
|
|
48
48
|
"@jupyter/react-components": "^0.15.2",
|
|
49
|
+
"@jupyter/ydoc": "^3.0.0 || ^4.0.0",
|
|
49
50
|
"@jupyterlab/application": "^4.6.0",
|
|
50
51
|
"@jupyterlab/apputils": "^4.7.0",
|
|
51
52
|
"@jupyterlab/codeeditor": "^4.6.0",
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
"@jupyterlab/filebrowser": "^4.6.0",
|
|
56
57
|
"@jupyterlab/fileeditor": "^4.6.0",
|
|
57
58
|
"@jupyterlab/notebook": "^4.6.0",
|
|
59
|
+
"@jupyterlab/observables": "^5.6.0",
|
|
58
60
|
"@jupyterlab/rendermime": "^4.6.0",
|
|
59
61
|
"@jupyterlab/translation": "^4.6.0",
|
|
60
62
|
"@jupyterlab/ui-components": "^4.6.0",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { InputModel } from '../input-model';
|
|
7
|
+
import { INewMessage } from '../types';
|
|
8
|
+
|
|
9
|
+
describe('test input model', () => {
|
|
10
|
+
describe('metadata', () => {
|
|
11
|
+
it('should start with empty metadata', () => {
|
|
12
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
13
|
+
expect(model.getMetadata()).toEqual({});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should seed metadata from options', () => {
|
|
17
|
+
const model = new InputModel({
|
|
18
|
+
onSend: jest.fn(),
|
|
19
|
+
metadata: { persona: 'kiro' }
|
|
20
|
+
});
|
|
21
|
+
expect(model.getMetadata()).toEqual({ persona: 'kiro' });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should merge patches with updateMetadata', () => {
|
|
25
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
26
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
27
|
+
model.updateMetadata({ model: { id: 'claude-opus-48' } });
|
|
28
|
+
expect(model.getMetadata()).toEqual({
|
|
29
|
+
persona: 'kiro',
|
|
30
|
+
model: { id: 'claude-opus-48' }
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should overwrite existing keys on update', () => {
|
|
35
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
36
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
37
|
+
model.updateMetadata({ persona: 'jupyternaut' });
|
|
38
|
+
expect(model.getMetadata()).toEqual({ persona: 'jupyternaut' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should shallow-merge: a top-level key replaces the whole value', () => {
|
|
42
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
43
|
+
model.updateMetadata({ model: { id: 'a' } });
|
|
44
|
+
// Passing `model` again replaces it wholesale (no recursive merge).
|
|
45
|
+
model.updateMetadata({ model: { id: 'b' } });
|
|
46
|
+
expect(model.getMetadata()).toEqual({ model: { id: 'b' } });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should not be mutated by later changes to a patch', () => {
|
|
50
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
51
|
+
const patch = { model: { id: 'a' } };
|
|
52
|
+
model.updateMetadata(patch);
|
|
53
|
+
// Mutating the patch after the fact must not reach into stored metadata.
|
|
54
|
+
patch.model.id = 'tampered';
|
|
55
|
+
expect(model.getMetadata()).toEqual({ model: { id: 'a' } });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should clear metadata', () => {
|
|
59
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
60
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
61
|
+
model.clearMetadata();
|
|
62
|
+
expect(model.getMetadata()).toEqual({});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should emit metadataChanged on update and clear', () => {
|
|
66
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
67
|
+
const emitted: any[] = [];
|
|
68
|
+
model.metadataChanged?.connect((_, metadata) => {
|
|
69
|
+
emitted.push({ ...metadata });
|
|
70
|
+
});
|
|
71
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
72
|
+
model.clearMetadata();
|
|
73
|
+
expect(emitted).toEqual([{ persona: 'kiro' }, {}]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('send', () => {
|
|
78
|
+
it('should attach metadata to the message when non-empty', () => {
|
|
79
|
+
const onSend = jest.fn();
|
|
80
|
+
const model = new InputModel({ onSend });
|
|
81
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
82
|
+
model.send('hello');
|
|
83
|
+
|
|
84
|
+
const message: INewMessage = onSend.mock.calls[0][0];
|
|
85
|
+
expect(message.body).toBe('hello');
|
|
86
|
+
expect(message.metadata).toEqual({ persona: 'kiro' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should omit metadata from the message when empty', () => {
|
|
90
|
+
const onSend = jest.fn();
|
|
91
|
+
const model = new InputModel({ onSend });
|
|
92
|
+
model.send('hello');
|
|
93
|
+
|
|
94
|
+
const message: INewMessage = onSend.mock.calls[0][0];
|
|
95
|
+
expect(message.metadata).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should send a copy of the metadata', () => {
|
|
99
|
+
const onSend = jest.fn();
|
|
100
|
+
const model = new InputModel({ onSend });
|
|
101
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
102
|
+
model.send('hello');
|
|
103
|
+
|
|
104
|
+
const message: INewMessage = onSend.mock.calls[0][0];
|
|
105
|
+
model.updateMetadata({ persona: 'jupyternaut' });
|
|
106
|
+
expect(message.metadata).toEqual({ persona: 'kiro' });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should keep metadata after sending (sticky selection)', () => {
|
|
110
|
+
// Unlike attachments/mentions, metadata carries the picker's
|
|
111
|
+
// persona/model/settings selection, which is sticky across messages.
|
|
112
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
113
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
114
|
+
model.send('hello');
|
|
115
|
+
expect(model.getMetadata()).toEqual({ persona: 'kiro' });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// `IMessageMetadata` is intentionally empty in the source; consumers augment it
|
|
121
|
+
// with their own fields via module augmentation. We do the same here purely so
|
|
122
|
+
// the tests can exercise `updateMetadata` with representative fields — this
|
|
123
|
+
// stays in the test file and no consumer-specific fields leak into the source.
|
|
124
|
+
declare module '../types' {
|
|
125
|
+
interface IMessageMetadata {
|
|
126
|
+
persona?: string;
|
|
127
|
+
model?: { id: string };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* Example of [Jest](https://jestjs.io/docs/getting-started) unit tests
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { IAwareness } from '@jupyter/ydoc';
|
|
11
|
+
|
|
10
12
|
import { AbstractChatModel, IChatContext, IChatModel } from '../model';
|
|
11
13
|
import { IMessage, IMessageContent, INewMessage } from '../types';
|
|
12
14
|
import { MockChatModel, MockChatContext } from './mocks';
|
|
@@ -149,4 +151,20 @@ describe('test chat model', () => {
|
|
|
149
151
|
expect(model.config.sendWithShiftEnter).toBeTruthy();
|
|
150
152
|
});
|
|
151
153
|
});
|
|
154
|
+
|
|
155
|
+
describe('awareness', () => {
|
|
156
|
+
it('should surface the model awareness on the context', () => {
|
|
157
|
+
class AwareChatModel extends MockChatModel {
|
|
158
|
+
readonly awareness = {} as IAwareness;
|
|
159
|
+
}
|
|
160
|
+
const model = new AwareChatModel();
|
|
161
|
+
const context = new MockChatContext({ model });
|
|
162
|
+
expect(context.awareness).toBe(model.awareness);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should be undefined when the model has no awareness', () => {
|
|
166
|
+
const context = new MockChatContext({ model: new MockChatModel() });
|
|
167
|
+
expect(context.awareness).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
152
170
|
});
|