Skip to content

Commit

Permalink
Gen AI: separate "publish" and "save" buttons when filling out model …
Browse files Browse the repository at this point in the history
…card (#57949)

* Add examples and topics

* Refactor

* Remove unneeded key

* Use design system Button

* Refactor to use notification component, clean up

* Clean up

* Move view mode state to redux, change mode on publish

* Add isPublished state, show most recently saved model card

* Hide publish tab when no presentation view available

* Clean up

* Move selector for filled out model card to redux

* Refactor async thunk

* Handle readonly model card

* Rename shared function, simplify args

* Fix typos

* Use preferred func name, static instead of func
  • Loading branch information
bencodeorg committed Apr 15, 2024
1 parent 18ad055 commit e5ecd41
Show file tree
Hide file tree
Showing 11 changed files with 337 additions and 98 deletions.
194 changes: 148 additions & 46 deletions apps/src/aichat/redux/aichatRedux.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
import moment from 'moment';
import {createSlice, PayloadAction, createAsyncThunk} from '@reduxjs/toolkit';
import {
createAsyncThunk,
createSlice,
createSelector,
AnyAction,
PayloadAction,
ThunkDispatch,
} from '@reduxjs/toolkit';

import {registerReducers} from '@cdo/apps/redux';
import {RootState} from '@cdo/apps/types/redux';
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 {postAichatCompletionMessage} from '../aichatCompletionApi';
import {
ChatCompletionMessage,
Role,
AichatInteractionStatus as Status,
AiCustomizations,
AichatInteractionStatus as Status,
ChatCompletionMessage,
ChatContext,
LevelAichatSettings,
ModelCardInfo,
Role,
ViewMode,
Visibility,
LevelAichatSettings,
ChatContext,
} from '../types';
import {RootState} from '@cdo/apps/types/redux';

const haveDifferentValues = (
value1: AiCustomizations[keyof AiCustomizations],
Expand Down Expand Up @@ -68,6 +77,8 @@ export interface AichatState {
currentAiCustomizations: AiCustomizations;
savedAiCustomizations: AiCustomizations;
fieldVisibilities: {[key in keyof AiCustomizations]: Visibility};
viewMode: ViewMode;
hasPublished: boolean;
}

const initialState: AichatState = {
Expand All @@ -78,6 +89,8 @@ const initialState: AichatState = {
currentAiCustomizations: EMPTY_AI_CUSTOMIZATIONS,
savedAiCustomizations: EMPTY_AI_CUSTOMIZATIONS,
fieldVisibilities: DEFAULT_VISIBILITIES,
viewMode: ViewMode.EDIT,
hasPublished: false,
};

// THUNKS
Expand All @@ -88,54 +101,110 @@ const initialState: AichatState = {
export const updateAiCustomization = createAsyncThunk(
'aichat/updateAiCustomization',
async (_, thunkAPI) => {
const state = thunkAPI.getState() as RootState;
const {currentAiCustomizations, savedAiCustomizations} = state.aichat;
const rootState = (await thunkAPI.getState()) as RootState;
const {currentAiCustomizations, savedAiCustomizations} = rootState.aichat;
const {dispatch} = thunkAPI;

// Remove any empty example topics on save
const trimmedExampleTopics =
currentAiCustomizations.modelCardInfo.exampleTopics.filter(
topic => topic.length
);
thunkAPI.dispatch(
setModelCardProperty({
property: 'exampleTopics',
value: trimmedExampleTopics,
})
await saveAiCustomization(
currentAiCustomizations,
savedAiCustomizations,
dispatch
);
}
);

const trimmedCurrentAiCustomizations = {
...currentAiCustomizations,
modelCardInfo: {
...currentAiCustomizations.modelCardInfo,
exampleTopics: trimmedExampleTopics,
},
};
// This thunk is used when a student fills out a model card and "publishes" their model,
// enabling access to a "presentation view" where they can interact with their model
// and view its details (temperature, system prompt, etc) in a summary view.
export const publishModel = createAsyncThunk(
'aichat/publishModelCard',
async (_, thunkAPI) => {
const rootState = (await thunkAPI.getState()) as RootState;
const {currentAiCustomizations, savedAiCustomizations} = rootState.aichat;
const {dispatch} = thunkAPI;

await Lab2Registry.getInstance()
.getProjectManager()
?.save({source: JSON.stringify(trimmedCurrentAiCustomizations)}, true);
dispatch(setHasPublished(true));
await saveAiCustomization(
currentAiCustomizations,
savedAiCustomizations,
dispatch
);
dispatch(setViewMode(ViewMode.PRESENTATION));
}
);

thunkAPI.dispatch(setSavedAiCustomizations(trimmedCurrentAiCustomizations));
// This thunk enables a student to save a partially completed model card
// in the "Publish" tab.
export const saveModelCard = createAsyncThunk(
'aichat/saveModelCard',
async (_, thunkAPI) => {
const rootState = (await thunkAPI.getState()) as RootState;
const {currentAiCustomizations, savedAiCustomizations} = rootState.aichat;
const {dispatch} = thunkAPI;

const changedProperties = findChangedProperties(
const {modelCardInfo} = currentAiCustomizations;
if (!hasFilledOutModelCard(modelCardInfo)) {
dispatch(setHasPublished(false));
}
await saveAiCustomization(
currentAiCustomizations,
savedAiCustomizations,
trimmedCurrentAiCustomizations
dispatch
);
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(),
})
);
});
}
);

// This is the "core" update logic that is shared when a student saves their
// model customizations (setup, retrieval, and "publish" tab)
const saveAiCustomization = async (
currentAiCustomizations: AiCustomizations,
savedAiCustomizations: AiCustomizations,
dispatch: ThunkDispatch<unknown, unknown, AnyAction>
) => {
// Remove any empty example topics on save
const trimmedExampleTopics =
currentAiCustomizations.modelCardInfo.exampleTopics.filter(
topic => topic.length
);
dispatch(
setModelCardProperty({
property: 'exampleTopics',
value: trimmedExampleTopics,
})
);

const trimmedCurrentAiCustomizations = {
...currentAiCustomizations,
modelCardInfo: {
...currentAiCustomizations.modelCardInfo,
exampleTopics: trimmedExampleTopics,
},
};

await Lab2Registry.getInstance()
.getProjectManager()
?.save({source: JSON.stringify(trimmedCurrentAiCustomizations)}, true);

dispatch(setSavedAiCustomizations(trimmedCurrentAiCustomizations));

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

// This thunk's callback function submits a user's chat content and AI customizations to
// the chat completion endpoint, then waits for a chat completion response, and updates
// the user messages.
Expand Down Expand Up @@ -233,6 +302,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 @@ -319,6 +394,31 @@ const aichatSlice = createSlice({
},
});

const hasFilledOutModelCard = (modelCardInfo: ModelCardInfo) => {
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;
};

// Selectors
export const selectHasFilledOutModelCard = createSelector(
(state: {aichat: AichatState}) =>
state.aichat.currentAiCustomizations.modelCardInfo,
hasFilledOutModelCard
);

registerReducers({aichat: aichatSlice.reducer});
export const {
addChatMessage,
Expand All @@ -327,6 +427,8 @@ export const {
setIsWaitingForChatResponse,
setShowWarningModal,
updateChatMessageStatus,
setViewMode,
setHasPublished,
setStartingAiCustomizations,
setSavedAiCustomizations,
setAiCustomizationProperty,
Expand Down
43 changes: 29 additions & 14 deletions apps/src/aichat/views/AichatView.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
/** @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 {isProjectTemplateLevel} from '@cdo/apps/lab2/lab2Redux';
import {sendSuccessReport} from '@cdo/apps/code-studio/progressRedux';
import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
import SegmentedButtons, {
SegmentedButtonsProps,
} from '@cdo/apps/componentLibrary/segmentedButtons/SegmentedButtons';
import Button from '@cdo/apps/componentLibrary/button/Button';
import ProjectTemplateWorkspaceIcon from '@cdo/apps/templates/ProjectTemplateWorkspaceIcon';
const commonI18n = require('@cdo/locale');
const aichatI18n = require('@cdo/aichat/locale');

import {
setStartingAiCustomizations,
setViewMode,
clearChatMessages,
} from '../redux/aichatRedux';
import {AichatLevelProperties, ViewMode} from '../types';
import {isDisabled} from './modelCustomization/utils';
import ChatWorkspace from './ChatWorkspace';
import ModelCustomizationWorkspace from './ModelCustomizationWorkspace';
import PresentationView from './presentation/PresentationView';
import CopyButton from './CopyButton';
import SegmentedButtons, {
SegmentedButtonsProps,
} from '@cdo/apps/componentLibrary/segmentedButtons/SegmentedButtons';
import Button from '@cdo/apps/componentLibrary/button/Button';
import moduleStyles from './aichatView.module.scss';
import {AichatLevelProperties, ViewMode} from '@cdo/apps/aichat/types';
import {isProjectTemplateLevel} from '@cdo/apps/lab2/lab2Redux';
import ProjectTemplateWorkspaceIcon from '@cdo/apps/templates/ProjectTemplateWorkspaceIcon';

const renderChatWorkspaceHeaderRight = (onClear: () => void) => {
return (
Expand All @@ -42,7 +45,6 @@ const renderChatWorkspaceHeaderRight = (onClear: () => void) => {
};

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

const beforeNextLevel = useCallback(() => {
Expand All @@ -61,6 +63,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 @@ -71,9 +78,17 @@ const AichatView: React.FunctionComponent = () => {
);
}, [dispatch, initialSources, levelAichatSettings]);

const {botName} = useAppSelector(
state => state.aichat.currentAiCustomizations.modelCardInfo
);
// Showing presentation view when:
// 1) levelbuilder hasn't explicitly configured the toggle to be hidden, and
// 2) we have a published model card (either by the student, or in readonly form from the levelbuilder)
const showPresentationToggle = () => {
return (
!levelAichatSettings?.hidePresentationPanel &&
(hasPublished ||
(levelAichatSettings?.visibilities &&
isDisabled(levelAichatSettings.visibilities.modelCardInfo)))
);
};

const viewModeButtonsProps: SegmentedButtonsProps = {
buttons: [
Expand All @@ -94,7 +109,7 @@ const AichatView: React.FunctionComponent = () => {
],
size: 'm',
selectedButtonValue: viewMode,
onChange: setViewMode,
onChange: viewMode => dispatch(setViewMode(viewMode as ViewMode)),
};

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

return (
<div id="aichat-lab" className={moduleStyles.aichatLab}>
{!levelAichatSettings?.hidePresentationPanel && (
{showPresentationToggle() && (
<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

0 comments on commit e5ecd41

Please sign in to comment.