Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gen AI: separate "publish" and "save" buttons when filling out model card #57949

Merged
merged 18 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
33 changes: 24 additions & 9 deletions apps/src/aichat/redux/aichatRedux.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
import moment from 'moment';
import {createSlice, PayloadAction, createAsyncThunk} from '@reduxjs/toolkit';
import {createAsyncThunk, createSlice, PayloadAction} from '@reduxjs/toolkit';

import {registerReducers} from '@cdo/apps/redux';
import {RootState} from '@cdo/apps/types/redux';
import {LabState} from '@cdo/apps/lab2/lab2Redux';
import Lab2Registry from '@cdo/apps/lab2/Lab2Registry';
const registerReducers = require('@cdo/apps/redux').registerReducers;

import {
AI_CUSTOMIZATIONS_LABELS,
DEFAULT_VISIBILITIES,
EMPTY_AI_CUSTOMIZATIONS,
AI_CUSTOMIZATIONS_LABELS,
} from '../views/modelCustomization/constants';
import {initialChatMessages} from '../constants';
import {getChatCompletionMessage} from '../chatApi';
import {
ChatCompletionMessage,
AichatLevelProperties,
Role,
AiCustomizations,
AITutorInteractionStatus as Status,
AITutorInteractionStatusType,
AiCustomizations,
ChatCompletionMessage,
LevelAichatSettings,
ModelCardInfo,
Role,
ViewMode,
Visibility,
LevelAichatSettings,
} from '../types';
import {RootState} from '@cdo/apps/types/redux';

const haveDifferentValues = (
value1: AiCustomizations[keyof AiCustomizations],
Expand Down Expand Up @@ -68,8 +70,10 @@ export interface AichatState {
// Denotes if there is an error with the chat completion response
chatMessageError: boolean;
currentAiCustomizations: AiCustomizations;
previouslySavedAiCustomizations?: AiCustomizations;
previouslySavedAiCustomizations: AiCustomizations;
fieldVisibilities: {[key in keyof AiCustomizations]: Visibility};
viewMode: ViewMode;
hasPublished: boolean;
}

const initialState: AichatState = {
Expand All @@ -78,7 +82,10 @@ const initialState: AichatState = {
showWarningModal: true,
chatMessageError: false,
currentAiCustomizations: EMPTY_AI_CUSTOMIZATIONS,
previouslySavedAiCustomizations: EMPTY_AI_CUSTOMIZATIONS,
fieldVisibilities: DEFAULT_VISIBILITIES,
viewMode: ViewMode.EDIT,
hasPublished: false,
bencodeorg marked this conversation as resolved.
Show resolved Hide resolved
};

// THUNKS
Expand Down Expand Up @@ -244,6 +251,12 @@ const aichatSlice = createSlice({
chatMessage.status = status;
}
},
setViewMode: (state, action: PayloadAction<ViewMode>) => {
state.viewMode = action.payload;
},
setHasPublished: (state, action: PayloadAction<boolean>) => {
state.hasPublished = action.payload;
},
setStartingAiCustomizations: (
state,
action: PayloadAction<{
Expand Down Expand Up @@ -338,6 +351,8 @@ export const {
setIsWaitingForChatResponse,
setShowWarningModal,
updateChatMessageStatus,
setViewMode,
setHasPublished,
setStartingAiCustomizations,
setPreviouslySavedAiCustomizations,
setAiCustomizationProperty,
Expand Down
18 changes: 9 additions & 9 deletions apps/src/aichat/views/AichatView.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
/** @file Top-level view for AI Chat Lab */

import React, {useCallback, useEffect, useState} from 'react';
import React, {useCallback, useEffect} from 'react';
import Instructions from '@cdo/apps/lab2/views/components/Instructions';
import PanelContainer from '@cdo/apps/lab2/views/components/PanelContainer';
import {sendSuccessReport} from '@cdo/apps/code-studio/progressRedux';
import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
const commonI18n = require('@cdo/locale');
const aichatI18n = require('@cdo/aichat/locale');

import {setStartingAiCustomizations} from '../redux/aichatRedux';
import {setStartingAiCustomizations, setViewMode} from '../redux/aichatRedux';
import ChatWorkspace from './ChatWorkspace';
import ModelCustomizationWorkspace from './ModelCustomizationWorkspace';
import PresentationView from './presentation/PresentationView';
Expand All @@ -22,7 +22,6 @@ import {isProjectTemplateLevel} from '@cdo/apps/lab2/lab2Redux';
import ProjectTemplateWorkspaceIcon from '@cdo/apps/templates/ProjectTemplateWorkspaceIcon';

const AichatView: React.FunctionComponent = () => {
const [viewMode, setViewMode] = useState<string>(ViewMode.EDIT);
const dispatch = useAppDispatch();

const beforeNextLevel = useCallback(() => {
Expand All @@ -41,6 +40,11 @@ const AichatView: React.FunctionComponent = () => {

const projectTemplateLevel = useAppSelector(isProjectTemplateLevel);

const {currentAiCustomizations, viewMode, hasPublished} = useAppSelector(
state => state.aichat
);
const {botName} = currentAiCustomizations.modelCardInfo;

useEffect(() => {
const studentAiCustomizations = JSON.parse(initialSources);
dispatch(
Expand All @@ -51,10 +55,6 @@ const AichatView: React.FunctionComponent = () => {
);
}, [dispatch, initialSources, levelAichatSettings]);

const {botName} = useAppSelector(
state => state.aichat.currentAiCustomizations.modelCardInfo
);

const viewModeButtonsProps: SegmentedButtonsProps = {
buttons: [
{
Expand All @@ -74,7 +74,7 @@ const AichatView: React.FunctionComponent = () => {
],
size: 'm',
selectedButtonValue: viewMode,
onChange: setViewMode,
onChange: viewMode => dispatch(setViewMode(viewMode as ViewMode)),
bencodeorg marked this conversation as resolved.
Show resolved Hide resolved
};

const chatWorkspaceHeader = (
Expand All @@ -90,7 +90,7 @@ const AichatView: React.FunctionComponent = () => {

return (
<div id="aichat-lab" className={moduleStyles.aichatLab}>
{!levelAichatSettings?.hidePresentationPanel && (
{!levelAichatSettings?.hidePresentationPanel && hasPublished && (
bencodeorg marked this conversation as resolved.
Show resolved Hide resolved
<div className={moduleStyles.viewModeButtons}>
<SegmentedButtons {...viewModeButtonsProps} />
</div>
Expand Down
16 changes: 12 additions & 4 deletions apps/src/aichat/views/ModelCustomizationWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ import PublishNotes from './modelCustomization/PublishNotes';
import styles from './model-customization-workspace.module.scss';
import {isVisible} from './modelCustomization/utils';
import {useAppSelector} from '@cdo/apps/util/reduxHooks';
import {AichatLevelProperties} from '@cdo/apps/aichat/types';

const ModelCustomizationWorkspace: React.FunctionComponent = () => {
const {temperature, systemPrompt, retrievalContexts, modelCardInfo} =
useAppSelector(state => state.aichat.fieldVisibilities);

const hidePresentationPanel = useAppSelector(
state =>
(state.lab.levelProperties as AichatLevelProperties | undefined)
?.aichatSettings?.hidePresentationPanel
);

const showSetupCustomization =
isVisible(temperature) || isVisible(systemPrompt);

Expand All @@ -28,10 +35,11 @@ const ModelCustomizationWorkspace: React.FunctionComponent = () => {
title: 'Retrieval',
content: <RetrievalCustomization />,
},
isVisible(modelCardInfo) && {
title: 'Publish',
content: <PublishNotes />,
},
isVisible(modelCardInfo) &&
!hidePresentationPanel && {
title: 'Publish',
content: <PublishNotes />,
},
].filter(Boolean) as Tab[]
}
name="model-customization"
Expand Down
9 changes: 9 additions & 0 deletions apps/src/aichat/views/chatMessage.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
color: $light_gray_700;
}
}

&Alert {
background-color: #FEF7DF;
}
}

.modelUpdateMessageTextContainer {
Expand All @@ -47,6 +51,11 @@
font-size: 16px;
}

.alert {
color: $light_caution_500;
font-size: 16px;
}

.userMessage {
background-color: $lightest_cyan;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,10 @@

.updateButton {
width: 100%;
margin: 10px;
}

.buttonNoMargin {
width: 100%;
margin: 0;
}
115 changes: 96 additions & 19 deletions apps/src/aichat/views/modelCustomization/PublishNotes.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import React, {useCallback} from 'react';
import classNames from 'classnames';

import {
setModelCardProperty,
setViewMode,
setHasPublished,
updateAiCustomization,
} from '@cdo/apps/aichat/redux/aichatRedux';
import {useAppSelector, useAppDispatch} from '@cdo/apps/util/reduxHooks';
import {StrongText} from '@cdo/apps/componentLibrary/typography/TypographyElements';
import Button from '@cdo/apps/componentLibrary/button/Button';

import {MODEL_CARD_FIELDS_LABELS_ICONS} from './constants';
import {isVisible, isDisabled} from './utils';
import {isDisabled} from './utils';
import ExampleTopicsInputs from './ExampleTopicsInputs';
import styles from '../model-customization-workspace.module.scss';
import {ModelCardInfo} from '../../types';
import PublishStatus from './PublishStatus';
import moduleStyles from './publish-notes.module.scss';
import modelCustomizationStyles from '../model-customization-workspace.module.scss';
bencodeorg marked this conversation as resolved.
Show resolved Hide resolved
import {ModelCardInfo, ViewMode} from '../../types';

const PublishNotes: React.FunctionComponent = () => {
const dispatch = useAppDispatch();
Expand All @@ -24,24 +29,34 @@ const PublishNotes: React.FunctionComponent = () => {
state => state.aichat.currentAiCustomizations
);

const onUpdate = useCallback(
() => dispatch(updateAiCustomization()),
[dispatch]
);
const onSave = useCallback(() => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These multiple dispatches I believe are not the right way to do this, so looking for input :)

I see we have a couple instances where we put some redux updates that we want to happen after some async operation in this structure:

extraReducers: builder => {
builder.addCase(submitChatMessage.fulfilled, state => {
state.isWaitingForChatResponse = false;
});
builder.addCase(submitChatMessage.rejected, (state, action) => {
state.isWaitingForChatResponse = false;
state.chatMessageError = true;
console.error(action.error);
});
builder.addCase(submitChatMessage.pending, state => {
state.isWaitingForChatResponse = true;
});
},
});

We also dispatch some redux actions inside of the thunk here:

thunkAPI.dispatch(
setPreviouslySavedAiCustomizations(trimmedCurrentAiCustomizations)
);
const changedProperties = findChangedProperties(
previouslySavedAiCustomizations,
trimmedCurrentAiCustomizations
);
changedProperties.forEach(property => {
thunkAPI.dispatch(
addChatMessage({
id: 0,
role: Role.MODEL_UPDATE,
chatMessageText:
AI_CUSTOMIZATIONS_LABELS[property as keyof AiCustomizations],
status: Status.OK,
timestamp: getCurrentTime(),
})
);
});

Is one of these preferable? Is there a difference? Skimming through docs here, it seems like the first approach might be more standard?

https://redux-toolkit.js.org/api/createAsyncThunk

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like either approach is reasonable but defer to @sanchitmalhotra126 on a preference.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I mean nothing wrong with back to back dispatches as such, but if the goal here is that we only update the publish state after saving has completed, we should move this into the async thunk. In fact, if we need to save the publish state to the project, it might actually be better to do this in the opposite order - on clicking save, if the model card is not filled out, set hasPublished to false so that when you do save, that value is saved to the project.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check out the updates in the last two commits...there's at least one thing to figure out (I have an any in there 😬 ), but I tried to refactor to use async thunks. I don't see the approach I have here (a shared bit of async code that takes in thunkAPI as an arg), which usually isn't good. LMK what you think!

dispatch(updateAiCustomization());
if (!hasFilledOutModelCard(modelCardInfo)) {
dispatch(setHasPublished(false));
}
}, [dispatch, modelCardInfo]);

const getInputTag = (property: keyof ModelCardInfo) => {
return property === 'botName' ? 'input' : 'textarea';
};
const onPublish = useCallback(() => {
dispatch(updateAiCustomization());
dispatch(setHasPublished(true));
dispatch(setViewMode(ViewMode.PRESENTATION));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the user clicks on 'Publish' and then the 'User View' is displayed, I imagine that the user may want to immediately update Model Card fields and click on 'Edit Mode'. What do you think about saving state so that the 'Publish' tab and not 'Setup' remains open to encourage an iterative process? Also related to comment below, that 'User View' remains displayed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about saving state so that the 'Publish' tab and not 'Setup' remains open to encourage an iterative process?

I think that makes sense! Currently, we don't manage the selected tab in redux, and the Tabs component I think would need to refactored to manage the selected tab as a prop. We also should probably switch over to using Denys's new Tabs component rather than my home baked one. Would it be ok to do that as a follow-up?

Also related to comment below, that 'User View' remains displayed?

Not sure I'm following this part, happy to chat offline if easier!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be great as a follow-up and +1 on using Denys's new Tabs component - thanks!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also related to comment below, that 'User View' remains displayed?
Not sure I'm following this part, happy to chat offline if easier!

Ah sorry. I think once we save hasPublished to sources, this will not be an issue when a user moves from level to level. I think I was referring to how when a model card has been published, the toggle remain visible.

}, [dispatch]);

return (
<div className={styles.verticalFlexContainer}>
{isVisible(visibility) && (
<div className={styles.customizationContainer}>
<div className={modelCustomizationStyles.verticalFlexContainer}>
<div>
{hasFilledOutModelCard(modelCardInfo)
? renderPublishOkNotification()
: renderCompleteToPublishNotification()}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is what others would expect, but it seems like once the model card is published the first time, that unless the model card is modified so that one of the fields is no longer filled out, it remains published but can be updated.

This implementation seems to imply that if a user publishes, and then updates the model card, the model card is then unpublished since message states 'Ready to publish'. Or if the user publishes then refreshes the browser, the model card is no longer published. Is that the intention? This is probably more of a product question @samantha-code .

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah good question, it's definitely a bit confusing. I was chatting with Sam about this yesterday and we were struggling a bit to come up with the right solution. Not sure I have the exact right answer, but I think we felt like this was "close enough" and we'd interate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think if we had different status messages based on whether you have a currently published model or not would be the right path? eg, "Ready to update published model" and "In order to update your published model, you must complete the model card"?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I defer to @samantha-code but I think that different status messages would be helpful to the user.

I see Sanchit suggested making this a separate component #57949 (comment)
If this was a separate component, we could also add a warning for RetrievalCustomization when a user adds/deletes a retrieval but still has to update/save.

<div className={modelCustomizationStyles.customizationContainer}>
{MODEL_CARD_FIELDS_LABELS_ICONS.map(([property, label, _]) => {
const InputTag = getInputTag(property);

return (
<div className={styles.inputContainer} key={property}>
<div
className={modelCustomizationStyles.inputContainer}
key={property}
>
<label htmlFor={property}>
<StrongText>{label}</StrongText>
</label>
Expand Down Expand Up @@ -70,18 +85,80 @@ const PublishNotes: React.FunctionComponent = () => {
);
})}
</div>
)}
<div className={styles.footerButtonContainer}>
</div>
<div className={modelCustomizationStyles.footerButtonContainer}>
<Button
text="Save"
iconLeft={{iconName: 'download'}}
type="secondary"
color="black"
disabled={isDisabled(visibility)}
onClick={onSave}
className={modelCustomizationStyles.updateButton}
/>
<Button
text="Publish"
iconLeft={{iconName: 'upload'}}
disabled={isDisabled(visibility)}
onClick={onUpdate}
className={styles.updateButton}
disabled={
isDisabled(visibility) || !hasFilledOutModelCard(modelCardInfo)
}
onClick={onPublish}
className={modelCustomizationStyles.updateButton}
/>
</div>
</div>
);
};

const getInputTag = (property: keyof ModelCardInfo) => {
return property === 'botName' ? 'input' : 'textarea';
};

const hasFilledOutModelCard = (modelCardInfo: ModelCardInfo) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to move this to a selector in aichatRedux since it's a state computation that directly drives some UI

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done (I think), LMK if this looks ok!

for (const key of Object.keys(modelCardInfo)) {
const typedKey = key as keyof ModelCardInfo;

if (typedKey === 'exampleTopics') {
if (
!modelCardInfo['exampleTopics'].filter(topic => topic.length).length
) {
return false;
}
} else if (!modelCardInfo[typedKey].length) {
return false;
}
}

return true;
};

const renderPublishOkNotification = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't exactly know what the performance implications are, but thoughts on just making these components instead of functions that create components every time the parent component is rendered?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm good q, I think I just had these returning HTML originally before refactoring :) I think I moved them out of the render function just for readability (and I just added some more logic to cover an edge case Alice identified here). Is there a difference between this and an inline ternary that returns a component on each render? Isn't the component created each time in that case as well, or is React smarter in that case and keeps them around or something?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think the key difference is using functions to render vs values? I don't think it matters a whole lot since these are small, but because they're static components, I was thinking we could benefit from memoizing them. I think we can either just make them static values if they're outside the main component (const publishOkNotification = <PublishStatus ... props />), or use useMemo inside the component (const publishOkNotification = useMemo(renderPublishOkNotification)))

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mentioned at #57949 (comment), but if a separate component, could use in other customization panels like 'Retrieval' (if retrieval context added/deleted but not updated yet).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is already ready as a reusable component, just would need to be renamed from PublishStatus.

d'oh re: static value / no args, updated

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome - thanks!

return (
<PublishStatus
iconName="check"
iconStyle={moduleStyles.check}
content="Ready to publish"
contentStyle={moduleStyles.messageTextContainer}
containerStyle={moduleStyles.messageContainerPublishOk}
/>
);
};

const renderCompleteToPublishNotification = () => {
return (
<PublishStatus
iconName="triangle-exclamation"
iconStyle={moduleStyles.alert}
content={
<>
In order to publish, you <StrongText>must</StrongText> fill out a
model card
</>
}
contentStyle={moduleStyles.messageTextContainer}
containerStyle={classNames(moduleStyles.messageContainerAlert)}
/>
);
};

export default PublishNotes;
20 changes: 20 additions & 0 deletions apps/src/aichat/views/modelCustomization/PublishStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';

import FontAwesomeV6Icon from '@cdo/apps/componentLibrary/fontAwesomeV6Icon';

const PublishStatus: React.FunctionComponent<{
iconName: string;
iconStyle: string;
content: React.ReactNode;
contentStyle: string;
containerStyle: string;
}> = ({iconName, iconStyle, content, contentStyle, containerStyle}) => {
return (
<div className={containerStyle}>
<FontAwesomeV6Icon iconName={iconName} className={iconStyle} />
<span className={contentStyle}>{content}</span>
</div>
);
};

export default PublishStatus;