diff --git a/projects/js-packages/ai-client/changelog/update-jetpack-ai-merge-hooks b/projects/js-packages/ai-client/changelog/update-jetpack-ai-merge-hooks new file mode 100644 index 0000000000000..7821b00cb4d8c --- /dev/null +++ b/projects/js-packages/ai-client/changelog/update-jetpack-ai-merge-hooks @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +AI Client: Add callbacks, initial requesting state and change error handling diff --git a/projects/js-packages/ai-client/package.json b/projects/js-packages/ai-client/package.json index 5e8553b9ac77e..25087223a8062 100644 --- a/projects/js-packages/ai-client/package.json +++ b/projects/js-packages/ai-client/package.json @@ -1,7 +1,7 @@ { "private": false, "name": "@automattic/jetpack-ai-client", - "version": "0.12.0", + "version": "0.12.1-alpha", "description": "A JS client for consuming Jetpack AI services", "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/ai-client/#readme", "bugs": { diff --git a/projects/js-packages/ai-client/src/hooks/use-ai-suggestions/index.ts b/projects/js-packages/ai-client/src/hooks/use-ai-suggestions/index.ts index ff0235c086659..810ad7d4ca4e3 100644 --- a/projects/js-packages/ai-client/src/hooks/use-ai-suggestions/index.ts +++ b/projects/js-packages/ai-client/src/hooks/use-ai-suggestions/index.ts @@ -3,17 +3,18 @@ */ import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import debugFactory from 'debug'; /** * Internal dependencies */ import askQuestion from '../../ask-question/index.js'; import { + ERROR_CONTEXT_TOO_LARGE, ERROR_MODERATION, ERROR_NETWORK, ERROR_QUOTA_EXCEEDED, ERROR_SERVICE_UNAVAILABLE, ERROR_UNCLEAR_PROMPT, + ERROR_RESPONSE, } from '../../types.js'; /** * Types & constants @@ -56,6 +57,11 @@ type useAiSuggestionsOptions = { */ askQuestionOptions?: AskQuestionOptionsArgProps; + /* + * Initial requesting state. + */ + initialRequestingState?: RequestingStateProp; + /* * onSuggestion callback. */ @@ -66,10 +72,20 @@ type useAiSuggestionsOptions = { */ onDone?: ( content: string ) => void; + /* + * onStop callback. + */ + onStop?: () => void; + /* * onError callback. */ onError?: ( error: RequestingErrorProps ) => void; + + /* + * Error callback common for all errors. + */ + onAllErrors?: ( error: RequestingErrorProps ) => void; }; type useAiSuggestionsProps = { @@ -107,9 +123,12 @@ type useAiSuggestionsProps = { * The handler to stop a suggestion. */ stopSuggestion: () => void; -}; -const debug = debugFactory( 'jetpack-ai-client:use-suggestion' ); + /* + * The handler to handle the quota exceeded error. + */ + handleErrorQuotaExceededError: () => void; +}; /** * Get the error data for a given error code. @@ -149,6 +168,15 @@ export function getErrorData( errorCode: SuggestionErrorCode ): RequestingErrorP ), severity: 'info', }; + case ERROR_CONTEXT_TOO_LARGE: + return { + code: ERROR_CONTEXT_TOO_LARGE, + message: __( + 'The content is too large to be processed all at once. Please try to shorten it or divide it into smaller parts.', + 'jetpack-ai-client' + ), + severity: 'info', + }; case ERROR_NETWORK: default: return { @@ -173,11 +201,15 @@ export default function useAiSuggestions( { prompt, autoRequest = false, askQuestionOptions = {}, + initialRequestingState = 'init', onSuggestion, onDone, + onStop, onError, + onAllErrors, }: useAiSuggestionsOptions = {} ): useAiSuggestionsProps { - const [ requestingState, setRequestingState ] = useState< RequestingStateProp >( 'init' ); + const [ requestingState, setRequestingState ] = + useState< RequestingStateProp >( initialRequestingState ); const [ suggestion, setSuggestion ] = useState< string >( '' ); const [ error, setError ] = useState< RequestingErrorProps >(); @@ -206,12 +238,20 @@ export default function useAiSuggestions( { */ const handleDone = useCallback( ( event: CustomEvent ) => { + closeEventSource(); onDone?.( event?.detail ); setRequestingState( 'done' ); }, [ onDone ] ); + const handleAnyError = useCallback( + ( event: CustomEvent ) => { + onAllErrors?.( event?.detail ); + }, + [ onAllErrors ] + ); + const handleError = useCallback( ( errorCode: SuggestionErrorCode ) => { eventSourceRef?.current?.close(); @@ -250,43 +290,34 @@ export default function useAiSuggestions( { promptArg: PromptProp, options: AskQuestionOptionsArgProps = { ...askQuestionOptions } ) => { - if ( Array.isArray( promptArg ) && promptArg?.length ) { - promptArg.forEach( ( { role, content: promptContent }, i ) => - debug( '(%s/%s) %o\n%s', i + 1, promptArg.length, `[${ role }]`, promptContent ) - ); - } else { - debug( '%o', promptArg ); - } + // Clear any error. + setError( undefined ); // Set the request status. setRequestingState( 'requesting' ); - try { - eventSourceRef.current = await askQuestion( promptArg, options ); + eventSourceRef.current = await askQuestion( promptArg, options ); - if ( ! eventSourceRef?.current ) { - return; - } + if ( ! eventSourceRef?.current ) { + return; + } - // Alias - const eventSource = eventSourceRef.current; + // Alias + const eventSource = eventSourceRef.current; - // Set the request status. - setRequestingState( 'suggesting' ); + // Set the request status. + setRequestingState( 'suggesting' ); - eventSource.addEventListener( 'suggestion', handleSuggestion ); + eventSource.addEventListener( 'suggestion', handleSuggestion ); - eventSource.addEventListener( ERROR_QUOTA_EXCEEDED, handleErrorQuotaExceededError ); - eventSource.addEventListener( ERROR_UNCLEAR_PROMPT, handleUnclearPromptError ); - eventSource.addEventListener( ERROR_SERVICE_UNAVAILABLE, handleServiceUnavailableError ); - eventSource.addEventListener( ERROR_MODERATION, handleModerationError ); - eventSource.addEventListener( ERROR_NETWORK, handleNetworkError ); + eventSource.addEventListener( ERROR_QUOTA_EXCEEDED, handleErrorQuotaExceededError ); + eventSource.addEventListener( ERROR_UNCLEAR_PROMPT, handleUnclearPromptError ); + eventSource.addEventListener( ERROR_SERVICE_UNAVAILABLE, handleServiceUnavailableError ); + eventSource.addEventListener( ERROR_MODERATION, handleModerationError ); + eventSource.addEventListener( ERROR_NETWORK, handleNetworkError ); + eventSource.addEventListener( ERROR_RESPONSE, handleAnyError ); - eventSource.addEventListener( 'done', handleDone ); - } catch ( e ) { - // eslint-disable-next-line no-console - console.error( e ); - } + eventSource.addEventListener( 'done', handleDone ); }, [ handleDone, @@ -311,11 +342,11 @@ export default function useAiSuggestions( { }, [] ); /** - * Stop suggestion handler. + * Close the event source connection. * * @returns {void} */ - const stopSuggestion = useCallback( () => { + const closeEventSource = useCallback( () => { if ( ! eventSourceRef?.current ) { return; } @@ -336,9 +367,6 @@ export default function useAiSuggestions( { eventSource.removeEventListener( ERROR_NETWORK, handleNetworkError ); eventSource.removeEventListener( 'done', handleDone ); - - // Set requesting state to done since the suggestion stopped. - setRequestingState( 'done' ); }, [ eventSourceRef, handleSuggestion, @@ -350,6 +378,17 @@ export default function useAiSuggestions( { handleDone, ] ); + /** + * Stop suggestion handler. + * + * @returns {void} + */ + const stopSuggestion = useCallback( () => { + closeEventSource(); + onStop?.(); + setRequestingState( 'done' ); + }, [ onStop ] ); + // Request suggestions automatically when ready. useEffect( () => { // Check if there is a prompt to request. @@ -379,6 +418,9 @@ export default function useAiSuggestions( { stopSuggestion, reset, + // Error handlers + handleErrorQuotaExceededError, + // SuggestionsEventSource eventSource: eventSourceRef.current, }; diff --git a/projects/js-packages/ai-client/src/hooks/use-transcription-post-processing/index.ts b/projects/js-packages/ai-client/src/hooks/use-transcription-post-processing/index.ts index 93145e0c16aa1..7aeee850b7e2e 100644 --- a/projects/js-packages/ai-client/src/hooks/use-transcription-post-processing/index.ts +++ b/projects/js-packages/ai-client/src/hooks/use-transcription-post-processing/index.ts @@ -83,7 +83,6 @@ export default function useTranscriptionPostProcessing( { ); const { request, stopSuggestion } = useAiSuggestions( { - autoRequest: false, onSuggestion: handleOnSuggestion, onDone: handleOnDone, onError: handleOnError, diff --git a/projects/js-packages/ai-client/src/index.ts b/projects/js-packages/ai-client/src/index.ts index 786cba086af99..2c448c2ec6c94 100644 --- a/projects/js-packages/ai-client/src/index.ts +++ b/projects/js-packages/ai-client/src/index.ts @@ -9,7 +9,7 @@ export { default as transcribeAudio } from './audio-transcription/index.js'; /* * Hooks */ -export { default as useAiSuggestions } from './hooks/use-ai-suggestions/index.js'; +export { default as useAiSuggestions, getErrorData } from './hooks/use-ai-suggestions/index.js'; export { default as useMediaRecording } from './hooks/use-media-recording/index.js'; export { default as useAudioTranscription } from './hooks/use-audio-transcription/index.js'; export { default as useTranscriptionPostProcessing } from './hooks/use-transcription-post-processing/index.js'; diff --git a/projects/js-packages/ai-client/src/suggestions-event-source/index.ts b/projects/js-packages/ai-client/src/suggestions-event-source/index.ts index 2d691668d5c5f..30b31d7877a00 100644 --- a/projects/js-packages/ai-client/src/suggestions-event-source/index.ts +++ b/projects/js-packages/ai-client/src/suggestions-event-source/index.ts @@ -197,7 +197,9 @@ export default class SuggestionsEventSource extends EventTarget { response.status <= 500 && ! [ 413, 422, 429 ].includes( response.status ) ) { - this.processConnectionError( response ); + debug( 'Connection error: %o', response ); + errorCode = ERROR_NETWORK; + this.dispatchEvent( new CustomEvent( ERROR_NETWORK, { detail: response } ) ); } /* @@ -358,16 +360,6 @@ export default class SuggestionsEventSource extends EventTarget { } } - processConnectionError( response ) { - debug( 'Connection error: %o', response ); - this.dispatchEvent( new CustomEvent( ERROR_NETWORK, { detail: response } ) ); - this.dispatchEvent( - new CustomEvent( ERROR_RESPONSE, { - detail: getErrorData( ERROR_NETWORK ), - } ) - ); - } - processErrorEvent( e ) { debug( 'onerror: %o', e ); diff --git a/projects/plugins/jetpack/changelog/update-jetpack-ai-merge-hooks b/projects/plugins/jetpack/changelog/update-jetpack-ai-merge-hooks new file mode 100644 index 0000000000000..47ce9b176a82e --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-jetpack-ai-merge-hooks @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +AI Assistant: Rename useSuggestionsFromOpenAI to useAIAssistant and deduplicate suggestion logic diff --git a/projects/plugins/jetpack/extensions/blocks/ai-assistant/edit.js b/projects/plugins/jetpack/extensions/blocks/ai-assistant/edit.js index 3e709c8940e02..d260306a0863f 100644 --- a/projects/plugins/jetpack/extensions/blocks/ai-assistant/edit.js +++ b/projects/plugins/jetpack/extensions/blocks/ai-assistant/edit.js @@ -24,9 +24,9 @@ import FeedbackControl from './components/feedback-control'; import ToolbarControls from './components/toolbar-controls'; import UpgradePrompt from './components/upgrade-prompt'; import { getStoreBlockId } from './extensions/ai-assistant/with-ai-assistant'; +import useAIAssistant from './hooks/use-ai-assistant'; import useAICheckout from './hooks/use-ai-checkout'; import useAiFeature from './hooks/use-ai-feature'; -import useSuggestionsFromOpenAI from './hooks/use-suggestions-from-openai'; import { isUserConnected } from './lib/connection'; import './editor.scss'; @@ -37,7 +37,6 @@ const markdownConverter = new MarkdownIt( { const isInBlockEditor = window?.Jetpack_Editor_Initial_State?.screenBase === 'post'; export default function AIAssistantEdit( { attributes, setAttributes, clientId, isSelected } ) { - const [ errorData, setError ] = useState( {} ); const [ errorDismissed, setErrorDismissed ] = useState( null ); const { tracks } = useAnalytics(); @@ -85,8 +84,6 @@ export default function AIAssistantEdit( { attributes, setAttributes, clientId, const contentRef = useRef( null ); const { - isLoadingCompletion, - wasCompletionJustRequested, getSuggestionFromOpenAI, stopSuggestion, showRetry, @@ -95,7 +92,8 @@ export default function AIAssistantEdit( { attributes, setAttributes, clientId, retryRequest, wholeContent, requestingState, - } = useSuggestionsFromOpenAI( { + error, + } = useAIAssistant( { onSuggestionDone: useCallback( () => { focusOnPrompt(); increaseRequestsCount(); @@ -108,15 +106,17 @@ export default function AIAssistantEdit( { attributes, setAttributes, clientId, attributes, clientId, content: attributes.content, - setError, tracks, userPrompt: attributes.userPrompt, requireUpgrade, - requestingState: attributes.requestingState, + initialRequestingState: attributes.requestingState, contentRef, blockRef, } ); + const isWaitingResponse = requestingState === 'requesting'; + const isLoadingCompletion = [ 'requesting', 'suggesting' ].includes( requestingState ); + const connected = isUserConnected(); /* @@ -144,10 +144,10 @@ export default function AIAssistantEdit( { attributes, setAttributes, clientId, }, [ storeBlockId, getSuggestionFromOpenAI ] ); useEffect( () => { - if ( errorData ) { + if ( error ) { setErrorDismissed( false ); } - }, [ errorData ] ); + }, [ error ] ); useEffect( () => { // we don't want to store "half way" states @@ -296,7 +296,7 @@ export default function AIAssistantEdit( { attributes, setAttributes, clientId, const blockProps = useBlockProps( { ref: blockRef, - className: classNames( { 'is-waiting-response': wasCompletionJustRequested } ), + className: classNames( { 'is-waiting-response': isWaitingResponse } ), } ); const promptPlaceholder = __( 'Ask Jetpack AI…', 'jetpack' ); @@ -309,15 +309,15 @@ export default function AIAssistantEdit( { attributes, setAttributes, clientId, ); - const error = ( + const errorNotice = ( <> - { errorData?.message && ! errorDismissed && errorData?.code !== 'error_quota_exceeded' && ( + { error?.message && ! errorDismissed && error?.code !== 'error_quota_exceeded' && ( - { errorData.message } + { error.message } ) } @@ -414,7 +414,7 @@ export default function AIAssistantEdit( { attributes, setAttributes, clientId, showGuideLine={ contentIsLoaded } showRemove={ attributes?.content?.length > 0 } bannerComponent={ banner } - errorComponent={ error } + errorComponent={ errorNotice } customFooter={ // Only show the upgrade message on each 5th request or if it's the first request - and only if the user is on the free plan ( requestsRemaining % 5 === 0 || requestsCount === 1 ) && diff --git a/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-assistant/index.js b/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-assistant/index.js new file mode 100644 index 0000000000000..57316fd0ccaa4 --- /dev/null +++ b/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-ai-assistant/index.js @@ -0,0 +1,262 @@ +/** + * External dependencies + */ +import { + useAiSuggestions, + ERROR_CONTEXT_TOO_LARGE, + ERROR_MODERATION, + ERROR_NETWORK, + ERROR_QUOTA_EXCEEDED, + ERROR_SERVICE_UNAVAILABLE, + ERROR_UNCLEAR_PROMPT, +} from '@automattic/jetpack-ai-client'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { useState, useRef } from '@wordpress/element'; +import debugFactory from 'debug'; +/** + * Internal dependencies + */ +import { DEFAULT_PROMPT_TONE } from '../../components/tone-dropdown-control'; +import { buildPromptForBlock, delimiter } from '../../lib/prompt'; +import { + getContentFromBlocks, + getPartialContentToBlock, + getTextContentFromInnerBlocks, +} from '../../lib/utils/block-content'; +import useAutoScroll from '../use-auto-scroll'; + +const debugError = debugFactory( 'jetpack-ai-assistant:error' ); + +const useAIAssistant = ( { + attributes, + clientId, + content, + tracks, + userPrompt, + onSuggestionDone, + onUnclearPrompt, + onModeration, + requireUpgrade, + initialRequestingState, + contentRef, + blockRef, +} ) => { + const [ showRetry, setShowRetry ] = useState( false ); + const [ lastPrompt, setLastPrompt ] = useState( '' ); + const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); + const { dequeueAiAssistantFeatureAsyncRequest, setAiAssistantFeatureRequireUpgrade } = + useDispatch( 'wordpress-com/plans' ); + + const { snapToBottom, enableAutoScroll, disableAutoScroll } = useAutoScroll( + blockRef, + contentRef + ); + + // Let's grab post data so that we can do something smart. + const currentPostTitle = useSelect( select => + select( 'core/editor' ).getEditedPostAttribute( 'title' ) + ); + + const postId = useSelect( select => select( 'core/editor' ).getCurrentPostId() ); + + const updatedMessages = useRef( [] ); + const lastUserPrompt = useRef(); + + const onSuggestion = detail => { + // Remove the delimiter from the suggestion and update the block. + updateBlockAttributes( clientId, { content: detail?.replaceAll( delimiter, '' ) } ); + snapToBottom(); + }; + + const onDone = detail => { + // Remove the delimiter from the suggestion. + const assistantResponse = detail.replaceAll( delimiter, '' ); + + // Populate the messages with the assistant response. + const lastAssistantPrompt = { + role: 'assistant', + content: assistantResponse, + }; + + updatedMessages.current.push( lastUserPrompt.current, lastAssistantPrompt ); + + /* + * Limit the messages to 20 items. + * @todo: limit the prompt based on tokens. + */ + if ( updatedMessages.current.length > 20 ) { + updatedMessages.current.splice( 0, updatedMessages.current.length - 20 ); + } + + updateBlockAttributes( clientId, { + content: assistantResponse, + messages: updatedMessages.current, + } ); + + snapToBottom(); + disableAutoScroll(); + onSuggestionDone?.(); + }; + + const onStop = () => { + snapToBottom(); + disableAutoScroll(); + onSuggestionDone?.(); + }; + + const onError = detail => { + switch ( detail?.code ) { + case ERROR_CONTEXT_TOO_LARGE: + setShowRetry( false ); + break; + case ERROR_MODERATION: + setShowRetry( false ); + onModeration?.(); + break; + case ERROR_NETWORK: + case ERROR_SERVICE_UNAVAILABLE: + setShowRetry( true ); + break; + case ERROR_QUOTA_EXCEEDED: + setShowRetry( false ); + // Dispatch the action to set the feature as requiring an upgrade. + setAiAssistantFeatureRequireUpgrade( true ); + break; + case ERROR_UNCLEAR_PROMPT: + onUnclearPrompt?.(); + break; + default: + break; + } + }; + + const onAllErrors = detail => { + debugError( detail ); + }; + + const { request, stopSuggestion, handleErrorQuotaExceededError, requestingState, error } = + useAiSuggestions( { + onSuggestion, + onDone, + onStop, + onError, + onAllErrors, + initialRequestingState, + askQuestionOptions: { + postId, + feature: 'ai-assistant', + functions: {}, + }, + } ); + + const isLoadingCompletion = [ 'requesting', 'suggesting' ].includes( requestingState ); + + const getStreamedSuggestionFromOpenAI = async ( type, options = {} ) => { + /* + * Always dequeue/cancel the AI Assistant feature async request, + * in case there is one pending, + * when performing a new AI suggestion request. + */ + dequeueAiAssistantFeatureAsyncRequest(); + + /* + * If the site requires an upgrade to use the feature, + * let's set the error and return an `undefined` event source. + */ + if ( requireUpgrade ) { + handleErrorQuotaExceededError(); + setShowRetry( false ); + + return; + } + + options = { + retryRequest: false, + tone: DEFAULT_PROMPT_TONE, + ...options, + }; + + if ( isLoadingCompletion ) { + return; + } + + setShowRetry( false ); + + let prompt = lastPrompt; + + tracks.recordEvent( 'jetpack_ai_chat_completion', { + post_id: postId, + } ); + + // Create a copy of the messages. + updatedMessages.current = [ ...attributes.messages ] ?? []; + + lastUserPrompt.current = {}; + + if ( ! options.retryRequest ) { + const allPostContent = ! attributes?.isLayoutBuldingModeEnable + ? getContentFromBlocks() + : getTextContentFromInnerBlocks( clientId ); + + // If there is a content already, let's iterate over it. + prompt = buildPromptForBlock( { + generatedContent: content, + allPostContent, + postContentAbove: getPartialContentToBlock( clientId ), + currentPostTitle, + options, + userPrompt: options?.userPrompt || userPrompt, + type, + isGeneratingTitle: attributes.promptType === 'generateTitle', + } ); + + /* + * Pop the last item from the messages array, + * which is the fresh `user` request by convention. + */ + lastUserPrompt.current = prompt.pop(); + + // Populate prompt with the messages. + prompt = [ ...prompt, ...updatedMessages.current ]; + + // Restore the last user prompt. + prompt.push( lastUserPrompt.current ); + + // Store the last prompt to be used when retrying. + setLastPrompt( prompt ); + + // If it is a title generation, keep the prompt type in subsequent changes. + if ( attributes.promptType !== 'generateTitle' ) { + updateBlockAttributes( clientId, { promptType: type } ); + } + } else { + lastUserPrompt.current = prompt[ prompt.length - 1 ]; + } + + try { + enableAutoScroll(); + + await request( prompt ); + } catch ( err ) { + debugError( err ); + setShowRetry( true ); + disableAutoScroll(); + } + }; + + return { + setShowRetry, + showRetry, + postTitle: currentPostTitle, + contentBefore: getPartialContentToBlock( clientId ), + wholeContent: getContentFromBlocks(), + requestingState, + error, + + getSuggestionFromOpenAI: getStreamedSuggestionFromOpenAI, + stopSuggestion, + retryRequest: () => getStreamedSuggestionFromOpenAI( '', { retryRequest: true } ), + }; +}; + +export default useAIAssistant; diff --git a/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-suggestions-from-openai/index.js b/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-suggestions-from-openai/index.js deleted file mode 100644 index 40ba263c0c883..0000000000000 --- a/projects/plugins/jetpack/extensions/blocks/ai-assistant/hooks/use-suggestions-from-openai/index.js +++ /dev/null @@ -1,461 +0,0 @@ -/** - * External dependencies - */ -import { askQuestion } from '@automattic/jetpack-ai-client'; -import { useSelect, useDispatch } from '@wordpress/data'; -import { useState, useRef } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import debugFactory from 'debug'; -/** - * Internal dependencies - */ -import { DEFAULT_PROMPT_TONE } from '../../components/tone-dropdown-control'; -import useAutoScroll from '../../hooks/use-auto-scroll'; -import { buildPromptForBlock, delimiter } from '../../lib/prompt'; -import { - getContentFromBlocks, - getPartialContentToBlock, - getTextContentFromInnerBlocks, -} from '../../lib/utils/block-content'; - -const debug = debugFactory( 'jetpack-ai-assistant:event' ); -const debugPrompt = debugFactory( 'jetpack-ai-assistant:prompt' ); - -const useSuggestionsFromOpenAI = ( { - attributes, - clientId, - content, - setError, - tracks, - userPrompt, - onSuggestionDone, - onUnclearPrompt, - onModeration, - requireUpgrade, - requestingState, - contentRef, - blockRef, -} ) => { - const [ isLoadingCompletion, setIsLoadingCompletion ] = useState( false ); - const [ wasCompletionJustRequested, setWasCompletionJustRequested ] = useState( false ); - const [ showRetry, setShowRetry ] = useState( false ); - const [ lastPrompt, setLastPrompt ] = useState( '' ); - const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); - const { dequeueAiAssistantFeatureAsyncRequest, setAiAssistantFeatureRequireUpgrade } = - useDispatch( 'wordpress-com/plans' ); - const [ requestState, setRequestState ] = useState( requestingState || 'init' ); - const source = useRef(); - - const { snapToBottom, enableAutoScroll, disableAutoScroll } = useAutoScroll( - blockRef, - contentRef - ); - - // Let's grab post data so that we can do something smart. - const currentPostTitle = useSelect( select => - select( 'core/editor' ).getEditedPostAttribute( 'title' ) - ); - - const postId = useSelect( select => select( 'core/editor' ).getCurrentPostId() ); - - const getStreamedSuggestionFromOpenAI = async ( type, options = {} ) => { - /* - * Always dequeue/cancel the AI Assistant feature async request, - * in case there is one pending, - * when performing a new AI suggestion request. - */ - dequeueAiAssistantFeatureAsyncRequest(); - - const implementedFunctions = options?.functions?.reduce( ( acc, { name, implementation } ) => { - return { - ...acc, - [ name ]: implementation, - }; - }, {} ); - - /* - * If the site requires an upgrade to use the feature, - * let's set the error and return an `undefined` event source. - */ - if ( requireUpgrade ) { - setRequestState( 'error' ); - setIsLoadingCompletion( false ); - setWasCompletionJustRequested( false ); - setShowRetry( false ); - setError( { - code: 'error_quota_exceeded', - message: __( 'You have reached the limit of requests for this site.', 'jetpack' ), - status: 'info', - } ); - - return; - } - - options = { - retryRequest: false, - tone: DEFAULT_PROMPT_TONE, - ...options, - }; - - if ( isLoadingCompletion ) { - return; - } - - setShowRetry( false ); - setError( {} ); - - let prompt = lastPrompt; - - tracks.recordEvent( 'jetpack_ai_chat_completion', { - post_id: postId, - } ); - - // Create a copy of the messages. - const updatedMessages = [ ...attributes.messages ] ?? []; - - let lastUserPrompt = {}; - - if ( ! options.retryRequest ) { - const allPostContent = ! attributes?.isLayoutBuldingModeEnable - ? getContentFromBlocks() - : getTextContentFromInnerBlocks( clientId ); - - // If there is a content already, let's iterate over it. - prompt = buildPromptForBlock( { - generatedContent: content, - allPostContent, - postContentAbove: getPartialContentToBlock( clientId ), - currentPostTitle, - options, - userPrompt: options?.userPrompt || userPrompt, - type, - isGeneratingTitle: attributes.promptType === 'generateTitle', - } ); - - /* - * Pop the last item from the messages array, - * which is the fresh `user` request by convention. - */ - lastUserPrompt = prompt.pop(); - - // Populate prompt with the messages. - prompt = [ ...prompt, ...updatedMessages ]; - - // Restore the last user prompt. - prompt.push( lastUserPrompt ); - - // Store the last prompt to be used when retrying. - setLastPrompt( prompt ); - - // If it is a title generation, keep the prompt type in subsequent changes. - if ( attributes.promptType !== 'generateTitle' ) { - updateBlockAttributes( clientId, { promptType: type } ); - } - } else { - lastUserPrompt = prompt[ prompt.length - 1 ]; - } - - try { - enableAutoScroll(); - setIsLoadingCompletion( true ); - setWasCompletionJustRequested( true ); - // debug all prompt items, one by one - prompt.forEach( ( { role, content: promptContent }, i ) => - debugPrompt( '(%s/%s) %o\n%s', i + 1, prompt.length, `[${ role }]`, promptContent ) - ); - - setRequestState( 'requesting' ); - - source.current = await askQuestion( prompt, { - postId, - requireUpgrade, - feature: 'ai-assistant', - functions: options?.functions, - } ); - - setRequestState( 'suggesting' ); - } catch ( err ) { - if ( err.message ) { - setError( { message: err.message, code: err?.code || 'unknown', status: 'error' } ); - } else { - setError( { - message: __( - 'Whoops, we have encountered an error. AI is like really, really hard and this is an experimental feature. Please try again later.', - 'jetpack' - ), - code: 'unknown', - status: 'error', - } ); - } - setShowRetry( true ); - setIsLoadingCompletion( false ); - setWasCompletionJustRequested( false ); - disableAutoScroll(); - } - - const onFunctionDone = async e => { - const { detail } = e; - - // Add assistant message with the function call request - const assistantResponse = { role: 'assistant', content: null, function_call: detail }; - - const response = await implementedFunctions[ detail.name ]?.( - JSON.parse( detail.arguments ) - ); - - // Add the function call response - const functionResponse = { - role: 'function', - name: detail?.name, - content: JSON.stringify( response ), - }; - - prompt = [ ...prompt, assistantResponse, functionResponse ]; - - // Remove source.current listeners - source?.current?.removeEventListener( 'function_done', onFunctionDone ); - source?.current?.removeEventListener( 'done', onDone ); - source?.current?.removeEventListener( 'error_unclear_prompt', onErrorUnclearPrompt ); - source?.current?.removeEventListener( 'error_network', onErrorNetwork ); - source?.current?.removeEventListener( 'error_context_too_large', onErrorContextTooLarge ); - source?.current?.removeEventListener( - 'error_service_unavailable', - onErrorServiceUnavailable - ); - source?.current?.removeEventListener( 'error_quota_exceeded', onErrorQuotaExceeded ); - source?.current?.removeEventListener( 'error_moderation', onErrorModeration ); - source?.current?.removeEventListener( 'suggestion', onSuggestion ); - - source.current = await askQuestion( prompt, { - postId, - requireUpgrade, - feature: 'ai-assistant', - functions: options.functions, - } ); - - // Add the listeners back - source?.current?.addEventListener( 'function_done', onFunctionDone ); - source?.current?.addEventListener( 'done', onDone ); - source?.current?.addEventListener( 'error_unclear_prompt', onErrorUnclearPrompt ); - source?.current?.addEventListener( 'error_network', onErrorNetwork ); - source?.current?.addEventListener( 'error_context_too_large', onErrorContextTooLarge ); - source?.current?.addEventListener( 'error_service_unavailable', onErrorServiceUnavailable ); - source?.current?.addEventListener( 'error_quota_exceeded', onErrorQuotaExceeded ); - source?.current?.addEventListener( 'error_moderation', onErrorModeration ); - source?.current?.addEventListener( 'suggestion', onSuggestion ); - }; - - const onDone = e => { - const { detail } = e; - - setRequestState( 'done' ); - - // Remove the delimiter from the suggestion. - const assistantResponse = detail.replaceAll( delimiter, '' ); - - // Populate the messages with the assistant response. - const lastAssistantPrompt = { - role: 'assistant', - content: assistantResponse, - }; - - updatedMessages.push( lastUserPrompt, lastAssistantPrompt ); - - debugPrompt( 'Add %o\n%s', `[${ lastUserPrompt.role }]`, lastUserPrompt.content ); - debugPrompt( 'Add %o\n%s', `[${ lastAssistantPrompt.role }]`, lastAssistantPrompt.content ); - - /* - * Limit the messages to 20 items. - * @todo: limit the prompt based on tokens. - */ - if ( updatedMessages.length > 20 ) { - updatedMessages.splice( 0, updatedMessages.length - 20 ); - } - - stopSuggestion(); - - updateBlockAttributes( clientId, { - content: assistantResponse, - messages: updatedMessages, - } ); - - snapToBottom(); - disableAutoScroll(); - }; - - const onErrorUnclearPrompt = () => { - setRequestState( 'error' ); - source?.current?.close(); - setIsLoadingCompletion( false ); - setWasCompletionJustRequested( false ); - setError( { - code: 'error_unclear_prompt', - message: __( 'Your request was unclear. Mind trying again?', 'jetpack' ), - status: 'info', - } ); - onUnclearPrompt?.(); - }; - - const onErrorContextTooLarge = () => { - setRequestState( 'error' ); - source?.current?.close(); - setIsLoadingCompletion( false ); - setWasCompletionJustRequested( false ); - setShowRetry( false ); - setError( { - code: 'error_context_too_large', - message: __( - 'The content is too large to be processed all at once. Please try to shorten it or divide it into smaller parts.', - 'jetpack' - ), - status: 'info', - } ); - }; - - const onErrorNetwork = ( { detail: error } ) => { - setRequestState( 'error' ); - const { name: errorName, message: errorMessage } = error; - if ( errorName === 'TypeError' && errorMessage === 'Failed to fetch' ) { - /* - * This is a network error. - * Probably: "414 Request-URI Too Large". - * Let's clean up the messages array and try again. - * @todo: improve the process based on tokens / URL length. - */ - updatedMessages.splice( 0, 8 ); - updateBlockAttributes( clientId, { - messages: updatedMessages, - } ); - - /* - * Update the last prompt with the new messages. - * @todo: Iterate over Prompt library to address properly the messages. - */ - prompt = buildPromptForBlock( { - generatedContent: content, - allPostContent: getContentFromBlocks(), - postContentAbove: getPartialContentToBlock( clientId ), - currentPostTitle, - options, - userPrompt, - type, - isGeneratingTitle: attributes.promptType === 'generateTitle', - } ); - - setLastPrompt( [ ...prompt, ...updatedMessages, lastUserPrompt ] ); - } - - source?.current?.close(); - setIsLoadingCompletion( false ); - setWasCompletionJustRequested( false ); - setShowRetry( true ); - setError( { - code: 'error_network', - message: __( 'It was not possible to process your request. Mind trying again?', 'jetpack' ), - status: 'info', - } ); - }; - - const onErrorServiceUnavailable = () => { - setRequestState( 'error' ); - source?.current?.close(); - setIsLoadingCompletion( false ); - setWasCompletionJustRequested( false ); - setShowRetry( true ); - setError( { - code: 'error_service_unavailable', - message: __( - 'Jetpack AI services are currently unavailable. Sorry for the inconvenience.', - 'jetpack' - ), - status: 'info', - } ); - }; - - const onErrorQuotaExceeded = () => { - setRequestState( 'error' ); - source?.current?.close(); - setIsLoadingCompletion( false ); - setWasCompletionJustRequested( false ); - setShowRetry( false ); - - // Dispatch the action to set the feature as requiring an upgrade. - setAiAssistantFeatureRequireUpgrade( true ); - - setError( { - code: 'error_quota_exceeded', - message: __( 'You have reached the limit of requests for this site.', 'jetpack' ), - status: 'info', - } ); - }; - - const onErrorModeration = () => { - setRequestState( 'error' ); - source?.current?.close(); - setIsLoadingCompletion( false ); - setWasCompletionJustRequested( false ); - setShowRetry( false ); - setError( { - code: 'error_moderation', - message: __( - 'This request has been flagged by our moderation system. Please try to rephrase it and try again.', - 'jetpack' - ), - status: 'info', - } ); - onModeration?.(); - }; - - const onSuggestion = e => { - setWasCompletionJustRequested( false ); - debug( '(suggestion)', e?.detail ); - - // Remove the delimiter from the suggestion and update the block. - updateBlockAttributes( clientId, { content: e?.detail?.replaceAll( delimiter, '' ) } ); - snapToBottom(); - }; - - source?.current?.addEventListener( 'function_done', onFunctionDone ); - source?.current?.addEventListener( 'done', onDone ); - source?.current?.addEventListener( 'error_unclear_prompt', onErrorUnclearPrompt ); - source?.current?.addEventListener( 'error_network', onErrorNetwork ); - source?.current?.addEventListener( 'error_context_too_large', onErrorContextTooLarge ); - source?.current?.addEventListener( 'error_service_unavailable', onErrorServiceUnavailable ); - source?.current?.addEventListener( 'error_quota_exceeded', onErrorQuotaExceeded ); - source?.current?.addEventListener( 'error_moderation', onErrorModeration ); - source?.current?.addEventListener( 'suggestion', onSuggestion ); - - return source?.current; - }; - - function stopSuggestion() { - if ( ! source?.current ) { - return; - } - - source?.current?.close(); - setIsLoadingCompletion( false ); - setWasCompletionJustRequested( false ); - onSuggestionDone?.(); - - // Set requesting state to done since the suggestion stopped. - setRequestState( 'done' ); - } - - return { - isLoadingCompletion, - wasCompletionJustRequested, - setShowRetry, - showRetry, - postTitle: currentPostTitle, - contentBefore: getPartialContentToBlock( clientId ), - wholeContent: getContentFromBlocks(), - requestingState: requestState, - - getSuggestionFromOpenAI: getStreamedSuggestionFromOpenAI, - stopSuggestion, - retryRequest: () => getStreamedSuggestionFromOpenAI( '', { retryRequest: true } ), - }; -}; - -export default useSuggestionsFromOpenAI;