Skip to content

Commit 43fc0ef

Browse files
author
Bogdan Tsechoev
committedJan 17, 2025·
Merge branch 'chats_email_notifications' into 'master'
Send email notifications from AI See merge request postgres-ai/database-lab!945
2 parents 4575b4a + f2cddf6 commit 43fc0ef

File tree

13 files changed

+327
-54
lines changed

13 files changed

+327
-54
lines changed
 

‎ui/packages/platform/src/actions/actions.js

+37
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const Actions = Reflux.createActions([{
3838
ASYNC_ACTION: ASYNC_ACTION,
3939
doAuth: ASYNC_ACTION,
4040
getUserProfile: ASYNC_ACTION,
41+
updateUserProfile: ASYNC_ACTION,
4142
getAccessTokens: ASYNC_ACTION,
4243
getAccessToken: ASYNC_ACTION,
4344
hideGeneratedAccessToken: {},
@@ -267,6 +268,42 @@ Actions.getUserProfile.listen(function (token) {
267268
);
268269
});
269270

271+
Actions.updateUserProfile.listen(function (token, data) {
272+
let action = this;
273+
274+
if (!api) {
275+
settings.init(function () {
276+
api = new Api(settings);
277+
});
278+
}
279+
280+
this.progressed();
281+
282+
timeoutPromise(REQUEST_TIMEOUT, api.updateUserProfile(token, data))
283+
.then(result => {
284+
result.json()
285+
.then(json => {
286+
if (json) {
287+
action.completed({ data: json?.result });
288+
} else {
289+
action.failed(new Error('wrong_reply'));
290+
}
291+
})
292+
.catch(err => {
293+
console.error(err);
294+
action.failed(new Error('wrong_reply'));
295+
});
296+
})
297+
.catch(err => {
298+
console.error(err);
299+
if (err && err.message && err.message === 'timeout') {
300+
action.failed(new Error('failed_fetch'));
301+
} else {
302+
action.failed(new Error('wrong_reply'));
303+
}
304+
});
305+
});
306+
270307
Actions.getAccessTokens.listen(function (token, orgId) {
271308
let action = this;
272309

‎ui/packages/platform/src/api/api.js

+25
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,31 @@ class Api {
112112
});
113113
}
114114

115+
updateUserProfile(token, data) {
116+
let headers = {
117+
Authorization: 'Bearer ' + token,
118+
Accept: 'application/vnd.pgrst.object+json'
119+
};
120+
121+
let body = {};
122+
123+
if (data.is_chats_email_notifications_enabled !== 'undefined') {
124+
body.chats_email_notifications_enabled = data.is_chats_email_notifications_enabled;
125+
}
126+
127+
if (data.first_name !== 'undefined') {
128+
body.first_name = data.first_name;
129+
}
130+
131+
if (data.last_name !== 'undefined') {
132+
body.last_name = data.last_name;
133+
}
134+
135+
return this.post(`${this.apiServer}/rpc/update_user_profile`, body, {
136+
headers: headers
137+
});
138+
}
139+
115140
getAccessTokens(token, orgId) {
116141
let params = {};
117142
let headers = {

‎ui/packages/platform/src/components/BotSettingsForm/BotSettingsForm.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ const BotSettingsForm: React.FC<BotSettingsFormProps> = (props) => {
161161
enableReinitialize: true,
162162
initialValues: {
163163
threadVisibility:
164-
data?.orgProfile?.data?.is_chat_public_by_default ? 'public' : 'private',
164+
data?.orgProfile?.data?.is_chat_public_by_default ? 'public' : 'private'
165165
},
166166
onSubmit: () => {
167167
const currentOrgId = orgId || null;

‎ui/packages/platform/src/pages/Bot/BotWrapper.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface BotWrapperProps {
77
orgId?: number;
88
envData: {
99
info?: {
10+
id?: number | null
1011
user_name?: string
1112
}
1213
};
@@ -38,7 +39,8 @@ export const BotWrapper = (props: BotWrapperProps) => {
3839
args={{
3940
threadId: props.match.params.threadId,
4041
orgId: props.orgData.id,
41-
isPublicByDefault: props.orgData.is_chat_public_by_default
42+
isPublicByDefault: props.orgData.is_chat_public_by_default,
43+
userId: props.envData.info?.id,
4244
}}>
4345
<BotPage {...props} />
4446
</AiBotProvider>

‎ui/packages/platform/src/pages/Bot/Messages/Message/Message.tsx

+44-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useMemo, useState } from 'react'
1+
import React, { useEffect, useMemo, useRef, useState } from 'react'
22
import cn from "classnames";
33
import ReactMarkdown, { Components } from "react-markdown";
44
import rehypeRaw from "rehype-raw";
@@ -9,8 +9,9 @@ import { icons } from "@postgres.ai/shared/styles/icons";
99
import { DebugDialog } from "../../DebugDialog/DebugDialog";
1010
import { CodeBlock } from "./CodeBlock";
1111
import { disallowedHtmlTagsForMarkdown, permalinkLinkBuilder } from "../../utils";
12-
import { StateMessage } from "../../../../types/api/entities/bot";
12+
import { MessageStatus, StateMessage } from "../../../../types/api/entities/bot";
1313
import { MermaidDiagram } from "./MermaidDiagram";
14+
import { useAiBot } from "../../hooks";
1415

1516

1617
type BaseMessageProps = {
@@ -20,17 +21,19 @@ type BaseMessageProps = {
2021
name?: string;
2122
isLoading?: boolean;
2223
formattedTime?: string;
23-
aiModel?: string
24-
stateMessage?: StateMessage | null
25-
isCurrentStreamMessage?: boolean
24+
aiModel?: string;
25+
stateMessage?: StateMessage | null;
26+
isCurrentStreamMessage?: boolean;
2627
isPublic?: boolean;
28+
threadId?: string;
29+
status?: MessageStatus
2730
}
2831

2932
type AiMessageProps = BaseMessageProps & {
3033
isAi: true;
3134
content: string;
32-
aiModel: string
33-
isCurrentStreamMessage?: boolean
35+
aiModel: string;
36+
isCurrentStreamMessage?: boolean;
3437
}
3538

3639
type HumanMessageProps = BaseMessageProps & {
@@ -42,8 +45,8 @@ type HumanMessageProps = BaseMessageProps & {
4245
type LoadingMessageProps = BaseMessageProps & {
4346
isLoading: true;
4447
isAi: true;
45-
content?: undefined
46-
stateMessage: StateMessage | null
48+
content?: undefined;
49+
stateMessage: StateMessage | null;
4750
}
4851

4952
type MessageProps = AiMessageProps | HumanMessageProps | LoadingMessageProps;
@@ -261,14 +264,44 @@ export const Message = React.memo((props: MessageProps) => {
261264
aiModel,
262265
stateMessage,
263266
isCurrentStreamMessage,
264-
isPublic
267+
isPublic,
268+
threadId,
269+
status
265270
} = props;
266271

272+
const { updateMessageStatus } = useAiBot()
273+
274+
const elementRef = useRef<HTMLDivElement | null>(null);
275+
276+
267277
const [isDebugVisible, setDebugVisible] = useState(false);
268278

269279

270280
const classes = useStyles();
271281

282+
useEffect(() => {
283+
if (!isAi || isCurrentStreamMessage || status === 'read') return;
284+
285+
const observer = new IntersectionObserver(
286+
(entries) => {
287+
const entry = entries[0];
288+
if (entry.isIntersecting && threadId && id) {
289+
updateMessageStatus(threadId, id, 'read');
290+
observer.disconnect();
291+
}
292+
},
293+
{ threshold: 0.1 }
294+
);
295+
296+
if (elementRef.current) {
297+
observer.observe(elementRef.current);
298+
}
299+
300+
return () => {
301+
observer.disconnect();
302+
};
303+
}, [id, updateMessageStatus, isCurrentStreamMessage, isAi, threadId, status]);
304+
272305
const contentToRender: string = content?.replace(/\n/g, ' \n') || ''
273306

274307
const toggleDebugDialog = () => {
@@ -301,7 +334,7 @@ export const Message = React.memo((props: MessageProps) => {
301334
onClose={toggleDebugDialog}
302335
messageId={id}
303336
/>}
304-
<div className={classes.message}>
337+
<div ref={elementRef} className={classes.message}>
305338
<div className={classes.messageAvatar}>
306339
{isAi
307340
? <img

‎ui/packages/platform/src/pages/Bot/Messages/Messages.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ type FormattedTime = {
125125
[id: string]: Time
126126
}
127127

128-
export const Messages = React.memo(({orgId}: {orgId: number}) => {
128+
export const Messages = React.memo(({orgId, threadId}: {orgId: number, threadId?: string}) => {
129129
const {
130130
messages,
131131
loading: isLoading,
@@ -254,7 +254,8 @@ export const Messages = React.memo(({orgId}: {orgId: number}) => {
254254
created_at,
255255
content,
256256
ai_model,
257-
is_public
257+
is_public,
258+
status
258259
} = message;
259260
let name = 'You';
260261

@@ -283,6 +284,8 @@ export const Messages = React.memo(({orgId}: {orgId: number}) => {
283284
formattedTime={formattedTime}
284285
aiModel={ai_model}
285286
isPublic={is_public}
287+
threadId={threadId}
288+
status={status}
286289
/>
287290
)
288291
})}

‎ui/packages/platform/src/pages/Bot/hooks.tsx

+27-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
AiModel,
1515
StateMessage,
1616
StreamMessage,
17-
ErrorMessage
17+
ErrorMessage, MessageStatus
1818
} from "../../types/api/entities/bot";
1919
import {getChatsWithWholeThreads} from "../../api/bot/getChatsWithWholeThreads";
2020
import {getChats} from "api/bot/getChats";
@@ -73,16 +73,18 @@ type UseAiBotReturnType = {
7373
isStreamingInProcess: boolean;
7474
currentStreamMessage: StreamMessage | null;
7575
errorMessage: ErrorMessage | null;
76+
updateMessageStatus: (threadId: string, messageId: string, status: MessageStatus) => void
7677
}
7778

7879
type UseAiBotArgs = {
7980
threadId?: string;
8081
orgId?: number
8182
isPublicByDefault?: boolean
83+
userId?: number | null
8284
}
8385

8486
export const useAiBotProviderValue = (args: UseAiBotArgs): UseAiBotReturnType => {
85-
const { threadId, orgId, isPublicByDefault } = args;
87+
const { threadId, orgId, isPublicByDefault, userId } = args;
8688
const { showMessage, closeSnackbar } = useAlertSnackbar();
8789
const {
8890
aiModels,
@@ -413,6 +415,27 @@ export const useAiBotProviderValue = (args: UseAiBotArgs): UseAiBotReturnType =>
413415
}))
414416
}
415417

418+
const updateMessageStatus = (threadId: string, messageId: string, status: MessageStatus) => {
419+
wsSendMessage(JSON.stringify({
420+
action: 'message_status_update',
421+
payload: {
422+
thread_id: threadId,
423+
message_id: messageId,
424+
read_by: userId,
425+
status
426+
}
427+
}))
428+
if (messages && messages.length > 0) {
429+
const updatedMessages = messages.map((item) => {
430+
if (item.id === messageId) {
431+
item["status"] = status
432+
}
433+
return item
434+
});
435+
setMessages(updatedMessages)
436+
}
437+
}
438+
416439
const getDebugMessagesForWholeThread = async () => {
417440
setDebugMessagesLoading(true)
418441
if (threadId) {
@@ -478,7 +501,8 @@ export const useAiBotProviderValue = (args: UseAiBotArgs): UseAiBotReturnType =>
478501
stateMessage,
479502
isStreamingInProcess,
480503
currentStreamMessage,
481-
errorMessage
504+
errorMessage,
505+
updateMessageStatus
482506
}
483507
}
484508

‎ui/packages/platform/src/pages/Bot/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ export const BotPage = (props: BotPageProps) => {
268268
</Box>
269269
</Box>
270270
<Box className={cn(classes.contentContainer, {[classes.isChatsListVisible]: isChatsListVisible})}>
271-
<Messages orgId={orgData.id} />
271+
<Messages orgId={orgData.id} threadId={match.params.threadId} />
272272
<Command
273273
threadId={match.params.threadId}
274274
orgId={orgData.id}

‎ui/packages/platform/src/pages/Profile/ProfileWrapper.tsx

+22
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,28 @@ export const ProfileWrapper = () => {
1717
marginLeft: theme.spacing(1),
1818
marginRight: theme.spacing(1),
1919
},
20+
formControlLabel: {
21+
marginLeft: theme.spacing(0),
22+
marginRight: theme.spacing(1),
23+
},
24+
formControlLabelCheckbox: {
25+
'& svg': {
26+
fontSize: 18
27+
}
28+
},
29+
updateButtonContainer: {
30+
marginTop: theme.spacing(3),
31+
marginLeft: theme.spacing(1),
32+
marginRight: theme.spacing(1),
33+
},
34+
label: {
35+
marginTop: theme.spacing(2),
36+
marginBottom: theme.spacing(1),
37+
marginLeft: theme.spacing(1),
38+
marginRight: theme.spacing(1),
39+
color: '#000!important',
40+
fontWeight: 'bold',
41+
},
2042
dense: {
2143
marginTop: 16,
2244
},

‎ui/packages/platform/src/pages/Profile/index.jsx

+118-32
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import React, { Component } from 'react';
99
import PropTypes from 'prop-types';
1010
import Grid from '@material-ui/core/Grid';
11-
11+
import * as Yup from 'yup';
1212
import { TextField } from '@postgres.ai/shared/components/TextField';
1313
import { PageSpinner } from '@postgres.ai/shared/components/PageSpinner';
1414

@@ -17,9 +17,18 @@ import Actions from 'actions/actions';
1717
import { ErrorWrapper } from 'components/Error/ErrorWrapper';
1818
import ConsolePageTitle from 'components/ConsolePageTitle';
1919
import { Head, createTitle } from 'components/Head';
20+
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
21+
import CheckBoxIcon from '@material-ui/icons/CheckBox';
22+
import {Button, Checkbox, FormControlLabel, InputLabel} from "@material-ui/core";
23+
import {Form, Formik} from "formik";
2024

2125
const PAGE_NAME = 'Profile';
2226

27+
const validationSchema = Yup.object({
28+
first_name: Yup.string().required('First name is required'),
29+
last_name: Yup.string().required('Last name is required'),
30+
});
31+
2332
class Profile extends Component {
2433
componentDidMount() {
2534
const that = this;
@@ -31,6 +40,14 @@ class Profile extends Component {
3140

3241
that.setState({ data: this.data });
3342

43+
if (userProfile && !userProfile.isProcessing && userProfile.data.info) {
44+
that.setState({
45+
is_chats_email_notifications_enabled: userProfile.data.info.chats_email_notifications_enabled,
46+
first_name: userProfile.data.info.first_name,
47+
last_name: userProfile.data.info.last_name
48+
});
49+
}
50+
3451
if (auth && auth.token && !userProfile.isProcessed && !userProfile.isProcessing &&
3552
!userProfile.error) {
3653
Actions.getUserProfile(auth.token);
@@ -44,10 +61,28 @@ class Profile extends Component {
4461
this.unsubscribe();
4562
}
4663

64+
handleSaveSettings = (values) => {
65+
const auth = this.state.data?.auth;
66+
if (auth) {
67+
Actions.updateUserProfile(auth.token, {
68+
is_chats_email_notifications_enabled: values.is_chats_email_notifications_enabled,
69+
first_name: values.first_name,
70+
last_name: values.last_name,
71+
});
72+
}
73+
};
74+
4775
render() {
4876
const { classes } = this.props;
4977
const data = this.state && this.state.data ? this.state.data.userProfile : null;
5078

79+
const initialValues = {
80+
first_name: data?.data?.info?.first_name || '',
81+
last_name: data?.data?.info?.last_name || '',
82+
is_chats_email_notifications_enabled: data?.data?.info?.chats_email_notifications_enabled || false,
83+
};
84+
85+
5186
const headRendered = (
5287
<Head title={createTitle([PAGE_NAME])} />
5388
);
@@ -85,38 +120,89 @@ class Profile extends Component {
85120
{ headRendered }
86121

87122
{pageTitle}
88-
89-
<Grid
90-
item
91-
xs={12}
92-
sm={6}
93-
md={4}
94-
lg={3}
95-
xl={2}
96-
className={classes.container}
123+
<Formik
124+
initialValues={initialValues}
125+
validationSchema={validationSchema}
126+
onSubmit={this.handleSaveSettings}
97127
>
98-
<TextField
99-
disabled
100-
label='Email'
101-
fullWidth
102-
defaultValue={data.data.info.email}
103-
className={classes.textField}
104-
/>
105-
<TextField
106-
disabled
107-
label='First name'
108-
fullWidth
109-
defaultValue={data.data.info.first_name}
110-
className={classes.textField}
111-
/>
112-
<TextField
113-
disabled
114-
label='Last name'
115-
fullWidth
116-
defaultValue={data.data.info.last_name}
117-
className={classes.textField}
118-
/>
119-
</Grid>
128+
{({ values, handleChange, setFieldValue, errors, touched }) => (
129+
<Form>
130+
<Grid
131+
item
132+
xs={12}
133+
sm={6}
134+
md={6}
135+
lg={4}
136+
xl={3}
137+
className={classes.container}
138+
>
139+
<TextField
140+
disabled
141+
label='Email'
142+
fullWidth
143+
defaultValue={data.data.info.email}
144+
className={classes.textField}
145+
/>
146+
<TextField
147+
label="First name"
148+
fullWidth
149+
name="first_name"
150+
value={values.first_name}
151+
onChange={handleChange}
152+
className={classes.textField}
153+
error={touched.first_name && !!errors.first_name}
154+
helperText={touched.first_name && errors.first_name}
155+
/>
156+
<TextField
157+
label="Last name"
158+
fullWidth
159+
name="last_name"
160+
value={values.last_name}
161+
onChange={handleChange}
162+
className={classes.textField}
163+
error={touched.last_name && !!errors.last_name}
164+
helperText={touched.last_name && errors.last_name}
165+
/>
166+
<InputLabel className={classes.label} id="visibility-radio-buttons-group-label">
167+
Notifications settings
168+
</InputLabel>
169+
<FormControlLabel
170+
className={classes.formControlLabel}
171+
control={
172+
<Checkbox
173+
icon={<CheckBoxOutlineBlankIcon fontSize="large" />}
174+
checkedIcon={<CheckBoxIcon fontSize="large" />}
175+
name="is_chats_email_notifications_enabled"
176+
className={classes.formControlLabelCheckbox}
177+
checked={values.is_chats_email_notifications_enabled}
178+
onChange={(event) =>
179+
setFieldValue('is_chats_email_notifications_enabled', event.target.checked)
180+
}
181+
/>
182+
}
183+
label="Notify about new messages in the AI Assistant"
184+
/>
185+
</Grid>
186+
<Grid
187+
item
188+
xs={12}
189+
sm={12}
190+
lg={8}
191+
className={classes.updateButtonContainer}
192+
>
193+
<Button
194+
variant="contained"
195+
color="primary"
196+
disabled={data?.isProcessing}
197+
id="userSaveButton"
198+
type="submit"
199+
>
200+
Save
201+
</Button>
202+
</Grid>
203+
</Form>
204+
)}
205+
</Formik>
120206
</div>
121207
);
122208
}

‎ui/packages/platform/src/stores/store.js

+26
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,32 @@ const Store = Reflux.createStore({
386386
this.trigger(this.data);
387387
},
388388

389+
onUpdateUserProfileFailed: function (error) {
390+
this.data.userProfile.isProcessing = false;
391+
this.data.userProfile.error = true;
392+
this.data.userProfile.errorMessage = error.message;
393+
Actions.showNotification(error.message, 'error');
394+
this.trigger(this.data);
395+
},
396+
397+
onUpdateUserProfileProgressed: function () {
398+
this.data.userProfile.isProcessing = true;
399+
this.trigger(this.data);
400+
},
401+
402+
onUpdateUserProfileCompleted: function (data) {
403+
this.data.userProfile.isProcessing = false;
404+
this.data.userProfile.errorMessage = this.getError(data);
405+
this.data.userProfile.error = !!this.data.userProfile.errorMessage;
406+
407+
if (!this.data.userProfile.error && data?.data?.length > 0) {
408+
this.data.userProfile.data = data?.data?.[0];
409+
this.data.userProfile.isProcessed = true;
410+
Actions.showNotification('Profile settings successfully saved.', 'success');
411+
}
412+
413+
this.trigger(this.data);
414+
},
389415

390416
onGetOrgsFailed: function (error) {
391417
this.data.orgProfile.isProcessing = false;

‎ui/packages/platform/src/types/api/entities/bot.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ export type BotMessage = {
1818
last_name: string | null
1919
display_name: string | null
2020
slack_profile: string | null
21-
user_id: string
21+
user_id: number
2222
org_id: string
2323
thread_id: string
2424
type: 'message' | undefined
2525
ai_model: string
26+
status?: MessageStatus
2627
}
2728

2829
export type BotMessageWithDebugInfo = BotMessage & {
@@ -54,4 +55,6 @@ export type ErrorMessage = {
5455
type: 'error'
5556
message: string
5657
thread_id: string
57-
}
58+
}
59+
60+
export type MessageStatus = 'read' | 'new' | null

‎ui/packages/shared/components/TextField/index.tsx

+13-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export type TextFieldProps = {
3535
placeholder?: string
3636
onBlur?: TextFieldPropsBase['onBlur']
3737
onFocus?: TextFieldPropsBase['onFocus']
38+
name?: TextFieldPropsBase['name']
39+
helperText?: TextFieldPropsBase['helperText']
3840
}
3941

4042
const useStyles = makeStyles(
@@ -51,6 +53,9 @@ const useStyles = makeStyles(
5153
input: {
5254
padding: '8px',
5355
},
56+
helperText: {
57+
fontSize: 12
58+
}
5459
},
5560
{ index: 1 },
5661
)
@@ -72,7 +77,7 @@ export const TextField = (props: TextFieldProps) => {
7277
value={props.value}
7378
margin="normal"
7479
fullWidth={props.fullWidth}
75-
classes={{}}
80+
classes={{ }}
7681
InputProps={{
7782
...props.InputProps,
7883

@@ -92,6 +97,11 @@ export const TextField = (props: TextFieldProps) => {
9297

9398
...props.InputLabelProps,
9499
}}
100+
FormHelperTextProps={{
101+
classes: {
102+
root: classes.helperText
103+
}
104+
}}
95105
onChange={props.onChange}
96106
children={props.children}
97107
select={props.select}
@@ -100,6 +110,8 @@ export const TextField = (props: TextFieldProps) => {
100110
placeholder={props.placeholder}
101111
onBlur={props.onBlur}
102112
onFocus={props.onFocus}
113+
name={props.name}
114+
helperText={props.helperText}
103115
/>
104116
)
105117
}

0 commit comments

Comments
 (0)
Please sign in to comment.