From 7c5d963a50ecd2e414119fea8201ee4342cc436a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:52:11 +0100 Subject: [PATCH 1/5] New orchestrator mode --- .../app/src/AiGeneration/AiConfiguration.js | 2 +- .../AiRequestChat/ChatMarkdownText.js | 17 +- .../AiRequestChat/ChatMessages.js | 413 +++++++++++++----- .../AiRequestChat/DislikeFeedbackDialog.js | 4 +- .../AiRequestChat/OrchestratorPlan.js | 132 ++++++ .../AiRequestChat/OrchestratorPlan.module.css | 43 ++ .../src/AiGeneration/AiRequestChat/index.js | 62 ++- .../src/AiGeneration/AiRequestChat/plan.json | 21 + .../app/src/AiGeneration/AiRequestContext.js | 40 +- newIDE/app/src/AiGeneration/AiRequestUtils.js | 34 ++ .../src/AiGeneration/AskAiEditorContainer.js | 14 +- .../src/AiGeneration/AskAiStandAloneForm.js | 24 +- newIDE/app/src/AiGeneration/Utils.js | 144 ++++-- newIDE/app/src/EditorFunctions/index.js | 14 + .../HomePage/LearnSection/MainPage.js | 10 +- newIDE/app/src/UI/Text.js | 3 + .../src/Utils/GDevelopServices/Generation.js | 21 +- .../app/src/Utils/GDevelopServices/Usage.js | 2 +- 18 files changed, 760 insertions(+), 240 deletions(-) create mode 100644 newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.js create mode 100644 newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.module.css create mode 100644 newIDE/app/src/AiGeneration/AiRequestChat/plan.json diff --git a/newIDE/app/src/AiGeneration/AiConfiguration.js b/newIDE/app/src/AiGeneration/AiConfiguration.js index 2e625e6f44a6..bc22fbae2382 100644 --- a/newIDE/app/src/AiGeneration/AiConfiguration.js +++ b/newIDE/app/src/AiGeneration/AiConfiguration.js @@ -50,7 +50,7 @@ export const getAiConfigurationPresetsWithAvailability = ({ }; export const getDefaultAiConfigurationPresetId = ( - mode: 'chat' | 'agent', + mode: 'chat' | 'agent' | 'orchestrator', aiConfigurationPresetsWithAvailability: Array ): string => { const defaultPresetWithAvailability = aiConfigurationPresetsWithAvailability.find( diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMarkdownText.js b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMarkdownText.js index 7c0d9f52d1bf..6ec1d7e3a333 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMarkdownText.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMarkdownText.js @@ -2,7 +2,6 @@ import * as React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { I18n } from '@lingui/react'; import classNames from 'classnames'; import Window from '../../Utils/Window'; import { getHelpLink } from '../../Utils/HelpLink'; @@ -156,16 +155,12 @@ export const ChatMarkdownText: React.ComponentType = React.memo( ); const markdownElement = ( - - {({ i18n }) => ( - - {props.source} - - )} - + + {props.source} + ); const className = classNames({ diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js index 626edff8f466..fa32206f1cb5 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js @@ -4,7 +4,10 @@ import { ChatBubble } from './ChatBubble'; import { Column, Line, Spacer } from '../../UI/Grid'; import { ChatMarkdownText } from './ChatMarkdownText'; import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; -import { getFunctionCallToFunctionCallOutputMap } from '../AiRequestUtils'; +import { + getFunctionCallToFunctionCallOutputMap, + getLatestActivePlan, +} from '../AiRequestUtils'; import { FunctionCallRow } from './FunctionCallRow'; import { FunctionCallsGroup } from './FunctionCallsGroup'; import IconButton from '../../UI/IconButton'; @@ -18,6 +21,7 @@ import { type AiRequestAssistantMessage, type AiRequestFunctionCallOutput, type AiRequestMessage, + type AiRequestUserMessage, } from '../../Utils/GDevelopServices/Generation'; import { type EditorFunctionCallResult, @@ -50,6 +54,73 @@ import CheckCircle from '@material-ui/icons/CheckCircle'; import Link from '../../UI/Link'; import { type FileMetadata } from '../../ProjectsStorage'; import UnsavedChangesContext from '../../MainFrame/UnsavedChangesContext'; +import { OrchestratorPlan } from './OrchestratorPlan'; + +type FunctionCallItem = {| + key: string, + messageContent: AiRequestMessageAssistantFunctionCall, + existingFunctionCallOutput: AiRequestFunctionCallOutput | null | void, + editorFunctionCallResult: EditorFunctionCallResult | null, +|}; + +type UserMessageRenderItem = {| + type: 'user_message', + messageIndex: number, + message: AiRequestUserMessage, +|}; + +type MessageContentRenderItem = {| + type: 'message_content', + messageIndex: number, + messageContentIndex: number, + message: AiRequestAssistantMessage, + messageContent: {| + type: 'output_text' | 'reasoning', + status: 'completed', + text?: string, + summary?: { + text: string, + type: 'summary_text', + }, + annotations?: Array<{}>, + |}, + isLastMessage: boolean, + functionCallItems?: Array, +|}; + +type FunctionCallGroupRenderItem = {| + type: 'function_call_group', + items: Array, +|}; + +type SaveRenderItem = {| + type: 'save', + messageIndex: number, + message: AiRequestMessage, + isRestored: boolean, + isSaving: boolean, +|}; + +type SuggestionsRenderItem = {| + type: 'suggestions', + messageIndex: number, + message: AiRequestAssistantMessage | AiRequestFunctionCallOutput, + onlyShowExplanationMessage: boolean, + functionCallItems?: Array, +|}; + +type OrchestratorPlanRenderItem = {| + type: 'orchestrator_plan', + plan: {| tasks: Array |}, +|}; + +type RenderItem = + | UserMessageRenderItem + | MessageContentRenderItem + | FunctionCallGroupRenderItem + | SaveRenderItem + | SuggestionsRenderItem + | OrchestratorPlanRenderItem; const styles = { subscriptionPaper: { @@ -360,8 +431,8 @@ export const ChatMessages: React.ComponentType = React.memo( // Group consecutive function calls. const renderItems = React.useMemo( () => { - const items = []; - let currentFunctionCallItems = []; + const items: Array = []; + let currentFunctionCallItems: Array = []; const forkedAfterNewMessageId = aiRequest.forkedAfterNewMessageId; const flushFunctionCallGroup = () => { @@ -379,7 +450,6 @@ export const ChatMessages: React.ComponentType = React.memo( if (message.type === 'message' && message.role === 'user') { flushFunctionCallGroup(); - // $FlowFixMe[incompatible-type] items.push({ type: 'user_message', messageIndex, @@ -389,8 +459,7 @@ export const ChatMessages: React.ComponentType = React.memo( message.type === 'message' && message.role === 'assistant' ) { - // $FlowFixMe[missing-empty-array-annot] - let pendingFunctionCallItems = []; + let pendingFunctionCallItems: Array = []; message.content.forEach((messageContent, messageContentIndex) => { if (messageContent.type === 'function_call') { @@ -406,6 +475,12 @@ export const ChatMessages: React.ComponentType = React.memo( )) || null; + // Don't display create_or_update_plan calls — the plan is shown + // separately via the OrchestratorPlan component. + if (messageContent.name === 'create_or_update_plan') { + return; + } + currentFunctionCallItems.push({ key: `messageIndex${messageIndex}-${messageContentIndex}`, messageContent, @@ -419,12 +494,12 @@ export const ChatMessages: React.ComponentType = React.memo( currentFunctionCallItems = []; } - // $FlowFixMe[incompatible-type] items.push({ type: 'message_content', messageIndex, messageContentIndex, message, + // $FlowFixMe[incompatible-type] - messageContent types are complex messageContent, isLastMessage, functionCallItems: @@ -450,17 +525,18 @@ export const ChatMessages: React.ComponentType = React.memo( isSavingProjectForThisMessage) ) { flushFunctionCallGroup(); - // $FlowFixMe[incompatible-type] items.push({ type: 'save', messageIndex: messageIndex, message: message, - isRestored: + isRestored: !!( forkedAfterNewMessageId && - message.messageId === forkedAfterNewMessageId, - isSaving: + message.messageId === forkedAfterNewMessageId + ), + isSaving: !!( isSavingProjectForThisMessage && - !message.projectVersionIdAfterMessage, + !message.projectVersionIdAfterMessage + ), }); } @@ -483,10 +559,10 @@ export const ChatMessages: React.ComponentType = React.memo( flushFunctionCallGroup(); } - // $FlowFixMe[incompatible-type] items.push({ type: 'suggestions', messageIndex: messageIndex, + // $FlowFixMe[incompatible-type] - message can be assistant or function_call_output message: message, onlyShowExplanationMessage: !isLastMessage, functionCallItems: functionCallItemsForSuggestions, @@ -517,9 +593,7 @@ export const ChatMessages: React.ComponentType = React.memo( const forkSaveIndex = renderItems.findIndex( item => item.type === 'save' && - // $FlowFixMe[prop-missing] item.message && - // $FlowFixMe[invalid-compare] item.message.messageId === forkingState.messageId ); @@ -532,10 +606,85 @@ export const ChatMessages: React.ComponentType = React.memo( [renderItems, forkingState, aiRequest.id] ); + const latestActivePlan = React.useMemo( + () => + aiRequest.mode === 'orchestrator' + ? getLatestActivePlan(aiRequest) + : null, + [aiRequest] + ); + + const displayItems: Array = React.useMemo( + () => { + if (!latestActivePlan || filteredRenderItems.length === 0) { + return filteredRenderItems; + } + const result = [...filteredRenderItems]; + + // Find the last "true message" item (message_content or user_message). + // We skip trailing save/function_call_group items so that the plan is + // always inserted immediately before the last real message, regardless + // of any save indicators that may follow it. + let insertionIndex = -1; + for (let i = result.length - 1; i >= 0; i--) { + const item = result[i]; + if (item.type === 'message_content' || item.type === 'user_message') { + insertionIndex = i; + break; + } + } + + if (insertionIndex === -1) { + // No true message found — append the plan at the end. + result.push( + ({ type: 'orchestrator_plan', plan: latestActivePlan }: any) + ); + return result; + } + + const targetItem = result[insertionIndex]; + + // Items to splice in before the target: optionally a detached + // function_call_group, then the plan. + const itemsToInsert: Array = []; + + // If the target message carries pending function calls, hoist those + // into their own group above the plan so only the plain text appears + // after the plan. + if ( + targetItem.type === 'message_content' && + targetItem.functionCallItems && + targetItem.functionCallItems.length > 0 + ) { + itemsToInsert.push( + ({ + type: 'function_call_group', + items: targetItem.functionCallItems, + }: any) + ); + result[insertionIndex] = ({ + ...targetItem, + functionCallItems: undefined, + }: any); + } + + itemsToInsert.push( + ({ + type: 'orchestrator_plan', + plan: latestActivePlan, + }: any) + ); + + result.splice(insertionIndex, 0, ...itemsToInsert); + return result; + }, + [filteredRenderItems, latestActivePlan] + ); + // Scroll to bottom when suggestions are added. const hasSuggestions = React.useMemo( - () => filteredRenderItems.some(item => item.type === 'suggestions'), - [filteredRenderItems] + () => displayItems.some(item => item.type === 'suggestions'), + [displayItems] ); React.useEffect( @@ -558,10 +707,17 @@ export const ChatMessages: React.ComponentType = React.memo( return ( <> - {filteredRenderItems + {displayItems .flatMap((item, itemIndex) => { + if (item.type === 'orchestrator_plan') { + return [ + + + , + ]; + } + if (item.type === 'user_message') { - // $FlowFixMe[prop-missing] const { messageIndex, message } = item; const currentVersionOpened = fileMetadata @@ -673,15 +829,10 @@ export const ChatMessages: React.ComponentType = React.memo( if (item.type === 'message_content') { const { - // $FlowFixMe[prop-missing] messageIndex, - // $FlowFixMe[prop-missing] messageContentIndex, - // $FlowFixMe[prop-missing] messageContent, - // $FlowFixMe[prop-missing] isLastMessage, - // $FlowFixMe[prop-missing] functionCallItems, } = item; // $FlowFixMe[incompatible-type] @@ -700,88 +851,113 @@ export const ChatMessages: React.ComponentType = React.memo( return null; } + // Don't show the "Did it work?" banner when this message is a + // plan confirmation (i.e. it immediately follows a + // function_call_output that returned a plan). + const previousOutputMessage = + messageIndex > 0 ? aiRequest.output[messageIndex - 1] : null; + const isAfterPlanOutput = + previousOutputMessage && + previousOutputMessage.type === 'function_call_output' && + (() => { + try { + return !!JSON.parse(previousOutputMessage.output).plan; + } catch (e) { + return false; + } + })(); + return [ - {/* $FlowFixMe[constant-condition] */} - {isLastMessage && shouldDisplayFeedbackBanner && ( - - Did it work? - - )} - - { - navigator.clipboard.writeText( - // $FlowFixMe[incompatible-use] - messageContent.text - ); - }} + isAfterPlanOutput ? ( + undefined + ) : ( +
+ {/* $FlowFixMe[constant-condition] */} + {isLastMessage && shouldDisplayFeedbackBanner && ( + + Did it work? + + )} + - - - { - // $FlowFixMe[incompatible-type] - setMessageFeedbacks({ - ...messageFeedbacks, - [feedbackKey]: 'like', - }); - onSendFeedback( - aiRequest.id, + { + if (messageContent.text) { + navigator.clipboard.writeText( + messageContent.text + ); + } + }} + > + + + { // $FlowFixMe[incompatible-type] - messageIndex, - 'like' - ); - }} - > - - - { - // $FlowFixMe[incompatible-type] - setMessageFeedbacks({ - ...messageFeedbacks, - [feedbackKey]: 'dislike', - }); - // $FlowFixMe[incompatible-type] - setDislikeFeedbackDialogOpenedFor({ - aiRequestId: aiRequest.id, - messageIndex, - }); - }} - > - - - -
+ setMessageFeedbacks({ + ...messageFeedbacks, + [feedbackKey]: 'like', + }); + onSendFeedback( + aiRequest.id, + // $FlowFixMe[incompatible-type] + messageIndex, + 'like' + ); + }} + > + +
+ { + // $FlowFixMe[incompatible-type] + setMessageFeedbacks({ + ...messageFeedbacks, + [feedbackKey]: 'dislike', + }); + // $FlowFixMe[incompatible-type] + setDislikeFeedbackDialogOpenedFor({ + aiRequestId: aiRequest.id, + messageIndex, + }); + }} + > + + +
+ + ) } > @@ -837,7 +1013,6 @@ export const ChatMessages: React.ComponentType = React.memo( } if (item.type === 'save') { - // $FlowFixMe[prop-missing] const { messageIndex, message, isRestored, isSaving } = item; const isForking = forkingState && @@ -938,13 +1113,9 @@ export const ChatMessages: React.ComponentType = React.memo( if (item.type === 'suggestions') { const { - // $FlowFixMe[prop-missing] messageIndex, - // $FlowFixMe[prop-missing] message, - // $FlowFixMe[prop-missing] onlyShowExplanationMessage, - // $FlowFixMe[prop-missing] functionCallItems, } = item; return [ @@ -998,22 +1169,24 @@ export const ChatMessages: React.ComponentType = React.memo(
{/* $FlowFixMe[constant-condition] */} - {onPause && aiRequest.mode === 'agent' && ( - onPause(!isPaused)} - size="small" - style={{ - backgroundColor: !isPaused - ? getBackgroundColor(theme, 'light') - : undefined, - borderRadius: 4, - padding: 0, - }} - selected={isPaused} - > - {isPaused ? : } - - )} + {onPause && + (aiRequest.mode === 'agent' || + aiRequest.mode === 'orchestrator') && ( + onPause(!isPaused)} + size="small" + style={{ + backgroundColor: !isPaused + ? getBackgroundColor(theme, 'light') + : undefined, + borderRadius: 4, + padding: 0, + }} + selected={isPaused} + > + {isPaused ? : } + + )} void, onSendFeedback: (reason: string, freeFormDetails: string) => void, - mode: 'chat' | 'agent', + mode: 'chat' | 'agent' | 'orchestrator', |}; export const DislikeFeedbackDialog = ({ @@ -43,7 +43,7 @@ export const DislikeFeedbackDialog = ({ {({ i18n }) => ( What went wrong? ) : ( What could be improved? diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.js b/newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.js new file mode 100644 index 000000000000..86bfc5b79b02 --- /dev/null +++ b/newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.js @@ -0,0 +1,132 @@ +// @flow +import * as React from 'react'; +import { Trans } from '@lingui/macro'; +import Paper from '../../UI/Paper'; +import { Column, Line } from '../../UI/Grid'; +import Text from '../../UI/Text'; +import { LineStackLayout } from '../../UI/Layout'; +import CheckCircle from '../../UI/CustomSvgIcons/CheckCircle'; +import ChevronArrowRight from '../../UI/CustomSvgIcons/ChevronArrowRight'; +import ChevronArrowBottom from '../../UI/CustomSvgIcons/ChevronArrowBottom'; +import classes from './OrchestratorPlan.module.css'; + +type Task = { + id: string, + title: string, + description: string, + status: 'pending' | 'in_progress' | 'done' | 'voided', + dependsOn: string[], +}; + +type Props = {| + tasks: Task[], +|}; + +const getStatusIcon = ( + status: 'pending' | 'in_progress' | 'done' | 'voided' +) => { + switch (status) { + case 'done': + return ; + case 'in_progress': + return
; + case 'pending': + return
; + case 'voided': + return null; + default: + return null; + } +}; + +const TaskRow = ({ task }: {| task: Task |}) => { + const [isExpanded, setIsExpanded] = React.useState(false); + + const toggle = () => { + if (task.description) setIsExpanded(expanded => !expanded); + }; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + } + : undefined + } + > + +
+ {getStatusIcon(task.status)} +
+ + + + {task.title} + + {task.description && ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ )} +
+ {isExpanded && task.description && ( + + {task.description} + + )} +
+
+
+ ); +}; + +export const OrchestratorPlan = ({ tasks }: Props): React.Node => { + // Filter out voided tasks + const visibleTasks = tasks.filter(task => task.status !== 'voided'); + + if (visibleTasks.length === 0) { + return null; + } + + return ( + +
+ + + Here is the plan: + + + + {visibleTasks.map(task => ( + + ))} + +
+
+ ); +}; diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.module.css b/newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.module.css new file mode 100644 index 000000000000..a0f7bbf3ec42 --- /dev/null +++ b/newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.module.css @@ -0,0 +1,43 @@ +.container { + padding: 12px 16px; +} + +.emptyCircle { + margin-top: 2px; + width: 6px; + height: 6px; + border: 1.5px solid currentColor; + border-radius: 50%; + opacity: 0.5; +} + +.filledCircle { + margin-top: 2px; + width: 6px; + height: 6px; + background-color: currentColor; + border-radius: 50%; +} + +.statusIconContainer { + display: flex; + align-items: center; + justify-content: center; + min-width: 20px; + padding-top: 2px; +} + +.taskRow { + padding: 2px 0; +} + +.taskRowClickable:hover { + opacity: 0.8; +} + +.chevron { + display: flex; + align-items: center; + opacity: 0.6; + padding: 0 2px; +} diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/index.js b/newIDE/app/src/AiGeneration/AiRequestChat/index.js index 853517acd02d..eb2397fbce3d 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/index.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/index.js @@ -100,7 +100,7 @@ const getPriceAndRequestsTextAndTooltip = ({ quota: Quota | null, price: UsagePrice | null, availableCredits: number, - selectedMode: 'chat' | 'agent', + selectedMode: 'chat' | 'agent' | 'orchestrator', automaticallyUseCreditsForAiRequests: boolean, |}): React.Node => { if (!quota || !price) { @@ -175,7 +175,7 @@ const getPriceAndRequestsTextAndTooltip = ({ quota.limitReached && automaticallyUseCreditsForAiRequests; return ( - + {shouldShowCredits && } {shouldShowCredits ? creditsText : currentQuotaText} @@ -205,7 +205,7 @@ const getSendButtonLabelAndIcon = ({ standAloneForm, }: {| aiRequest: AiRequest | null, - selectedMode?: 'chat' | 'agent', + selectedMode?: 'chat' | 'agent' | 'orchestrator', isWorking: boolean, isMobile: boolean, hasOpenedProject: boolean, @@ -217,7 +217,7 @@ const getSendButtonLabelAndIcon = ({ return { label: null, icon: }; } - return selectedMode === 'agent' + return selectedMode === 'agent' || selectedMode === 'orchestrator' ? isWorking ? { label: Building..., icon: } : isMobile @@ -274,13 +274,13 @@ type Props = {| isSending: boolean, onStartNewAiRequest: ({| - mode: 'chat' | 'agent', + mode: 'chat' | 'agent' | 'orchestrator', userRequest: string, aiConfigurationPresetId: string, |}) => void, onSendUserMessage: ({| userMessage: string, - mode: 'chat' | 'agent', + mode: 'chat' | 'agent' | 'orchestrator', |}) => Promise, onSendFeedback: ( aiRequestId: string, @@ -367,8 +367,11 @@ export const AiRequestChat: React.ComponentType<{ const { aiRequestHistory: { handleNavigateHistory, resetNavigation }, } = React.useContext(AiRequestContext); - const [selectedMode, setSelectedMode] = React.useState<'chat' | 'agent'>( - (aiRequest && aiRequest.mode) || (hasOpenedProject ? 'chat' : 'agent') + const [selectedMode, setSelectedMode] = React.useState< + 'chat' | 'agent' | 'orchestrator' + >( + (aiRequest && aiRequest.mode) || + (hasOpenedProject ? 'chat' : 'orchestrator') ); const { values: { automaticallyUseCreditsForAiRequests }, @@ -465,7 +468,7 @@ export const AiRequestChat: React.ComponentType<{ const newChatPlaceholder = React.useMemo( () => { const newChatPlaceholders: Array = - selectedMode === 'agent' + selectedMode === 'agent' || selectedMode === 'orchestrator' ? hasOpenedProject && !standAloneForm ? actionsOnExistingProject : actionsToCreateAProject @@ -683,7 +686,7 @@ export const AiRequestChat: React.ComponentType<{ !isSending && !!aiRequest && aiRequest.status === 'ready' && - aiRequest.mode === 'agent'; + (aiRequest.mode === 'agent' || aiRequest.mode === 'orchestrator'); const shouldDisplayFeedbackBanner = useStickyVisibility({ shouldShow: shouldDisplayFeedbackBannerNow, showDelayMs: 1000, @@ -869,7 +872,7 @@ export const AiRequestChat: React.ComponentType<{
)} - { - if (value !== 'chat' && value !== 'agent') { + if ( + value !== 'chat' && + value !== 'agent' && + value !== 'orchestrator' + ) { return; } setSelectedMode(value); @@ -899,13 +906,18 @@ export const AiRequestChat: React.ComponentType<{ + )} {errorText || priceAndRequestsText} - + @@ -1043,7 +1055,8 @@ export const AiRequestChat: React.ComponentType<{ } onNavigateHistory={handleNavigateHistory} placeholder={ - aiRequest.mode === 'agent' + aiRequest.mode === 'agent' || + aiRequest.mode === 'orchestrator' ? isForAnotherProject ? t`You must re-open the project to continue this chat.` : isWorking @@ -1072,7 +1085,7 @@ export const AiRequestChat: React.ComponentType<{ } /> )} - { - if (value !== 'chat' && value !== 'agent') { + if ( + value !== 'chat' && + value !== 'agent' && + value !== 'orchestrator' + ) { return; } setSelectedMode(value); @@ -1098,13 +1115,22 @@ export const AiRequestChat: React.ComponentType<{ rounded > - + + {isForAnotherProjectText || errorText || priceAndRequestsText} - +
diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/plan.json b/newIDE/app/src/AiGeneration/AiRequestChat/plan.json new file mode 100644 index 000000000000..4e056390f8f1 --- /dev/null +++ b/newIDE/app/src/AiGeneration/AiRequestChat/plan.json @@ -0,0 +1,21 @@ +{ + "success": true, + "plan": { + "tasks": [ + { + "id": "01HXT000000000000000000001", + "title": "Implement Player Animations", + "description": "Add animations for idle, running, jumping, and falling to the Player object. This will involve adding new animations and setting conditions to switch between them based on the player's state (e.g., on ground, jumping, falling, moving).", + "status": "voided", + "dependsOn": [] + }, + { + "id": "01HXT000000000000000000002", + "title": "Create Basic Enemy and Movement", + "description": "Create a new Sprite object for an enemy (e.g., a Goomba). Implement basic movement for this enemy, such as walking back and forth on platforms, flipping direction when hitting an obstacle or the edge of a platform.", + "status": "voided", + "dependsOn": ["01HXT000000000000000000001"] + } + ] + } +} diff --git a/newIDE/app/src/AiGeneration/AiRequestContext.js b/newIDE/app/src/AiGeneration/AiRequestContext.js index 9aa880db4c3c..0022797c97c0 100644 --- a/newIDE/app/src/AiGeneration/AiRequestContext.js +++ b/newIDE/app/src/AiGeneration/AiRequestContext.js @@ -44,31 +44,33 @@ const useEditorFunctionCallResultsStorage = (): EditorFunctionCallResultsStorage aiRequestId: string, editorFunctionCallResults: EditorFunctionCallResult[] ) => { - const existingEditorFunctionCallResults = ( - editorFunctionCallResultsPerRequest[aiRequestId] || [] - ).filter(existingEditorFunctionCallResult => { - return !editorFunctionCallResults.some(editorFunctionCallResult => { - return ( - editorFunctionCallResult.call_id === - existingEditorFunctionCallResult.call_id - ); + let computedResults: EditorFunctionCallResult[] = []; + setEditorFunctionCallResultsPerRequest(prevState => { + const existingEditorFunctionCallResults = ( + prevState[aiRequestId] || [] + ).filter(existingEditorFunctionCallResult => { + return !editorFunctionCallResults.some(editorFunctionCallResult => { + return ( + editorFunctionCallResult.call_id === + existingEditorFunctionCallResult.call_id + ); + }); }); - }); - const newEditorFunctionCallResultsPerRequest = { - ...editorFunctionCallResultsPerRequest, - [aiRequestId]: [ + computedResults = [ ...existingEditorFunctionCallResults, ...editorFunctionCallResults, - ], - }; - setEditorFunctionCallResultsPerRequest( - newEditorFunctionCallResultsPerRequest - ); + ]; + + return { + ...prevState, + [aiRequestId]: computedResults, + }; + }); - return newEditorFunctionCallResultsPerRequest[aiRequestId]; + return computedResults; }, - [editorFunctionCallResultsPerRequest] + [] ), clearEditorFunctionCallResults: React.useCallback((aiRequestId: string) => { setEditorFunctionCallResultsPerRequest( diff --git a/newIDE/app/src/AiGeneration/AiRequestUtils.js b/newIDE/app/src/AiGeneration/AiRequestUtils.js index 6c8d54e587ea..9f6017368219 100644 --- a/newIDE/app/src/AiGeneration/AiRequestUtils.js +++ b/newIDE/app/src/AiGeneration/AiRequestUtils.js @@ -136,6 +136,40 @@ export const getFunctionCallNameByCallId = ({ return null; }; +/** + * Extract the latest plan from the AI request if it exists and should be displayed. + * Returns null if no plan should be displayed (no plan exists, or all tasks are done/voided). + */ +export const getLatestActivePlan = ( + aiRequest: AiRequest +): {| tasks: Array |} | null => { + let latestPlan = null; + for (let i = aiRequest.output.length - 1; i >= 0; i--) { + const message = aiRequest.output[i]; + if (message.type === 'function_call_output' && message.output) { + try { + const output = JSON.parse(message.output); + if (output && output.plan && output.plan.tasks) { + latestPlan = output.plan; + break; + } + } catch (e) { + // Ignore parse errors + } + } + } + + if (!latestPlan) return null; + + const hasActiveTasks = latestPlan.tasks.some( + task => task.status !== 'done' && task.status !== 'voided' + ); + + if (!hasActiveTasks) return null; + + return latestPlan; +}; + export const getFunctionCallOutputsFromEditorFunctionCallResults = ( editorFunctionCallResults: Array | null ): {| diff --git a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js index 659f4884a194..701e7055ffc9 100644 --- a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js +++ b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js @@ -73,6 +73,7 @@ import { useProcessFunctionCalls, AI_AGENT_TOOLS_VERSION, AI_CHAT_TOOLS_VERSION, + AI_ORCHESTRATOR_TOOLS_VERSION, } from './Utils'; import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; import UnsavedChangesContext from '../MainFrame/UnsavedChangesContext'; @@ -508,6 +509,8 @@ export const AskAiEditor: React.ComponentType = React.memo( toolsVersion: mode === 'agent' ? AI_AGENT_TOOLS_VERSION + : mode === 'orchestrator' + ? AI_ORCHESTRATOR_TOOLS_VERSION : AI_CHAT_TOOLS_VERSION, aiConfiguration: { presetId: aiConfigurationPresetId, @@ -595,7 +598,7 @@ export const AskAiEditor: React.ComponentType = React.memo( createdSceneNames?: Array, createdProject?: ?gdProject, editorFunctionCallResults: Array, - mode?: 'chat' | 'agent', + mode?: 'chat' | 'agent' | 'orchestrator', |}) => { if ( !profile || @@ -679,7 +682,7 @@ export const AskAiEditor: React.ComponentType = React.memo( }); // If we're updating the request, following a function call to initialize the project, - // pause the request, so that suggestions can be given by the agent. + // pause the request, so that suggestions can be given by the agent (only for agent mode). const hasJustInitializedProject = functionCallOutputs.length > 0 && functionCallOutputs.some( @@ -713,11 +716,14 @@ export const AskAiEditor: React.ComponentType = React.memo( : undefined, payWithCredits, userMessage, - paused: hasJustInitializedProject, + paused: + hasJustInitializedProject && modeForThisMessage === 'agent', mode, toolsVersion: mode === 'agent' ? AI_AGENT_TOOLS_VERSION + : mode === 'orchestrator' + ? AI_ORCHESTRATOR_TOOLS_VERSION : mode === 'chat' ? AI_CHAT_TOOLS_VERSION : undefined, @@ -1231,7 +1237,7 @@ export const AskAiEditor: React.ComponentType = React.memo( mode, }: {| userMessage: string, - mode: 'chat' | 'agent', + mode: 'chat' | 'agent' | 'orchestrator', |}) => onSendMessage({ userMessage, diff --git a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js index 29eddeeeb05f..7f1213d412a2 100644 --- a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js +++ b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js @@ -42,13 +42,12 @@ import { useAiRequestState, useProcessFunctionCalls, type NewAiRequestOptions, - AI_AGENT_TOOLS_VERSION, + AI_ORCHESTRATOR_TOOLS_VERSION, } from './Utils'; -import { LineStackLayout } from '../UI/Layout'; +import { ColumnStackLayout, LineStackLayout } from '../UI/Layout'; import RobotIcon from '../ProjectCreation/RobotIcon'; import Text from '../UI/Text'; import { Trans } from '@lingui/macro'; -import { Column } from '../UI/Grid'; import IconButton from '../UI/IconButton'; import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; import Cross from '../UI/CustomSvgIcons/Cross'; @@ -176,7 +175,7 @@ export const AskAiStandAloneForm = ({ const [aiRequestIdForForm, setAiRequestIdForForm] = React.useState< string | null >(null); - const aiRequestModeForForm = 'agent'; // Standalone form is only for AI agent requests for the moment. + const aiRequestModeForForm = 'orchestrator'; // Standalone form is for orchestrator mode requests. const aiRequestForForm = (aiRequestIdForForm && aiRequests[aiRequestIdForForm]) || null; const upToDateSelectedAiRequestId = useStableUpToDateRef(aiRequestIdForForm); @@ -295,7 +294,7 @@ export const AskAiStandAloneForm = ({ fileMetadata: null, // No file metadata when starting from the standalone form. storageProviderName, mode: aiRequestModeForForm, - toolsVersion: AI_AGENT_TOOLS_VERSION, + toolsVersion: AI_ORCHESTRATOR_TOOLS_VERSION, aiConfiguration: { presetId: aiConfigurationPresetId, }, @@ -438,8 +437,8 @@ export const AskAiStandAloneForm = ({ }); // If we're updating the request, following a function call to initialize the project, - // pause the request, so that suggestions can be given by the agent. - const paused = + // pause the request, so that suggestions can be given by the agent (only for agent mode). + const hasJustInitializedProject = functionCallOutputs.length > 0 && functionCallOutputs.some( output => @@ -466,9 +465,9 @@ export const AskAiStandAloneForm = ({ : undefined, payWithCredits: false, userMessage: '', // No user message when sending only function call outputs. - paused, + paused: hasJustInitializedProject && aiRequestIdForForm === 'agent', mode: aiRequestModeForForm, - toolsVersion: AI_AGENT_TOOLS_VERSION, + toolsVersion: AI_ORCHESTRATOR_TOOLS_VERSION, }) ); updateAiRequest(aiRequest.id, () => aiRequest); @@ -567,7 +566,7 @@ export const AskAiStandAloneForm = ({ } return ( - + @@ -605,7 +605,7 @@ export const AskAiStandAloneForm = ({ mode, }: {| userMessage: string, - mode: 'chat' | 'agent', + mode: 'chat' | 'agent' | 'orchestrator', |}) => onSendMessage({ userMessage, @@ -654,6 +654,6 @@ export const AskAiStandAloneForm = ({ forkingState={null} onRestore={async () => {}} /> - + ); }; diff --git a/newIDE/app/src/AiGeneration/Utils.js b/newIDE/app/src/AiGeneration/Utils.js index 47acf82eec76..e47e8f69261c 100644 --- a/newIDE/app/src/AiGeneration/Utils.js +++ b/newIDE/app/src/AiGeneration/Utils.js @@ -27,6 +27,7 @@ import { getFunctionCallOutputsFromEditorFunctionCallResults, getFunctionCallsToProcess, getLastMessagesFromAiRequestOutput, + getLatestActivePlan, } from './AiRequestUtils'; import { useEnsureExtensionInstalled } from './UseEnsureExtensionInstalled'; import { useGenerateEvents } from './UseGenerateEvents'; @@ -53,6 +54,7 @@ const gd: libGDevelop = global.gd; export const AI_AGENT_TOOLS_VERSION = 'v8'; export const AI_CHAT_TOOLS_VERSION = 'v8'; +export const AI_ORCHESTRATOR_TOOLS_VERSION = 'v1'; export const useProcessFunctionCalls = ({ i18n, @@ -336,6 +338,8 @@ export const useAiRequestState = ({ authenticatedUser ); const isSavingRef = React.useRef(false); + const lastFullFetchTimeRef = React.useRef(0); + const fullFetchIntervalInMs = 5000; const currentlyOpenedCloudProjectVersionId = fileMetadata && storageProviderName === CloudStorageProvider.internalName @@ -343,31 +347,20 @@ export const useAiRequestState = ({ : null; // If the selected AI request is in a "working" state, watch it until it's finished. + // Every ~1.4s we do a partial (status-only) fetch; every 5s we do a full fetch to + // pick up new messages from the orchestrator/agent while it is still running. const status = selectedAiRequest ? selectedAiRequest.status : null; const onWatch = async () => { if (!profile) return; if (!selectedAiRequestId || !status || status !== 'working') return; - try { - // Use partial request to only fetch the status - const partialAiRequest = await getPartialAiRequest( - getAuthorizationHeader, - { - userId: profile.id, - aiRequestId: selectedAiRequestId, - include: 'status', - } - ); + const now = Date.now(); + const shouldDoFullFetch = + now - lastFullFetchTimeRef.current >= fullFetchIntervalInMs; - if (partialAiRequest.status === 'working') { - updateAiRequest(selectedAiRequestId, prevRequest => ({ - ...(prevRequest || {}), - ...partialAiRequest, - })); - } else { - // The request is not "working" anymore, refresh it entirely. - // Note: if this fails, the request will be refreshed again on the next interval - // (because no call to updateAiRequest is made). + try { + if (shouldDoFullFetch) { + lastFullFetchTimeRef.current = now; const aiRequest = await retryIfFailed({ times: 2 }, () => getAiRequest(getAuthorizationHeader, { userId: profile.id, @@ -377,8 +370,6 @@ export const useAiRequestState = ({ updateAiRequest(selectedAiRequestId, () => aiRequest); - // If we were fetching suggestions and the request is now ready, or if suggestions - // are present in the last message, clear the flag if (isFetchingSuggestions) { const lastMessage = aiRequest.output.length > 0 @@ -395,6 +386,51 @@ export const useAiRequestState = ({ setIsFetchingSuggestions(false); } } + } else { + // Use partial request to only fetch the status between full fetches. + const partialAiRequest = await getPartialAiRequest( + getAuthorizationHeader, + { + userId: profile.id, + aiRequestId: selectedAiRequestId, + include: 'status', + } + ); + + if (partialAiRequest.status === 'working') { + updateAiRequest(selectedAiRequestId, prevRequest => ({ + ...(prevRequest || {}), + ...partialAiRequest, + })); + } else { + // Status changed — do a full fetch immediately to get the latest data. + lastFullFetchTimeRef.current = now; + const aiRequest = await retryIfFailed({ times: 2 }, () => + getAiRequest(getAuthorizationHeader, { + userId: profile.id, + aiRequestId: selectedAiRequestId, + }) + ); + + updateAiRequest(selectedAiRequestId, () => aiRequest); + + if (isFetchingSuggestions) { + const lastMessage = + aiRequest.output.length > 0 + ? aiRequest.output[aiRequest.output.length - 1] + : null; + const hasSuggestions = + lastMessage && + ((lastMessage.type === 'message' && + lastMessage.role === 'assistant') || + lastMessage.type === 'function_call_output') && + lastMessage.suggestions; + + if (aiRequest.status === 'ready' || hasSuggestions) { + setIsFetchingSuggestions(false); + } + } + } } } catch (error) { console.warn( @@ -429,7 +465,8 @@ export const useAiRequestState = ({ // Then ask for some. if ( !selectedAiRequest || - selectedAiRequest.mode !== 'agent' || + (selectedAiRequest.mode !== 'agent' && + selectedAiRequest.mode !== 'orchestrator') || isSendingAiRequest(selectedAiRequest.id) || selectedAiRequest.output.length === 0 || selectedAiRequest.status !== 'ready' || @@ -469,6 +506,26 @@ export const useAiRequestState = ({ return; } + const isLastMessageFunctionCallOutputProjectInitialization = + lastMessage.type === 'function_call_output' && + getFunctionCallNameByCallId({ + aiRequest: selectedAiRequest, + callId: lastMessage.call_id, + }) === 'initialize_project'; + + if (selectedAiRequest.mode === 'orchestrator') { + if (isLastMessageFunctionCallOutputProjectInitialization) { + // Don't fetch suggestions right after project initialization, as a plan + // will be generated in the next messages and we want to display it instead. + return; + } + if (getLatestActivePlan(selectedAiRequest)) { + // For orchestrator mode, don't fetch suggestions if there is an active plan + // being displayed. + return; + } + } + const simplifiedProjectBuilder = makeSimplifiedProjectBuilder(gd); const simplifiedProjectJson = project ? JSON.stringify( @@ -490,13 +547,6 @@ export const useAiRequestState = ({ eventsJson: null, }); - const isLastMessageFunctionCallOutputProjectInitialization = - lastMessage.type === 'function_call_output' && - getFunctionCallNameByCallId({ - aiRequest: selectedAiRequest, - callId: lastMessage.call_id, - }) === 'initialize_project'; - try { // The request will switch from "ready" to "working" while suggestions are generated. // It will be watched and eventually return to "ready" with suggestions. @@ -644,7 +694,8 @@ export const useAiRequestState = ({ // to allow the user to restore the project to that state later. if ( !selectedAiRequest || - selectedAiRequest.mode !== 'agent' || + (selectedAiRequest.mode !== 'agent' && + selectedAiRequest.mode !== 'orchestrator') || isSendingAiRequest(selectedAiRequest.id) || selectedAiRequest.output.length === 0 || !profile || @@ -679,6 +730,17 @@ export const useAiRequestState = ({ getEditorFunctionCallResults(selectedAiRequest.id) ); + const hasJustInitializedProject = + lastMessage.type === 'function_call_output' && + lastMessage.call_id.indexOf('initialize_project') !== -1; + + // Save as cloud project right after initialization, even if the request + // is still working (orchestrator keeps running after initialize_project). + const shouldSaveProjectAsAfterInitialization = + hasJustInitializedProject && + !currentlyOpenedCloudProjectVersionId && + !isCloudProjectsMaximumReached; + const shouldSaveVersionBeforeMessage = lastMessage.type === 'message' && lastMessage.role === 'user' && @@ -690,18 +752,13 @@ export const useAiRequestState = ({ !lastMessage.projectVersionIdAfterMessage && !hasFunctionsCallsToProcess && !hasUnfinishedResult; - if (!shouldSaveVersionBeforeMessage && !shouldSaveVersionAfterMessage) { + if ( + !shouldSaveVersionBeforeMessage && + !shouldSaveVersionAfterMessage && + !shouldSaveProjectAsAfterInitialization + ) { return; } - - const hasJustInitializedProject = - lastMessage.type === 'function_call_output' && - lastMessage.call_id.indexOf('initialize_project') !== -1; - - const shouldSaveProjectAsAfterInitialization = - hasJustInitializedProject && - !currentlyOpenedCloudProjectVersionId && - !isCloudProjectsMaximumReached; try { if (shouldSaveProjectAsAfterInitialization) { console.info( @@ -734,8 +791,9 @@ export const useAiRequestState = ({ await updateAiRequestWithProjectVersion({ lastMessageId, version: newVersion, - shouldSaveVersionBeforeMessage, - shouldSaveVersionAfterMessage, + // The initialization output is always an "after message" version. + shouldSaveVersionBeforeMessage: false, + shouldSaveVersionAfterMessage: true, }); } catch (error) { console.error( @@ -922,7 +980,7 @@ export type OpenAskAiOptions = {| |}; export type NewAiRequestOptions = {| - mode: 'chat' | 'agent', + mode: 'chat' | 'agent' | 'orchestrator', userRequest: string, aiConfigurationPresetId: string, |}; diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index f9d33d7654c9..96d52d4d1fe1 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -4849,6 +4849,19 @@ const addOrEditVariable: EditorFunction = { }, }; +const createOrUpdatePlan: EditorFunction = { + renderForEditor: ({ args }) => { + return { + text: Refining plan., + }; + }, + launchFunction: async ({ args }) => { + return makeGenericFailure( + `Unable to create or update plan - this is handled server-side.` + ); + }, +}; + const readFullDocs: EditorFunction = { renderForEditor: ({ args }) => { const extension_names = SafeExtractor.extractStringProperty( @@ -4976,6 +4989,7 @@ export const editorFunctions: { [string]: EditorFunction } = { change_scene_properties_layers_effects_groups: changeScenePropertiesLayersEffectsGroups, add_or_edit_variable: addOrEditVariable, read_full_docs: readFullDocs, + create_or_update_plan: createOrUpdatePlan, }; export const editorFunctionsWithoutProject: { diff --git a/newIDE/app/src/MainFrame/EditorContainers/HomePage/LearnSection/MainPage.js b/newIDE/app/src/MainFrame/EditorContainers/HomePage/LearnSection/MainPage.js index 4acad62becc2..afebd2f0e415 100644 --- a/newIDE/app/src/MainFrame/EditorContainers/HomePage/LearnSection/MainPage.js +++ b/newIDE/app/src/MainFrame/EditorContainers/HomePage/LearnSection/MainPage.js @@ -93,9 +93,9 @@ const MainPage = ({ onSelectExampleShortHeader, }: Props): React.Node => { const { limits } = React.useContext(AuthenticatedUserContext); - const { - palette: { type: paletteType }, - } = React.useContext(GDevelopThemeContext); + const gdevelopTheme = React.useContext(GDevelopThemeContext); + const paletteType = gdevelopTheme.palette.type; + const paperDarkBackgroundColor = gdevelopTheme.paper.backgroundColor.dark; const { listedCourses } = React.useContext(CourseStoreContext); const { @@ -192,8 +192,8 @@ const MainPage = ({ backgroundSize: isMobile && !isLandscape ? 'contain' : 'auto', backgroundImage: `url('res/premium/premium_dialog_background.png'),${ paletteType === 'dark' - ? 'linear-gradient(180deg, #322659 0px, #3F2458 20px, #1D1D26 200px, #1D1D26 100%)' - : 'linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, #F5F5F7 200px, #F5F5F7 100%)' + ? `linear-gradient(180deg, #322659 0px, #3F2458 20px, ${paperDarkBackgroundColor} 200px, ${paperDarkBackgroundColor} 100%)` + : `linear-gradient(180deg, #CBBAFF 0px, #DEBBFF 20px, ${paperDarkBackgroundColor} 200px, ${paperDarkBackgroundColor} 100%)` }`, }} > diff --git a/newIDE/app/src/UI/Text.js b/newIDE/app/src/UI/Text.js index 315ccfd0f6dd..4c612bea3060 100644 --- a/newIDE/app/src/UI/Text.js +++ b/newIDE/app/src/UI/Text.js @@ -62,6 +62,9 @@ type Props = {| // Allow to prevent numbers from changing size when they change fontVariantNumeric?: 'tabular-nums', + + // Allow to override the font weight + fontWeight?: 'bold' | 'normal' | number, |}, tooltip?: string, |}; diff --git a/newIDE/app/src/Utils/GDevelopServices/Generation.js b/newIDE/app/src/Utils/GDevelopServices/Generation.js index 6e9676b3c4ee..acd11e316d47 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Generation.js +++ b/newIDE/app/src/Utils/GDevelopServices/Generation.js @@ -24,6 +24,18 @@ export type AiRequestSuggestions = { suggestions: Array, }; +export type AiRequestPlanTask = { + id: string, + title: string, + description: string, + status: 'pending' | 'in_progress' | 'done' | 'voided', + dependsOn: string[], +}; + +export type AiRequestPlan = { + tasks: AiRequestPlanTask[], +}; + export type AiRequestMessageAssistantFunctionCall = {| type: 'function_call', status: 'completed', @@ -40,6 +52,7 @@ export type AiRequestFunctionCallOutput = { messageId?: string, projectVersionIdAfterMessage?: string, }; + export type AiRequestAssistantMessage = { type: 'message', status: 'completed', @@ -101,7 +114,7 @@ export type AiRequest = { gameId?: string | null, gameProjectJson?: string | null, status: GenerationStatus, - mode?: 'chat' | 'agent', + mode?: 'chat' | 'agent' | 'orchestrator', aiConfiguration?: AiConfiguration, toolsVersion?: string, toolOptions?: AiRequestToolOptions | null, @@ -373,7 +386,7 @@ export const createAiRequest = async ( projectSpecificExtensionsSummaryJson: string | null, projectSpecificExtensionsSummaryJsonUserRelativeKey: string | null, payWithCredits: boolean, - mode: 'chat' | 'agent', + mode: 'chat' | 'agent' | 'orchestrator', aiConfiguration: AiConfiguration, gameId: string | null, projectVersionIdBeforeMessage?: string | null, @@ -453,7 +466,7 @@ export const addMessageToAiRequest = async ( projectSpecificExtensionsSummaryJson: string | null, projectSpecificExtensionsSummaryJsonUserRelativeKey: string | null, paused?: boolean, - mode?: 'chat' | 'agent', + mode?: 'chat' | 'agent' | 'orchestrator', toolsVersion?: string, |} ): Promise => { @@ -882,7 +895,7 @@ export const createAiUserContentPresignedUrls = async ( }; export type AiConfigurationPreset = {| - mode: 'chat' | 'agent', + mode: 'chat' | 'agent' | 'orchestrator', id: string, nameByLocale: MessageByLocale, disabled: boolean, diff --git a/newIDE/app/src/Utils/GDevelopServices/Usage.js b/newIDE/app/src/Utils/GDevelopServices/Usage.js index 7be8aed8e578..70afa7413715 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Usage.js +++ b/newIDE/app/src/Utils/GDevelopServices/Usage.js @@ -63,7 +63,7 @@ export type Subscription = {| type AiCapability = { availablePresets: Array<{ - mode: 'chat' | 'agent', + mode: 'chat' | 'agent' | 'orchestrator', name: string, id: string, disabled?: boolean, From 2af63703d41c1bfc072f4f7a3e1ec85bcd6c5a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:55:15 +0100 Subject: [PATCH 2/5] Rename actions for consistency --- .../AiRequestChat/ChatMessages.js | 118 ++++++++++++++- .../AiRequestChat/ChatMessages.module.css | 45 +++++- newIDE/app/src/EditorFunctions/index.js | 138 +++++++++--------- 3 files changed, 231 insertions(+), 70 deletions(-) diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js index fa32206f1cb5..fc96644a4c57 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js @@ -10,6 +10,10 @@ import { } from '../AiRequestUtils'; import { FunctionCallRow } from './FunctionCallRow'; import { FunctionCallsGroup } from './FunctionCallsGroup'; +import { + editorFunctions, + editorFunctionsWithoutProject, +} from '../../EditorFunctions'; import IconButton from '../../UI/IconButton'; import Like from '../../UI/CustomSvgIcons/Like'; import Dislike from '../../UI/CustomSvgIcons/Dislike'; @@ -137,6 +141,19 @@ const styles = { }, }; +// Phrases displayed while the AI is thinking/waiting (no active function calls). +// Defined outside the component so the array is stable across renders. +const thinkingPhrases: Array = [ + Looking for documentation, + Refining the plan, + Analyzing the project, + Exploring possibilities, + Thinking through the steps, + Reviewing the game structure, + Planning the approach, + Considering the best strategy, +]; + const getMessageSuggestionsLines = ({ aiRequest, onUserRequestTextChange, @@ -584,6 +601,82 @@ export const ChatMessages: React.ComponentType = React.memo( ] ); + // Collect text descriptions of function calls that are actively being worked on, + // so we can display them in the status bar instead of a generic "Working..." label. + const workingFunctionCallTexts: Array = React.useMemo( + () => { + if (!shouldBeWorkingIfNotPaused || isPaused) return []; + const texts: Array = []; + for (const item of renderItems) { + let fcItems: Array | null = null; + if (item.type === 'function_call_group') { + // $FlowFixMe[incompatible-type] + fcItems = item.items; + } else if ( + item.type === 'message_content' && + item.functionCallItems + ) { + // $FlowFixMe[incompatible-type] + fcItems = item.functionCallItems; + } + if (!fcItems) continue; + for (const fcItem of fcItems) { + const { editorFunctionCallResult, messageContent } = fcItem; + if ( + !editorFunctionCallResult || + editorFunctionCallResult.status !== 'working' + ) + continue; + const editorFunction = + // $FlowFixMe[incompatible-type] + editorFunctions[messageContent.name] || + // $FlowFixMe[incompatible-type] + editorFunctionsWithoutProject[messageContent.name] || + null; + if (!editorFunction) continue; + try { + const result = editorFunction.renderForEditor({ + project, + args: JSON.parse(messageContent.arguments), + editorCallbacks, + shouldShowDetails: false, + editorFunctionCallResultOutput: null, + }); + if (result.text) texts.push(result.text); + } catch (e) { + // Ignore rendering errors for the status bar. + } + } + } + return texts; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + shouldBeWorkingIfNotPaused, + isPaused, + renderItems, + project, + editorCallbacks, + ] + ); + + // Index used to rotate through working function call texts or thinking phrases. + const [rotationIndex, setRotationIndex] = React.useState(0); + + const isActivelyWorking = + (!!shouldBeWorkingIfNotPaused && !isPaused) || isFetchingSuggestions; + + React.useEffect( + () => { + if (!isActivelyWorking) return; + const interval = setInterval(() => { + setRotationIndex(prev => prev + 1); + }, 4000); + return () => clearInterval(interval); + }, + [isActivelyWorking] + ); + const filteredRenderItems = React.useMemo( () => { if (!forkingState || forkingState.aiRequestId !== aiRequest.id) { @@ -1161,7 +1254,12 @@ export const ChatMessages: React.ComponentType = React.memo( size="body-small" color="inherit" > - Thinking... + + + {thinkingPhrases[rotationIndex % thinkingPhrases.length]} + + +
@@ -1196,10 +1294,22 @@ export const ChatMessages: React.ComponentType = React.memo( > {isPaused ? ( Paused - ) : aiRequest.mode === 'chat' ? ( - Thinking... ) : ( - Working... + + + {workingFunctionCallTexts.length > 0 + ? workingFunctionCallTexts[ + rotationIndex % workingFunctionCallTexts.length + ] + : thinkingPhrases[ + rotationIndex % thinkingPhrases.length + ]} + + + )} diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.module.css b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.module.css index 0a52bf077e50..d4815b4aa4b8 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.module.css +++ b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.module.css @@ -1,7 +1,50 @@ .thinkingText { display: flex; align-items: center; - color: var(--theme-text-disabled-color) + color: var(--theme-text-secondary-color); + opacity: 0.6; +} + +@keyframes typeReveal { + from { + clip-path: inset(0 100% 0 0); + } + to { + clip-path: inset(0 0% 0 0); + } +} + +/* Stays visible during the 1s reveal, then vanishes. */ +@keyframes cursorBlink { + 0%, 90% { opacity: 1; } + 100% { opacity: 0; } +} + +.cursorWrapper { + position: relative; + display: inline-block; +} + +.typeReveal { + display: inline-block; + animation: typeReveal 1s ease-out forwards; +} + +@keyframes cursorMove { + from { left: 0; } + to { left: 100%; } +} + +.cursor { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 8px; + background: currentColor; + animation: + cursorMove 1s ease-out forwards, + cursorBlink 1s linear 1 forwards; } .feedbackButtonsContainer { diff --git a/newIDE/app/src/EditorFunctions/index.js b/newIDE/app/src/EditorFunctions/index.js index 96d52d4d1fe1..2d73cbdeb8e3 100644 --- a/newIDE/app/src/EditorFunctions/index.js +++ b/newIDE/app/src/EditorFunctions/index.js @@ -615,7 +615,7 @@ const createOrReplaceObject: EditorFunction = { return { text: replaceExistingObject ? ( - Replace object {object_name} in scene{' '} + Replacing {object_name} in scene{' '} @@ -632,8 +632,8 @@ const createOrReplaceObject: EditorFunction = { ) : duplicatedObjectName ? ( - Duplicate object {duplicatedObjectName} as {object_name}{' '} - in scene{' '} + Duplicating {duplicatedObjectName} as {object_name} in + scene{' '} @@ -650,7 +650,7 @@ const createOrReplaceObject: EditorFunction = { ) : ( - Create object {object_name} in scene{' '} + Adding {object_name} to scene{' '} @@ -1188,7 +1188,7 @@ const inspectObjectProperties: EditorFunction = { return { text: ( - Inspecting properties of object {object_name} in scene{' '} + Reading {object_name}'s properties in scene{' '} @@ -1314,13 +1314,13 @@ const changeObjectProperty: EditorFunction = { text: label === 'name' ? ( - Rename object "{object_name}" to "{newValue}" (in scene{' '} + Renaming {object_name} to {newValue} (in scene{' '} {scene_name}). ) : ( - Change property "{label}" of object {object_name}{' '} - (in scene {scene_name}) to {newValue}. + Updating {label} of {object_name} (in scene{' '} + {scene_name}) to {newValue}. ), }; @@ -1329,8 +1329,8 @@ const changeObjectProperty: EditorFunction = { return { text: ( - Change {changes.length} properties of object {object_name} (in scene{' '} - {scene_name}). + Updating {changes.length} properties of {object_name} (in + scene {scene_name}). ), hasDetailsToShow: true, @@ -1610,7 +1610,7 @@ const addBehavior: EditorFunction = { return { text: ( - Add behavior {behaviorName} ({behaviorTypeLabel}) on object{' '} + Adding {behaviorName} ({behaviorTypeLabel}) behavior to{' '} {object_name} in scene{' '} - Remove behavior {behavior_name} from object {object_name} in scene{' '} - {scene_name}. + Removing {behavior_name} behavior from {object_name} in + scene {scene_name}. ), }; @@ -1896,8 +1896,8 @@ const inspectBehaviorProperties: EditorFunction = { return { text: ( - Inspecting properties of behavior {behavior_name} on object{' '} - {object_name} in scene {scene_name}. + Reading {behavior_name}'s settings on {object_name} in + scene {scene_name}. ), }; @@ -1997,8 +1997,8 @@ const changeBehaviorProperty: EditorFunction = { return { text: ( - Change property "{label}" of behavior {behavior_name} on - object {object_name} (in scene{' '} + Updating {label} of behavior {behavior_name} on object{' '} + {object_name} (in scene{' '} @@ -2020,7 +2020,7 @@ const changeBehaviorProperty: EditorFunction = { return { text: ( - Changed {changes.length} properties of behavior {behavior_name} on + Updating {changes.length} settings of behavior {behavior_name} on object {object_name} (in scene {scene_name}). ), @@ -2289,7 +2289,7 @@ const describeInstances: EditorFunction = { return { text: ( - Inspecting instances of scene{' '} + Reading instances in scene{' '} @@ -2300,8 +2300,9 @@ const describeInstances: EditorFunction = { }) } > - {scene_name}. + {scene_name} + . ), }; @@ -2438,7 +2439,7 @@ const put2dInstances: EditorFunction = { return { text: ( - Erase {existingInstanceCount} instance(s) in scene {scene_name}. + Erasing {existingInstanceCount} instance(s) in scene {scene_name}. ), }; @@ -2448,7 +2449,7 @@ const put2dInstances: EditorFunction = { return { text: ( - Add {newInstancesCount} instance(s) of object {object_name} at{' '} + Placing {newInstancesCount} {object_name} instance(s) at{' '} {brushPosition ? ( brushPosition.join(', ') ) : ( @@ -2462,7 +2463,7 @@ const put2dInstances: EditorFunction = { return { text: ( - Move {existingInstanceCount} instance(s) of object {object_name} to{' '} + Moving {existingInstanceCount} {object_name} instance(s) to{' '} {brushPosition ? ( brushPosition.join(', ') ) : ( @@ -2476,8 +2477,8 @@ const put2dInstances: EditorFunction = { return { text: ( - Add {newInstancesCount} instance(s) and move {existingInstanceCount}{' '} - instance(s) of object {object_name} to{' '} + Placing {newInstancesCount} and moving {existingInstanceCount}{' '} + {object_name} instance(s) to{' '} {brushPosition ? ( brushPosition.join(', ') ) : ( @@ -2984,7 +2985,7 @@ const put3dInstances: EditorFunction = { return { text: ( - Erase {existingInstanceCount} instance(s) in scene {scene_name}. + Erasing {existingInstanceCount} instance(s) in scene {scene_name}. ), }; @@ -2994,7 +2995,7 @@ const put3dInstances: EditorFunction = { return { text: ( - Add {newInstancesCount} instance(s) of object {object_name} at{' '} + Placing {newInstancesCount} {object_name} instance(s) at{' '} {brushPosition ? ( brushPosition.join(', ') ) : ( @@ -3008,7 +3009,7 @@ const put3dInstances: EditorFunction = { return { text: ( - Move {existingInstanceCount} instance(s) of object {object_name} to{' '} + Moving {existingInstanceCount} {object_name} instance(s) to{' '} {brushPosition ? ( brushPosition.join(', ') ) : ( @@ -3022,8 +3023,8 @@ const put3dInstances: EditorFunction = { return { text: ( - Add {newInstancesCount} instance(s) and move {existingInstanceCount}{' '} - instance(s) of object {object_name} to{' '} + Placing {newInstancesCount} and moving {existingInstanceCount}{' '} + {object_name} instance(s) to{' '} {brushPosition ? ( brushPosition.join(', ') ) : ( @@ -3451,7 +3452,7 @@ const readSceneEvents: EditorFunction = { return { text: ( - Inspecting event sheet of scene{' '} + Reading events in scene{' '} @@ -3559,7 +3560,7 @@ const addSceneEvents: EditorFunction = { return { text: ( - Add or rework{' '} + Writing events for scene{' '} @@ -3570,7 +3571,7 @@ const addSceneEvents: EditorFunction = { }) } > - events of scene {scene_name} + {scene_name} . @@ -3582,7 +3583,7 @@ const addSceneEvents: EditorFunction = { return { text: ( - Adapt{' '} + Adapting events in scene{' '} @@ -3593,7 +3594,7 @@ const addSceneEvents: EditorFunction = { }) } > - events of scene {scene_name} + {scene_name} {' '} ("{placementHint}"). @@ -3605,7 +3606,7 @@ const addSceneEvents: EditorFunction = { return { text: ( - Modify{' '} + Updating events in scene{' '} @@ -3616,7 +3617,7 @@ const addSceneEvents: EditorFunction = { }) } > - events of scene {scene_name} + {scene_name} . @@ -3894,7 +3895,7 @@ const createScene: EditorFunction = { return { text: ( - Create a new scene called {scene_name}.{' '} + Creating scene {scene_name}.{' '} @@ -3971,7 +3972,11 @@ const deleteScene: EditorFunction = { const scene_name = extractRequiredString(args, 'scene_name'); return { - text: Delete scene {scene_name}., + text: ( + + Removing scene {scene_name}. + + ), }; }, launchFunction: async ({ project, args }) => { @@ -4031,8 +4036,7 @@ const inspectScenePropertiesLayersEffects: EditorFunction = { return { text: ( - Inspecting scene properties, layers and effects for scene {scene_name} - . + Reading {scene_name}'s scene settings. ), }; @@ -4139,62 +4143,62 @@ const changeScenePropertiesLayersEffectsGroups: EditorFunction = { changedLayerEffectsCount > 0 && changedGroupsCount > 0 ? ( - Changing some scene properties, layers, effects and groups for scene{' '} + Updating some scene properties, layers, effects and groups for scene{' '} {scene_name}. ) : changedPropertiesCount > 0 && changedLayersCount > 0 && changedGroupsCount > 0 ? ( - Changing some scene properties, layers and groups for scene{' '} + Updating some scene properties, layers and groups for scene{' '} {scene_name}. ) : changedPropertiesCount > 0 && changedLayerEffectsCount > 0 && changedGroupsCount > 0 ? ( - Changing some scene properties, effects and groups for scene{' '} + Updating some scene properties, effects and groups for scene{' '} {scene_name}. ) : changedLayerEffectsCount > 0 && changedLayersCount > 0 && changedGroupsCount > 0 ? ( - Changing some scene effects, layers and groups for scene{' '} + Updating some scene effects, layers and groups for scene{' '} {scene_name}. ) : changedPropertiesCount > 0 && changedGroupsCount > 0 ? ( - Changing some scene properties and groups for scene {scene_name}. + Updating some scene properties and groups for scene {scene_name}. ) : changedLayersCount > 0 && changedGroupsCount > 0 ? ( - Changing some scene layers and groups for scene {scene_name}. + Updating some scene layers and groups for scene {scene_name}. ) : changedLayerEffectsCount > 0 && changedGroupsCount > 0 ? ( - Changing some scene effects and groups for scene {scene_name}. + Updating some scene effects and groups for scene {scene_name}. ) : changedPropertiesCount > 0 && changedLayersCount > 0 ? ( - Changing some scene properties and layers for scene {scene_name}. + Updating some scene properties and layers for scene {scene_name}. ) : changedPropertiesCount > 0 && changedLayerEffectsCount > 0 ? ( - Changing some scene properties and effects for scene {scene_name}. + Updating some scene properties and effects for scene {scene_name}. ) : changedLayerEffectsCount > 0 && changedLayersCount > 0 ? ( - Changing some scene effects and layers for scene {scene_name}. + Updating some scene effects and layers for scene {scene_name}. ) : changedPropertiesCount > 0 ? ( - Changing some scene properties for scene {scene_name}. + Updating some scene properties for scene {scene_name}. ) : changedLayersCount > 0 ? ( - Changing some scene layers for scene {scene_name}. + Updating some scene layers for scene {scene_name}. ) : changedLayerEffectsCount > 0 ? ( - Changing some scene effects for scene {scene_name}. + Updating some scene effects for scene {scene_name}. ) : changedGroupsCount > 0 ? ( - Changing some scene groups for scene {scene_name}. + Updating some scene groups for scene {scene_name}. ) : ( Unknown changes attempted for scene {scene_name}. ), @@ -4736,7 +4740,7 @@ const addOrEditVariable: EditorFunction = { return { text: ( - Add or edit scene variable {variable_name_or_path} in scene{' '} + Setting scene variable {variable_name_or_path} in scene{' '} {scene_name}. ), @@ -4747,8 +4751,8 @@ const addOrEditVariable: EditorFunction = { return { text: ( - Add or edit object variable {variable_name_or_path} for object{' '} - {object_name}. + Setting {object_name}'s variable{' '} + {variable_name_or_path}. ), details, @@ -4757,7 +4761,9 @@ const addOrEditVariable: EditorFunction = { } else if (variable_scope === 'global') { return { text: ( - Add or edit global variable {variable_name_or_path}. + + Setting global variable {variable_name_or_path}. + ), details, hasDetailsToShow: true, @@ -4765,7 +4771,11 @@ const addOrEditVariable: EditorFunction = { } return { - text: Add or edit variable {variable_name_or_path}., + text: ( + + Setting variable {variable_name_or_path}. + + ), }; }, launchFunction: async ({ project, args }) => { @@ -4852,7 +4862,7 @@ const addOrEditVariable: EditorFunction = { const createOrUpdatePlan: EditorFunction = { renderForEditor: ({ args }) => { return { - text: Refining plan., + text: Updating the plan., }; }, launchFunction: async ({ args }) => { @@ -4870,9 +4880,7 @@ const readFullDocs: EditorFunction = { ); return { - text: ( - Inspecting {extension_names} features and capabilities. - ), + text: Reading docs for {extension_names}., }; }, launchFunction: async ({ args }) => { @@ -4889,7 +4897,7 @@ const initializeProject: EditorFunctionWithoutProject = { return { text: ( - Initializing a new game project "{{project_name}}". + Setting up the base for your project {project_name}. ), }; From 7a216d8dbeb40c8e168bfe06e1cdf5ec0492bd67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Tue, 24 Feb 2026 17:55:14 +0100 Subject: [PATCH 3/5] Improve credits message --- .../AiRequestChat/ChatMessages.js | 42 +++++----- .../src/AiGeneration/AiRequestChat/index.js | 76 ++++++++++++++----- .../src/AiGeneration/AskAiEditorContainer.js | 20 ++++- .../src/AiGeneration/AskAiStandAloneForm.js | 20 ++++- 4 files changed, 115 insertions(+), 43 deletions(-) diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js index fc96644a4c57..6e4896f115d5 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js @@ -969,7 +969,6 @@ export const ChatMessages: React.ComponentType = React.memo( undefined ) : (
- {/* $FlowFixMe[constant-condition] */} {isLastMessage && shouldDisplayFeedbackBanner && ( = React.memo( } > - {/* $FlowFixMe[constant-condition] */} {functionCallItems && functionCallItems.length > 0 && ( {functionCallItems.map( @@ -1135,8 +1133,7 @@ export const ChatMessages: React.ComponentType = React.memo( Restoring... - ) : // $FlowFixMe[constant-condition] - isSaving ? ( + ) : isSaving ? ( @@ -1173,7 +1170,6 @@ export const ChatMessages: React.ComponentType = React.memo( )} - {/* $FlowFixMe[constant-condition] */} {isRestored && !isForking && forkedFromAiRequestId && ( = React.memo( ) : shouldBeWorkingIfNotPaused ? (
- {/* $FlowFixMe[constant-condition] */} - {onPause && - (aiRequest.mode === 'agent' || - aiRequest.mode === 'orchestrator') && ( - onPause(!isPaused)} - size="small" - style={{ - backgroundColor: !isPaused - ? getBackgroundColor(theme, 'light') - : undefined, - borderRadius: 4, - padding: 0, - }} - selected={isPaused} - > - {isPaused ? : } - - )} + {(aiRequest.mode === 'agent' || + aiRequest.mode === 'orchestrator') && ( + onPause(!isPaused)} + size="small" + style={{ + backgroundColor: !isPaused + ? getBackgroundColor(theme, 'light') + : undefined, + borderRadius: 4, + padding: 0, + }} + selected={isPaused} + > + {isPaused ? : } + + )} { if (!quota || !price) { + if (isRefreshingLimits) { + // Placeholder to avoid layout shift, while showing the (i) icon. + return ( + + Calculating... + + + + + ); + } // Placeholder to avoid layout shift. return
; } @@ -174,25 +200,38 @@ const getPriceAndRequestsTextAndTooltip = ({ const shouldShowCredits = quota.limitReached && automaticallyUseCreditsForAiRequests; + const iconSpanStyle = { + verticalAlign: 'middle', + display: 'inline-block', + marginTop: 1, + }; + return ( - - {shouldShowCredits && } - - {shouldShowCredits ? creditsText : currentQuotaText} - - - - + + {!isRefreshingLimits && shouldShowCredits && ( + + - - + )} + {isRefreshingLimits ? ( + Calculating... + ) : shouldShowCredits ? ( + creditsText + ) : ( + currentQuotaText + )} + + + + + + ); }; @@ -313,6 +352,7 @@ type Props = {| increaseQuotaOffering: 'subscribe' | 'upgrade' | 'none', price: UsagePrice | null, availableCredits: number, + isRefreshingLimits?: boolean, standAloneForm?: boolean, @@ -349,6 +389,7 @@ export const AiRequestChat: React.ComponentType<{ lastSendError, price, availableCredits, + isRefreshingLimits, hasOpenedProject, editorFunctionCallResults, onProcessFunctionCalls, @@ -532,6 +573,7 @@ export const AiRequestChat: React.ComponentType<{ availableCredits, selectedMode, automaticallyUseCreditsForAiRequests, + isRefreshingLimits, }); const chosenOrDefaultAiConfigurationPresetId = diff --git a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js index 701e7055ffc9..bc4a0663db4c 100644 --- a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js +++ b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js @@ -403,6 +403,8 @@ export const AskAiEditor: React.ComponentType = React.memo( subscription, } = authenticatedUser; + const [isRefreshingLimits, setIsRefreshingLimits] = React.useState(false); + const availableCredits = limits ? limits.credits.userBalance.amount : 0; const quota = (limits && limits.quotas && limits.quotas['consumed-ai-credits']) || @@ -419,7 +421,16 @@ export const AskAiEditor: React.ComponentType = React.memo( React.useEffect( () => { if (isActive) { - onRefreshLimits(); + (async () => { + setIsRefreshingLimits(true); + try { + await onRefreshLimits(); + } catch (error) { + // Ignore limits refresh error. + } + await delay(200); + setIsRefreshingLimits(false); + })(); } }, [isActive, onRefreshLimits] @@ -555,11 +566,14 @@ export const AskAiEditor: React.ComponentType = React.memo( // Refresh the user limits, to ensure quota and credits information // is up-to-date after an AI request. await delay(500); + setIsRefreshingLimits(true); try { await retryIfFailed({ times: 2 }, onRefreshLimits); } catch (error) { // Ignore limits refresh error. } + await delay(200); + setIsRefreshingLimits(false); })(); }, [ @@ -763,11 +777,14 @@ export const AskAiEditor: React.ComponentType = React.memo( // Refresh the user limits, to ensure quota and credits information // is up-to-date after an AI request. await delay(500); + setIsRefreshingLimits(true); try { await retryIfFailed({ times: 2 }, onRefreshLimits); } catch (error) { // Ignore limits refresh error. } + await delay(200); + setIsRefreshingLimits(false); if ( selectedAiRequest && @@ -1265,6 +1282,7 @@ export const AskAiEditor: React.ComponentType = React.memo( } price={aiRequestPrice} availableCredits={availableCredits} + isRefreshingLimits={isRefreshingLimits} onSendFeedback={onSendFeedback} hasOpenedProject={!!project} isAutoProcessingFunctionCalls={ diff --git a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js index 7f1213d412a2..781a97f90f0c 100644 --- a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js +++ b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js @@ -199,6 +199,8 @@ export const AskAiStandAloneForm = ({ } = React.useContext(AuthenticatedUserContext); const { openSubscriptionDialog } = React.useContext(SubscriptionContext); + const [isRefreshingLimits, setIsRefreshingLimits] = React.useState(false); + const hideAskAi = !!limits && !!limits.capabilities.classrooms && @@ -217,7 +219,16 @@ export const AskAiStandAloneForm = ({ // we display the proper quota and credits information for the user. React.useEffect( () => { - onRefreshLimits(); + (async () => { + setIsRefreshingLimits(true); + try { + await onRefreshLimits(); + } catch (error) { + // Ignore limits refresh error. + } + await delay(200); + setIsRefreshingLimits(false); + })(); }, // Only on mount, we'll refresh again when sending an AI request. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -331,11 +342,14 @@ export const AskAiStandAloneForm = ({ // Refresh the user limits, to ensure quota and credits information // is up-to-date after an AI request. await delay(500); + setIsRefreshingLimits(true); try { await retryIfFailed({ times: 2 }, onRefreshLimits); } catch (error) { // Ignore limits refresh error. } + await delay(200); + setIsRefreshingLimits(false); })(); }, [ @@ -491,11 +505,14 @@ export const AskAiStandAloneForm = ({ // Refresh the user limits, to ensure quota and credits information // is up-to-date after an AI request. await delay(500); + setIsRefreshingLimits(true); try { await retryIfFailed({ times: 2 }, onRefreshLimits); } catch (error) { // Ignore limits refresh error. } + await delay(200); + setIsRefreshingLimits(false); }, [ profile, @@ -633,6 +650,7 @@ export const AskAiStandAloneForm = ({ } price={aiRequestPrice} availableCredits={availableCredits} + isRefreshingLimits={isRefreshingLimits} onSendFeedback={async () => {}} hasOpenedProject={!!project} isAutoProcessingFunctionCalls={ From 89e26da20c22ae9fb51302821a7992d5fcde5b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:40:50 +0100 Subject: [PATCH 4/5] Allow stopping a request mid-conversation and start again --- .../AiRequestChat/ChatMessages.js | 503 +++++++++++------- .../AiRequestChat/ChatMessages.module.css | 14 + .../AiRequestChat/FunctionCallRow.js | 4 +- .../AiRequestChat/FunctionCallRow.module.css | 6 + .../AiRequestChat/OrchestratorPlan.js | 281 +++++++--- .../AiRequestChat/OrchestratorPlan.module.css | 51 +- .../src/AiGeneration/AiRequestChat/index.js | 98 ++-- newIDE/app/src/AiGeneration/AiRequestUtils.js | 21 + .../src/AiGeneration/AskAiEditorContainer.js | 175 ++++-- newIDE/app/src/AiGeneration/AskAiHistory.js | 2 +- .../src/AiGeneration/AskAiStandAloneForm.js | 72 ++- newIDE/app/src/AiGeneration/Utils.js | 51 +- newIDE/app/src/UI/CustomSvgIcons/Stop.js | 18 + .../src/Utils/GDevelopServices/Generation.js | 20 +- newIDE/app/src/Utils/useStableValue.js | 34 ++ .../AiRequestChat/Agent.stories.js | 24 +- .../AiRequestChat/Chat.stories.js | 12 +- .../AiRequestChat/Form.stories.js | 3 +- .../AiRequestChat/StandAloneForm.stories.js | 3 +- 19 files changed, 950 insertions(+), 442 deletions(-) create mode 100644 newIDE/app/src/UI/CustomSvgIcons/Stop.js create mode 100644 newIDE/app/src/Utils/useStableValue.js diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js index 6e4896f115d5..950c0d85cbe2 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.js @@ -1,13 +1,11 @@ // @flow import * as React from 'react'; +import useStableValue from '../../Utils/useStableValue'; import { ChatBubble } from './ChatBubble'; import { Column, Line, Spacer } from '../../UI/Grid'; import { ChatMarkdownText } from './ChatMarkdownText'; import GDevelopThemeContext from '../../UI/Theme/GDevelopThemeContext'; -import { - getFunctionCallToFunctionCallOutputMap, - getLatestActivePlan, -} from '../AiRequestUtils'; +import { getFunctionCallToFunctionCallOutputMap } from '../AiRequestUtils'; import { FunctionCallRow } from './FunctionCallRow'; import { FunctionCallsGroup } from './FunctionCallsGroup'; import { @@ -41,9 +39,7 @@ import { ResponsiveLineStackLayout, } from '../../UI/Layout'; import FlatButton from '../../UI/FlatButton'; -import Pause from '../../UI/CustomSvgIcons/Pause'; -import Paper, { getBackgroundColor } from '../../UI/Paper'; -import Play from '../../UI/CustomSvgIcons/Play'; +import Paper from '../../UI/Paper'; import Floppy from '../../UI/CustomSvgIcons/Floppy'; import SubscriptionPlanTableSummary from '../../Profile/Subscription/SubscriptionDialog/SubscriptionPlanTableSummary'; import { SubscriptionContext } from '../../Profile/Subscription/SubscriptionContext'; @@ -116,6 +112,8 @@ type SuggestionsRenderItem = {| type OrchestratorPlanRenderItem = {| type: 'orchestrator_plan', plan: {| tasks: Array |}, + messageIndex: number, + messageId: string, |}; type RenderItem = @@ -144,14 +142,22 @@ const styles = { // Phrases displayed while the AI is thinking/waiting (no active function calls). // Defined outside the component so the array is stable across renders. const thinkingPhrases: Array = [ - Looking for documentation, - Refining the plan, + Reading the documentation, Analyzing the project, - Exploring possibilities, - Thinking through the steps, Reviewing the game structure, - Planning the approach, - Considering the best strategy, + Studying the event sheets, + Thinking through the approach, + Considering the possibilities, + Examining the behaviors, + Reading through the events, + Understanding the context, + Evaluating the game logic, + Reviewing the scene data, + Analyzing the object properties, + Thinking through the details, + Reviewing the current state, + Studying the object behaviors, + Considering the best approach, ]; const getMessageSuggestionsLines = ({ @@ -293,16 +299,15 @@ type Props = {| aiRequestIdToChange: string ) => void, shouldBeWorkingIfNotPaused?: boolean, - isPaused?: boolean, isForAnotherProject?: boolean, shouldDisplayFeedbackBanner?: boolean, - onPause: (pause: boolean) => void, onScrollToBottom: () => void, hasStartedRequestButCannotContinue: boolean, onSwitchedToGDevelopCredits: () => void, onStartOrOpenChat: (options: ?{| aiRequestId: string | null |}) => void, isFetchingSuggestions: boolean, + isSending?: boolean, savingProjectForMessageId: ?string, forkingState: ?{| aiRequestId: string, messageId: string |}, onRestore: ({| @@ -322,15 +327,14 @@ export const ChatMessages: React.ComponentType = React.memo( fileMetadata, onUserRequestTextChange, shouldBeWorkingIfNotPaused, - isPaused, isForAnotherProject, shouldDisplayFeedbackBanner, - onPause, onScrollToBottom, hasStartedRequestButCannotContinue, onSwitchedToGDevelopCredits, onStartOrOpenChat, isFetchingSuggestions, + isSending, savingProjectForMessageId, forkingState, onRestore, @@ -399,7 +403,7 @@ export const ChatMessages: React.ComponentType = React.memo( if ( shouldShowCreditsOrSubscriptionPrompt || isFetchingSuggestions || - (shouldBeWorkingIfNotPaused && !isPaused) + shouldBeWorkingIfNotPaused ) { onScrollToBottom(); } @@ -408,12 +412,11 @@ export const ChatMessages: React.ComponentType = React.memo( shouldShowCreditsOrSubscriptionPrompt, isFetchingSuggestions, shouldBeWorkingIfNotPaused, - isPaused, onScrollToBottom, ] ); - const isWorking = !!shouldBeWorkingIfNotPaused && !isPaused; + const isWorking = !!shouldBeWorkingIfNotPaused; const [isRestoring, setIsRestoring] = React.useState(false); const disabled = isWorking || isForAnotherProject || isRestoring; @@ -451,6 +454,7 @@ export const ChatMessages: React.ComponentType = React.memo( const items: Array = []; let currentFunctionCallItems: Array = []; const forkedAfterNewMessageId = aiRequest.forkedAfterNewMessageId; + const seenProjectVersionIds: Set = new Set(); const flushFunctionCallGroup = () => { if (currentFunctionCallItems.length > 0) { @@ -498,6 +502,12 @@ export const ChatMessages: React.ComponentType = React.memo( return; } + // Don't display function calls with a taskId here — they are + // shown inside their task row in the OrchestratorPlan component. + if (messageContent.taskId) { + return; + } + currentFunctionCallItems.push({ key: `messageIndex${messageIndex}-${messageContentIndex}`, messageContent, @@ -541,20 +551,27 @@ export const ChatMessages: React.ComponentType = React.memo( (message.projectVersionIdAfterMessage || isSavingProjectForThisMessage) ) { - flushFunctionCallGroup(); - items.push({ - type: 'save', - messageIndex: messageIndex, - message: message, - isRestored: !!( - forkedAfterNewMessageId && - message.messageId === forkedAfterNewMessageId - ), - isSaving: !!( - isSavingProjectForThisMessage && - !message.projectVersionIdAfterMessage - ), - }); + // Deduplicate: skip if we already showed a save item for this version + const versionId = message.projectVersionIdAfterMessage; + const isDuplicate = + versionId && seenProjectVersionIds.has(versionId); + if (!isDuplicate) { + if (versionId) seenProjectVersionIds.add(versionId); + flushFunctionCallGroup(); + items.push({ + type: 'save', + messageIndex: messageIndex, + message: message, + isRestored: !!( + forkedAfterNewMessageId && + message.messageId === forkedAfterNewMessageId + ), + isSaving: !!( + isSavingProjectForThisMessage && + !message.projectVersionIdAfterMessage + ), + }); + } } if ( @@ -586,6 +603,28 @@ export const ChatMessages: React.ComponentType = React.memo( }); } + // Display plan when a function_call_output contains plan data. + if ( + aiRequest.mode === 'orchestrator' && + message.type === 'function_call_output' && + message.output + ) { + try { + const output = JSON.parse(message.output); + if (output && output.plan && Array.isArray(output.plan.tasks)) { + flushFunctionCallGroup(); + items.push({ + type: 'orchestrator_plan', + plan: output.plan, + messageIndex, + messageId: message.messageId || '', + }); + } + } catch (e) { + // Ignore parse errors. + } + } + if (isLastMessage) { flushFunctionCallGroup(); } @@ -605,23 +644,27 @@ export const ChatMessages: React.ComponentType = React.memo( // so we can display them in the status bar instead of a generic "Working..." label. const workingFunctionCallTexts: Array = React.useMemo( () => { - if (!shouldBeWorkingIfNotPaused || isPaused) return []; + if (!shouldBeWorkingIfNotPaused) return []; const texts: Array = []; - for (const item of renderItems) { - let fcItems: Array | null = null; - if (item.type === 'function_call_group') { - // $FlowFixMe[incompatible-type] - fcItems = item.items; - } else if ( - item.type === 'message_content' && - item.functionCallItems - ) { - // $FlowFixMe[incompatible-type] - fcItems = item.functionCallItems; - } - if (!fcItems) continue; - for (const fcItem of fcItems) { - const { editorFunctionCallResult, messageContent } = fcItem; + // Iterate all assistant messages directly so we capture function calls + // that have a taskId (which are excluded from renderItems but still + // need to appear in the status bar when running). + for (const message of aiRequest.output) { + if (message.type !== 'message' || message.role !== 'assistant') + continue; + for (const messageContent of message.content) { + if (messageContent.type !== 'function_call') continue; + if (messageContent.name === 'create_or_update_plan') continue; + const existingFunctionCallOutput = functionCallToFunctionCallOutput.get( + messageContent + ); + const editorFunctionCallResult = + (!existingFunctionCallOutput && + editorFunctionCallResults && + editorFunctionCallResults.find( + r => r.call_id === messageContent.call_id + )) || + null; if ( !editorFunctionCallResult || editorFunctionCallResult.status !== 'working' @@ -650,28 +693,64 @@ export const ChatMessages: React.ComponentType = React.memo( } return texts; }, - // eslint-disable-next-line react-hooks/exhaustive-deps [ shouldBeWorkingIfNotPaused, - isPaused, - renderItems, + aiRequest.output, project, editorCallbacks, + editorFunctionCallResults, + functionCallToFunctionCallOutput, ] ); // Index used to rotate through working function call texts or thinking phrases. - const [rotationIndex, setRotationIndex] = React.useState(0); + // Start at a random offset so the first phrase shown is not always the same. + const [rotationIndex, setRotationIndex] = React.useState(() => + Math.floor(Math.random() * thinkingPhrases.length) + ); + + // Keep function-call texts visible for at least 3s after they finish, so + // fast calls don't flash in and out immediately. + const hasWorkingFunctionCallTexts = useStableValue({ + minimumDuration: 3000, + value: workingFunctionCallTexts.length > 0, + }); + // Remember the last non-empty texts so they're still shown during the + // grace period after workingFunctionCallTexts becomes empty. + const lastWorkingFunctionCallTextsRef = React.useRef( + workingFunctionCallTexts + ); + if (workingFunctionCallTexts.length > 0) { + lastWorkingFunctionCallTextsRef.current = workingFunctionCallTexts; + } + + // Compute here (not inside JSX) so the ref is always up to date regardless + // of which render branch is active (e.g. isFetchingSuggestions vs working). + const textsToShow = hasWorkingFunctionCallTexts + ? lastWorkingFunctionCallTextsRef.current + : thinkingPhrases; + + // Ref holding current texts so the setInterval callback can pick a random + // next index without going stale. + const textsToShowRef = React.useRef>(textsToShow); + textsToShowRef.current = textsToShow; const isActivelyWorking = - (!!shouldBeWorkingIfNotPaused && !isPaused) || isFetchingSuggestions; + !!shouldBeWorkingIfNotPaused || isFetchingSuggestions; React.useEffect( () => { if (!isActivelyWorking) return; const interval = setInterval(() => { - setRotationIndex(prev => prev + 1); - }, 4000); + setRotationIndex(prev => { + const texts = textsToShowRef.current; + const len = texts.length; + if (len <= 1) return prev; + // Random skip of 1..len-1 to guarantee a different item each rotation. + const skip = 1 + Math.floor(Math.random() * (len - 1)); + return (prev + skip) % len; + }); + }, 8000); return () => clearInterval(interval); }, [isActivelyWorking] @@ -679,105 +758,100 @@ export const ChatMessages: React.ComponentType = React.memo( const filteredRenderItems = React.useMemo( () => { - if (!forkingState || forkingState.aiRequestId !== aiRequest.id) { - return renderItems; - } + let items = renderItems; + + if (forkingState && forkingState.aiRequestId === aiRequest.id) { + const forkSaveIndex = items.findIndex( + item => + item.type === 'save' && + item.message && + item.message.messageId === forkingState.messageId + ); - const forkSaveIndex = renderItems.findIndex( - item => - item.type === 'save' && - item.message && - item.message.messageId === forkingState.messageId - ); + if (forkSaveIndex !== -1) { + // Only show items up to and including the save item. + // The save item itself will show "Restoring..." state. + items = items.slice(0, forkSaveIndex + 1); + } + } - if (forkSaveIndex === -1) return renderItems; + // Deduplicate consecutive plan items: if multiple plans appear with no + // user or assistant message between them, only show the latest one + // (it's just a status update to the same plan). + const planIndicesToRemove: Set = new Set(); + let lastPlanIndex = -1; + items.forEach((item, index) => { + if (item.type === 'orchestrator_plan') { + if (lastPlanIndex !== -1) { + planIndicesToRemove.add(lastPlanIndex); + } + lastPlanIndex = index; + } else if ( + item.type === 'user_message' || + item.type === 'message_content' + ) { + // A real message resets the sequence — plans after it are independent. + lastPlanIndex = -1; + } + }); + if (planIndicesToRemove.size > 0) { + items = items.filter((_, index) => !planIndicesToRemove.has(index)); + } - // Only show items up to and including the save item. - // The save item itself will show "Restoring..." state. - return renderItems.slice(0, forkSaveIndex + 1); + return items; }, [renderItems, forkingState, aiRequest.id] ); - const latestActivePlan = React.useMemo( - () => - aiRequest.mode === 'orchestrator' - ? getLatestActivePlan(aiRequest) - : null, - [aiRequest] - ); - - const displayItems: Array = React.useMemo( + // Map each plan task id to the function call items linked to it (via taskId), + // so the OrchestratorPlan component can show them inside the task row. + const functionCallItemsByTaskId: Map< + string, + Array + > = React.useMemo( () => { - if (!latestActivePlan || filteredRenderItems.length === 0) { - return filteredRenderItems; - } - const result = [...filteredRenderItems]; - - // Find the last "true message" item (message_content or user_message). - // We skip trailing save/function_call_group items so that the plan is - // always inserted immediately before the last real message, regardless - // of any save indicators that may follow it. - let insertionIndex = -1; - for (let i = result.length - 1; i >= 0; i--) { - const item = result[i]; - if (item.type === 'message_content' || item.type === 'user_message') { - insertionIndex = i; - break; + const map: Map> = new Map(); + aiRequest.output.forEach(message => { + if (message.type === 'message' && message.role === 'assistant') { + message.content.forEach(messageContent => { + if ( + messageContent.type === 'function_call' && + messageContent.taskId + ) { + const taskId = messageContent.taskId; + const existingFunctionCallOutput = functionCallToFunctionCallOutput.get( + messageContent + ); + const editorFunctionCallResult = + (!existingFunctionCallOutput && + editorFunctionCallResults && + editorFunctionCallResults.find( + r => r.call_id === messageContent.call_id + )) || + null; + if (!map.has(taskId)) map.set(taskId, []); + const taskItems = map.get(taskId); + if (taskItems) { + taskItems.push({ + key: `task-${taskId}-${messageContent.call_id}`, + messageContent, + existingFunctionCallOutput, + editorFunctionCallResult, + }); + } + } + }); } - } - - if (insertionIndex === -1) { - // No true message found — append the plan at the end. - result.push( - ({ type: 'orchestrator_plan', plan: latestActivePlan }: any) - ); - return result; - } - - const targetItem = result[insertionIndex]; - - // Items to splice in before the target: optionally a detached - // function_call_group, then the plan. - const itemsToInsert: Array = []; - - // If the target message carries pending function calls, hoist those - // into their own group above the plan so only the plain text appears - // after the plan. - if ( - targetItem.type === 'message_content' && - targetItem.functionCallItems && - targetItem.functionCallItems.length > 0 - ) { - itemsToInsert.push( - ({ - type: 'function_call_group', - items: targetItem.functionCallItems, - }: any) - ); - result[insertionIndex] = ({ - ...targetItem, - functionCallItems: undefined, - }: any); - } - - itemsToInsert.push( - ({ - type: 'orchestrator_plan', - plan: latestActivePlan, - }: any) - ); - - result.splice(insertionIndex, 0, ...itemsToInsert); - return result; + }); + return map; }, - [filteredRenderItems, latestActivePlan] + [aiRequest, editorFunctionCallResults, functionCallToFunctionCallOutput] ); // Scroll to bottom when suggestions are added. const hasSuggestions = React.useMemo( - () => displayItems.some(item => item.type === 'suggestions'), - [displayItems] + () => filteredRenderItems.some(item => item.type === 'suggestions'), + [filteredRenderItems] ); React.useEffect( @@ -798,18 +872,59 @@ export const ChatMessages: React.ComponentType = React.memo( const forkedFromAiRequestId = aiRequest.forkedFromAiRequestId; + // Pre-compute which message_content items are absorbed into the preceding + // plan bubble so they don't render as a separate ChatBubble. + const absorbedMessageContentIndices: Set = new Set(); + filteredRenderItems.forEach((item, index) => { + if (item.type === 'orchestrator_plan') { + const next = filteredRenderItems[index + 1]; + if ( + next && + next.type === 'message_content' && + next.messageContent.type === 'output_text' && + next.messageContent.text && + next.messageContent.text.trim() + ) { + absorbedMessageContentIndices.add(index + 1); + } + } + }); + return ( <> - {displayItems + {filteredRenderItems .flatMap((item, itemIndex) => { if (item.type === 'orchestrator_plan') { + const nextItem = filteredRenderItems[itemIndex + 1]; + const followingText = + nextItem && + nextItem.type === 'message_content' && + nextItem.messageContent.type === 'output_text' && + nextItem.messageContent.text + ? nextItem.messageContent.text.trim() + : undefined; return [ - - + + , ]; } + if (absorbedMessageContentIndices.has(itemIndex)) { + return null; + } + if (item.type === 'user_message') { const { messageIndex, message } = item; @@ -1239,6 +1354,21 @@ export const ChatMessages: React.ComponentType = React.memo( + ) : aiRequest.status === 'suspended' && !shouldBeWorkingIfNotPaused ? ( + +
+
+ + + Stopped. Ready when you are. + +
+ ) : isFetchingSuggestions ? (
@@ -1251,60 +1381,63 @@ export const ChatMessages: React.ComponentType = React.memo( color="inherit" > - - {thinkingPhrases[rotationIndex % thinkingPhrases.length]} + + Thinking... - +
- ) : shouldBeWorkingIfNotPaused ? ( + ) : isSending ? (
- {(aiRequest.mode === 'agent' || - aiRequest.mode === 'orchestrator') && ( - onPause(!isPaused)} - size="small" - style={{ - backgroundColor: !isPaused - ? getBackgroundColor(theme, 'light') - : undefined, - borderRadius: 4, - padding: 0, - }} - selected={isPaused} - > - {isPaused ? : } - - )} - - {isPaused ? ( - Paused - ) : ( - - - {workingFunctionCallTexts.length > 0 - ? workingFunctionCallTexts[ - rotationIndex % workingFunctionCallTexts.length - ] - : thinkingPhrases[ - rotationIndex % thinkingPhrases.length - ]} - - + + + Sending... - )} + + + +
+
+ ) : shouldBeWorkingIfNotPaused ? ( + +
+ + + {(() => { + const singleItem = textsToShow.length === 1; + return ( + <> + + {textsToShow[rotationIndex % textsToShow.length]} + {'...'} + + {!singleItem && ( + + )} + + ); + })()} +
diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.module.css b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.module.css index d4815b4aa4b8..ea5c07b8d47f 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.module.css +++ b/newIDE/app/src/AiGeneration/AiRequestChat/ChatMessages.module.css @@ -54,3 +54,17 @@ gap: 4px; margin-top: 4px; } + +.suspendedIndicator { + display: flex; + align-items: center; + color: var(--theme-text-secondary-color); +} + +.suspendedDot { + width: 6px; + height: 6px; + background-color: #ff9800; + border-radius: 50%; + flex-shrink: 0; +} diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/FunctionCallRow.js b/newIDE/app/src/AiGeneration/AiRequestChat/FunctionCallRow.js index dd7995e9be98..4bf24c8e49ad 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/FunctionCallRow.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/FunctionCallRow.js @@ -153,13 +153,13 @@ export const FunctionCallRow: React.ComponentType = React.memo( return (
- + - + {hasErrored ? ( , + messageId: string, + followingText?: string, + functionCallItemsByTaskId: Map>, + project: ?gdProject, + onProcessFunctionCalls: ( + functionCalls: Array, + options: ?{| ignore?: boolean |} + ) => Promise, + editorCallbacks: EditorCallbacks, |}; +// A set of natural-sounding intro phrases for the plan header. +// One is picked deterministically based on the plan's messageId so it stays +// consistent across React re-renders (same plan → same phrase). +const PLAN_INTRO_PHRASES = [ + t`I've mapped out a plan — here's what I'll do:`, + t`Alright, here's my approach:`, + t`I've broken this into steps — let me walk you through it:`, + t`Here's how I'll tackle this:`, + t`Got it! I've put together a plan:`, + t`Let me lay out the steps:`, + t`I've thought this through — here's the plan:`, +]; + const getStatusIcon = ( status: 'pending' | 'in_progress' | 'done' | 'voided' ) => { switch (status) { case 'done': - return ; + return ( +
+ +
+ ); case 'in_progress': - return
; + return ( +
+
+
+ ); case 'pending': - return
; + return ( +
+
+
+ ); case 'voided': return null; default: @@ -39,94 +90,196 @@ const getStatusIcon = ( } }; -const TaskRow = ({ task }: {| task: Task |}) => { +const TaskRow = ({ + task, + isLast, + functionCallItems, + project, + onProcessFunctionCalls, + editorCallbacks, +}: {| + task: Task, + isLast: boolean, + functionCallItems: Array, + project: ?gdProject, + onProcessFunctionCalls: ( + functionCalls: Array, + options: ?{| ignore?: boolean |} + ) => Promise, + editorCallbacks: EditorCallbacks, +|}) => { const [isExpanded, setIsExpanded] = React.useState(false); + const hasLinkedFunctionCalls = + functionCallItems.length > 0 && task.status !== 'pending'; + const hasExpandableContent = hasLinkedFunctionCalls || !!task.description; + + // Auto-expand when the task is in_progress and has function calls (or gains new ones). + React.useEffect( + () => { + if (functionCallItems.length > 0 && task.status === 'in_progress') { + setIsExpanded(true); + } + }, + [functionCallItems.length, task.status] + ); const toggle = () => { - if (task.description) setIsExpanded(expanded => !expanded); + if (hasExpandableContent) setIsExpanded(expanded => !expanded); }; return ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggle(); - } - } - : undefined - } - > - +
+
{getStatusIcon(task.status)} + {!isLast &&
}
- +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + } + : undefined + } + > + {hasExpandableContent && ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ )} {task.title} - {task.description && ( -
- {isExpanded ? ( - - ) : ( - +
+ {isExpanded && + (hasLinkedFunctionCalls ? ( +
+ {functionCallItems.map( + ({ + key, + messageContent, + existingFunctionCallOutput, + editorFunctionCallResult, + }) => ( + + ) )}
- )} - - {isExpanded && task.description && ( - - {task.description} - - )} + ) : task.description ? ( + + {task.description} + + ) : null)} - +
); }; -export const OrchestratorPlan = ({ tasks }: Props): React.Node => { +export const OrchestratorPlan = ({ + tasks, + messageId, + followingText, + functionCallItemsByTaskId, + project, + onProcessFunctionCalls, + editorCallbacks, +}: Props): React.Node => { // Filter out voided tasks const visibleTasks = tasks.filter(task => task.status !== 'voided'); + // Pick a phrase deterministically from the plan's messageId so it stays + // stable across React re-renders (same plan message → same phrase). + const phraseIndex = React.useMemo( + () => + messageId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0) % + PLAN_INTRO_PHRASES.length, + [messageId] + ); + + const allPending = visibleTasks.every(task => task.status === 'pending'); + if (visibleTasks.length === 0) { - return null; + // All tasks were voided. Still render followingText if present (the absorbed + // sibling item won't render on its own since it's in absorbedMessageContentIndices). + if (!followingText) return null; + return ( + + + + ); } return ( - -
- - - Here is the plan: - - - - {visibleTasks.map(task => ( - - ))} - -
-
+ + {({ i18n }) => ( + + + + + +
+ {visibleTasks.map((task, index) => ( + + ))} +
+ {followingText && ( + + + + )} +
+
+ )} +
); }; diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.module.css b/newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.module.css index a0f7bbf3ec42..6d2acb6f4be8 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.module.css +++ b/newIDE/app/src/AiGeneration/AiRequestChat/OrchestratorPlan.module.css @@ -3,7 +3,6 @@ } .emptyCircle { - margin-top: 2px; width: 6px; height: 6px; border: 1.5px solid currentColor; @@ -12,7 +11,6 @@ } .filledCircle { - margin-top: 2px; width: 6px; height: 6px; background-color: currentColor; @@ -21,14 +19,35 @@ .statusIconContainer { display: flex; + flex-direction: column; align-items: center; - justify-content: center; min-width: 20px; - padding-top: 2px; + gap: 2px; /* breathing room between icon and line below */ +} + +/* Fixed-size wrapper so all status icons occupy the same height, preventing + row height variation between circle (6px) and check (14px). */ +.statusIconFixed { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +.doneIcon { + width: 16px; + height: 16px; + font-size: 16px; } .taskRow { - padding: 2px 0; + padding: 4px 0; +} + +.taskRowHeader { + display: flex; + align-items: flex-start; } .taskRowClickable:hover { @@ -38,6 +57,28 @@ .chevron { display: flex; align-items: center; + align-self: flex-start; opacity: 0.6; padding: 0 2px; } + +.functionCallsContainer { + margin-top: 4px; +} + +.tasksContainer { + display: flex; + flex-direction: column; +} + +.lineSegmentBottom { + flex: 1; + width: 2px; + background-color: currentColor; + opacity: 0.2; + /* -4px bridges the full inter-row gap: taskRow's 4px bottom padding + + next taskRow's 4px top padding, so the line reaches the next row's + container top. The next container's padding-top: 4px then provides + the breathing room before the icon. */ + margin-bottom: -8px; +} diff --git a/newIDE/app/src/AiGeneration/AiRequestChat/index.js b/newIDE/app/src/AiGeneration/AiRequestChat/index.js index fea9160b10eb..7f5655e55297 100644 --- a/newIDE/app/src/AiGeneration/AiRequestChat/index.js +++ b/newIDE/app/src/AiGeneration/AiRequestChat/index.js @@ -1,5 +1,6 @@ // @flow import * as React from 'react'; +import useStableValue from '../../Utils/useStableValue'; import type { I18n as I18nType } from '@lingui/core'; import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout'; import Text from '../../UI/Text'; @@ -56,6 +57,7 @@ import SelectOption from '../../UI/SelectOption'; import CompactSelectField from '../../UI/CompactSelectField'; import useAlertDialog from '../../UI/Alert/useAlertDialog'; import { type FileMetadata } from '../../ProjectsStorage'; +import Stop from '../../UI/CustomSvgIcons/Stop'; const TOO_MANY_USER_MESSAGES_WARNING_COUNT = 15; const TOO_MANY_USER_MESSAGES_ERROR_COUNT = 20; @@ -312,6 +314,7 @@ type Props = {| aiRequest: AiRequest | null, isSending: boolean, + isSendingUserMessage?: boolean, onStartNewAiRequest: ({| mode: 'chat' | 'agent' | 'orchestrator', userRequest: string, @@ -329,8 +332,7 @@ type Props = {| freeFormDetails?: string ) => Promise, hasOpenedProject: boolean, - isAutoProcessingFunctionCalls: boolean, - setAutoProcessFunctionCalls: boolean => void, + onStop: () => Promise, onStartOrOpenChat: ( ?{| aiRequestId: string | null, @@ -380,6 +382,7 @@ export const AiRequestChat: React.ComponentType<{ fileMetadata, aiRequest, isSending, + isSendingUserMessage, onStartNewAiRequest, onSendUserMessage, onSendFeedback, @@ -393,8 +396,7 @@ export const AiRequestChat: React.ComponentType<{ hasOpenedProject, editorFunctionCallResults, onProcessFunctionCalls, - isAutoProcessingFunctionCalls, - setAutoProcessFunctionCalls, + onStop, i18n, editorCallbacks, standAloneForm, @@ -430,6 +432,9 @@ export const AiRequestChat: React.ComponentType<{ hasSwitchedToGDevelopCreditsMidChat, setHasSwitchedToGDevelopCreditsMidChat, ] = React.useState(false); + const [isButtonLoading, setIsButtonLoading] = React.useState( + false + ); const { showConfirmation } = useAlertDialog(); const [ aiConfigurationPresetId, @@ -567,13 +572,19 @@ export const AiRequestChat: React.ComponentType<{ ) : null; + // Show "Calculating..." for at least 2s so it doesn't flash on fast calls. + const isRefreshingLimitsStable = useStableValue({ + minimumDuration: 2000, + value: !!isRefreshingLimits, + }); + const priceAndRequestsText = getPriceAndRequestsTextAndTooltip({ quota, price, availableCredits, selectedMode, automaticallyUseCreditsForAiRequests, - isRefreshingLimits, + isRefreshingLimits: isRefreshingLimitsStable, }); const chosenOrDefaultAiConfigurationPresetId = @@ -603,8 +614,8 @@ export const AiRequestChat: React.ComponentType<{ !!hasWorkingFunctionCalls || !!hasFunctionsCallsToProcess || (!!aiRequest && aiRequest.status === 'working'); - const isPaused = !!aiRequest && !isAutoProcessingFunctionCalls; - const isWorking = isSending || (hasWorkToProcess && !isPaused); + const isWorking = isSending || hasWorkToProcess; + const canRequestBeStopped = isWorking && !!aiRequest; const doesNotHaveEnoughCreditsToContinue = !!price && availableCredits < price.priceInCredits; @@ -694,8 +705,7 @@ export const AiRequestChat: React.ComponentType<{ setHasStartedRequestButCannotContinue(cannotContinue); if (cannotContinue) return; - setAutoProcessFunctionCalls(true); - onSendUserMessage({ + return onSendUserMessage({ userMessage: userRequestTextPerAiRequestId[aiRequestId] || '', mode: selectedMode, }); @@ -705,26 +715,42 @@ export const AiRequestChat: React.ComponentType<{ onSendUserMessage, userRequestTextPerAiRequestId, scrollToBottom, - setAutoProcessFunctionCalls, cannotContinue, selectedMode, ] ); + const onClickExistingChatButton = React.useCallback( + () => { + setIsButtonLoading(true); + if (canRequestBeStopped) { + onStop() + .catch(err => console.error('Failed to stop AI request:', err)) + .finally(() => setIsButtonLoading(false)); + } else { + const promise = onSubmitForExistingChat(); + (promise || Promise.resolve()) + .catch(err => console.error('Failed to send message:', err)) + .finally(() => setIsButtonLoading(false)); + } + }, + [canRequestBeStopped, onStop, onSubmitForExistingChat] + ); + + const onClickNewChatButton = React.useCallback( + () => { + setIsButtonLoading(true); + onSubmitForNewChat() + .catch(err => console.error('Failed to start chat:', err)) + .finally(() => setIsButtonLoading(false)); + }, + [onSubmitForNewChat] + ); + // Calculate feedback banner visibility for sticky behavior // (must be before conditional returns to follow React hooks rules) - const allFunctionCallsToProcess = - aiRequest && editorFunctionCallResults - ? getFunctionCallsToProcess({ - aiRequest, - editorFunctionCallResults, - }) - : []; - const isPausedAndHasFunctionCallsToProcess = - !isAutoProcessingFunctionCalls && allFunctionCallsToProcess.length > 0; const shouldDisplayFeedbackBannerNow = !hasWorkingFunctionCalls && - !isPausedAndHasFunctionCallsToProcess && !isSending && !!aiRequest && aiRequest.status === 'ready' && @@ -757,7 +783,7 @@ export const AiRequestChat: React.ComponentType<{
)} -
+ @@ -1034,11 +1060,9 @@ export const AiRequestChat: React.ComponentType<{ fileMetadata={fileMetadata} onProcessFunctionCalls={onProcessFunctionCalls} onUserRequestTextChange={onUserRequestTextChange} - isPaused={isPaused} shouldBeWorkingIfNotPaused={hasWorkToProcess || isWorking} isForAnotherProject={isForAnotherProject} shouldDisplayFeedbackBanner={shouldDisplayFeedbackBanner} - onPause={(pause: boolean) => setAutoProcessFunctionCalls(!pause)} onScrollToBottom={scrollToBottom} hasStartedRequestButCannotContinue={ hasStartedRequestButCannotContinue @@ -1048,6 +1072,7 @@ export const AiRequestChat: React.ComponentType<{ } onStartOrOpenChat={onStartOrOpenChat} isFetchingSuggestions={isFetchingSuggestions} + isSending={isSendingUserMessage} savingProjectForMessageId={savingProjectForMessageId} forkingState={forkingState} onRestore={onRestore} @@ -1108,7 +1133,7 @@ export const AiRequestChat: React.ComponentType<{ } rows={2} maxRows={6} - onSubmit={onSubmitForExistingChat} + onSubmit={onClickExistingChatButton} controls={ + ) : ( + sendButtonIcon + ) + } + label={canRequestBeStopped ? null : sendButtonLabel} + onClick={onClickExistingChatButton} /> diff --git a/newIDE/app/src/AiGeneration/AiRequestUtils.js b/newIDE/app/src/AiGeneration/AiRequestUtils.js index 9f6017368219..e03d57564fe8 100644 --- a/newIDE/app/src/AiGeneration/AiRequestUtils.js +++ b/newIDE/app/src/AiGeneration/AiRequestUtils.js @@ -170,6 +170,27 @@ export const getLatestActivePlan = ( return latestPlan; }; +/** + * Returns true if the AI request has work in progress that should be suspended: + * - The server is actively processing (status === 'working') + * - OR the request is ready with function calls that still need to be processed and sent back + */ +export const aiRequestHasWorkInProgress = ( + aiRequest: AiRequest, + editorFunctionCallResults: Array | null +): boolean => { + if (aiRequest.status === 'working') return true; + if (aiRequest.status === 'ready') { + return ( + getFunctionCallsToProcess({ + aiRequest, + editorFunctionCallResults, + }).length > 0 + ); + } + return false; +}; + export const getFunctionCallOutputsFromEditorFunctionCallResults = ( editorFunctionCallResults: Array | null ): {| diff --git a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js index bc4a0663db4c..411382a34425 100644 --- a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js +++ b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js @@ -17,6 +17,7 @@ import { createAiRequest, sendAiRequestFeedback, forkAiRequest, + suspendAiRequest, type AiRequest, type AiRequestMessage, } from '../Utils/GDevelopServices/Generation'; @@ -36,7 +37,7 @@ import { import { retryIfFailed } from '../Utils/RetryIfFailed'; import { type EditorCallbacks } from '../EditorFunctions'; import { - getFunctionCallNameByCallId, + aiRequestHasWorkInProgress, getFunctionCallOutputsFromEditorFunctionCallResults, getFunctionCallsToProcess, } from './AiRequestUtils'; @@ -404,6 +405,9 @@ export const AskAiEditor: React.ComponentType = React.memo( } = authenticatedUser; const [isRefreshingLimits, setIsRefreshingLimits] = React.useState(false); + const [isSendingUserMessage, setIsSendingUserMessage] = React.useState( + false + ); const availableCredits = limits ? limits.credits.userBalance.amount : 0; const quota = @@ -492,6 +496,7 @@ export const AskAiEditor: React.ComponentType = React.memo( : null; setSendingAiRequest(null, true); + setIsSendingUserMessage(true); const preparedAiUserContent = await prepareAiUserContent({ getAuthorizationHeader, @@ -530,6 +535,7 @@ export const AskAiEditor: React.ComponentType = React.memo( console.info('Successfully created a new AI request:', aiRequest); setSendingAiRequest(null, false); + setIsSendingUserMessage(false); updateAiRequest(aiRequest.id, () => aiRequest); // Select the new AI request just created - unless the user switched to another one @@ -561,6 +567,7 @@ export const AskAiEditor: React.ComponentType = React.memo( } catch (error) { console.error('Error starting a new AI request:', error); setLastSendError(null, error); + setIsSendingUserMessage(false); } // Refresh the user limits, to ensure quota and credits information @@ -591,6 +598,7 @@ export const AskAiEditor: React.ComponentType = React.memo( setLastSendError, setAiState, setSendingAiRequest, + setIsSendingUserMessage, upToDateSelectedAiRequestId, updateAiRequest, newAiRequestOptions, @@ -667,6 +675,7 @@ export const AskAiEditor: React.ComponentType = React.memo( try { setSendingAiRequest(selectedAiRequestId, true); + if (userMessage) setIsSendingUserMessage(true); const upToDateProject = createdProject || project; @@ -695,17 +704,6 @@ export const AskAiEditor: React.ComponentType = React.memo( eventsJson: null, }); - // If we're updating the request, following a function call to initialize the project, - // pause the request, so that suggestions can be given by the agent (only for agent mode). - const hasJustInitializedProject = - functionCallOutputs.length > 0 && - functionCallOutputs.some( - output => - getFunctionCallNameByCallId({ - aiRequest: selectedAiRequest, - callId: output.call_id, - }) === 'initialize_project' - ); if (functionCallOutputs.length > 0) { // Assume changes have happened, trigger unsaved changes. triggerUnsavedChanges(); @@ -730,8 +728,6 @@ export const AskAiEditor: React.ComponentType = React.memo( : undefined, payWithCredits, userMessage, - paused: - hasJustInitializedProject && modeForThisMessage === 'agent', mode, toolsVersion: mode === 'agent' @@ -745,6 +741,7 @@ export const AskAiEditor: React.ComponentType = React.memo( ); updateAiRequest(aiRequest.id, () => aiRequest); setSendingAiRequest(aiRequest.id, false); + setIsSendingUserMessage(false); clearEditorFunctionCallResults(aiRequest.id); if (userMessage) { @@ -764,6 +761,7 @@ export const AskAiEditor: React.ComponentType = React.memo( } catch (error) { // TODO: update the label of the button to send again. setLastSendError(selectedAiRequestId, error); + setIsSendingUserMessage(false); } if (userMessage) { @@ -808,6 +806,7 @@ export const AskAiEditor: React.ComponentType = React.memo( aiRequestPriceInCredits, availableCredits, setSendingAiRequest, + setIsSendingUserMessage, updateAiRequest, clearEditorFunctionCallResults, getAuthorizationHeader, @@ -837,11 +836,7 @@ export const AskAiEditor: React.ComponentType = React.memo( }, [onSendMessage] ); - const { - isAutoProcessingFunctionCalls, - setAutoProcessFunctionCalls, - onProcessFunctionCalls, - } = useProcessFunctionCalls({ + const { onProcessFunctionCalls } = useProcessFunctionCalls({ project, resourceManagementProps, selectedAiRequest, @@ -861,23 +856,14 @@ export const AskAiEditor: React.ComponentType = React.memo( React.useEffect(() => { // When component is mounted, and an AI request was already selected, - // ensure function calls are not auto-processed, - // except if specified otherwise. - // Otherwise it will automatically resume processing on old requests, - // affecting the project without the user explicitly asking for it. + // ensure we reset the selection if not logged in. if (selectedAiRequestId) { - // If not logged in, reset selection. if (!profile) { setAiState({ aiRequestId: null, }); return; } - - setAutoProcessFunctionCalls( - selectedAiRequestId, - !!continueProcessingFunctionCallsOnMount - ); } setIsReadyToProcessFunctionCalls(true); @@ -891,18 +877,29 @@ export const AskAiEditor: React.ComponentType = React.memo( aiRequestId: string | null, |} ) => { - const newOpenedRequestId = options && options.aiRequestId; - if (newOpenedRequestId) { - // If we're opening a new request, - // ensure it is paused, so we don't resume processing - // without the user's consent. - setAutoProcessFunctionCalls(newOpenedRequestId, false); - } if (options) { + // Suspend the current request when navigating away from it. + // upToDateOnStop is defined below - it is always up-to-date via the ref. + if ( + selectedAiRequest && + options.aiRequestId !== selectedAiRequest.id + ) { + // upToDateOnStop is declared below this callback, but it is only + // ever called at event-handler time (post-render), so it is always + // initialised by the time this runs. + // eslint-disable-next-line no-use-before-define + upToDateOnStop.current().catch(err => { + console.error( + 'Failed to suspend AI request when starting new chat:', + err + ); + }); + } setAiState(options); } }, - [setAiState, setAutoProcessFunctionCalls] + // eslint-disable-next-line react-hooks/exhaustive-deps + [setAiState, selectedAiRequest] ); const onStartNewChat = React.useCallback( () => { @@ -980,6 +977,58 @@ export const AskAiEditor: React.ComponentType = React.memo( [getAuthorizationHeader, profile] ); + const onStop = React.useCallback( + async () => { + if (!selectedAiRequest || !profile) return; + const editorFunctionCallResultsForRequest = + getEditorFunctionCallResults(selectedAiRequest.id) || []; + if ( + !aiRequestHasWorkInProgress( + selectedAiRequest, + editorFunctionCallResultsForRequest + ) + ) + return; + const suspendedRequest = await suspendAiRequest( + getAuthorizationHeader, + { + userId: profile.id, + aiRequestId: selectedAiRequest.id, + } + ); + updateAiRequest(suspendedRequest.id, () => suspendedRequest); + clearEditorFunctionCallResults(suspendedRequest.id); + }, + [ + selectedAiRequest, + profile, + getAuthorizationHeader, + updateAiRequest, + clearEditorFunctionCallResults, + getEditorFunctionCallResults, + ] + ); + + const upToDateOnStop = useStableUpToDateRef(onStop); + + // Suspend any running AI request when this editor tab is closed. + React.useEffect( + () => { + return () => { + // Fire and forget - cannot await in a cleanup function. + // We intentionally read upToDateOnStop.current at cleanup time so + // we get the latest selectedAiRequest snapshot (that's the point of + // the stable ref). + // eslint-disable-next-line react-hooks/exhaustive-deps + upToDateOnStop.current().catch(err => { + console.error('Failed to suspend AI request on tab close:', err); + }); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + const onRestore = React.useCallback( async ({ message, @@ -1265,6 +1314,7 @@ export const AskAiEditor: React.ComponentType = React.memo( }) } isSending={isSendingAiRequest(selectedAiRequestId)} + isSendingUserMessage={isSendingUserMessage} lastSendError={getLastSendError(selectedAiRequestId)} quota={quota} increaseQuotaOffering={ @@ -1285,18 +1335,7 @@ export const AskAiEditor: React.ComponentType = React.memo( isRefreshingLimits={isRefreshingLimits} onSendFeedback={onSendFeedback} hasOpenedProject={!!project} - isAutoProcessingFunctionCalls={ - selectedAiRequest - ? isAutoProcessingFunctionCalls(selectedAiRequest.id) - : false - } - setAutoProcessFunctionCalls={shouldAutoProcess => { - if (!selectedAiRequest) return; - setAutoProcessFunctionCalls( - selectedAiRequest.id, - shouldAutoProcess - ); - }} + onStop={onStop} i18n={i18n} editorCallbacks={editorCallbacks} onStartOrOpenChat={onStartOrOpenChat} @@ -1310,16 +1349,40 @@ export const AskAiEditor: React.ComponentType = React.memo( { - // Ensure function calls are not auto-processed when opening from history, - // otherwise it will automatically resume processing. - setAutoProcessFunctionCalls(aiRequest.id, false); + onSelectAiRequest={async aiRequest => { + let requestToOpen = aiRequest; + // Suspend the request if it was left with work in progress (e.g. from a previous session). + const editorFunctionCallResultsForRequest = + getEditorFunctionCallResults(aiRequest.id) || []; + if ( + aiRequestHasWorkInProgress( + aiRequest, + editorFunctionCallResultsForRequest + ) && + profile + ) { + try { + requestToOpen = await suspendAiRequest( + getAuthorizationHeader, + { + userId: profile.id, + aiRequestId: aiRequest.id, + } + ); + clearEditorFunctionCallResults(requestToOpen.id); + } catch (err) { + console.error( + 'Failed to suspend AI request when opening from history:', + err + ); + } + } // Immediately switch the UI and refresh in the background. - updateAiRequest(aiRequest.id, () => aiRequest); + updateAiRequest(requestToOpen.id, () => requestToOpen); setAiState({ - aiRequestId: aiRequest.id, + aiRequestId: requestToOpen.id, }); - refreshAiRequest(aiRequest.id); + refreshAiRequest(requestToOpen.id); onCloseHistory(); }} selectedAiRequestId={selectedAiRequestId} diff --git a/newIDE/app/src/AiGeneration/AskAiHistory.js b/newIDE/app/src/AiGeneration/AskAiHistory.js index fda626e743ab..9f0d9378bfb7 100644 --- a/newIDE/app/src/AiGeneration/AskAiHistory.js +++ b/newIDE/app/src/AiGeneration/AskAiHistory.js @@ -22,7 +22,7 @@ import { AiRequestContext } from './AiRequestContext'; type Props = {| open: boolean, onClose: () => void, - onSelectAiRequest: (aiRequest: AiRequest) => void, + onSelectAiRequest: (aiRequest: AiRequest) => void | Promise, selectedAiRequestId: string | null, |}; diff --git a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js index 781a97f90f0c..d0996e195e5e 100644 --- a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js +++ b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js @@ -5,6 +5,7 @@ import { AiRequestChat, type AiRequestChatInterface } from './AiRequestChat'; import { addMessageToAiRequest, createAiRequest, + suspendAiRequest, type AiRequest, } from '../Utils/GDevelopServices/Generation'; import { delay } from '../Utils/Delay'; @@ -18,6 +19,7 @@ import { retryIfFailed } from '../Utils/RetryIfFailed'; import { CreditsPackageStoreContext } from '../AssetStore/CreditsPackages/CreditsPackageStoreContext'; import { type EditorCallbacks } from '../EditorFunctions'; import { + aiRequestHasWorkInProgress, getFunctionCallNameByCallId, getFunctionCallOutputsFromEditorFunctionCallResults, getFunctionCallsToProcess, @@ -200,6 +202,7 @@ export const AskAiStandAloneForm = ({ const { openSubscriptionDialog } = React.useContext(SubscriptionContext); const [isRefreshingLimits, setIsRefreshingLimits] = React.useState(false); + const [isSendingUserMessage, setIsSendingUserMessage] = React.useState(false); const hideAskAi = !!limits && @@ -281,6 +284,7 @@ export const AskAiStandAloneForm = ({ : null; setSendingAiRequest(null, true); + setIsSendingUserMessage(true); const preparedAiUserContent = await prepareAiUserContent({ getAuthorizationHeader, @@ -313,6 +317,7 @@ export const AskAiStandAloneForm = ({ console.info('Successfully created a new AI request:', aiRequest); setSendingAiRequest(null, false); + setIsSendingUserMessage(false); updateAiRequest(aiRequest.id, () => aiRequest); // Select the new AI request just created - unless the user switched to another one @@ -337,6 +342,7 @@ export const AskAiStandAloneForm = ({ } catch (error) { console.error('Error starting a new AI request:', error); setLastSendError(null, error); + setIsSendingUserMessage(false); } // Refresh the user limits, to ensure quota and credits information @@ -545,11 +551,7 @@ export const AskAiStandAloneForm = ({ }, [onSendMessage] ); - const { - isAutoProcessingFunctionCalls, - setAutoProcessFunctionCalls, - onProcessFunctionCalls, - } = useProcessFunctionCalls({ + const { onProcessFunctionCalls } = useProcessFunctionCalls({ project, resourceManagementProps, selectedAiRequest: aiRequestForForm, @@ -567,6 +569,55 @@ export const AskAiStandAloneForm = ({ isReadyToProcessFunctionCalls: true, }); + const onStop = React.useCallback( + async () => { + if (!aiRequestForForm || !profile) return; + const editorFunctionCallResultsForRequest = + getEditorFunctionCallResults(aiRequestForForm.id) || []; + if ( + !aiRequestHasWorkInProgress( + aiRequestForForm, + editorFunctionCallResultsForRequest + ) + ) + return; + const suspendedRequest = await suspendAiRequest(getAuthorizationHeader, { + userId: profile.id, + aiRequestId: aiRequestForForm.id, + }); + updateAiRequest(suspendedRequest.id, () => suspendedRequest); + clearEditorFunctionCallResults(suspendedRequest.id); + }, + [ + aiRequestForForm, + profile, + getAuthorizationHeader, + updateAiRequest, + clearEditorFunctionCallResults, + getEditorFunctionCallResults, + ] + ); + + const upToDateOnStop = useStableUpToDateRef(onStop); + + // Suspend any running AI request when this form is unmounted. + React.useEffect( + () => { + return () => { + // Fire and forget - cannot await in a cleanup function. + // We intentionally read upToDateOnStop.current at cleanup time so + // we get the latest aiRequestForForm snapshot (that's the point of + // the stable ref). + // eslint-disable-next-line react-hooks/exhaustive-deps + upToDateOnStop.current().catch(err => { + console.error('Failed to suspend AI request on unmount:', err); + }); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + const { values, showAskAiStandAloneForm } = React.useContext( PreferencesContext ); @@ -633,6 +684,7 @@ export const AskAiStandAloneForm = ({ }) } isSending={isLoading} + isSendingUserMessage={isSendingUserMessage} lastSendError={getLastSendError(aiRequestIdForForm)} quota={quota} increaseQuotaOffering={ @@ -653,15 +705,7 @@ export const AskAiStandAloneForm = ({ isRefreshingLimits={isRefreshingLimits} onSendFeedback={async () => {}} hasOpenedProject={!!project} - isAutoProcessingFunctionCalls={ - aiRequestForForm - ? isAutoProcessingFunctionCalls(aiRequestForForm.id) - : false - } - setAutoProcessFunctionCalls={shouldAutoProcess => { - if (!aiRequestForForm) return; - setAutoProcessFunctionCalls(aiRequestForForm.id, shouldAutoProcess); - }} + onStop={onStop} i18n={i18n} editorCallbacks={editorCallbacks} onStartOrOpenChat={() => {}} diff --git a/newIDE/app/src/AiGeneration/Utils.js b/newIDE/app/src/AiGeneration/Utils.js index e47e8f69261c..9c09d1d7035e 100644 --- a/newIDE/app/src/AiGeneration/Utils.js +++ b/newIDE/app/src/AiGeneration/Utils.js @@ -106,15 +106,10 @@ export const useProcessFunctionCalls = ({ onExtensionInstalled: (extensionNames: Array) => void, isReadyToProcessFunctionCalls: boolean, |}): { - isAutoProcessingFunctionCalls: (aiRequestId: string) => boolean, onProcessFunctionCalls: ( functionCalls: Array, options: ?{ ignore?: boolean } ) => Promise, - setAutoProcessFunctionCalls: ( - aiRequestId: string, - shouldAutoProcess: boolean - ) => void, } => { const { ensureExtensionInstalled } = useEnsureExtensionInstalled({ project, @@ -132,30 +127,6 @@ export const useProcessFunctionCalls = ({ }); const { generateEvents } = useGenerateEvents({ project }); - const [ - aiRequestAutoProcessState, - setAiRequestAutoprocessState, - ] = React.useState<{ - [string]: boolean, - }>({}); - const isAutoProcessingFunctionCalls = React.useCallback( - (aiRequestId: string) => - aiRequestAutoProcessState[aiRequestId] !== undefined - ? aiRequestAutoProcessState[aiRequestId] - : true, - [aiRequestAutoProcessState] - ); - - const setAutoProcessFunctionCalls = React.useCallback( - (aiRequestId: string, shouldAutoProcess: boolean) => { - setAiRequestAutoprocessState(aiRequestAutoProcessState => ({ - ...aiRequestAutoProcessState, - [aiRequestId]: shouldAutoProcess, - })); - }, - [setAiRequestAutoprocessState] - ); - const onProcessFunctionCalls = React.useCallback( async ( functionCalls: Array, @@ -164,6 +135,7 @@ export const useProcessFunctionCalls = ({ |} ) => { if (!selectedAiRequest || !isReadyToProcessFunctionCalls) return; + if (selectedAiRequest.status === 'suspended') return; addEditorFunctionCallResults( selectedAiRequest.id, @@ -254,27 +226,16 @@ export const useProcessFunctionCalls = ({ () => { (async () => { if (!selectedAiRequest) return; - - if (isAutoProcessingFunctionCalls(selectedAiRequest.id)) { - if (allFunctionCallsToProcess.length === 0) { - return; - } - console.info('Automatically processing AI function calls...'); - await onProcessFunctionCalls(allFunctionCallsToProcess); - } + if (selectedAiRequest.status === 'suspended') return; + if (allFunctionCallsToProcess.length === 0) return; + console.info('Automatically processing AI function calls...'); + await onProcessFunctionCalls(allFunctionCallsToProcess); })(); }, - [ - selectedAiRequest, - isAutoProcessingFunctionCalls, - onProcessFunctionCalls, - allFunctionCallsToProcess, - ] + [selectedAiRequest, onProcessFunctionCalls, allFunctionCallsToProcess] ); return { - isAutoProcessingFunctionCalls, - setAutoProcessFunctionCalls, onProcessFunctionCalls, }; }; diff --git a/newIDE/app/src/UI/CustomSvgIcons/Stop.js b/newIDE/app/src/UI/CustomSvgIcons/Stop.js new file mode 100644 index 000000000000..c22f03cc8350 --- /dev/null +++ b/newIDE/app/src/UI/CustomSvgIcons/Stop.js @@ -0,0 +1,18 @@ +import React from 'react'; +import SvgIcon from '@material-ui/core/SvgIcon'; + +export default React.memo(props => ( + + + +)); diff --git a/newIDE/app/src/Utils/GDevelopServices/Generation.js b/newIDE/app/src/Utils/GDevelopServices/Generation.js index acd11e316d47..0d77f9470a1f 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Generation.js +++ b/newIDE/app/src/Utils/GDevelopServices/Generation.js @@ -12,7 +12,7 @@ import { export type Environment = 'staging' | 'live'; -export type GenerationStatus = 'working' | 'ready' | 'error'; +export type GenerationStatus = 'working' | 'ready' | 'error' | 'suspended'; export type AiRequestSuggestion = { title: string, @@ -42,6 +42,7 @@ export type AiRequestMessageAssistantFunctionCall = {| call_id: string, name: string, arguments: string, + taskId?: string, |}; export type AiRequestFunctionCallOutput = { @@ -505,6 +506,23 @@ export const addMessageToAiRequest = async ( }); }; +export const suspendAiRequest = async ( + getAuthorizationHeader: () => Promise, + { userId, aiRequestId }: {| userId: string, aiRequestId: string |} +): Promise => { + const authorizationHeader = await getAuthorizationHeader(); + const response = await apiClient.post( + `/ai-request/${aiRequestId}/action/suspend`, + {}, + { params: { userId }, headers: { Authorization: authorizationHeader } } + ); + return ensureObjectHasProperty({ + data: response.data, + propertyName: 'id', + endpointName: '/ai-request/{id}/action/suspend of Generation API', + }); +}; + export const updateAiRequestMessage = async ( getAuthorizationHeader: () => Promise, { diff --git a/newIDE/app/src/Utils/useStableValue.js b/newIDE/app/src/Utils/useStableValue.js new file mode 100644 index 000000000000..83b1f605c35d --- /dev/null +++ b/newIDE/app/src/Utils/useStableValue.js @@ -0,0 +1,34 @@ +// @flow +import * as React from 'react'; + +/** + * Returns a stable version of `value` that won't revert to `false` until at + * least `minimumDuration` ms have passed since it last became `true`. + * Useful to prevent UI elements from flashing when the underlying state + * changes very quickly (e.g. a network call that completes in < 100ms). + */ +const useStableValue = ({ + minimumDuration, + value, +}: {| + minimumDuration: number, + value: boolean, +|}): boolean => { + const [stableValue, setStableValue] = React.useState(value); + + React.useEffect( + () => { + if (value) { + setStableValue(true); + return; + } + const timeout = setTimeout(() => setStableValue(false), minimumDuration); + return () => clearTimeout(timeout); + }, + [value, minimumDuration] + ); + + return stableValue; +}; + +export default useStableValue; diff --git a/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/Agent.stories.js b/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/Agent.stories.js index b59fdb36c7af..ee269c467ef8 100644 --- a/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/Agent.stories.js +++ b/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/Agent.stories.js @@ -112,8 +112,7 @@ export const commonProps = { editorFunctionCallResults: ([]: Array), increaseQuotaOffering: 'subscribe', onProcessFunctionCalls: async () => {}, - setAutoProcessFunctionCalls: () => {}, - isAutoProcessingFunctionCalls: false, + onStop: async () => {}, onStartOrOpenChat: () => {}, aiRequestMode: 'agent', saveProject: async () => {}, @@ -317,7 +316,6 @@ export const ReadyAiRequestWithWorkingFunctionCall = (): React.Node => ( call_id: fakeFunctionCallId, }, ]} - isAutoProcessingFunctionCalls={true} /> ); export const ReadyAiRequestWithFinishedFunctionCallAndLaunchingRequest = (): React.Node => ( @@ -344,7 +342,6 @@ export const ReadyAiRequestWithFinishedFunctionCallAndLaunchingRequest = (): Rea }, }, ]} - isAutoProcessingFunctionCalls={true} /> ); @@ -371,7 +368,6 @@ export const WorkingAiRequestWithFinishedFunctionCall = (): React.Node => ( }, }, ]} - isAutoProcessingFunctionCalls={true} /> ); @@ -394,7 +390,6 @@ export const ReadyAiRequestWithIgnoredFunctionCall = (): React.Node => ( call_id: fakeFunctionCallId, }, ]} - isAutoProcessingFunctionCalls={true} /> ); @@ -421,7 +416,6 @@ export const ReadyAiRequestWithFailedFunctionCall = (): React.Node => ( }, }, ]} - isAutoProcessingFunctionCalls={true} /> ); @@ -438,7 +432,6 @@ export const ReadyAiRequestWithFunctionCallAndOutput = (): React.Node => ( output: fakeOutputWithFunctionCallAndOutput, error: null, }} - isAutoProcessingFunctionCalls={true} /> ); @@ -465,7 +458,6 @@ export const ReadyAiRequestWithFunctionCallWithSameCallId = (): React.Node => ( }, }, ]} - isAutoProcessingFunctionCalls={true} /> ); @@ -476,16 +468,12 @@ export const ReadyAiRequestWithFailedAndIgnoredFunctionCallOutputs = (): React.N ); export const LongReadyAiRequest = (): React.Node => ( - + ); export const LongReadyAiRequestForAnotherProject = (): React.Node => ( ); @@ -517,7 +505,6 @@ export const QuotaLimitsReachedAndAutomaticallyUsingCredits = (): React.Node => {}, - setAutoProcessFunctionCalls: () => {}, - isAutoProcessingFunctionCalls: false, + onStop: async () => {}, onStartOrOpenChat: () => {}, aiRequestMode: 'chat', saveProject: async () => {}, @@ -366,7 +365,6 @@ export const QuotaLimitsReachedAndAutomaticallyUsingCredits = (): React.Node => {}, - setAutoProcessFunctionCalls: () => {}, - isAutoProcessingFunctionCalls: false, + onStop: async () => {}, onStartOrOpenChat: () => {}, aiRequestMode: 'agent', saveProject: async () => {}, diff --git a/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/StandAloneForm.stories.js b/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/StandAloneForm.stories.js index 521cd1af9925..037ab088a387 100644 --- a/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/StandAloneForm.stories.js +++ b/newIDE/app/src/stories/componentStories/AiGeneration/AiRequestChat/StandAloneForm.stories.js @@ -98,8 +98,7 @@ const commonProps = { editorFunctionCallResults: [], increaseQuotaOffering: 'subscribe', onProcessFunctionCalls: async () => {}, - setAutoProcessFunctionCalls: () => {}, - isAutoProcessingFunctionCalls: false, + onStop: async () => {}, onStartOrOpenChat: () => {}, aiRequestMode: 'agent', saveProject: async () => {}, From dd70f397004d7bab4ba2515c2f4e1e4631eee5be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:03:07 +0100 Subject: [PATCH 5/5] Update flow for suspension of request --- .../app/src/AiGeneration/AiRequestContext.js | 158 +++++++++++++++++- newIDE/app/src/AiGeneration/AiRequestUtils.js | 7 + .../src/AiGeneration/AskAiEditorContainer.js | 14 +- .../src/AiGeneration/AskAiStandAloneForm.js | 17 +- newIDE/app/src/AiGeneration/Utils.js | 143 +--------------- 5 files changed, 193 insertions(+), 146 deletions(-) diff --git a/newIDE/app/src/AiGeneration/AiRequestContext.js b/newIDE/app/src/AiGeneration/AiRequestContext.js index 0022797c97c0..ead03635a280 100644 --- a/newIDE/app/src/AiGeneration/AiRequestContext.js +++ b/newIDE/app/src/AiGeneration/AiRequestContext.js @@ -2,6 +2,7 @@ import * as React from 'react'; import { getAiRequest, + getPartialAiRequest, fetchAiSettings, type AiRequest, type AiSettings, @@ -13,6 +14,8 @@ import Window from '../Utils/Window'; import { AI_SETTINGS_FETCH_TIMEOUT } from '../Utils/GlobalFetchTimeouts'; import { useAsyncLazyMemo } from '../Utils/UseLazyMemo'; import { retryIfFailed } from '../Utils/RetryIfFailed'; +import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; +import { useInterval } from '../Utils/UseInterval'; type EditorFunctionCallResultsStorage = {| getEditorFunctionCallResults: ( @@ -436,6 +439,8 @@ type AiRequestContextState = {| aiRequestHistory: AiRequestHistory, editorFunctionCallResultsStorage: EditorFunctionCallResultsStorage, getAiSettings: () => AiSettings | null, + isFetchingSuggestions: boolean, + setIsFetchingSuggestions: (value: boolean) => void, |}; export const initialAiRequestContextState: AiRequestContextState = { @@ -465,6 +470,8 @@ export const initialAiRequestContextState: AiRequestContextState = { clearEditorFunctionCallResults: () => {}, }, getAiSettings: () => null, + isFetchingSuggestions: false, + setIsFetchingSuggestions: () => {}, }; export const AiRequestContext: React.Context = React.createContext( initialAiRequestContextState @@ -481,6 +488,151 @@ export const AiRequestProvider = ({ const aiRequestStorage = useAiRequestsStorage(); const aiRequestHistory = useAiRequestHistory(aiRequestStorage); + const { profile, getAuthorizationHeader } = React.useContext( + AuthenticatedUserContext + ); + const { values } = React.useContext(PreferencesContext); + const selectedAiRequestId = values.aiState.aiRequestId; + const { aiRequests, updateAiRequest } = aiRequestStorage; + const selectedAiRequest = + (selectedAiRequestId && aiRequests[selectedAiRequestId]) || null; + + const [shouldWatchRequest, setShouldWatchRequest] = React.useState( + false + ); + const [ + isFetchingSuggestions, + setIsFetchingSuggestions, + ] = React.useState(false); + const lastFullFetchTimeRef = React.useRef(0); + const fullFetchIntervalInMs = 5000; + + // If the selected AI request is in a "working" state, watch it until it's finished. + // Every ~1.4s we do a partial (status-only) fetch; every 5s we do a full fetch to + // pick up new messages from the orchestrator/agent while it is still running. + const status = selectedAiRequest ? selectedAiRequest.status : null; + const onWatch = async () => { + if (!profile) return; + if (!selectedAiRequestId || !status || status !== 'working') return; + + const now = Date.now(); + const shouldDoFullFetch = + now - lastFullFetchTimeRef.current >= fullFetchIntervalInMs; + + try { + if (shouldDoFullFetch) { + lastFullFetchTimeRef.current = now; + const aiRequest = await retryIfFailed({ times: 2 }, () => + getAiRequest(getAuthorizationHeader, { + userId: profile.id, + aiRequestId: selectedAiRequestId, + }) + ); + + updateAiRequest(selectedAiRequestId, () => aiRequest); + + if (isFetchingSuggestions) { + const lastMessage = + aiRequest.output.length > 0 + ? aiRequest.output[aiRequest.output.length - 1] + : null; + const hasSuggestions = + lastMessage && + ((lastMessage.type === 'message' && + lastMessage.role === 'assistant') || + lastMessage.type === 'function_call_output') && + lastMessage.suggestions; + + if (aiRequest.status === 'ready' || hasSuggestions) { + setIsFetchingSuggestions(false); + } + } + } else { + // Use partial request to only fetch the status between full fetches. + const partialAiRequest = await getPartialAiRequest( + getAuthorizationHeader, + { + userId: profile.id, + aiRequestId: selectedAiRequestId, + include: 'status', + } + ); + + if (partialAiRequest.status === 'working') { + updateAiRequest(selectedAiRequestId, prevRequest => ({ + ...(prevRequest || {}), + ...partialAiRequest, + })); + } else { + // Status changed — do a full fetch immediately to get the latest data. + lastFullFetchTimeRef.current = now; + const aiRequest = await retryIfFailed({ times: 2 }, () => + getAiRequest(getAuthorizationHeader, { + userId: profile.id, + aiRequestId: selectedAiRequestId, + }) + ); + + updateAiRequest(selectedAiRequestId, () => aiRequest); + + if (isFetchingSuggestions) { + const lastMessage = + aiRequest.output.length > 0 + ? aiRequest.output[aiRequest.output.length - 1] + : null; + const hasSuggestions = + lastMessage && + ((lastMessage.type === 'message' && + lastMessage.role === 'assistant') || + lastMessage.type === 'function_call_output') && + lastMessage.suggestions; + + if (aiRequest.status === 'ready' || hasSuggestions) { + setIsFetchingSuggestions(false); + } + } + } + } + } catch (error) { + console.warn( + 'Error while watching AI request. Ignoring and will retry on the next interval.', + error + ); + } + }; + + const watchPollingIntervalInMs = + (selectedAiRequest && + selectedAiRequest.toolOptions && + selectedAiRequest.toolOptions.watchPollingIntervalInMs) || + 1400; + useInterval( + () => { + onWatch(); + }, + shouldWatchRequest ? watchPollingIntervalInMs : null + ); + + React.useEffect( + () => { + if ( + selectedAiRequestId && + selectedAiRequest && + selectedAiRequest.status === 'working' + ) { + setShouldWatchRequest(true); + } else { + setShouldWatchRequest(false); + } + + // Ensure we stop watching when the request is no longer working. + return () => { + setShouldWatchRequest(false); + }; + }, + [selectedAiRequestId, selectedAiRequest] + ); + const environment = Window.isDev() ? 'staging' : 'live'; const getAiSettings = useAsyncLazyMemo( React.useCallback( @@ -513,17 +665,21 @@ export const AiRequestProvider = ({ ); const state = React.useMemo( - () => ({ + (): AiRequestContextState => ({ aiRequestStorage, aiRequestHistory, editorFunctionCallResultsStorage, getAiSettings, + isFetchingSuggestions, + setIsFetchingSuggestions, }), [ aiRequestStorage, aiRequestHistory, editorFunctionCallResultsStorage, getAiSettings, + isFetchingSuggestions, + setIsFetchingSuggestions, ] ); diff --git a/newIDE/app/src/AiGeneration/AiRequestUtils.js b/newIDE/app/src/AiGeneration/AiRequestUtils.js index e03d57564fe8..a731a6a64136 100644 --- a/newIDE/app/src/AiGeneration/AiRequestUtils.js +++ b/newIDE/app/src/AiGeneration/AiRequestUtils.js @@ -180,6 +180,13 @@ export const aiRequestHasWorkInProgress = ( editorFunctionCallResults: Array | null ): boolean => { if (aiRequest.status === 'working') return true; + // A function call is actively being executed by the editor (e.g. generateEvents + // polling). Even if the request status is 'ready', there's still work in progress. + if ( + editorFunctionCallResults && + editorFunctionCallResults.some(r => r.status === 'working') + ) + return true; if (aiRequest.status === 'ready') { return ( getFunctionCallsToProcess({ diff --git a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js index 411382a34425..fba3b9afc3ae 100644 --- a/newIDE/app/src/AiGeneration/AskAiEditorContainer.js +++ b/newIDE/app/src/AiGeneration/AskAiEditorContainer.js @@ -989,15 +989,25 @@ export const AskAiEditor: React.ComponentType = React.memo( ) ) return; + // Optimistic update: mark as suspended locally immediately so that + // any in-flight async code (e.g. processEditorFunctionCalls, + // prepareAiUserContent) sees the suspended status after the next + // React render — before the API call even completes. + const requestIdToSuspend = selectedAiRequest.id; + updateAiRequest(requestIdToSuspend, prevRequest => ({ + ...(prevRequest || selectedAiRequest), + status: 'suspended', + })); + clearEditorFunctionCallResults(requestIdToSuspend); + const suspendedRequest = await suspendAiRequest( getAuthorizationHeader, { userId: profile.id, - aiRequestId: selectedAiRequest.id, + aiRequestId: requestIdToSuspend, } ); updateAiRequest(suspendedRequest.id, () => suspendedRequest); - clearEditorFunctionCallResults(suspendedRequest.id); }, [ selectedAiRequest, diff --git a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js index d0996e195e5e..590f83642b7f 100644 --- a/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js +++ b/newIDE/app/src/AiGeneration/AskAiStandAloneForm.js @@ -41,7 +41,6 @@ import { getAiConfigurationPresetsWithAvailability } from './AiConfiguration'; import { type CreateProjectResult } from '../Utils/UseCreateProject'; import { SubscriptionContext } from '../Profile/Subscription/SubscriptionContext'; import { - useAiRequestState, useProcessFunctionCalls, type NewAiRequestOptions, AI_ORCHESTRATOR_TOOLS_VERSION, @@ -173,7 +172,6 @@ export const AskAiStandAloneForm = ({ setSendingAiRequest, setLastSendError, } = aiRequestStorage; - const { setAiState } = useAiRequestState({ project }); const [aiRequestIdForForm, setAiRequestIdForForm] = React.useState< string | null >(null); @@ -189,6 +187,7 @@ export const AskAiStandAloneForm = ({ ); const { values: { automaticallyUseCreditsForAiRequests }, + setAiState, } = React.useContext(PreferencesContext); const { @@ -581,12 +580,22 @@ export const AskAiStandAloneForm = ({ ) ) return; + // Optimistic update: mark as suspended locally immediately so that + // any in-flight async code (e.g. processEditorFunctionCalls, + // prepareAiUserContent) sees the suspended status after the next + // React render — before the API call even completes. + const requestIdToSuspend = aiRequestForForm.id; + updateAiRequest(requestIdToSuspend, prevRequest => ({ + ...(prevRequest || aiRequestForForm), + status: 'suspended', + })); + clearEditorFunctionCallResults(requestIdToSuspend); + const suspendedRequest = await suspendAiRequest(getAuthorizationHeader, { userId: profile.id, - aiRequestId: aiRequestForForm.id, + aiRequestId: requestIdToSuspend, }); updateAiRequest(suspendedRequest.id, () => suspendedRequest); - clearEditorFunctionCallResults(suspendedRequest.id); }, [ aiRequestForForm, diff --git a/newIDE/app/src/AiGeneration/Utils.js b/newIDE/app/src/AiGeneration/Utils.js index 9c09d1d7035e..96c812f2db74 100644 --- a/newIDE/app/src/AiGeneration/Utils.js +++ b/newIDE/app/src/AiGeneration/Utils.js @@ -9,7 +9,6 @@ import { } from '../MainFrame/EditorContainers/BaseEditor'; import { getAiRequest, - getPartialAiRequest, getAiRequestSuggestions, type AiRequest, type AiRequestMessage, @@ -36,11 +35,10 @@ import { useSearchAndInstallResource } from './UseSearchAndInstallResource'; import { type ResourceManagementProps } from '../ResourcesList/ResourceSource'; import { AiRequestContext } from './AiRequestContext'; import PreferencesContext from '../MainFrame/Preferences/PreferencesContext'; -import { useInterval } from '../Utils/UseInterval'; + import { makeSimplifiedProjectBuilder } from '../EditorFunctions/SimplifiedProject/SimplifiedProject'; import { prepareAiUserContent } from './PrepareAiUserContent'; import { extractGDevelopApiErrorStatusAndCode } from '../Utils/GDevelopServices/Errors'; -import { retryIfFailed } from '../Utils/RetryIfFailed'; import UnsavedChangesContext from '../MainFrame/UnsavedChangesContext'; import { type FileMetadata, @@ -272,6 +270,8 @@ export const useAiRequestState = ({ const { aiRequestStorage, editorFunctionCallResultsStorage, + isFetchingSuggestions, + setIsFetchingSuggestions, } = React.useContext(AiRequestContext); const { aiRequests, updateAiRequest, isSendingAiRequest } = aiRequestStorage; const { getEditorFunctionCallResults } = editorFunctionCallResultsStorage; @@ -281,14 +281,6 @@ export const useAiRequestState = ({ const selectedAiRequest = (selectedAiRequestId && aiRequests[selectedAiRequestId]) || null; - - const [shouldWatchRequest, setShouldWatchRequest] = React.useState( - false - ); - const [ - isFetchingSuggestions, - setIsFetchingSuggestions, - ] = React.useState(false); const [ savingProjectForMessageId, setSavingProjectForMessageId, @@ -299,120 +291,12 @@ export const useAiRequestState = ({ authenticatedUser ); const isSavingRef = React.useRef(false); - const lastFullFetchTimeRef = React.useRef(0); - const fullFetchIntervalInMs = 5000; const currentlyOpenedCloudProjectVersionId = fileMetadata && storageProviderName === CloudStorageProvider.internalName ? fileMetadata.version : null; - // If the selected AI request is in a "working" state, watch it until it's finished. - // Every ~1.4s we do a partial (status-only) fetch; every 5s we do a full fetch to - // pick up new messages from the orchestrator/agent while it is still running. - const status = selectedAiRequest ? selectedAiRequest.status : null; - const onWatch = async () => { - if (!profile) return; - if (!selectedAiRequestId || !status || status !== 'working') return; - - const now = Date.now(); - const shouldDoFullFetch = - now - lastFullFetchTimeRef.current >= fullFetchIntervalInMs; - - try { - if (shouldDoFullFetch) { - lastFullFetchTimeRef.current = now; - const aiRequest = await retryIfFailed({ times: 2 }, () => - getAiRequest(getAuthorizationHeader, { - userId: profile.id, - aiRequestId: selectedAiRequestId, - }) - ); - - updateAiRequest(selectedAiRequestId, () => aiRequest); - - if (isFetchingSuggestions) { - const lastMessage = - aiRequest.output.length > 0 - ? aiRequest.output[aiRequest.output.length - 1] - : null; - const hasSuggestions = - lastMessage && - ((lastMessage.type === 'message' && - lastMessage.role === 'assistant') || - lastMessage.type === 'function_call_output') && - lastMessage.suggestions; - - if (aiRequest.status === 'ready' || hasSuggestions) { - setIsFetchingSuggestions(false); - } - } - } else { - // Use partial request to only fetch the status between full fetches. - const partialAiRequest = await getPartialAiRequest( - getAuthorizationHeader, - { - userId: profile.id, - aiRequestId: selectedAiRequestId, - include: 'status', - } - ); - - if (partialAiRequest.status === 'working') { - updateAiRequest(selectedAiRequestId, prevRequest => ({ - ...(prevRequest || {}), - ...partialAiRequest, - })); - } else { - // Status changed — do a full fetch immediately to get the latest data. - lastFullFetchTimeRef.current = now; - const aiRequest = await retryIfFailed({ times: 2 }, () => - getAiRequest(getAuthorizationHeader, { - userId: profile.id, - aiRequestId: selectedAiRequestId, - }) - ); - - updateAiRequest(selectedAiRequestId, () => aiRequest); - - if (isFetchingSuggestions) { - const lastMessage = - aiRequest.output.length > 0 - ? aiRequest.output[aiRequest.output.length - 1] - : null; - const hasSuggestions = - lastMessage && - ((lastMessage.type === 'message' && - lastMessage.role === 'assistant') || - lastMessage.type === 'function_call_output') && - lastMessage.suggestions; - - if (aiRequest.status === 'ready' || hasSuggestions) { - setIsFetchingSuggestions(false); - } - } - } - } - } catch (error) { - console.warn( - 'Error while watching AI request. Ignoring and will retry on the next interval.', - error - ); - } - }; - - const watchPollingIntervalInMs = - (selectedAiRequest && - selectedAiRequest.toolOptions && - selectedAiRequest.toolOptions.watchPollingIntervalInMs) || - 1400; - useInterval( - () => { - onWatch(); - }, - shouldWatchRequest ? watchPollingIntervalInMs : null - ); - React.useEffect( () => { async function fetchSuggestionsIfNeeded() { @@ -576,6 +460,7 @@ export const useAiRequestState = ({ updateAiRequest, isSendingAiRequest, isFetchingSuggestions, + setIsFetchingSuggestions, ] ); @@ -856,26 +741,6 @@ export const useAiRequestState = ({ ] ); - React.useEffect( - () => { - if ( - selectedAiRequestId && - selectedAiRequest && - selectedAiRequest.status === 'working' - ) { - setShouldWatchRequest(true); - } else { - setShouldWatchRequest(false); - } - - // Ensure we stop watching when the request is no longer working. - return () => { - setShouldWatchRequest(false); - }; - }, - [selectedAiRequestId, selectedAiRequest] - ); - React.useEffect( () => { // Reset selected request if user logs out.