@jupyter/chat 0.23.0-alpha.2 → 0.23.0-alpha.3
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__/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.js +1 -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 +32 -48
- package/package.json +2 -1
- package/src/__tests__/input-model.spec.ts +129 -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 +1 -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 +53 -66
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
import { InputModel } from '../input-model';
|
|
6
|
+
describe('test input model', () => {
|
|
7
|
+
describe('metadata', () => {
|
|
8
|
+
it('should start with empty metadata', () => {
|
|
9
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
10
|
+
expect(model.getMetadata()).toEqual({});
|
|
11
|
+
});
|
|
12
|
+
it('should seed metadata from options', () => {
|
|
13
|
+
const model = new InputModel({
|
|
14
|
+
onSend: jest.fn(),
|
|
15
|
+
metadata: { persona: 'kiro' }
|
|
16
|
+
});
|
|
17
|
+
expect(model.getMetadata()).toEqual({ persona: 'kiro' });
|
|
18
|
+
});
|
|
19
|
+
it('should merge patches with updateMetadata', () => {
|
|
20
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
21
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
22
|
+
model.updateMetadata({ model: { id: 'claude-opus-48' } });
|
|
23
|
+
expect(model.getMetadata()).toEqual({
|
|
24
|
+
persona: 'kiro',
|
|
25
|
+
model: { id: 'claude-opus-48' }
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it('should overwrite existing keys on update', () => {
|
|
29
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
30
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
31
|
+
model.updateMetadata({ persona: 'jupyternaut' });
|
|
32
|
+
expect(model.getMetadata()).toEqual({ persona: 'jupyternaut' });
|
|
33
|
+
});
|
|
34
|
+
it('should shallow-merge: a top-level key replaces the whole value', () => {
|
|
35
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
36
|
+
model.updateMetadata({ model: { id: 'a' } });
|
|
37
|
+
// Passing `model` again replaces it wholesale (no recursive merge).
|
|
38
|
+
model.updateMetadata({ model: { id: 'b' } });
|
|
39
|
+
expect(model.getMetadata()).toEqual({ model: { id: 'b' } });
|
|
40
|
+
});
|
|
41
|
+
it('should not be mutated by later changes to a patch', () => {
|
|
42
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
43
|
+
const patch = { model: { id: 'a' } };
|
|
44
|
+
model.updateMetadata(patch);
|
|
45
|
+
// Mutating the patch after the fact must not reach into stored metadata.
|
|
46
|
+
patch.model.id = 'tampered';
|
|
47
|
+
expect(model.getMetadata()).toEqual({ model: { id: 'a' } });
|
|
48
|
+
});
|
|
49
|
+
it('should clear metadata', () => {
|
|
50
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
51
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
52
|
+
model.clearMetadata();
|
|
53
|
+
expect(model.getMetadata()).toEqual({});
|
|
54
|
+
});
|
|
55
|
+
it('should emit metadataChanged on update and clear', () => {
|
|
56
|
+
var _a;
|
|
57
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
58
|
+
const emitted = [];
|
|
59
|
+
(_a = model.metadataChanged) === null || _a === void 0 ? void 0 : _a.connect((_, metadata) => {
|
|
60
|
+
emitted.push({ ...metadata });
|
|
61
|
+
});
|
|
62
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
63
|
+
model.clearMetadata();
|
|
64
|
+
expect(emitted).toEqual([{ persona: 'kiro' }, {}]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('send', () => {
|
|
68
|
+
it('should attach metadata to the message when non-empty', () => {
|
|
69
|
+
const onSend = jest.fn();
|
|
70
|
+
const model = new InputModel({ onSend });
|
|
71
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
72
|
+
model.send('hello');
|
|
73
|
+
const message = onSend.mock.calls[0][0];
|
|
74
|
+
expect(message.body).toBe('hello');
|
|
75
|
+
expect(message.metadata).toEqual({ persona: 'kiro' });
|
|
76
|
+
});
|
|
77
|
+
it('should omit metadata from the message when empty', () => {
|
|
78
|
+
const onSend = jest.fn();
|
|
79
|
+
const model = new InputModel({ onSend });
|
|
80
|
+
model.send('hello');
|
|
81
|
+
const message = onSend.mock.calls[0][0];
|
|
82
|
+
expect(message.metadata).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
it('should send a copy of the metadata', () => {
|
|
85
|
+
const onSend = jest.fn();
|
|
86
|
+
const model = new InputModel({ onSend });
|
|
87
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
88
|
+
model.send('hello');
|
|
89
|
+
const message = onSend.mock.calls[0][0];
|
|
90
|
+
model.updateMetadata({ persona: 'jupyternaut' });
|
|
91
|
+
expect(message.metadata).toEqual({ persona: 'kiro' });
|
|
92
|
+
});
|
|
93
|
+
it('should keep metadata after sending (sticky selection)', () => {
|
|
94
|
+
// Unlike attachments/mentions, metadata carries the picker's
|
|
95
|
+
// persona/model/settings selection, which is sticky across messages.
|
|
96
|
+
const model = new InputModel({ onSend: jest.fn() });
|
|
97
|
+
model.updateMetadata({ persona: 'kiro' });
|
|
98
|
+
model.send('hello');
|
|
99
|
+
expect(model.getMetadata()).toEqual({ persona: 'kiro' });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
5
|
import { RenderMimeRegistry } from '@jupyterlab/rendermime';
|
|
6
|
+
import { ObservableList } from '@jupyterlab/observables';
|
|
6
7
|
import { Widget } from '@lumino/widgets';
|
|
7
8
|
import { MultiChatPanel } from '../widgets/multichat-panel';
|
|
8
9
|
import { defaultPlaceholder } from '../widgets/placeholder';
|
|
10
|
+
import { MockChatModel } from './mocks';
|
|
9
11
|
describe('MultiChatPanel', () => {
|
|
10
12
|
let rmRegistry;
|
|
11
13
|
beforeEach(() => {
|
|
@@ -56,31 +58,6 @@ describe('MultiChatPanel', () => {
|
|
|
56
58
|
panel.dispose();
|
|
57
59
|
});
|
|
58
60
|
});
|
|
59
|
-
describe('openInMain', () => {
|
|
60
|
-
it('should not dispose the model when moving to main area', async () => {
|
|
61
|
-
const { MockChatModel } = await import('./mocks');
|
|
62
|
-
const model = new MockChatModel();
|
|
63
|
-
const openInMain = jest.fn().mockResolvedValue(true);
|
|
64
|
-
const panel = new MultiChatPanel({ rmRegistry, openInMain });
|
|
65
|
-
panel.open({ model, displayName: 'test-chat' });
|
|
66
|
-
// Find the 'moveMain' toolbar button by name and invoke its click handler directly.
|
|
67
|
-
// ToolbarButton stores the onClick handler independently of DOM/React rendering,
|
|
68
|
-
// so this works without attaching the widget to the document.
|
|
69
|
-
const toolbar = panel.current.toolbar;
|
|
70
|
-
const names = Array.from(toolbar.names());
|
|
71
|
-
const moveMainIdx = names.indexOf('moveMain');
|
|
72
|
-
expect(moveMainIdx).toBeGreaterThan(-1);
|
|
73
|
-
const moveButton = toolbar.layout.widgets[moveMainIdx];
|
|
74
|
-
moveButton.onClick();
|
|
75
|
-
// Wait for the async onClick handler: openInMain resolves, then onClose is called.
|
|
76
|
-
await Promise.resolve();
|
|
77
|
-
expect(openInMain).toHaveBeenCalledWith(model.name);
|
|
78
|
-
expect(model.isDisposed).toBe(false);
|
|
79
|
-
expect(panel.getLoadedModel('test-chat')).toBeUndefined();
|
|
80
|
-
model.dispose();
|
|
81
|
-
panel.dispose();
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
61
|
describe('placeholderFactory', () => {
|
|
85
62
|
it('should use defaultPlaceholder when no factory is provided', () => {
|
|
86
63
|
const panel = new MultiChatPanel({ rmRegistry });
|
|
@@ -164,7 +141,7 @@ describe('MultiChatPanel', () => {
|
|
|
164
141
|
expect(props.onCreate).toBeDefined();
|
|
165
142
|
panel.dispose();
|
|
166
143
|
});
|
|
167
|
-
it('should call factory.create again when the placeholder is re-added after closing a chat',
|
|
144
|
+
it('should call factory.create again when the placeholder is re-added after closing a chat', () => {
|
|
168
145
|
const factory = {
|
|
169
146
|
create: jest.fn().mockReturnValue(new Widget())
|
|
170
147
|
};
|
|
@@ -175,7 +152,6 @@ describe('MultiChatPanel', () => {
|
|
|
175
152
|
createModel
|
|
176
153
|
});
|
|
177
154
|
// Simulate opening and closing a chat to trigger _addPlaceholder again.
|
|
178
|
-
const { MockChatModel } = await import('./mocks');
|
|
179
155
|
const model = new MockChatModel();
|
|
180
156
|
panel.open({ model, displayName: 'test-chat' });
|
|
181
157
|
panel.unsetLoadedModel('test-chat');
|
|
@@ -194,4 +170,79 @@ describe('MultiChatPanel', () => {
|
|
|
194
170
|
panel.dispose();
|
|
195
171
|
});
|
|
196
172
|
});
|
|
173
|
+
describe('chatToolbarFactory', () => {
|
|
174
|
+
const makeFactory = (items) => jest.fn().mockReturnValue(new ObservableList({ values: items }));
|
|
175
|
+
it('should call factory with the panel when a chat is opened', () => {
|
|
176
|
+
const factory = makeFactory([{ name: 'myButton', widget: new Widget() }]);
|
|
177
|
+
const panel = new MultiChatPanel({
|
|
178
|
+
rmRegistry,
|
|
179
|
+
chatToolbarFactory: factory
|
|
180
|
+
});
|
|
181
|
+
const model = new MockChatModel();
|
|
182
|
+
const chatWidget = panel.open({ model, displayName: 'test' });
|
|
183
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(factory.mock.calls[0][0].widget).toBe(chatWidget);
|
|
185
|
+
panel.dispose();
|
|
186
|
+
});
|
|
187
|
+
it('should add the created widget to the chat toolbar', () => {
|
|
188
|
+
const panel = new MultiChatPanel({
|
|
189
|
+
rmRegistry,
|
|
190
|
+
chatToolbarFactory: makeFactory([
|
|
191
|
+
{ name: 'myButton', widget: new Widget() }
|
|
192
|
+
])
|
|
193
|
+
});
|
|
194
|
+
const model = new MockChatModel();
|
|
195
|
+
panel.open({ model, displayName: 'test' });
|
|
196
|
+
const toolbar = panel.current.toolbar;
|
|
197
|
+
expect(Array.from(toolbar.names())).toContain('myButton');
|
|
198
|
+
panel.dispose();
|
|
199
|
+
});
|
|
200
|
+
it('should insert the item before "close"', () => {
|
|
201
|
+
const panel = new MultiChatPanel({
|
|
202
|
+
rmRegistry,
|
|
203
|
+
chatToolbarFactory: makeFactory([
|
|
204
|
+
{ name: 'myButton', widget: new Widget() }
|
|
205
|
+
])
|
|
206
|
+
});
|
|
207
|
+
const model = new MockChatModel();
|
|
208
|
+
panel.open({ model, displayName: 'test' });
|
|
209
|
+
const toolbar = panel.current.toolbar;
|
|
210
|
+
const names = Array.from(toolbar.names());
|
|
211
|
+
expect(names.indexOf('myButton')).toBeLessThan(names.indexOf('close'));
|
|
212
|
+
panel.dispose();
|
|
213
|
+
});
|
|
214
|
+
it('should add all items when factory returns multiple items', () => {
|
|
215
|
+
const panel = new MultiChatPanel({
|
|
216
|
+
rmRegistry,
|
|
217
|
+
chatToolbarFactory: makeFactory([
|
|
218
|
+
{ name: 'item1', widget: new Widget() },
|
|
219
|
+
{ name: 'item2', widget: new Widget() }
|
|
220
|
+
])
|
|
221
|
+
});
|
|
222
|
+
const model = new MockChatModel();
|
|
223
|
+
panel.open({ model, displayName: 'test' });
|
|
224
|
+
const toolbar = panel.current.toolbar;
|
|
225
|
+
const names = Array.from(toolbar.names());
|
|
226
|
+
expect(names).toContain('item1');
|
|
227
|
+
expect(names).toContain('item2');
|
|
228
|
+
panel.dispose();
|
|
229
|
+
});
|
|
230
|
+
it('should call factory again for each newly opened chat', () => {
|
|
231
|
+
const factory = jest.fn().mockImplementation(() => new ObservableList({
|
|
232
|
+
values: [{ name: 'myButton', widget: new Widget() }]
|
|
233
|
+
}));
|
|
234
|
+
const panel = new MultiChatPanel({
|
|
235
|
+
rmRegistry,
|
|
236
|
+
chatToolbarFactory: factory
|
|
237
|
+
});
|
|
238
|
+
const model1 = new MockChatModel();
|
|
239
|
+
panel.open({ model: model1, displayName: 'chat1' });
|
|
240
|
+
panel.unsetLoadedModel('chat1');
|
|
241
|
+
const model2 = new MockChatModel();
|
|
242
|
+
panel.open({ model: model2, displayName: 'chat2' });
|
|
243
|
+
expect(factory).toHaveBeenCalledTimes(2);
|
|
244
|
+
expect(factory.mock.results[0].value).not.toBe(factory.mock.results[1].value);
|
|
245
|
+
panel.dispose();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
197
248
|
});
|
|
@@ -70,7 +70,7 @@ export function ChatMessageBase(props) {
|
|
|
70
70
|
});
|
|
71
71
|
const inputModel = new InputModel({
|
|
72
72
|
chatContext: model.createChatContext(),
|
|
73
|
-
onSend: (
|
|
73
|
+
onSend: (newMessage) => updateMessage(message.id, newMessage),
|
|
74
74
|
onCancel: () => cancelEdition(),
|
|
75
75
|
value: body,
|
|
76
76
|
activeCellManager: model.activeCellManager,
|
|
@@ -92,18 +92,25 @@ export function ChatMessageBase(props) {
|
|
|
92
92
|
setEdit(false);
|
|
93
93
|
}, [model, message]);
|
|
94
94
|
// Update the content of the message.
|
|
95
|
-
const updateMessage = useCallback((id,
|
|
96
|
-
|
|
97
|
-
if (!canEdit || !inputModel) {
|
|
95
|
+
const updateMessage = useCallback((id, newMessage) => {
|
|
96
|
+
const inputModel = model.getEditionModel(id);
|
|
97
|
+
if (!canEdit || !inputModel || !model.updateMessage) {
|
|
98
|
+
setEdit(false);
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
100
101
|
// Update the message
|
|
101
102
|
const updatedMessage = { ...message };
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
if (newMessage.body) {
|
|
104
|
+
updatedMessage.body = newMessage.body;
|
|
105
|
+
}
|
|
106
|
+
if (newMessage.attachments) {
|
|
107
|
+
updatedMessage.attachments = newMessage.attachments;
|
|
108
|
+
}
|
|
109
|
+
if (newMessage.mentions) {
|
|
110
|
+
updatedMessage.mentions = newMessage.mentions;
|
|
111
|
+
}
|
|
105
112
|
model.updateMessage(id, updatedMessage);
|
|
106
|
-
|
|
113
|
+
inputModel.dispose();
|
|
107
114
|
setEdit(false);
|
|
108
115
|
}, [model, message, canEdit]);
|
|
109
116
|
// Delete the message.
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
package/lib/input-model.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { ISignal } from '@lumino/signaling';
|
|
|
4
4
|
import { IActiveCellManager } from './active-cell-manager';
|
|
5
5
|
import { ISelectionWatcher } from './selection-watcher';
|
|
6
6
|
import { IChatContext } from './model';
|
|
7
|
-
import { IAttachment, IUser } from './types';
|
|
7
|
+
import { IAttachment, IMessageMetadata, INewMessage, IUser } from './types';
|
|
8
8
|
/**
|
|
9
9
|
* The chat input interface.
|
|
10
10
|
*/
|
|
@@ -118,6 +118,27 @@ export interface IInputModel extends IDisposable {
|
|
|
118
118
|
* Clear mentions list.
|
|
119
119
|
*/
|
|
120
120
|
clearMentions(): void;
|
|
121
|
+
/**
|
|
122
|
+
* Get the metadata to attach to the next message to send.
|
|
123
|
+
*/
|
|
124
|
+
getMetadata(): IMessageMetadata;
|
|
125
|
+
/**
|
|
126
|
+
* Merge a patch into the metadata to attach to the next message to send.
|
|
127
|
+
*
|
|
128
|
+
* This is a *shallow* merge: each key in `patch` replaces the existing value
|
|
129
|
+
* at that key wholesale, rather than being merged recursively. To change a
|
|
130
|
+
* nested field, pass the whole new value for its top-level key. The patch is
|
|
131
|
+
* deep-copied, so mutating it afterwards won't affect the stored metadata.
|
|
132
|
+
*/
|
|
133
|
+
updateMetadata(patch: IMessageMetadata): void;
|
|
134
|
+
/**
|
|
135
|
+
* Clear the metadata.
|
|
136
|
+
*/
|
|
137
|
+
clearMetadata(): void;
|
|
138
|
+
/**
|
|
139
|
+
* A signal emitting when the metadata has changed.
|
|
140
|
+
*/
|
|
141
|
+
readonly metadataChanged?: ISignal<IInputModel, IMessageMetadata>;
|
|
121
142
|
/**
|
|
122
143
|
* A signal emitting when disposing of the model.
|
|
123
144
|
*/
|
|
@@ -240,6 +261,27 @@ export declare class InputModel implements IInputModel {
|
|
|
240
261
|
* Clear mentions list.
|
|
241
262
|
*/
|
|
242
263
|
clearMentions: () => void;
|
|
264
|
+
/**
|
|
265
|
+
* Get the metadata to attach to the next message to send.
|
|
266
|
+
*/
|
|
267
|
+
getMetadata(): IMessageMetadata;
|
|
268
|
+
/**
|
|
269
|
+
* Merge a patch into the metadata to attach to the next message to send.
|
|
270
|
+
*
|
|
271
|
+
* This is a *shallow* merge: each key in `patch` replaces the existing value
|
|
272
|
+
* at that key wholesale, rather than being merged recursively. To change a
|
|
273
|
+
* nested field, pass the whole new value for its top-level key. The patch is
|
|
274
|
+
* deep-copied, so mutating it afterwards won't affect the stored metadata.
|
|
275
|
+
*/
|
|
276
|
+
updateMetadata: (patch: IMessageMetadata) => void;
|
|
277
|
+
/**
|
|
278
|
+
* Clear the metadata.
|
|
279
|
+
*/
|
|
280
|
+
clearMetadata: () => void;
|
|
281
|
+
/**
|
|
282
|
+
* A signal emitting when the metadata has changed.
|
|
283
|
+
*/
|
|
284
|
+
get metadataChanged(): ISignal<IInputModel, IMessageMetadata>;
|
|
243
285
|
/**
|
|
244
286
|
* Dispose the input model.
|
|
245
287
|
*/
|
|
@@ -260,6 +302,7 @@ export declare class InputModel implements IInputModel {
|
|
|
260
302
|
private _currentWord;
|
|
261
303
|
private _attachments;
|
|
262
304
|
private _mentions;
|
|
305
|
+
private _metadata;
|
|
263
306
|
private _activeCellManager;
|
|
264
307
|
private _selectionWatcher;
|
|
265
308
|
private _documentManager;
|
|
@@ -270,6 +313,7 @@ export declare class InputModel implements IInputModel {
|
|
|
270
313
|
private _configChanged;
|
|
271
314
|
private _focusInputSignal;
|
|
272
315
|
private _attachmentsChanged;
|
|
316
|
+
private _metadataChanged;
|
|
273
317
|
private _onDisposed;
|
|
274
318
|
private _isDisposed;
|
|
275
319
|
}
|
|
@@ -284,7 +328,7 @@ export declare namespace InputModel {
|
|
|
284
328
|
* @param content - the content of the message.
|
|
285
329
|
* @param model - the model of the input sending the message.
|
|
286
330
|
*/
|
|
287
|
-
onSend: (
|
|
331
|
+
onSend: (message: INewMessage) => void;
|
|
288
332
|
/**
|
|
289
333
|
* Function that should cancel the message edition.
|
|
290
334
|
*/
|
|
@@ -301,6 +345,10 @@ export declare namespace InputModel {
|
|
|
301
345
|
* The initial mentions.
|
|
302
346
|
*/
|
|
303
347
|
mentions?: IUser[];
|
|
348
|
+
/**
|
|
349
|
+
* The initial metadata.
|
|
350
|
+
*/
|
|
351
|
+
metadata?: IMessageMetadata;
|
|
304
352
|
/**
|
|
305
353
|
* The current cursor index.
|
|
306
354
|
* This refers to the index of the character in front of the cursor.
|
package/lib/input-model.js
CHANGED
|
@@ -14,8 +14,27 @@ export class InputModel {
|
|
|
14
14
|
* Function to send a message.
|
|
15
15
|
*/
|
|
16
16
|
this.send = (input) => {
|
|
17
|
-
|
|
17
|
+
const message = {
|
|
18
|
+
body: input
|
|
19
|
+
};
|
|
20
|
+
// Add the attachments
|
|
21
|
+
if (this.attachments.length) {
|
|
22
|
+
message.attachments = [...this.attachments];
|
|
23
|
+
}
|
|
24
|
+
// Add the mentions
|
|
25
|
+
if (this.mentions.length) {
|
|
26
|
+
message.mentions = [...this.mentions];
|
|
27
|
+
}
|
|
28
|
+
// Add the metadata
|
|
29
|
+
if (Object.keys(this._metadata).length) {
|
|
30
|
+
message.metadata = { ...this._metadata };
|
|
31
|
+
}
|
|
32
|
+
// Send the message
|
|
33
|
+
this._onSend(message);
|
|
34
|
+
// Clear the input
|
|
18
35
|
this.value = '';
|
|
36
|
+
this.clearAttachments();
|
|
37
|
+
this.clearMentions();
|
|
19
38
|
};
|
|
20
39
|
/**
|
|
21
40
|
* Add attachment to send with next message.
|
|
@@ -75,6 +94,29 @@ export class InputModel {
|
|
|
75
94
|
this.clearMentions = () => {
|
|
76
95
|
this._mentions = [];
|
|
77
96
|
};
|
|
97
|
+
/**
|
|
98
|
+
* Merge a patch into the metadata to attach to the next message to send.
|
|
99
|
+
*
|
|
100
|
+
* This is a *shallow* merge: each key in `patch` replaces the existing value
|
|
101
|
+
* at that key wholesale, rather than being merged recursively. To change a
|
|
102
|
+
* nested field, pass the whole new value for its top-level key. The patch is
|
|
103
|
+
* deep-copied, so mutating it afterwards won't affect the stored metadata.
|
|
104
|
+
*/
|
|
105
|
+
this.updateMetadata = (patch) => {
|
|
106
|
+
// Deep-copy the patch so a caller mutating a nested field afterwards can't
|
|
107
|
+
// reach into the stored metadata. `this._metadata` only ever holds values
|
|
108
|
+
// that were themselves deep-copied on the way in, so a shallow spread of it
|
|
109
|
+
// is safe.
|
|
110
|
+
this._metadata = { ...this._metadata, ...structuredClone(patch) };
|
|
111
|
+
this._metadataChanged.emit(this._metadata);
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Clear the metadata.
|
|
115
|
+
*/
|
|
116
|
+
this.clearMetadata = () => {
|
|
117
|
+
this._metadata = {};
|
|
118
|
+
this._metadataChanged.emit(this._metadata);
|
|
119
|
+
};
|
|
78
120
|
this._cursorIndex = null;
|
|
79
121
|
this._currentWord = null;
|
|
80
122
|
this._valueChanged = new Signal(this);
|
|
@@ -83,6 +125,7 @@ export class InputModel {
|
|
|
83
125
|
this._configChanged = new Signal(this);
|
|
84
126
|
this._focusInputSignal = new Signal(this);
|
|
85
127
|
this._attachmentsChanged = new Signal(this);
|
|
128
|
+
this._metadataChanged = new Signal(this);
|
|
86
129
|
this._onDisposed = new Signal(this);
|
|
87
130
|
this._isDisposed = false;
|
|
88
131
|
this._id = (_a = options.id) !== null && _a !== void 0 ? _a : `input-${UUID.uuid4()}`;
|
|
@@ -91,6 +134,7 @@ export class InputModel {
|
|
|
91
134
|
this._value = options.value || '';
|
|
92
135
|
this._attachments = options.attachments || [];
|
|
93
136
|
this._mentions = options.mentions || [];
|
|
137
|
+
this._metadata = options.metadata || {};
|
|
94
138
|
this.cursorIndex = options.cursorIndex || this.value.length;
|
|
95
139
|
this._activeCellManager = (_b = options.activeCellManager) !== null && _b !== void 0 ? _b : null;
|
|
96
140
|
this._selectionWatcher = (_c = options.selectionWatcher) !== null && _c !== void 0 ? _c : null;
|
|
@@ -262,6 +306,18 @@ export class InputModel {
|
|
|
262
306
|
this._mentions.splice(index, 1);
|
|
263
307
|
}
|
|
264
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Get the metadata to attach to the next message to send.
|
|
311
|
+
*/
|
|
312
|
+
getMetadata() {
|
|
313
|
+
return this._metadata;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* A signal emitting when the metadata has changed.
|
|
317
|
+
*/
|
|
318
|
+
get metadataChanged() {
|
|
319
|
+
return this._metadataChanged;
|
|
320
|
+
}
|
|
265
321
|
/**
|
|
266
322
|
* Dispose the input model.
|
|
267
323
|
*/
|
package/lib/model.js
CHANGED
|
@@ -57,7 +57,7 @@ export class AbstractChatModel {
|
|
|
57
57
|
config: {
|
|
58
58
|
sendWithShiftEnter: config.sendWithShiftEnter
|
|
59
59
|
},
|
|
60
|
-
onSend:
|
|
60
|
+
onSend: this.sendMessage.bind(this)
|
|
61
61
|
});
|
|
62
62
|
this._commands = options.commands;
|
|
63
63
|
this._activeCellManager = (_c = options.activeCellManager) !== null && _c !== void 0 ? _c : null;
|
package/lib/tokens.d.ts
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
|
-
import { IWidgetTracker
|
|
1
|
+
import { IWidgetTracker } from '@jupyterlab/apputils';
|
|
2
2
|
import { Token } from '@lumino/coreutils';
|
|
3
3
|
import { Widget } from '@lumino/widgets';
|
|
4
4
|
import { ChatWidget, Placeholder } from './widgets';
|
|
5
5
|
import { IChatModel } from './model';
|
|
6
|
+
import { ChatArea } from './types';
|
|
6
7
|
/**
|
|
7
|
-
* The main area
|
|
8
|
+
* The interface for any widget displaying a chat (main area or side panel).
|
|
8
9
|
*/
|
|
9
|
-
export
|
|
10
|
+
export interface IChatPanel extends Widget {
|
|
11
|
+
/**
|
|
12
|
+
* The chat widget embedded in the panel.
|
|
13
|
+
*/
|
|
14
|
+
widget: ChatWidget;
|
|
15
|
+
/**
|
|
16
|
+
* The model of the chat widget.
|
|
17
|
+
*/
|
|
10
18
|
model: IChatModel;
|
|
11
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The area of the panel.
|
|
21
|
+
*/
|
|
22
|
+
area: ChatArea;
|
|
23
|
+
/**
|
|
24
|
+
* The chat panel toolbar.
|
|
25
|
+
*/
|
|
26
|
+
toolbar: Widget;
|
|
27
|
+
}
|
|
12
28
|
/**
|
|
13
29
|
* the chat tracker type.
|
|
14
30
|
*/
|
|
15
|
-
export type IChatTracker = IWidgetTracker<
|
|
31
|
+
export type IChatTracker = IWidgetTracker<IChatPanel>;
|
|
16
32
|
/**
|
|
17
33
|
* A chat tracker token.
|
|
18
34
|
*/
|
package/lib/types.d.ts
CHANGED
|
@@ -161,10 +161,7 @@ export interface IChatHistory {
|
|
|
161
161
|
/**
|
|
162
162
|
* The content of a new message.
|
|
163
163
|
*/
|
|
164
|
-
export
|
|
165
|
-
body: string;
|
|
166
|
-
id?: string;
|
|
167
|
-
}
|
|
164
|
+
export type INewMessage<T = IUser, U = IAttachment> = Partial<Pick<IMessageContent<T, U>, 'body' | 'attachments' | 'mentions' | 'metadata' | 'mime_model' | 'sender'>>;
|
|
168
165
|
/**
|
|
169
166
|
* The attachment type. Jupyter Chat allows for two types of attachments
|
|
170
167
|
* currently:
|
package/lib/utils.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { ToolbarRegistry } from '@jupyterlab/apputils';
|
|
1
2
|
import { CodeMirrorEditor } from '@jupyterlab/codemirror';
|
|
2
3
|
import { Notebook } from '@jupyterlab/notebook';
|
|
4
|
+
import { IObservableList } from '@jupyterlab/observables';
|
|
5
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
3
6
|
import { Widget } from '@lumino/widgets';
|
|
7
|
+
import { IChatPanel } from './tokens';
|
|
4
8
|
import { IUser } from './types';
|
|
5
9
|
/**
|
|
6
10
|
* Gets the editor instance used by a document widget. Returns `null` if unable.
|
|
@@ -24,3 +28,10 @@ export declare function replaceMentionToSpan(content: string, user: IUser): stri
|
|
|
24
28
|
* @param user - the user mentioned.
|
|
25
29
|
*/
|
|
26
30
|
export declare function replaceSpanToMention(content: string, user: IUser): string;
|
|
31
|
+
/**
|
|
32
|
+
* Wraps a toolbar factory so that every CommandToolbarButton it creates
|
|
33
|
+
* automatically receives the panel's area as an arg.
|
|
34
|
+
* This lets commands branch on `args.area` without each plugin having to
|
|
35
|
+
* register a per-item toolbarRegistry.addFactory call.
|
|
36
|
+
*/
|
|
37
|
+
export declare function injectAreaArg(baseFactory: (panel: IChatPanel) => IObservableList<ToolbarRegistry.IToolbarItem>, commands: CommandRegistry): (panel: IChatPanel) => IObservableList<ToolbarRegistry.IToolbarItem>;
|
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
|
+
}
|