@jupyter/chat 0.1.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/__tests__/model.spec.d.ts +1 -0
- package/lib/__tests__/model.spec.js +72 -0
- package/lib/__tests__/widgets.spec.d.ts +1 -0
- package/lib/__tests__/widgets.spec.js +33 -0
- package/lib/components/chat-input.d.ts +33 -0
- package/lib/components/chat-input.js +60 -0
- package/lib/components/chat-messages.d.ts +32 -0
- package/lib/components/chat-messages.js +162 -0
- package/lib/components/chat.d.ts +43 -0
- package/lib/components/chat.js +100 -0
- package/lib/components/copy-button.d.ts +6 -0
- package/lib/components/copy-button.js +35 -0
- package/lib/components/jl-theme-provider.d.ts +6 -0
- package/lib/components/jl-theme-provider.js +19 -0
- package/lib/components/mui-extras/stacking-alert.d.ts +28 -0
- package/lib/components/mui-extras/stacking-alert.js +56 -0
- package/lib/components/rendermime-markdown.d.ts +12 -0
- package/lib/components/rendermime-markdown.js +54 -0
- package/lib/components/scroll-container.d.ts +23 -0
- package/lib/components/scroll-container.js +51 -0
- package/lib/components/toolbar.d.ts +11 -0
- package/lib/components/toolbar.js +30 -0
- package/lib/icons.d.ts +2 -0
- package/lib/icons.js +11 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +10 -0
- package/lib/model.d.ts +177 -0
- package/lib/model.js +128 -0
- package/lib/theme-provider.d.ts +3 -0
- package/lib/theme-provider.js +133 -0
- package/lib/types.d.ts +49 -0
- package/lib/types.js +5 -0
- package/lib/widgets/chat-error.d.ts +2 -0
- package/lib/widgets/chat-error.js +26 -0
- package/lib/widgets/chat-sidebar.d.ts +4 -0
- package/lib/widgets/chat-sidebar.js +15 -0
- package/lib/widgets/chat-widget.d.ts +19 -0
- package/lib/widgets/chat-widget.js +28 -0
- package/package.json +209 -0
- package/src/__tests__/model.spec.ts +84 -0
- package/src/__tests__/widgets.spec.ts +43 -0
- package/src/components/chat-input.tsx +143 -0
- package/src/components/chat-messages.tsx +283 -0
- package/src/components/chat.tsx +179 -0
- package/src/components/copy-button.tsx +55 -0
- package/src/components/jl-theme-provider.tsx +28 -0
- package/src/components/mui-extras/stacking-alert.tsx +105 -0
- package/src/components/rendermime-markdown.tsx +88 -0
- package/src/components/scroll-container.tsx +74 -0
- package/src/components/toolbar.tsx +50 -0
- package/src/icons.ts +15 -0
- package/src/index.ts +11 -0
- package/src/model.ts +272 -0
- package/src/theme-provider.ts +137 -0
- package/src/types/mui.d.ts +18 -0
- package/src/types/svg.d.ts +17 -0
- package/src/types.ts +58 -0
- package/src/widgets/chat-error.tsx +43 -0
- package/src/widgets/chat-sidebar.tsx +30 -0
- package/src/widgets/chat-widget.tsx +51 -0
- package/style/base.css +13 -0
- package/style/chat-settings.css +10 -0
- package/style/chat.css +53 -0
- package/style/icons/chat.svg +6 -0
- package/style/index.css +6 -0
- package/style/index.js +6 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
Box,
|
|
10
|
+
SxProps,
|
|
11
|
+
TextField,
|
|
12
|
+
Theme,
|
|
13
|
+
IconButton,
|
|
14
|
+
InputAdornment
|
|
15
|
+
} from '@mui/material';
|
|
16
|
+
import { Send, Cancel } from '@mui/icons-material';
|
|
17
|
+
import clsx from 'clsx';
|
|
18
|
+
|
|
19
|
+
const INPUT_BOX_CLASS = 'jp-chat-input-container';
|
|
20
|
+
const SEND_BUTTON_CLASS = 'jp-chat-send-button';
|
|
21
|
+
const CANCEL_BUTTON_CLASS = 'jp-chat-cancel-button';
|
|
22
|
+
|
|
23
|
+
export function ChatInput(props: ChatInput.IProps): JSX.Element {
|
|
24
|
+
const [input, setInput] = useState(props.value || '');
|
|
25
|
+
|
|
26
|
+
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
|
27
|
+
if (
|
|
28
|
+
event.key === 'Enter' &&
|
|
29
|
+
((props.sendWithShiftEnter && event.shiftKey) ||
|
|
30
|
+
(!props.sendWithShiftEnter && !event.shiftKey))
|
|
31
|
+
) {
|
|
32
|
+
onSend();
|
|
33
|
+
event.stopPropagation();
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Triggered when sending the message.
|
|
40
|
+
*/
|
|
41
|
+
function onSend() {
|
|
42
|
+
setInput('');
|
|
43
|
+
props.onSend(input);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Triggered when cancelling edition.
|
|
48
|
+
*/
|
|
49
|
+
function onCancel() {
|
|
50
|
+
setInput(props.value || '');
|
|
51
|
+
props.onCancel!();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Set the helper text based on whether Shift+Enter is used for sending.
|
|
55
|
+
const helperText = props.sendWithShiftEnter ? (
|
|
56
|
+
<span>
|
|
57
|
+
Press <b>Shift</b>+<b>Enter</b> to send message
|
|
58
|
+
</span>
|
|
59
|
+
) : (
|
|
60
|
+
<span>
|
|
61
|
+
Press <b>Shift</b>+<b>Enter</b> to add a new line
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Box sx={props.sx} className={clsx(INPUT_BOX_CLASS)}>
|
|
67
|
+
<Box sx={{ display: 'flex' }}>
|
|
68
|
+
<TextField
|
|
69
|
+
value={input}
|
|
70
|
+
onChange={e => setInput(e.target.value)}
|
|
71
|
+
fullWidth
|
|
72
|
+
variant="outlined"
|
|
73
|
+
multiline
|
|
74
|
+
onKeyDown={handleKeyDown}
|
|
75
|
+
placeholder="Start chatting"
|
|
76
|
+
InputProps={{
|
|
77
|
+
endAdornment: (
|
|
78
|
+
<InputAdornment position="end">
|
|
79
|
+
{props.onCancel && (
|
|
80
|
+
<IconButton
|
|
81
|
+
size="small"
|
|
82
|
+
color="primary"
|
|
83
|
+
onClick={onCancel}
|
|
84
|
+
disabled={!input.trim().length}
|
|
85
|
+
title={'Cancel edition'}
|
|
86
|
+
className={clsx(CANCEL_BUTTON_CLASS)}
|
|
87
|
+
>
|
|
88
|
+
<Cancel />
|
|
89
|
+
</IconButton>
|
|
90
|
+
)}
|
|
91
|
+
<IconButton
|
|
92
|
+
size="small"
|
|
93
|
+
color="primary"
|
|
94
|
+
onClick={onSend}
|
|
95
|
+
disabled={!input.trim().length}
|
|
96
|
+
title={`Send message ${props.sendWithShiftEnter ? '(SHIFT+ENTER)' : '(ENTER)'}`}
|
|
97
|
+
className={clsx(SEND_BUTTON_CLASS)}
|
|
98
|
+
>
|
|
99
|
+
<Send />
|
|
100
|
+
</IconButton>
|
|
101
|
+
</InputAdornment>
|
|
102
|
+
)
|
|
103
|
+
}}
|
|
104
|
+
FormHelperTextProps={{
|
|
105
|
+
sx: { marginLeft: 'auto', marginRight: 0 }
|
|
106
|
+
}}
|
|
107
|
+
helperText={input.length > 2 ? helperText : ' '}
|
|
108
|
+
/>
|
|
109
|
+
</Box>
|
|
110
|
+
</Box>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The chat input namespace.
|
|
116
|
+
*/
|
|
117
|
+
export namespace ChatInput {
|
|
118
|
+
/**
|
|
119
|
+
* The properties of the react element.
|
|
120
|
+
*/
|
|
121
|
+
export interface IProps {
|
|
122
|
+
/**
|
|
123
|
+
* The initial value of the input (default to '')
|
|
124
|
+
*/
|
|
125
|
+
value?: string;
|
|
126
|
+
/**
|
|
127
|
+
* The function to be called to send the message.
|
|
128
|
+
*/
|
|
129
|
+
onSend: (input: string) => unknown;
|
|
130
|
+
/**
|
|
131
|
+
* The function to be called to cancel editing.
|
|
132
|
+
*/
|
|
133
|
+
onCancel?: () => unknown;
|
|
134
|
+
/**
|
|
135
|
+
* Whether using shift+enter to send the message.
|
|
136
|
+
*/
|
|
137
|
+
sendWithShiftEnter: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Custom mui/material styles.
|
|
140
|
+
*/
|
|
141
|
+
sx?: SxProps<Theme>;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
|
|
7
|
+
import { Avatar, Box, Typography } from '@mui/material';
|
|
8
|
+
import type { SxProps, Theme } from '@mui/material';
|
|
9
|
+
import clsx from 'clsx';
|
|
10
|
+
import React, { useState, useEffect } from 'react';
|
|
11
|
+
|
|
12
|
+
import { ChatInput } from './chat-input';
|
|
13
|
+
import { RendermimeMarkdown } from './rendermime-markdown';
|
|
14
|
+
import { IChatModel } from '../model';
|
|
15
|
+
import { IChatMessage, IUser } from '../types';
|
|
16
|
+
|
|
17
|
+
const MESSAGES_BOX_CLASS = 'jp-chat-messages-container';
|
|
18
|
+
const MESSAGE_CLASS = 'jp-chat-message';
|
|
19
|
+
const MESSAGE_HEADER_CLASS = 'jp-chat-message-header';
|
|
20
|
+
const MESSAGE_TIME_CLASS = 'jp-chat-message-time';
|
|
21
|
+
|
|
22
|
+
type BaseMessageProps = {
|
|
23
|
+
rmRegistry: IRenderMimeRegistry;
|
|
24
|
+
model: IChatModel;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ChatMessageProps = BaseMessageProps & {
|
|
28
|
+
message: IChatMessage;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type ChatMessagesProps = BaseMessageProps & {
|
|
32
|
+
messages: IChatMessage[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ChatMessageHeaderProps = IUser & {
|
|
36
|
+
timestamp: number;
|
|
37
|
+
rawTime?: boolean;
|
|
38
|
+
deleted?: boolean;
|
|
39
|
+
edited?: boolean;
|
|
40
|
+
sx?: SxProps<Theme>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function ChatMessageHeader(props: ChatMessageHeaderProps): JSX.Element {
|
|
44
|
+
const [datetime, setDatetime] = useState<Record<number, string>>({});
|
|
45
|
+
const sharedStyles: SxProps<Theme> = {
|
|
46
|
+
height: '24px',
|
|
47
|
+
width: '24px'
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Effect: update cached datetime strings upon receiving a new message.
|
|
52
|
+
*/
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!datetime[props.timestamp]) {
|
|
55
|
+
const newDatetime: Record<number, string> = {};
|
|
56
|
+
let datetime: string;
|
|
57
|
+
const currentDate = new Date();
|
|
58
|
+
const sameDay = (date: Date) =>
|
|
59
|
+
date.getFullYear() === currentDate.getFullYear() &&
|
|
60
|
+
date.getMonth() === currentDate.getMonth() &&
|
|
61
|
+
date.getDate() === currentDate.getDate();
|
|
62
|
+
|
|
63
|
+
const msgDate = new Date(props.timestamp * 1000); // Convert message time to milliseconds
|
|
64
|
+
|
|
65
|
+
// Display only the time if the day of the message is the current one.
|
|
66
|
+
if (sameDay(msgDate)) {
|
|
67
|
+
// Use the browser's default locale
|
|
68
|
+
datetime = msgDate.toLocaleTimeString([], {
|
|
69
|
+
hour: 'numeric',
|
|
70
|
+
minute: '2-digit'
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
// Use the browser's default locale
|
|
74
|
+
datetime = msgDate.toLocaleString([], {
|
|
75
|
+
day: 'numeric',
|
|
76
|
+
month: 'numeric',
|
|
77
|
+
year: 'numeric',
|
|
78
|
+
hour: 'numeric',
|
|
79
|
+
minute: '2-digit'
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
newDatetime[props.timestamp] = datetime;
|
|
83
|
+
setDatetime(newDatetime);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const bgcolor = props.color;
|
|
88
|
+
const avatar = props.avatar_url ? (
|
|
89
|
+
<Avatar
|
|
90
|
+
sx={{
|
|
91
|
+
...sharedStyles,
|
|
92
|
+
...(bgcolor && { bgcolor })
|
|
93
|
+
}}
|
|
94
|
+
src={props.avatar_url}
|
|
95
|
+
></Avatar>
|
|
96
|
+
) : props.initials ? (
|
|
97
|
+
<Avatar
|
|
98
|
+
sx={{
|
|
99
|
+
...sharedStyles,
|
|
100
|
+
...(bgcolor && { bgcolor })
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<Typography
|
|
104
|
+
sx={{
|
|
105
|
+
fontSize: 'var(--jp-ui-font-size1)',
|
|
106
|
+
color: 'var(--jp-ui-inverse-font-color1)'
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{props.initials}
|
|
110
|
+
</Typography>
|
|
111
|
+
</Avatar>
|
|
112
|
+
) : null;
|
|
113
|
+
|
|
114
|
+
const name =
|
|
115
|
+
props.display_name ?? props.name ?? (props.username || 'User undefined');
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<Box
|
|
119
|
+
className={MESSAGE_HEADER_CLASS}
|
|
120
|
+
sx={{
|
|
121
|
+
display: 'flex',
|
|
122
|
+
alignItems: 'center',
|
|
123
|
+
'& > :not(:last-child)': {
|
|
124
|
+
marginRight: 3
|
|
125
|
+
},
|
|
126
|
+
...props.sx
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{avatar}
|
|
130
|
+
<Box
|
|
131
|
+
sx={{
|
|
132
|
+
display: 'flex',
|
|
133
|
+
flexGrow: 1,
|
|
134
|
+
flexWrap: 'wrap',
|
|
135
|
+
justifyContent: 'space-between',
|
|
136
|
+
alignItems: 'center'
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
140
|
+
<Typography
|
|
141
|
+
sx={{ fontWeight: 700, color: 'var(--jp-ui-font-color1)' }}
|
|
142
|
+
>
|
|
143
|
+
{name}
|
|
144
|
+
</Typography>
|
|
145
|
+
{(props.deleted || props.edited) && (
|
|
146
|
+
<Typography
|
|
147
|
+
sx={{
|
|
148
|
+
fontStyle: 'italic',
|
|
149
|
+
fontSize: 'var(--jp-content-font-size0)',
|
|
150
|
+
paddingLeft: '0.5em'
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
{props.deleted ? '(message deleted)' : '(edited)'}
|
|
154
|
+
</Typography>
|
|
155
|
+
)}
|
|
156
|
+
</Box>
|
|
157
|
+
<Typography
|
|
158
|
+
className={MESSAGE_TIME_CLASS}
|
|
159
|
+
sx={{
|
|
160
|
+
fontSize: '0.8em',
|
|
161
|
+
color: 'var(--jp-ui-font-color2)',
|
|
162
|
+
fontWeight: 300
|
|
163
|
+
}}
|
|
164
|
+
title={props.rawTime ? 'Unverified time' : ''}
|
|
165
|
+
>
|
|
166
|
+
{`${datetime[props.timestamp]}${props.rawTime ? '*' : ''}`}
|
|
167
|
+
</Typography>
|
|
168
|
+
</Box>
|
|
169
|
+
</Box>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The messages list UI.
|
|
175
|
+
*/
|
|
176
|
+
export function ChatMessages(props: ChatMessagesProps): JSX.Element {
|
|
177
|
+
return (
|
|
178
|
+
<Box
|
|
179
|
+
sx={{
|
|
180
|
+
'& > :not(:last-child)': {
|
|
181
|
+
borderBottom: '1px solid var(--jp-border-color2)'
|
|
182
|
+
}
|
|
183
|
+
}}
|
|
184
|
+
className={clsx(MESSAGES_BOX_CLASS)}
|
|
185
|
+
>
|
|
186
|
+
{props.messages.map((message, i) => {
|
|
187
|
+
let sender: IUser;
|
|
188
|
+
if (typeof message.sender === 'string') {
|
|
189
|
+
sender = { username: message.sender };
|
|
190
|
+
} else {
|
|
191
|
+
sender = message.sender;
|
|
192
|
+
}
|
|
193
|
+
return (
|
|
194
|
+
// extra div needed to ensure each bubble is on a new line
|
|
195
|
+
<Box
|
|
196
|
+
key={i}
|
|
197
|
+
sx={{ padding: '1em 1em 0 1em' }}
|
|
198
|
+
className={clsx(MESSAGE_CLASS)}
|
|
199
|
+
>
|
|
200
|
+
<ChatMessageHeader
|
|
201
|
+
{...sender}
|
|
202
|
+
timestamp={message.time}
|
|
203
|
+
rawTime={message.raw_time}
|
|
204
|
+
deleted={message.deleted}
|
|
205
|
+
edited={message.edited}
|
|
206
|
+
sx={{ marginBottom: 3 }}
|
|
207
|
+
/>
|
|
208
|
+
<ChatMessage {...props} message={message} />
|
|
209
|
+
</Box>
|
|
210
|
+
);
|
|
211
|
+
})}
|
|
212
|
+
</Box>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* the message UI.
|
|
218
|
+
*/
|
|
219
|
+
export function ChatMessage(props: ChatMessageProps): JSX.Element {
|
|
220
|
+
const { message, model, rmRegistry } = props;
|
|
221
|
+
let canEdit = false;
|
|
222
|
+
let canDelete = false;
|
|
223
|
+
if (model.user !== undefined && !message.deleted) {
|
|
224
|
+
const username =
|
|
225
|
+
typeof message.sender === 'string'
|
|
226
|
+
? message.sender
|
|
227
|
+
: message.sender.username;
|
|
228
|
+
|
|
229
|
+
if (model.user.username === username && model.updateMessage !== undefined) {
|
|
230
|
+
canEdit = true;
|
|
231
|
+
}
|
|
232
|
+
if (model.user.username === username && model.deleteMessage !== undefined) {
|
|
233
|
+
canDelete = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const [edit, setEdit] = useState<boolean>(false);
|
|
237
|
+
|
|
238
|
+
const cancelEdition = (): void => {
|
|
239
|
+
setEdit(false);
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const updateMessage = (id: string, input: string): void => {
|
|
243
|
+
if (!canEdit) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// Update the message
|
|
247
|
+
const updatedMessage = { ...message };
|
|
248
|
+
updatedMessage.body = input;
|
|
249
|
+
model.updateMessage!(id, updatedMessage);
|
|
250
|
+
setEdit(false);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const deleteMessage = (id: string): void => {
|
|
254
|
+
if (!canDelete) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Delete the message
|
|
258
|
+
model.deleteMessage!(id);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// Empty if the message has been deleted
|
|
262
|
+
return message.deleted ? (
|
|
263
|
+
<></>
|
|
264
|
+
) : (
|
|
265
|
+
<div>
|
|
266
|
+
{edit && canEdit ? (
|
|
267
|
+
<ChatInput
|
|
268
|
+
value={message.body}
|
|
269
|
+
onSend={(input: string) => updateMessage(message.id, input)}
|
|
270
|
+
onCancel={() => cancelEdition()}
|
|
271
|
+
sendWithShiftEnter={model.config.sendWithShiftEnter ?? false}
|
|
272
|
+
/>
|
|
273
|
+
) : (
|
|
274
|
+
<RendermimeMarkdown
|
|
275
|
+
rmRegistry={rmRegistry}
|
|
276
|
+
markdownStr={message.body}
|
|
277
|
+
edit={canEdit ? () => setEdit(true) : undefined}
|
|
278
|
+
delete={canDelete ? () => deleteMessage(message.id) : undefined}
|
|
279
|
+
/>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { IThemeManager } from '@jupyterlab/apputils';
|
|
7
|
+
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
|
|
8
|
+
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
9
|
+
import SettingsIcon from '@mui/icons-material/Settings';
|
|
10
|
+
import { IconButton } from '@mui/material';
|
|
11
|
+
import { Box } from '@mui/system';
|
|
12
|
+
import React, { useState, useEffect } from 'react';
|
|
13
|
+
|
|
14
|
+
import { JlThemeProvider } from './jl-theme-provider';
|
|
15
|
+
import { ChatMessages } from './chat-messages';
|
|
16
|
+
import { ChatInput } from './chat-input';
|
|
17
|
+
import { ScrollContainer } from './scroll-container';
|
|
18
|
+
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]);
|
|
62
|
+
|
|
63
|
+
// no need to append to messageGroups imperatively here. all of that is
|
|
64
|
+
// handled by the listeners registered in the effect hooks above.
|
|
65
|
+
const onSend = async (input: string) => {
|
|
66
|
+
// send message to backend
|
|
67
|
+
model.addMessage({ body: input });
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<ScrollContainer sx={{ flexGrow: 1 }}>
|
|
73
|
+
<ChatMessages
|
|
74
|
+
messages={messages}
|
|
75
|
+
rmRegistry={renderMimeRegistry}
|
|
76
|
+
model={model}
|
|
77
|
+
/>
|
|
78
|
+
</ScrollContainer>
|
|
79
|
+
<ChatInput
|
|
80
|
+
onSend={onSend}
|
|
81
|
+
sx={{
|
|
82
|
+
paddingLeft: 4,
|
|
83
|
+
paddingRight: 4,
|
|
84
|
+
paddingTop: 3.5,
|
|
85
|
+
paddingBottom: 0,
|
|
86
|
+
borderTop: '1px solid var(--jp-border-color1)'
|
|
87
|
+
}}
|
|
88
|
+
sendWithShiftEnter={model.config.sendWithShiftEnter ?? false}
|
|
89
|
+
/>
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function Chat(props: Chat.IOptions): JSX.Element {
|
|
95
|
+
const [view, setView] = useState<Chat.ChatView>(
|
|
96
|
+
props.chatView || Chat.ChatView.Chat
|
|
97
|
+
);
|
|
98
|
+
return (
|
|
99
|
+
<JlThemeProvider themeManager={props.themeManager ?? null}>
|
|
100
|
+
<Box
|
|
101
|
+
// root box should not include padding as it offsets the vertical
|
|
102
|
+
// scrollbar to the left
|
|
103
|
+
sx={{
|
|
104
|
+
width: '100%',
|
|
105
|
+
height: '100%',
|
|
106
|
+
boxSizing: 'border-box',
|
|
107
|
+
background: 'var(--jp-layout-color0)',
|
|
108
|
+
display: 'flex',
|
|
109
|
+
flexDirection: 'column'
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{/* top bar */}
|
|
113
|
+
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
114
|
+
{view !== Chat.ChatView.Chat ? (
|
|
115
|
+
<IconButton onClick={() => setView(Chat.ChatView.Chat)}>
|
|
116
|
+
<ArrowBackIcon />
|
|
117
|
+
</IconButton>
|
|
118
|
+
) : (
|
|
119
|
+
<Box />
|
|
120
|
+
)}
|
|
121
|
+
{view === Chat.ChatView.Chat && props.settingsPanel ? (
|
|
122
|
+
<IconButton onClick={() => setView(Chat.ChatView.Settings)}>
|
|
123
|
+
<SettingsIcon />
|
|
124
|
+
</IconButton>
|
|
125
|
+
) : (
|
|
126
|
+
<Box />
|
|
127
|
+
)}
|
|
128
|
+
</Box>
|
|
129
|
+
{/* body */}
|
|
130
|
+
{view === Chat.ChatView.Chat && (
|
|
131
|
+
<ChatBody model={props.model} rmRegistry={props.rmRegistry} />
|
|
132
|
+
)}
|
|
133
|
+
{view === Chat.ChatView.Settings && props.settingsPanel && (
|
|
134
|
+
<props.settingsPanel />
|
|
135
|
+
)}
|
|
136
|
+
</Box>
|
|
137
|
+
</JlThemeProvider>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The chat UI namespace
|
|
143
|
+
*/
|
|
144
|
+
export namespace Chat {
|
|
145
|
+
/**
|
|
146
|
+
* The options to build the Chat UI.
|
|
147
|
+
*/
|
|
148
|
+
export interface IOptions {
|
|
149
|
+
/**
|
|
150
|
+
* The chat model.
|
|
151
|
+
*/
|
|
152
|
+
model: IChatModel;
|
|
153
|
+
/**
|
|
154
|
+
* The rendermime registry.
|
|
155
|
+
*/
|
|
156
|
+
rmRegistry: IRenderMimeRegistry;
|
|
157
|
+
/**
|
|
158
|
+
* The theme manager.
|
|
159
|
+
*/
|
|
160
|
+
themeManager?: IThemeManager | null;
|
|
161
|
+
/**
|
|
162
|
+
* The view to render.
|
|
163
|
+
*/
|
|
164
|
+
chatView?: ChatView;
|
|
165
|
+
/**
|
|
166
|
+
* A settings panel that can be used for dedicated settings (e.g. jupyter-ai)
|
|
167
|
+
*/
|
|
168
|
+
settingsPanel?: () => JSX.Element;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The view to render.
|
|
173
|
+
* The settings view is available only if the settings panel is provided in options.
|
|
174
|
+
*/
|
|
175
|
+
export enum ChatView {
|
|
176
|
+
Chat,
|
|
177
|
+
Settings
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState, useCallback } from 'react';
|
|
7
|
+
|
|
8
|
+
import { Box, Button } from '@mui/material';
|
|
9
|
+
|
|
10
|
+
enum CopyStatus {
|
|
11
|
+
None,
|
|
12
|
+
Copied
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const COPYBTN_TEXT_BY_STATUS: Record<CopyStatus, string> = {
|
|
16
|
+
[CopyStatus.None]: 'Copy to Clipboard',
|
|
17
|
+
[CopyStatus.Copied]: 'Copied!'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type CopyButtonProps = {
|
|
21
|
+
value: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function CopyButton(props: CopyButtonProps): JSX.Element {
|
|
25
|
+
const [copyStatus, setCopyStatus] = useState<CopyStatus>(CopyStatus.None);
|
|
26
|
+
|
|
27
|
+
const copy = useCallback(async () => {
|
|
28
|
+
try {
|
|
29
|
+
await navigator.clipboard.writeText(props.value);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error('Failed to copy text: ', err);
|
|
32
|
+
setCopyStatus(CopyStatus.None);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setCopyStatus(CopyStatus.Copied);
|
|
37
|
+
setTimeout(() => setCopyStatus(CopyStatus.None), 1000);
|
|
38
|
+
}, [props.value]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
|
42
|
+
<Button
|
|
43
|
+
onClick={copy}
|
|
44
|
+
disabled={copyStatus !== CopyStatus.None}
|
|
45
|
+
aria-label="Copy To Clipboard"
|
|
46
|
+
sx={{
|
|
47
|
+
alignSelf: 'flex-end',
|
|
48
|
+
textTransform: 'none'
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{COPYBTN_TEXT_BY_STATUS[copyStatus]}
|
|
52
|
+
</Button>
|
|
53
|
+
</Box>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState, useEffect } from 'react';
|
|
7
|
+
import type { IThemeManager } from '@jupyterlab/apputils';
|
|
8
|
+
import { Theme, ThemeProvider, createTheme } from '@mui/material/styles';
|
|
9
|
+
|
|
10
|
+
import { getJupyterLabTheme } from '../theme-provider';
|
|
11
|
+
|
|
12
|
+
export function JlThemeProvider(props: {
|
|
13
|
+
themeManager: IThemeManager | null;
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
}): JSX.Element {
|
|
16
|
+
const [theme, setTheme] = useState<Theme>(createTheme());
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
async function setJlTheme() {
|
|
20
|
+
setTheme(await getJupyterLabTheme());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setJlTheme();
|
|
24
|
+
props.themeManager?.themeChanged.connect(setJlTheme);
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
return <ThemeProvider theme={theme}>{props.children}</ThemeProvider>;
|
|
28
|
+
}
|