Compare commits

...

1 Commits

Author SHA1 Message Date
Clément Pasteau
d1b53485b8 Paginate AI Requests history, and allow navigating history in chat with up and down 2025-10-14 10:56:31 +02:00
7 changed files with 654 additions and 361 deletions

View File

@@ -6,6 +6,7 @@ import Text from '../../UI/Text';
import { Trans, t } from '@lingui/macro';
import {
type AiRequest,
type AiRequestUserMessage,
type AiRequestMessageAssistantFunctionCall,
} from '../../Utils/GDevelopServices/Generation';
import RaisedButton from '../../UI/RaisedButton';
@@ -43,6 +44,7 @@ import {
getDefaultAiConfigurationPresetId,
} from '../AiConfiguration';
import { AiConfigurationPresetSelector } from './AiConfigurationPresetSelector';
import { AiRequestContext } from '../AiRequestContext';
const TOO_MANY_USER_MESSAGES_WARNING_COUNT = 5;
const TOO_MANY_USER_MESSAGES_ERROR_COUNT = 10;
@@ -282,6 +284,9 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
}: Props,
ref
) => {
const { aiRequestStorage } = React.useContext(AiRequestContext);
const { aiRequests } = aiRequestStorage;
// TODO: store the default mode in the user preferences?
const [newAiRequestMode, setNewAiRequestMode] = React.useState<
'chat' | 'agent'
@@ -336,7 +341,11 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
userRequestTextPerAiRequestId,
setUserRequestTextPerRequestId,
] = React.useState<{ [string]: string }>({});
const [historyIndex, setHistoryIndex] = React.useState<number>(-1);
const [savedCurrentText, setSavedCurrentText] = React.useState<string>('');
const scrollViewRef = React.useRef<ScrollViewInterface | null>(null);
const textAreaRefForNewChat = React.useRef<any>(null);
const textAreaRefForExistingChat = React.useRef<any>(null);
const [shouldAutoScroll, setShouldAutoScroll] = React.useState<boolean>(
true
);
@@ -399,13 +408,26 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
[newAiRequestMode, hasOpenedProject]
);
const onUserRequestTextChange = React.useCallback(
(userRequestText: string, aiRequestIdToChange: string) => {
setUserRequestTextPerRequestId(userRequestTextPerAiRequestId => ({
...userRequestTextPerAiRequestId,
[aiRequestIdToChange]: userRequestText,
}));
// Reset history navigation when field is cleared,
// so that pressing up goes to the last message again.
if (!userRequestText && historyIndex !== -1) {
setHistoryIndex(-1);
setSavedCurrentText('');
}
},
[historyIndex]
);
React.useImperativeHandle(ref, () => ({
resetUserInput: (aiRequestId: string | null) => {
const aiRequestIdToReset: string = aiRequestId || '';
setUserRequestTextPerRequestId(userRequestTextPerAiRequestId => ({
...userRequestTextPerAiRequestId,
[aiRequestIdToReset]: '',
}));
onUserRequestTextChange('', aiRequestIdToReset);
if (scrollViewRef.current) {
scrollViewRef.current.scrollToBottom({
@@ -417,6 +439,109 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
const { isMobile } = useResponsiveWindowSize();
// Build history from sent user messages across all aiRequests
const requestsHistory = React.useMemo(
() => {
const history: Array<string> = [];
// Iterate through all aiRequests in reverse order (most recent first)
Object.values(aiRequests)
.reverse()
.forEach(
// $FlowFixMe - Object.values() loses the type of aiRequests.
(request: AiRequest) => {
const userMessages = request.output
.filter(
message =>
message.type === 'message' && message.role === 'user'
)
.map(
// $FlowFixMe - We filtered the type above.
(message: AiRequestUserMessage) => {
const userRequest = message.content.find(
item => item.type === 'user_request'
);
return userRequest ? userRequest.text : '';
}
)
.filter(text => text !== '');
history.push(...userMessages);
}
);
return history;
},
[aiRequests]
);
// Reset history index when aiRequest changes,
// ensuring pressing up and down doesn't depend on the previous aiRequest.
React.useEffect(
() => {
setHistoryIndex(-1);
setSavedCurrentText('');
},
[aiRequestId]
);
const handleNavigateHistory = React.useCallback(
(direction: 'up' | 'down') => {
const currentText = userRequestTextPerAiRequestId[aiRequestId] || '';
const textAreaRef = aiRequest
? textAreaRefForExistingChat
: textAreaRefForNewChat;
if (direction === 'up') {
// Save current text when starting navigation,
// so we can restore it if going back to current.
if (historyIndex === -1) {
setSavedCurrentText(currentText);
}
const newIndex = historyIndex + 1;
if (newIndex < requestsHistory.length) {
setHistoryIndex(newIndex);
const historicalText =
requestsHistory[requestsHistory.length - 1 - newIndex];
onUserRequestTextChange(historicalText, aiRequestId);
// Set cursor to start when navigating up,
// otherwise it goes to the end of the text, making it harder
// to navigate with one key press.
if (textAreaRef.current) {
// Use timeout so that the text is updated before setting the cursor position.
setTimeout(() => {
textAreaRef.current.setCursorPosition(0);
}, 0);
}
}
} else if (direction === 'down') {
const newIndex = historyIndex - 1;
if (newIndex === -1) {
// We're at the end of the history. Restore the saved current text.
setHistoryIndex(-1);
onUserRequestTextChange(savedCurrentText, aiRequestId);
} else if (newIndex >= 0) {
setHistoryIndex(newIndex);
const historicalText =
requestsHistory[requestsHistory.length - 1 - newIndex];
onUserRequestTextChange(historicalText, aiRequestId);
}
}
},
[
aiRequestId,
historyIndex,
requestsHistory,
userRequestTextPerAiRequestId,
savedCurrentText,
onUserRequestTextChange,
aiRequest,
]
);
const priceText = (
<Text size="body-small" color="secondary" noMargin>
{getPriceText({
@@ -551,20 +676,17 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
<Column noMargin alignItems="stretch" justifyContent="stretch">
<Spacer />
<CompactTextAreaFieldWithControls
ref={textAreaRefForNewChat}
maxLength={6000}
value={userRequestTextPerAiRequestId[''] || ''}
disabled={isSending}
hasNeonCorner
hasAnimatedNeonCorner={isSending}
errored={!!lastSendError}
onChange={userRequestText =>
setUserRequestTextPerRequestId(
userRequestTextPerAiRequestId => ({
...userRequestTextPerAiRequestId,
'': userRequestText,
})
)
}
onChange={userRequestText => {
onUserRequestTextChange(userRequestText, '');
}}
onNavigateHistory={handleNavigateHistory}
onSubmit={() => {
onStartNewAiRequest({
mode: newAiRequestMode,
@@ -878,6 +1000,7 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
</Paper>
) : null}
<CompactTextAreaFieldWithControls
ref={textAreaRefForExistingChat}
maxLength={6000}
value={userRequestTextPerAiRequestId[aiRequestId] || ''}
disabled={isSending || isForAnotherProject}
@@ -885,13 +1008,9 @@ export const AiRequestChat = React.forwardRef<Props, AiRequestChatInterface>(
hasNeonCorner
hasAnimatedNeonCorner={isSending}
onChange={userRequestText =>
setUserRequestTextPerRequestId(
userRequestTextPerAiRequestId => ({
...userRequestTextPerAiRequestId,
[aiRequestId]: userRequestText,
})
)
onUserRequestTextChange(userRequestText, aiRequestId)
}
onNavigateHistory={handleNavigateHistory}
placeholder={
aiRequest.mode === 'agent'
? isForAnotherProject

View File

@@ -5,6 +5,7 @@ import {
fetchAiSettings,
type AiRequest,
type AiSettings,
getAiRequests,
} from '../Utils/GDevelopServices/Generation';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { type EditorFunctionCallResult } from '../EditorFunctions/EditorFunctionCallRunner';
@@ -82,6 +83,11 @@ const useEditorFunctionCallResultsStorage = (): EditorFunctionCallResultsStorage
};
type AiRequestStorage = {|
fetchAiRequests: () => Promise<void>,
onLoadMoreAiRequests: () => Promise<void>,
canLoadMore: boolean,
error: ?Error,
isLoading: boolean,
aiRequests: { [string]: AiRequest },
updateAiRequest: (aiRequestId: string, aiRequest: AiRequest) => void,
refreshAiRequest: (aiRequestId: string) => Promise<void>,
@@ -96,20 +102,105 @@ type AiRequestSendState = {|
lastSendError: ?Error,
|};
type PaginationState = {|
aiRequests: { [string]: AiRequest },
nextPageUri: ?Object,
|};
const emptyPaginationState: PaginationState = {
aiRequests: {},
nextPageUri: null,
};
export const useAiRequestsStorage = (): AiRequestStorage => {
const { profile, getAuthorizationHeader } = React.useContext(
AuthenticatedUserContext
);
const [aiRequests, setAiRequests] = React.useState<{ [string]: AiRequest }>(
{}
const [state, setState] = React.useState<PaginationState>(
emptyPaginationState
);
const [error, setError] = React.useState<Error | null>(null);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const fetchAiRequests = React.useCallback(
async () => {
if (!profile) return;
setIsLoading(true);
setError(null);
try {
const history = await getAiRequests(getAuthorizationHeader, {
userId: profile.id,
forceUri: null, // Fetch the first page.
});
if (!history) return;
const aiRequestsById = history.aiRequests.reduce(
(accumulator, aiRequest) => {
accumulator[aiRequest.id] = aiRequest;
return accumulator;
},
{}
);
setState({
aiRequests: aiRequestsById,
nextPageUri: history.nextPageUri,
});
} catch (err) {
setError(err);
console.error('Error fetching AI requests:', err);
} finally {
setIsLoading(false);
}
},
[profile, getAuthorizationHeader]
);
const onLoadMoreAiRequests = React.useCallback(
async () => {
if (!profile) return;
setIsLoading(true);
setError(null);
try {
const history = await getAiRequests(getAuthorizationHeader, {
userId: profile.id,
forceUri: state.nextPageUri,
});
if (!history) return;
const newRequests = history.aiRequests;
const currentRequestsById = state.aiRequests;
newRequests.forEach(newRequest => {
// Add new requests to the state.
if (!currentRequestsById[newRequest.id]) {
currentRequestsById[newRequest.id] = newRequest;
}
});
setState({
aiRequests: currentRequestsById,
nextPageUri: history.nextPageUri,
});
} catch (err) {
setError(err);
console.error('Error fetching AI requests:', err);
} finally {
setIsLoading(false);
}
},
[profile, getAuthorizationHeader, state.nextPageUri, state.aiRequests]
);
const updateAiRequest = React.useCallback(
(aiRequestId: string, aiRequest: AiRequest) => {
setAiRequests(aiRequests => ({
...aiRequests,
[aiRequestId]: aiRequest,
setState(prevState => ({
...prevState,
aiRequests: {
...(prevState.aiRequests || {}),
[aiRequestId]: aiRequest,
},
}));
},
[]
@@ -179,7 +270,12 @@ export const useAiRequestsStorage = (): AiRequestStorage => {
);
return {
aiRequests,
fetchAiRequests,
onLoadMoreAiRequests,
canLoadMore: !!state.nextPageUri,
error,
isLoading,
aiRequests: state.aiRequests,
updateAiRequest,
refreshAiRequest,
isSendingAiRequest,
@@ -195,8 +291,13 @@ type AiRequestContextState = {|
getAiSettings: () => AiSettings | null,
|};
export const AiRequestContext = React.createContext<AiRequestContextState>({
export const initialAiRequestContextState: AiRequestContextState = {
aiRequestStorage: {
fetchAiRequests: async () => {},
onLoadMoreAiRequests: async () => {},
canLoadMore: true,
error: null,
isLoading: false,
aiRequests: {},
updateAiRequest: () => {},
refreshAiRequest: async () => {},
@@ -211,7 +312,10 @@ export const AiRequestContext = React.createContext<AiRequestContextState>({
clearEditorFunctionCallResults: () => {},
},
getAiSettings: () => null,
});
};
export const AiRequestContext = React.createContext<AiRequestContextState>(
initialAiRequestContextState
);
type AiRequestProviderProps = {|
children: React.Node,

View File

@@ -480,6 +480,9 @@ export const AskAiEditor = React.memo<Props>(
[onOpenLayout, onCreateProject]
);
const {
aiRequestStorage: { fetchAiRequests },
} = React.useContext(AiRequestContext);
const {
selectedAiRequest,
selectedAiRequestId,
@@ -511,6 +514,15 @@ export const AskAiEditor = React.memo<Props>(
[initialMode]
);
React.useEffect(
() => {
fetchAiRequests();
},
// Only fetch once on mount (we provide a way to refresh in the history).
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const canStartNewChat = !!selectedAiRequestId;
const onStartOrOpenChat = React.useCallback(
({

View File

@@ -3,14 +3,10 @@ import * as React from 'react';
import Drawer from '@material-ui/core/Drawer';
import ButtonBase from '@material-ui/core/ButtonBase';
import { Line, Column } from '../UI/Grid';
import { ColumnStackLayout } from '../UI/Layout';
import { ColumnStackLayout, LineStackLayout } from '../UI/Layout';
import Text from '../UI/Text';
import { Trans } from '@lingui/macro';
import {
getAiRequests,
type AiRequest,
} from '../Utils/GDevelopServices/Generation';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { type AiRequest } from '../Utils/GDevelopServices/Generation';
import Paper from '../UI/Paper';
import ScrollView from '../UI/ScrollView';
import FlatButton from '../UI/FlatButton';
@@ -21,6 +17,7 @@ import formatDate from 'date-fns/format';
import DrawerTopBar from '../UI/DrawerTopBar';
import PlaceholderError from '../UI/PlaceholderError';
import { textEllipsisStyle } from '../UI/TextEllipsis';
import { AiRequestContext } from './AiRequestContext';
type Props = {|
open: boolean,
@@ -79,23 +76,27 @@ const getFirstUserRequestText = (aiRequest: AiRequest): string => {
};
type AskAiHistoryContentProps = {|
aiRequests: Array<AiRequest> | null,
isLoading: boolean,
error: ?Error,
onSelectAiRequest: (aiRequest: AiRequest) => void,
selectedAiRequestId: string | null,
onFetchAiRequests: () => Promise<void>,
|};
export const AskAiHistoryContent = ({
aiRequests,
isLoading,
error,
onSelectAiRequest,
selectedAiRequestId,
onFetchAiRequests,
}: AskAiHistoryContentProps) => {
if (!aiRequests && isLoading) {
const {
aiRequestStorage: {
aiRequests,
fetchAiRequests,
onLoadMoreAiRequests,
canLoadMore,
isLoading,
error,
},
} = React.useContext(AiRequestContext);
// $FlowFixMe - Flow loses type with Object.values
const aiRequestsArray: AiRequest[] = Object.values(aiRequests);
if (!aiRequestsArray.length && isLoading) {
return (
<Column
noMargin
@@ -111,13 +112,13 @@ export const AskAiHistoryContent = ({
if (error) {
return (
<PlaceholderError onRetry={onFetchAiRequests}>
<PlaceholderError onRetry={fetchAiRequests}>
<Trans>An error occurred while loading your AI requests.</Trans>
</PlaceholderError>
);
}
if (!aiRequests || aiRequests.length === 0) {
if (aiRequestsArray.length === 0) {
return (
<EmptyMessage>
<Trans>
@@ -130,7 +131,7 @@ export const AskAiHistoryContent = ({
return (
<ScrollView>
<ColumnStackLayout expand>
{aiRequests.map(aiRequest => {
{aiRequestsArray.map(aiRequest => {
const isSelected = selectedAiRequestId === aiRequest.id;
const userRequestText = getFirstUserRequestText(aiRequest);
const requestDate = new Date(aiRequest.createdAt);
@@ -176,14 +177,20 @@ export const AskAiHistoryContent = ({
</Paper>
);
})}
<Line justifyContent="center">
<LineStackLayout justifyContent="center">
<FlatButton
primary
label={<Trans>Refresh</Trans>}
onClick={onFetchAiRequests}
onClick={fetchAiRequests}
disabled={isLoading}
/>
</Line>
<FlatButton
primary
label={<Trans>Load more</Trans>}
onClick={onLoadMoreAiRequests}
disabled={isLoading || !canLoadMore}
/>
</LineStackLayout>
</ColumnStackLayout>
</ScrollView>
);
@@ -196,46 +203,6 @@ export const AskAiHistory = ({
selectedAiRequestId,
}: Props) => {
const { isMobile } = useResponsiveWindowSize();
const [aiRequests, setAiRequests] = React.useState<Array<AiRequest> | null>(
null
);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState<Error | null>(null);
const { profile, getAuthorizationHeader } = React.useContext(
AuthenticatedUserContext
);
const fetchAiRequests = React.useCallback(
async () => {
if (!profile) return;
setIsLoading(true);
setError(null);
try {
const requests = await getAiRequests(getAuthorizationHeader, {
userId: profile.id,
});
setAiRequests(requests);
} catch (err) {
setError(err);
console.error('Error fetching AI requests:', err);
} finally {
setIsLoading(false);
}
},
[profile, getAuthorizationHeader]
);
React.useEffect(
() => {
if (open) {
fetchAiRequests();
}
},
[open, fetchAiRequests]
);
const handleSelectAiRequest = (aiRequest: AiRequest) => {
onSelectAiRequest(aiRequest);
@@ -265,12 +232,8 @@ export const AskAiHistory = ({
onClose={onClose}
/>
<AskAiHistoryContent
aiRequests={aiRequests}
isLoading={isLoading}
error={error}
onSelectAiRequest={handleSelectAiRequest}
selectedAiRequestId={selectedAiRequestId}
onFetchAiRequests={fetchAiRequests}
/>
</ColumnStackLayout>
</Drawer>

View File

@@ -12,6 +12,7 @@ export type CompactTextAreaFieldWithControlsProps = {|
value: string,
onChange: (newValue: string) => void,
onSubmit?: () => void,
onNavigateHistory?: (direction: 'up' | 'down') => void,
id?: string,
disabled?: boolean,
errored?: boolean,
@@ -23,60 +24,125 @@ export type CompactTextAreaFieldWithControlsProps = {|
hasAnimatedNeonCorner?: boolean,
|};
export const CompactTextAreaFieldWithControls = ({
value,
onChange,
id,
disabled,
errored,
placeholder,
rows,
maxLength,
onSubmit,
controls,
hasNeonCorner,
hasAnimatedNeonCorner,
}: CompactTextAreaFieldWithControlsProps) => {
const idToUse = React.useRef<string>(id || makeTimestampedId());
export type CompactTextAreaFieldWithControlsInterface = {|
setCursorPosition: (position: number) => void,
|};
return (
<I18n>
{({ i18n }) => (
<label
className={classNames({
[classes.container]: true,
[classes.disabled]: disabled,
[classes.errored]: errored,
})}
>
<div
export const CompactTextAreaFieldWithControls = React.forwardRef<
CompactTextAreaFieldWithControlsProps,
CompactTextAreaFieldWithControlsInterface
>(
(
{
value,
onChange,
id,
disabled,
errored,
placeholder,
rows,
maxLength,
onSubmit,
onNavigateHistory,
controls,
hasNeonCorner,
hasAnimatedNeonCorner,
}: CompactTextAreaFieldWithControlsProps,
ref
) => {
const idToUse = React.useRef<string>(id || makeTimestampedId());
const textareaRef = React.useRef<?HTMLTextAreaElement>(null);
React.useImperativeHandle(ref, () => ({
setCursorPosition: (position: number) => {
if (textareaRef.current) {
textareaRef.current.setSelectionRange(position, position);
}
},
}));
const handleKeyDown = React.useCallback(
(e: SyntheticKeyboardEvent<HTMLTextAreaElement>) => {
// Handle submit first
if (onSubmit && shouldSubmit(e)) {
onSubmit();
return;
}
if (!onNavigateHistory) {
return;
}
const isArrowUp = e.key === 'ArrowUp';
const isArrowDown = e.key === 'ArrowDown';
if (!isArrowUp && !isArrowDown) {
return;
}
const textarea = e.currentTarget;
const { selectionStart, value: textValue } = textarea;
// Calculate cursor position info
const textBeforeCursor = textValue.substring(0, selectionStart);
const lines = textValue.split('\n');
const currentLineIndex = textBeforeCursor.split('\n').length - 1;
const currentLineStart = textBeforeCursor.lastIndexOf('\n') + 1;
const currentLine = lines[currentLineIndex];
const positionInLine = selectionStart - currentLineStart;
// Check if we should navigate history
const isAtFirstLineStart =
currentLineIndex === 0 && positionInLine === 0;
const isAtLastLineEnd =
currentLineIndex === lines.length - 1 &&
positionInLine === currentLine.length;
if (
(isArrowUp && isAtFirstLineStart) ||
(isArrowDown && isAtLastLineEnd)
) {
e.preventDefault();
onNavigateHistory(isArrowUp ? 'up' : 'down');
}
},
[onSubmit, onNavigateHistory]
);
return (
<I18n>
{({ i18n }) => (
<label
className={classNames({
[classes.compactTextAreaField]: true,
[classes.neonCorner]: hasNeonCorner,
[classes.animatedNeonCorner]:
hasNeonCorner && hasAnimatedNeonCorner,
[classes.container]: true,
[classes.disabled]: disabled,
[classes.errored]: errored,
})}
>
<textarea
id={idToUse.current}
disabled={disabled}
value={value === null ? '' : value}
onChange={e => onChange(e.currentTarget.value)}
placeholder={i18n._(placeholder)}
onKeyDown={
onSubmit
? e => {
if (shouldSubmit(e)) onSubmit();
}
: undefined
}
rows={rows || 3}
maxLength={maxLength}
/>
{controls}
</div>
</label>
)}
</I18n>
);
};
<div
className={classNames({
[classes.compactTextAreaField]: true,
[classes.neonCorner]: hasNeonCorner,
[classes.animatedNeonCorner]:
hasNeonCorner && hasAnimatedNeonCorner,
})}
>
<textarea
ref={textareaRef}
id={idToUse.current}
disabled={disabled}
value={value === null ? '' : value}
onChange={e => onChange(e.currentTarget.value)}
placeholder={i18n._(placeholder)}
onKeyDown={handleKeyDown}
rows={rows || 3}
maxLength={maxLength}
/>
{controls}
</div>
</label>
)}
</I18n>
);
}
);

View File

@@ -3,6 +3,7 @@ import axios from 'axios';
import { GDevelopAiCdn, GDevelopGenerationApi } from './ApiConfigs';
import { type MessageByLocale } from '../i18n/MessageByLocale';
import { getIDEVersionWithHash } from '../../Version';
import { extractNextPageUriFromLinkHeader } from './Play';
export type Environment = 'staging' | 'live';
@@ -22,39 +23,43 @@ export type AiRequestFunctionCallOutput = {
output: string,
};
export type AiRequestMessage =
| {
type: 'message',
status: 'completed',
role: 'assistant',
content: Array<
| {
type: 'reasoning',
status: 'completed',
summary: {
text: string,
type: 'summary_text',
},
}
| {
type: 'output_text',
status: 'completed',
text: string,
annotations: Array<{}>,
}
| AiRequestMessageAssistantFunctionCall
>,
}
| {
type: 'message',
status: 'completed',
role: 'user',
content: Array<{
type: 'user_request',
export type AiRequestAssistantMessage = {
type: 'message',
status: 'completed',
role: 'assistant',
content: Array<
| {
type: 'reasoning',
status: 'completed',
summary: {
text: string,
type: 'summary_text',
},
}
| {
type: 'output_text',
status: 'completed',
text: string,
}>,
}
annotations: Array<{}>,
}
| AiRequestMessageAssistantFunctionCall
>,
};
export type AiRequestUserMessage = {
type: 'message',
status: 'completed',
role: 'user',
content: Array<{
type: 'user_request',
status: 'completed',
text: string,
}>,
};
export type AiRequestMessage =
| AiRequestAssistantMessage
| AiRequestUserMessage
| AiRequestFunctionCallOutput;
export type AiConfiguration = {
@@ -166,6 +171,10 @@ export type AssetSearch = {
}> | null,
};
export const apiClient = axios.create({
baseURL: GDevelopGenerationApi.baseUrl,
});
export const getAiRequest = async (
getAuthorizationHeader: () => Promise<string>,
{
@@ -195,23 +204,37 @@ export const getAiRequests = async (
getAuthorizationHeader: () => Promise<string>,
{
userId,
forceUri,
}: {|
userId: string,
forceUri: ?string,
|}
): Promise<Array<AiRequest>> => {
): Promise<{
aiRequests: Array<AiRequest>,
nextPageUri: ?string,
}> => {
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.get(
`${GDevelopGenerationApi.baseUrl}/ai-request`,
{
params: {
userId,
},
headers: {
Authorization: authorizationHeader,
},
}
);
return response.data;
const uri = forceUri || '/ai-request';
// $FlowFixMe
const response = await apiClient.get(uri, {
headers: {
Authorization: authorizationHeader,
},
params: forceUri ? { userId } : { userId, perPage: 10 },
});
const nextPageUri = response.headers.link
? extractNextPageUriFromLinkHeader(response.headers.link)
: null;
const aiRequests = response.data;
if (!Array.isArray(aiRequests)) {
throw new Error('Invalid response from Ai requests API.');
}
return {
aiRequests,
nextPageUri,
};
};
export const createAiRequest = async (
@@ -252,8 +275,8 @@ export const createAiRequest = async (
|}
): Promise<AiRequest> => {
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.post(
`${GDevelopGenerationApi.baseUrl}/ai-request`,
const response = await apiClient.post(
'/ai-request',
{
gdevelopVersionWithHash: getIDEVersionWithHash(),
userRequest,
@@ -306,10 +329,8 @@ export const addMessageToAiRequest = async (
|}
): Promise<AiRequest> => {
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.post(
`${
GDevelopGenerationApi.baseUrl
}/ai-request/${aiRequestId}/action/add-message`,
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/add-message`,
{
gdevelopVersionWithHash: getIDEVersionWithHash(),
functionCallOutputs,
@@ -351,10 +372,8 @@ export const sendAiRequestFeedback = async (
|}
): Promise<AiRequest> => {
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.post(
`${
GDevelopGenerationApi.baseUrl
}/ai-request/${aiRequestId}/action/set-feedback`,
const response = await apiClient.post(
`/ai-request/${aiRequestId}/action/set-feedback`,
{
gdevelopVersionWithHash: getIDEVersionWithHash(),
messageIndex,
@@ -415,8 +434,8 @@ export const createAiGeneratedEvent = async (
|}
): Promise<CreateAiGeneratedEventResult> => {
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.post(
`${GDevelopGenerationApi.baseUrl}/ai-generated-event`,
const response = await apiClient.post(
`/ai-generated-event`,
{
gdevelopVersionWithHash: getIDEVersionWithHash(),
gameProjectJson,
@@ -474,8 +493,8 @@ export const getAiGeneratedEvent = async (
|}
): Promise<AiGeneratedEvent> => {
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.get(
`${GDevelopGenerationApi.baseUrl}/ai-generated-event/${aiGeneratedEventId}`,
const response = await apiClient.get(
`/ai-generated-event/${aiGeneratedEventId}`,
{
params: {
userId,
@@ -505,8 +524,8 @@ export const createAssetSearch = async (
|}
): Promise<AssetSearch> => {
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.post(
`${GDevelopGenerationApi.baseUrl}/asset-search`,
const response = await apiClient.post(
`/asset-search`,
{
gdevelopVersionWithHash: getIDEVersionWithHash(),
searchTerms,
@@ -546,10 +565,8 @@ export const createAiUserContentPresignedUrls = async (
|}
): Promise<AiUserContentPresignedUrlsResult> => {
const authorizationHeader = await getAuthorizationHeader();
const response = await axios.post(
`${
GDevelopGenerationApi.baseUrl
}/ai-user-content/action/create-presigned-urls`,
const response = await apiClient.post(
`/ai-user-content/action/create-presigned-urls`,
{
gdevelopVersionWithHash: getIDEVersionWithHash(),
gameProjectJsonHash,

View File

@@ -3,6 +3,11 @@ import * as React from 'react';
import paperDecorator from '../../PaperDecorator';
import { AskAiHistoryContent } from '../../../AiGeneration/AskAiHistory';
import FixedHeightFlexContainer from '../../FixedHeightFlexContainer';
import {
AiRequestContext,
initialAiRequestContextState,
} from '../../../AiGeneration/AiRequestContext';
import { type AiRequest } from '../../../Utils/GDevelopServices/Generation';
// Re-use fake AI request data from AiRequestChat.stories.js
const fakeOutputWithUserRequestOnly = [
@@ -93,183 +98,190 @@ export default {
decorators: [paperDecorator],
};
export const Loading = () => (
const AskAIHistoryContentStoryTemplate = ({
error,
isLoading,
aiRequests,
canLoadMore,
selectedAiRequestId,
}: {|
error: ?Error,
isLoading: boolean,
aiRequests: { [string]: AiRequest },
canLoadMore: boolean,
selectedAiRequestId: string | null,
|}) => (
<FixedHeightFlexContainer height={500}>
<AskAiHistoryContent
aiRequests={null}
isLoading={true}
error={null}
onSelectAiRequest={() => {}}
selectedAiRequestId={null}
onFetchAiRequests={async () => {}}
/>
<AiRequestContext.Provider
value={{
...initialAiRequestContextState,
aiRequestStorage: {
...initialAiRequestContextState.aiRequestStorage,
aiRequests,
isLoading,
error,
canLoadMore,
},
}}
>
<AskAiHistoryContent
onSelectAiRequest={() => {}}
selectedAiRequestId={selectedAiRequestId}
/>
</AiRequestContext.Provider>
</FixedHeightFlexContainer>
);
export const Loading = () => (
<AskAIHistoryContentStoryTemplate
aiRequests={{}}
isLoading={true}
error={null}
selectedAiRequestId={null}
canLoadMore={false}
/>
);
export const Errored = () => (
<FixedHeightFlexContainer height={500}>
<AskAiHistoryContent
aiRequests={null}
isLoading={false}
error={new Error('Failed to fetch AI requests')}
onSelectAiRequest={() => {}}
selectedAiRequestId={null}
onFetchAiRequests={async () => {}}
/>
</FixedHeightFlexContainer>
<AskAIHistoryContentStoryTemplate
aiRequests={{}}
isLoading={false}
error={new Error('Failed to fetch AI requests')}
selectedAiRequestId={null}
canLoadMore={false}
/>
);
export const Empty = () => (
<FixedHeightFlexContainer height={500}>
<AskAiHistoryContent
aiRequests={[]}
isLoading={false}
error={null}
onSelectAiRequest={() => {}}
selectedAiRequestId={null}
onFetchAiRequests={async () => {}}
/>
</FixedHeightFlexContainer>
<AskAIHistoryContentStoryTemplate
aiRequests={{}}
isLoading={false}
error={null}
selectedAiRequestId={null}
canLoadMore={false}
/>
);
export const SingleAiRequest = () => (
<FixedHeightFlexContainer height={500}>
<AskAiHistoryContent
aiRequests={[
createFakeAiRequest({
id: 'request-1',
createdAt: '2024-03-15T10:30:00Z',
output: fakeOutputWithAiResponses,
}),
]}
isLoading={false}
error={null}
onSelectAiRequest={() => {}}
selectedAiRequestId={null}
onFetchAiRequests={async () => {}}
/>
</FixedHeightFlexContainer>
<AskAIHistoryContentStoryTemplate
aiRequests={{
'request-1': createFakeAiRequest({
id: 'request-1',
createdAt: '2024-03-15T10:30:00Z',
output: fakeOutputWithAiResponses,
}),
}}
isLoading={false}
error={null}
selectedAiRequestId={null}
canLoadMore={false}
/>
);
export const MultipleAiRequests = () => (
<FixedHeightFlexContainer height={500}>
<AskAiHistoryContent
aiRequests={[
createFakeAiRequest({
id: 'request-1',
createdAt: '2024-03-15T14:30:00Z',
output: fakeOutputWithAiResponses,
}),
createFakeAiRequest({
id: 'request-2',
createdAt: '2024-03-14T09:45:00Z',
output: fakeOutputWithDifferentUserRequest,
}),
createFakeAiRequest({
id: 'request-3',
createdAt: '2024-03-10T16:20:00Z',
}),
]}
isLoading={false}
error={null}
onSelectAiRequest={() => {}}
selectedAiRequestId={null}
onFetchAiRequests={async () => {}}
/>
</FixedHeightFlexContainer>
<AskAIHistoryContentStoryTemplate
aiRequests={{
'request-1': createFakeAiRequest({
id: 'request-1',
createdAt: '2024-03-15T14:30:00Z',
output: fakeOutputWithAiResponses,
}),
'request-2': createFakeAiRequest({
id: 'request-2',
createdAt: '2024-03-14T09:45:00Z',
output: fakeOutputWithDifferentUserRequest,
}),
'request-3': createFakeAiRequest({
id: 'request-3',
createdAt: '2024-03-10T16:20:00Z',
}),
}}
isLoading={false}
error={null}
selectedAiRequestId={null}
canLoadMore
/>
);
export const WithSelectedRequest = () => (
<FixedHeightFlexContainer height={500}>
<AskAiHistoryContent
aiRequests={[
createFakeAiRequest({
id: 'request-1',
createdAt: '2024-03-15T14:30:00Z',
output: fakeOutputWithAiResponses,
}),
createFakeAiRequest({
id: 'request-2',
createdAt: '2024-03-14T09:45:00Z',
output: fakeOutputWithDifferentUserRequest,
}),
]}
isLoading={false}
error={null}
onSelectAiRequest={() => {}}
selectedAiRequestId="request-2"
onFetchAiRequests={async () => {}}
/>
</FixedHeightFlexContainer>
<AskAIHistoryContentStoryTemplate
aiRequests={{
'request-1': createFakeAiRequest({
id: 'request-1',
createdAt: '2024-03-15T14:30:00Z',
output: fakeOutputWithAiResponses,
}),
'request-2': createFakeAiRequest({
id: 'request-2',
createdAt: '2024-03-14T09:45:00Z',
output: fakeOutputWithDifferentUserRequest,
}),
}}
isLoading={false}
error={null}
selectedAiRequestId="request-2"
canLoadMore={false}
/>
);
export const WithWorkingRequest = () => (
<FixedHeightFlexContainer height={500}>
<AskAiHistoryContent
aiRequests={[
createFakeAiRequest({
id: 'request-1',
status: 'working',
createdAt: '2024-03-15T14:30:00Z',
}),
createFakeAiRequest({
id: 'request-2',
createdAt: '2024-03-14T09:45:00Z',
output: fakeOutputWithDifferentUserRequest,
}),
]}
isLoading={false}
error={null}
onSelectAiRequest={() => {}}
selectedAiRequestId={null}
onFetchAiRequests={async () => {}}
/>
</FixedHeightFlexContainer>
<AskAIHistoryContentStoryTemplate
aiRequests={{
'request-1': createFakeAiRequest({
id: 'request-1',
status: 'working',
createdAt: '2024-03-15T14:30:00Z',
}),
'request-2': createFakeAiRequest({
id: 'request-2',
createdAt: '2024-03-14T09:45:00Z',
output: fakeOutputWithDifferentUserRequest,
}),
}}
isLoading={false}
error={null}
selectedAiRequestId={null}
canLoadMore={false}
/>
);
export const WithErroredRequest = () => (
<FixedHeightFlexContainer height={500}>
<AskAiHistoryContent
aiRequests={[
createFakeAiRequest({
id: 'request-1',
status: 'error',
createdAt: '2024-03-15T14:30:00Z',
error: { code: 'internal-error', message: 'Some error happened' },
}),
createFakeAiRequest({
id: 'request-2',
createdAt: '2024-03-14T09:45:00Z',
output: fakeOutputWithDifferentUserRequest,
}),
]}
isLoading={false}
error={null}
onSelectAiRequest={() => {}}
selectedAiRequestId={null}
onFetchAiRequests={async () => {}}
/>
</FixedHeightFlexContainer>
<AskAIHistoryContentStoryTemplate
aiRequests={{
'request-1': createFakeAiRequest({
id: 'request-1',
status: 'error',
createdAt: '2024-03-15T14:30:00Z',
error: { code: 'internal-error', message: 'Some error happened' },
}),
'request-2': createFakeAiRequest({
id: 'request-2',
createdAt: '2024-03-14T09:45:00Z',
output: fakeOutputWithDifferentUserRequest,
}),
}}
isLoading={false}
error={null}
selectedAiRequestId={null}
canLoadMore={false}
/>
);
export const RefreshingRequests = () => (
<FixedHeightFlexContainer height={500}>
<AskAiHistoryContent
aiRequests={[
createFakeAiRequest({
id: 'request-1',
createdAt: '2024-03-15T14:30:00Z',
}),
createFakeAiRequest({
id: 'request-2',
createdAt: '2024-03-14T09:45:00Z',
}),
]}
isLoading={true}
error={null}
onSelectAiRequest={() => {}}
selectedAiRequestId={null}
onFetchAiRequests={async () => {}}
/>
</FixedHeightFlexContainer>
<AskAIHistoryContentStoryTemplate
aiRequests={{
'request-1': createFakeAiRequest({
id: 'request-1',
createdAt: '2024-03-15T14:30:00Z',
}),
'request-2': createFakeAiRequest({
id: 'request-2',
createdAt: '2024-03-14T09:45:00Z',
}),
}}
isLoading={true}
error={null}
selectedAiRequestId={null}
canLoadMore={false}
/>
);