Skip to content

Commit b1993dc

Browse files
brichetdlqqq
andauthored
Add new InputModel class for managing input state (#171)
* Add an input model * Set the input value using the input model * Create a new input model when editing a message * Use the 'inputChange' signal in the jupyterlab-chat model * Use the chat model with the chat commands * Remove a unused module, its functions have been moved to the chat model * Allow adding or removing attachment when editing a message * Passes the input model to the getChatCommand() method * fix regression in how commands are handled * Update the autocomplete if the cursor is at the begining of the input value --------- Co-authored-by: David L. Qiu <[email protected]>
1 parent 708e407 commit b1993dc

File tree

16 files changed

+556
-258
lines changed

16 files changed

+556
-258
lines changed

docs/jupyter-chat-example/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ class MyChatModel extends ChatModel {
3434
type: 'msg',
3535
time: Date.now() / 1000,
3636
sender: { username: 'me' },
37-
attachments: this.inputAttachments
37+
attachments: this.input.attachments
3838
};
3939
this.messageAdded(message);
40-
this.clearAttachments();
40+
this.input.clearAttachments();
4141
}
4242
}
4343

packages/jupyter-chat/src/chat-commands/registry.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { Token } from '@lumino/coreutils';
77
import { ChatCommand, IChatCommandProvider } from './types';
8+
import { IInputModel } from '../input-model';
89

910
/**
1011
* Interface of a chat command registry, which tracks a list of chat command
@@ -19,11 +20,7 @@ export interface IChatCommandRegistry {
1920
* Handles a chat command by calling `handleChatCommand()` on the provider
2021
* corresponding to this chat command.
2122
*/
22-
handleChatCommand(
23-
command: ChatCommand,
24-
currentWord: string,
25-
replaceCurrentWord: (newWord: string) => void
26-
): void;
23+
handleChatCommand(command: ChatCommand, inputModel: IInputModel): void;
2724
}
2825

2926
/**
@@ -42,11 +39,7 @@ export class ChatCommandRegistry implements IChatCommandRegistry {
4239
return Array.from(this._providers.values());
4340
}
4441

45-
handleChatCommand(
46-
command: ChatCommand,
47-
currentWord: string,
48-
replaceCurrentWord: (newWord: string) => void
49-
) {
42+
handleChatCommand(command: ChatCommand, inputModel: IInputModel) {
5043
const provider = this._providers.get(command.providerId);
5144
if (!provider) {
5245
console.error(
@@ -56,7 +49,7 @@ export class ChatCommandRegistry implements IChatCommandRegistry {
5649
return;
5750
}
5851

59-
provider.handleChatCommand(command, currentWord, replaceCurrentWord);
52+
provider.handleChatCommand(command, inputModel);
6053
}
6154

6255
private _providers: Map<string, IChatCommandProvider>;

packages/jupyter-chat/src/chat-commands/types.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import { LabIcon } from '@jupyterlab/ui-components';
7+
import { IInputModel } from '../input-model';
78

89
export type ChatCommand = {
910
/**
@@ -49,25 +50,18 @@ export interface IChatCommandProvider {
4950
id: string;
5051

5152
/**
52-
* Async function which accepts the current word and returns a list of
53+
* Async function which accepts the input model and returns a list of
5354
* valid chat commands that match the current word. The current word is
5455
* space-separated word at the user's cursor.
55-
*
56-
* TODO: Pass a ChatModel/InputModel instance here to give the command access
57-
* to more than the current word.
5856
*/
59-
getChatCommands(currentWord: string): Promise<ChatCommand[]>;
57+
getChatCommands(inputModel: IInputModel): Promise<ChatCommand[]>;
6058

6159
/**
6260
* Function called when a chat command is run by the user through the chat
6361
* commands menu.
64-
*
65-
* TODO: Pass a ChatModel/InputModel instance here to provide a function to
66-
* replace the current word.
6762
*/
6863
handleChatCommand(
6964
command: ChatCommand,
70-
currentWord: string,
71-
replaceCurrentWord: (newWord: string) => void
65+
inputModel: IInputModel
7266
): Promise<void>;
7367
}

packages/jupyter-chat/src/components/chat-input.tsx

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { IDocumentManager } from '@jupyterlab/docmanager';
77
import {
88
Autocomplete,
9+
AutocompleteInputChangeReason,
910
Box,
1011
InputAdornment,
1112
SxProps,
@@ -17,33 +18,27 @@ import React, { useEffect, useRef, useState } from 'react';
1718

1819
import { AttachmentPreviewList } from './attachments';
1920
import { AttachButton, CancelButton, SendButton } from './input';
20-
import { IChatModel } from '../model';
21+
import { IInputModel, InputModel } from '../input-model';
2122
import { IAutocompletionRegistry } from '../registry';
22-
import { IAttachment, IConfig, Selection } from '../types';
23+
import { IAttachment, Selection } from '../types';
2324
import { useChatCommands } from './input/use-chat-commands';
2425
import { IChatCommandRegistry } from '../chat-commands';
2526

2627
const INPUT_BOX_CLASS = 'jp-chat-input-container';
2728

2829
export function ChatInput(props: ChatInput.IProps): JSX.Element {
2930
const { documentManager, model } = props;
30-
const [input, setInput] = useState<string>(props.value || '');
31+
const [input, setInput] = useState<string>(model.value);
3132
const inputRef = useRef<HTMLInputElement>();
3233

33-
const chatCommands = useChatCommands(
34-
input,
35-
setInput,
36-
inputRef,
37-
props.chatCommandRegistry
38-
);
34+
const chatCommands = useChatCommands(model, props.chatCommandRegistry);
3935

4036
const [sendWithShiftEnter, setSendWithShiftEnter] = useState<boolean>(
4137
model.config.sendWithShiftEnter ?? false
4238
);
43-
const [typingNotification, setTypingNotification] = useState<boolean>(
44-
model.config.sendTypingNotification ?? false
39+
const [attachments, setAttachments] = useState<IAttachment[]>(
40+
model.attachments
4541
);
46-
const [attachments, setAttachments] = useState<IAttachment[]>([]);
4742

4843
// Display the include selection menu if it is not explicitly hidden, and if at least
4944
// one of the tool to check for text or cell selection is enabled.
@@ -53,9 +48,13 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
5348
}
5449

5550
useEffect(() => {
56-
const configChanged = (_: IChatModel, config: IConfig) => {
51+
const inputChanged = (_: IInputModel, value: string) => {
52+
setInput(value);
53+
};
54+
model.valueChanged.connect(inputChanged);
55+
56+
const configChanged = (_: IInputModel, config: InputModel.IConfig) => {
5757
setSendWithShiftEnter(config.sendWithShiftEnter ?? false);
58-
setTypingNotification(config.sendTypingNotification ?? false);
5958
};
6059
model.configChanged.connect(configChanged);
6160

@@ -66,15 +65,15 @@ export function ChatInput(props: ChatInput.IProps): JSX.Element {
6665
};
6766
model.focusInputSignal?.connect(focusInputElement);
6867

69-
const attachmentChanged = (_: IChatModel, attachments: IAttachment[]) => {
68+
const attachmentChanged = (_: IInputModel, attachments: IAttachment[]) => {
7069
setAttachments([...attachments]);
7170
};
72-
model.inputAttachmentsChanged?.connect(attachmentChanged);
71+
model.attachmentsChanged?.connect(attachmentChanged);
7372

7473
return () => {
7574
model.configChanged?.disconnect(configChanged);
7675
model.focusInputSignal?.disconnect(focusInputElement);
77-
model.inputAttachmentsChanged?.disconnect(attachmentChanged);
76+
model.attachmentsChanged?.disconnect(attachmentChanged);
7877
};
7978
}, [model]);
8079

@@ -160,15 +159,14 @@ ${selection.source}
160159
`;
161160
}
162161
props.onSend(content);
163-
setInput('');
162+
model.value = '';
164163
}
165164

166165
/**
167166
* Triggered when cancelling edition.
168167
*/
169168
function onCancel() {
170-
setInput(props.value || '');
171-
props.onCancel!();
169+
props.onCancel?.();
172170
}
173171

174172
// Set the helper text based on whether Shift+Enter is used for sending.
@@ -218,6 +216,9 @@ ${selection.source}
218216
placeholder="Start chatting"
219217
inputRef={inputRef}
220218
sx={{ marginTop: '1px' }}
219+
onSelect={() =>
220+
(model.cursorIndex = inputRef.current?.selectionStart ?? null)
221+
}
221222
InputProps={{
222223
...params.InputProps,
223224
endAdornment: (
@@ -247,10 +248,16 @@ ${selection.source}
247248
/>
248249
)}
249250
inputValue={input}
250-
onInputChange={(_, newValue: string) => {
251-
setInput(newValue);
252-
if (typingNotification && model.inputChanged) {
253-
model.inputChanged(newValue);
251+
onInputChange={(
252+
_,
253+
newValue: string,
254+
reason: AutocompleteInputChangeReason
255+
) => {
256+
// Do not update the value if the reason is 'reset', which should occur only
257+
// if an autocompletion command has been selected. In this case, the value is
258+
// set in the 'onChange()' callback of the autocompletion (to avoid conflicts).
259+
if (reason !== 'reset') {
260+
model.value = newValue;
254261
}
255262
}}
256263
/>
@@ -269,11 +276,7 @@ export namespace ChatInput {
269276
/**
270277
* The chat model.
271278
*/
272-
model: IChatModel;
273-
/**
274-
* The initial value of the input (default to '')
275-
*/
276-
value?: string;
279+
model: IInputModel;
277280
/**
278281
* The function to be called to send the message.
279282
*/

packages/jupyter-chat/src/components/chat-messages.tsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55

66
import { Button } from '@jupyter/react-components';
7+
import { IDocumentManager } from '@jupyterlab/docmanager';
78
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
89
import {
910
LabIcon,
@@ -16,12 +17,14 @@ import type { SxProps, Theme } from '@mui/material';
1617
import clsx from 'clsx';
1718
import React, { useEffect, useState, useRef, forwardRef } from 'react';
1819

20+
import { AttachmentPreviewList } from './attachments';
1921
import { ChatInput } from './chat-input';
2022
import { MarkdownRenderer } from './markdown-renderer';
2123
import { ScrollContainer } from './scroll-container';
24+
import { IChatCommandRegistry } from '../chat-commands';
25+
import { IInputModel, InputModel } from '../input-model';
2226
import { IChatModel } from '../model';
2327
import { IChatMessage, IUser } from '../types';
24-
import { AttachmentPreviewList } from './attachments';
2528

2629
const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
2730
const MESSAGE_CLASS = 'jp-chat-message';
@@ -40,6 +43,8 @@ const NAVIGATION_BOTTOM_CLASS = 'jp-chat-navigation-bottom';
4043
type BaseMessageProps = {
4144
rmRegistry: IRenderMimeRegistry;
4245
model: IChatModel;
46+
chatCommandRegistry?: IChatCommandRegistry;
47+
documentManager?: IDocumentManager;
4348
};
4449

4550
/**
@@ -338,6 +343,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
338343
const [deleted, setDeleted] = useState<boolean>(false);
339344
const [canEdit, setCanEdit] = useState<boolean>(false);
340345
const [canDelete, setCanDelete] = useState<boolean>(false);
346+
const [inputModel, setInputModel] = useState<IInputModel | null>(null);
341347

342348
// Look if the message can be deleted or edited.
343349
useEffect(() => {
@@ -353,6 +359,25 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
353359
}
354360
}, [model, message]);
355361

362+
// Create an input model only if the message is edited.
363+
useEffect(() => {
364+
if (edit && canEdit) {
365+
setInputModel(
366+
new InputModel({
367+
value: message.body,
368+
activeCellManager: model.activeCellManager,
369+
selectionWatcher: model.selectionWatcher,
370+
config: {
371+
sendWithShiftEnter: model.config.sendWithShiftEnter
372+
},
373+
attachments: message.attachments
374+
})
375+
);
376+
} else {
377+
setInputModel(null);
378+
}
379+
}, [edit]);
380+
356381
// Cancel the current edition of the message.
357382
const cancelEdition = (): void => {
358383
setEdit(false);
@@ -366,6 +391,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
366391
// Update the message
367392
const updatedMessage = { ...message };
368393
updatedMessage.body = input;
394+
updatedMessage.attachments = inputModel?.attachments;
369395
model.updateMessage!(id, updatedMessage);
370396
setEdit(false);
371397
};
@@ -383,13 +409,14 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
383409
<div ref={ref} data-index={props.index}></div>
384410
) : (
385411
<div ref={ref} data-index={props.index}>
386-
{edit && canEdit ? (
412+
{edit && canEdit && inputModel ? (
387413
<ChatInput
388-
value={message.body}
389414
onSend={(input: string) => updateMessage(message.id, input)}
390415
onCancel={() => cancelEdition()}
391-
model={model}
416+
model={inputModel}
392417
hideIncludeSelection={true}
418+
chatCommandRegistry={props.chatCommandRegistry}
419+
documentManager={props.documentManager}
393420
/>
394421
) : (
395422
<MarkdownRenderer
@@ -401,7 +428,9 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
401428
rendered={props.renderedPromise}
402429
/>
403430
)}
404-
{message.attachments && (
431+
{message.attachments && !edit && (
432+
// Display the attachments only if message is not edited, otherwise the
433+
// input component display them.
405434
<AttachmentPreviewList attachments={message.attachments} />
406435
)}
407436
</div>

packages/jupyter-chat/src/components/chat.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element {
3232

3333
return (
3434
<AttachmentOpenerContext.Provider value={props.attachmentOpenerRegistry}>
35-
<ChatMessages rmRegistry={props.rmRegistry} model={model} />
35+
<ChatMessages
36+
rmRegistry={props.rmRegistry}
37+
model={model}
38+
chatCommandRegistry={props.chatCommandRegistry}
39+
documentManager={props.documentManager}
40+
/>
3641
<ChatInput
3742
onSend={onSend}
3843
sx={{
@@ -42,7 +47,7 @@ export function ChatBody(props: Chat.IChatBodyProps): JSX.Element {
4247
paddingBottom: 0,
4348
borderTop: '1px solid var(--jp-border-color1)'
4449
}}
45-
model={model}
50+
model={model.input}
4651
documentManager={props.documentManager}
4752
autocompletionRegistry={props.autocompletionRegistry}
4853
chatCommandRegistry={props.chatCommandRegistry}

packages/jupyter-chat/src/components/input/send-button.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import SendIcon from '@mui/icons-material/Send';
88
import { Box, Menu, MenuItem, Typography } from '@mui/material';
99
import React, { useCallback, useEffect, useState } from 'react';
1010

11-
import { IChatModel } from '../../model';
1211
import { TooltippedButton } from '../mui-extras/tooltipped-button';
1312
import { includeSelectionIcon } from '../../icons';
13+
import { IInputModel } from '../../input-model';
1414
import { Selection } from '../../types';
1515

1616
const SEND_BUTTON_CLASS = 'jp-chat-send-button';
@@ -21,7 +21,7 @@ const SEND_INCLUDE_LI_CLASS = 'jp-chat-send-include';
2121
* The send button props.
2222
*/
2323
export type SendButtonProps = {
24-
model: IChatModel;
24+
model: IInputModel;
2525
sendWithShiftEnter: boolean;
2626
inputExists: boolean;
2727
onSend: (selection?: Selection) => unknown;

0 commit comments

Comments
 (0)