567 lines
17 KiB
TypeScript
567 lines
17 KiB
TypeScript
/*
|
|
* External dependencies
|
|
*/
|
|
import {
|
|
ERROR_NETWORK,
|
|
ERROR_QUOTA_EXCEEDED,
|
|
useAiSuggestions,
|
|
} from '@automattic/jetpack-ai-client';
|
|
import { BlockControls, useBlockProps } from '@wordpress/block-editor';
|
|
import { createHigherOrderComponent } from '@wordpress/compose';
|
|
import { useDispatch, useSelect } from '@wordpress/data';
|
|
import { useCallback, useEffect, useState, useRef, useMemo } from '@wordpress/element';
|
|
import { addFilter } from '@wordpress/hooks';
|
|
import clsx from 'clsx';
|
|
import debugFactory from 'debug';
|
|
import React from 'react';
|
|
/*
|
|
* Internal dependencies
|
|
*/
|
|
import useAiFeature from '../hooks/use-ai-feature';
|
|
import useAutoScroll from '../hooks/use-auto-scroll';
|
|
import { mapInternalPromptTypeToBackendPromptType } from '../lib/prompt/backend-prompt';
|
|
import AiAssistantInput from './components/ai-assistant-input';
|
|
import AiAssistantExtensionToolbarDropdown from './components/ai-assistant-toolbar-dropdown';
|
|
import { getBlockHandler, InlineExtensionsContext } from './get-block-handler';
|
|
import { isPossibleToExtendBlock } from './lib/is-possible-to-extend-block';
|
|
/*
|
|
* Types
|
|
*/
|
|
import type {
|
|
AiAssistantDropdownOnChangeOptionsArgProps,
|
|
OnRequestSuggestion,
|
|
} from '../components/ai-assistant-toolbar-dropdown/dropdown-content';
|
|
import type { ExtendedInlineBlockProp } from '../extensions/ai-assistant';
|
|
import type { PromptTypeProp } from '../lib/prompt';
|
|
import type {
|
|
PromptMessagesProp,
|
|
PromptItemProps,
|
|
RequestingStateProp,
|
|
} from '@automattic/jetpack-ai-client';
|
|
|
|
const debug = debugFactory( 'jetpack-ai-assistant:extensions:with-ai-extension' );
|
|
|
|
const blockExtensionMapper = {
|
|
'core/heading': 'heading',
|
|
'core/paragraph': 'paragraph',
|
|
'core/list-item': 'list-item',
|
|
'core/list': 'list',
|
|
'jetpack/contact-form': 'form-ai',
|
|
};
|
|
|
|
// Defines where the block controls should be placed in the toolbar
|
|
const blockControlsProps = {
|
|
group: 'block' as const,
|
|
};
|
|
|
|
const BLOCK_INPUT_GAP = 16;
|
|
|
|
type RequestOptions = {
|
|
promptType: PromptTypeProp;
|
|
options?: AiAssistantDropdownOnChangeOptionsArgProps;
|
|
humanText?: string;
|
|
message?: PromptItemProps;
|
|
};
|
|
|
|
type CoreEditorDispatch = { undo: () => Promise< void > };
|
|
type CoreEditorSelect = { getCurrentPostId: () => number };
|
|
|
|
// HOC to populate the block's edit component with the AI Assistant control inpuit and toolbar button.
|
|
const blockEditWithAiComponents = createHigherOrderComponent( BlockEdit => {
|
|
return props => {
|
|
const { clientId, isSelected, name: blockName } = props;
|
|
// Ref to the control wrapper, its height and its ResizeObserver, for positioning adjustments.
|
|
const controlRef: React.MutableRefObject< HTMLDivElement | null > = useRef( null );
|
|
const controlHeight = useRef< number >( 0 );
|
|
const controlObserver = useRef< ResizeObserver | null >( null );
|
|
// Ref to the original block padding to reset it when the AI Control is closed.
|
|
const blockOriginalPaddingBottom = useRef< string >( '' );
|
|
// Ref to the input element to focus on it when the AI Control is displayed or when a request is done.
|
|
// Also used to determine the ownerDocument, as the editor can be in an iframe.
|
|
const inputRef: React.MutableRefObject< HTMLInputElement | null > = useRef( null );
|
|
const ownerDocument = useRef< Document >( document );
|
|
// Ref to the chat history to keep track of the messages that were sent and the assistant responses.
|
|
const chatHistory = useRef< PromptMessagesProp >( [] );
|
|
// A human-readable action to be displayed in the input when a toolbar suggestion is requested, like "Translate: Japanese".
|
|
const [ action, setAction ] = useState< string >( '' );
|
|
// The last request made by the user, to be used when the user clicks the "Try Again" button.
|
|
const lastRequest = useRef< RequestOptions | null >( null );
|
|
// Ref to the requesting state to use it in the hideOnBlockFocus effect.
|
|
const requestingStateRef = useRef< RequestingStateProp | null >( null );
|
|
// Data and functions from the editor.
|
|
const { undo } = useDispatch( 'core/editor' ) as CoreEditorDispatch;
|
|
const { postId } = useSelect( select => {
|
|
const { getCurrentPostId } = select( 'core/editor' ) as CoreEditorSelect;
|
|
|
|
return { postId: getCurrentPostId() };
|
|
}, [] );
|
|
// The block's id to find it in the DOM for the positioning adjustments
|
|
// The classname is used by nested blocks to determine which block's toolbar to display when the input is focused.
|
|
const { id, className } = useBlockProps( {
|
|
className: clsx( { [ blockName?.replace?.( '/', '-' ) ]: true } ),
|
|
} );
|
|
|
|
// Jetpack AI Assistant feature functions.
|
|
const { increaseRequestsCount, dequeueAsyncRequest, requireUpgrade } = useAiFeature();
|
|
|
|
// Auto-scroll
|
|
const { snapToBottom, enableAutoScroll, disableAutoScroll } = useAutoScroll(
|
|
{
|
|
current: ownerDocument?.current?.getElementById( id ),
|
|
},
|
|
undefined,
|
|
true
|
|
);
|
|
|
|
const focusInput = useCallback( () => {
|
|
inputRef.current?.focus();
|
|
}, [] );
|
|
|
|
// Data and functions with block-specific implementations.
|
|
const {
|
|
onSuggestion: onBlockSuggestion,
|
|
onDone: onBlockDone,
|
|
getContent,
|
|
behavior,
|
|
isChildBlock,
|
|
feature,
|
|
adjustPosition,
|
|
startOpen,
|
|
hideOnBlockFocus,
|
|
} = useMemo( () => getBlockHandler( blockName, clientId ), [ blockName, clientId ] );
|
|
|
|
// State to display the AI Control or not.
|
|
const [ showAiControl, setShowAiControl ] = useState( startOpen );
|
|
|
|
// Called when the user clicks the "Ask AI Assistant" button.
|
|
const handleAskAiAssistant = useCallback( () => {
|
|
setShowAiControl( current => ! current );
|
|
}, [] );
|
|
|
|
// Function to get the messages array for the request.
|
|
const getRequestMessages = useCallback(
|
|
( {
|
|
promptType,
|
|
options,
|
|
}: {
|
|
promptType: PromptTypeProp;
|
|
options?: AiAssistantDropdownOnChangeOptionsArgProps;
|
|
} ) => {
|
|
const blockContent = getContent();
|
|
|
|
const extension = blockExtensionMapper[ blockName ];
|
|
|
|
return [
|
|
...chatHistory.current,
|
|
{
|
|
role: 'jetpack-ai' as const,
|
|
context: {
|
|
type: mapInternalPromptTypeToBackendPromptType( promptType, extension ),
|
|
content: blockContent,
|
|
request: options?.userPrompt,
|
|
tone: options?.tone,
|
|
language: options?.language,
|
|
is_follow_up: chatHistory.current.length > 0,
|
|
},
|
|
},
|
|
];
|
|
},
|
|
[ blockName, getContent ]
|
|
);
|
|
|
|
const adjustBlockPadding = useCallback(
|
|
( blockElement?: HTMLElement | null ) => {
|
|
const block = blockElement || ownerDocument.current.getElementById( id );
|
|
|
|
if ( block && controlRef.current ) {
|
|
// The gap between the input and the block's bottom is set at BLOCK_INPUT_GAP, regardless of the theme
|
|
block.style.setProperty(
|
|
'padding-bottom',
|
|
`calc(${ controlHeight.current + BLOCK_INPUT_GAP }px + ${
|
|
blockOriginalPaddingBottom.current || '0px'
|
|
} )`,
|
|
'important'
|
|
);
|
|
}
|
|
},
|
|
[ id ]
|
|
);
|
|
|
|
// Called when a suggestion chunk is received.
|
|
const onSuggestion = useCallback(
|
|
( suggestion: string ) => {
|
|
onBlockSuggestion( suggestion );
|
|
|
|
// Make sure the block element has the necessary bottom padding, as it can be replaced or changed
|
|
if ( adjustPosition ) {
|
|
adjustBlockPadding();
|
|
}
|
|
|
|
// Scroll to the bottom when a new suggestion is received.
|
|
snapToBottom();
|
|
},
|
|
[ onBlockSuggestion, adjustPosition, snapToBottom, adjustBlockPadding ]
|
|
);
|
|
|
|
// Called after the last suggestion chunk is received.
|
|
const onDone = useCallback(
|
|
( suggestion: string ) => {
|
|
disableAutoScroll();
|
|
onBlockDone( suggestion );
|
|
increaseRequestsCount();
|
|
setAction( '' );
|
|
|
|
if ( lastRequest.current?.message ) {
|
|
const assistantMessage = {
|
|
role: 'assistant' as const,
|
|
content: getContent(),
|
|
};
|
|
|
|
chatHistory.current.push( lastRequest.current.message, assistantMessage );
|
|
|
|
// Limit the messages to 20 items.
|
|
if ( chatHistory.current.length > 20 ) {
|
|
chatHistory.current.splice( 0, chatHistory.current.length - 20 );
|
|
|
|
// Make sure the first message is a 'jetpack-ai' message and not marked as a follow-up.
|
|
const firstJetpackAiMessageIndex = chatHistory.current.findIndex(
|
|
message => message.role === 'jetpack-ai'
|
|
);
|
|
|
|
if ( firstJetpackAiMessageIndex !== -1 ) {
|
|
chatHistory.current = chatHistory.current.slice( firstJetpackAiMessageIndex );
|
|
|
|
chatHistory.current[ 0 ].context = {
|
|
...chatHistory.current[ 0 ].context,
|
|
is_follow_up: false,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
lastRequest.current = null;
|
|
|
|
// Make sure the block element has the necessary bottom padding, as it can be replaced or changed
|
|
setTimeout( () => {
|
|
if ( adjustPosition ) {
|
|
adjustBlockPadding();
|
|
}
|
|
focusInput();
|
|
}, 100 );
|
|
},
|
|
[
|
|
disableAutoScroll,
|
|
onBlockDone,
|
|
increaseRequestsCount,
|
|
getContent,
|
|
adjustPosition,
|
|
focusInput,
|
|
adjustBlockPadding,
|
|
]
|
|
);
|
|
|
|
// Called when an error is received.
|
|
const onError = useCallback(
|
|
error => {
|
|
disableAutoScroll();
|
|
setAction( '' );
|
|
|
|
debug( 'Request error', error );
|
|
|
|
// Increase the AI Suggestion counter only for valid errors.
|
|
if ( error.code === ERROR_NETWORK || error.code === ERROR_QUOTA_EXCEEDED ) {
|
|
return;
|
|
}
|
|
|
|
increaseRequestsCount();
|
|
},
|
|
[ disableAutoScroll, increaseRequestsCount ]
|
|
);
|
|
|
|
const {
|
|
request,
|
|
stopSuggestion,
|
|
requestingState,
|
|
error,
|
|
reset: resetSuggestions,
|
|
} = useAiSuggestions( {
|
|
onSuggestion,
|
|
onDone,
|
|
onError,
|
|
askQuestionOptions: {
|
|
postId,
|
|
feature,
|
|
},
|
|
} );
|
|
|
|
// Save the requesting state to use it in the hideOnBlockFocus effect.
|
|
useEffect( () => {
|
|
requestingStateRef.current = requestingState;
|
|
}, [ requestingState ] );
|
|
|
|
// Called when a suggestion from the toolbar is requested, like "Change tone".
|
|
const handleRequestSuggestion = useCallback< OnRequestSuggestion >(
|
|
( promptType, options, humanText ) => {
|
|
setShowAiControl( true );
|
|
|
|
// If the user needs to upgrade, don't make the request, but show the input with the upgrade message.
|
|
if ( requireUpgrade ) {
|
|
return;
|
|
}
|
|
|
|
if ( humanText ) {
|
|
setAction( humanText );
|
|
}
|
|
|
|
const messages = getRequestMessages( { promptType, options } );
|
|
|
|
debug( 'Request suggestion', promptType, options );
|
|
|
|
const lastMessage = messages[ messages.length - 1 ];
|
|
lastRequest.current = { promptType, options, humanText, message: lastMessage };
|
|
|
|
/*
|
|
* Always dequeue/cancel the AI Assistant feature async request,
|
|
* in case there is one pending,
|
|
* when performing a new AI suggestion request.
|
|
*/
|
|
dequeueAsyncRequest();
|
|
|
|
enableAutoScroll();
|
|
|
|
request( messages );
|
|
},
|
|
[ dequeueAsyncRequest, enableAutoScroll, getRequestMessages, request, requireUpgrade ]
|
|
);
|
|
|
|
// Called when the user types a custom prompt.
|
|
const handleUserRequest = useCallback(
|
|
( userPrompt: string ) => {
|
|
const promptType = 'userPrompt';
|
|
const options = { userPrompt };
|
|
|
|
enableAutoScroll();
|
|
handleRequestSuggestion( promptType, options );
|
|
},
|
|
[ enableAutoScroll, handleRequestSuggestion ]
|
|
);
|
|
|
|
// Called when the user clicks the "Stop" button in the input.
|
|
const handleStopSuggestion = useCallback( () => {
|
|
disableAutoScroll();
|
|
stopSuggestion();
|
|
|
|
focusInput();
|
|
}, [ disableAutoScroll, stopSuggestion, focusInput ] );
|
|
|
|
// Called when the user clicks the "Try Again" button in the input error message.
|
|
const handleTryAgain = useCallback( () => {
|
|
if ( lastRequest.current ) {
|
|
handleRequestSuggestion(
|
|
lastRequest.current.promptType,
|
|
lastRequest.current.options,
|
|
lastRequest.current.humanText
|
|
);
|
|
}
|
|
}, [ lastRequest, handleRequestSuggestion ] );
|
|
|
|
// Cleanup function.
|
|
const handleClose = useCallback( () => {
|
|
setShowAiControl( false );
|
|
resetSuggestions();
|
|
setAction( '' );
|
|
lastRequest.current = null;
|
|
chatHistory.current = [];
|
|
}, [ resetSuggestions ] );
|
|
|
|
// Called when the user clicks the "Undo" button after a successful request.
|
|
const handleUndo = useCallback( async () => {
|
|
await undo();
|
|
|
|
handleClose();
|
|
}, [ undo, handleClose ] );
|
|
|
|
// Closes the AI Control if the block is deselected.
|
|
useEffect( () => {
|
|
if ( ! isSelected ) {
|
|
handleClose();
|
|
}
|
|
}, [ isSelected, handleClose ] );
|
|
|
|
// Focus the input when the AI Control is displayed and set the ownerDocument.
|
|
useEffect( () => {
|
|
if ( inputRef.current ) {
|
|
// Save the block's ownerDocument to use it later, as the editor can be in an iframe.
|
|
ownerDocument.current = inputRef.current.ownerDocument;
|
|
// Focus the input when the AI Control is displayed.
|
|
focusInput();
|
|
}
|
|
}, [ showAiControl, focusInput ] );
|
|
|
|
// Adjusts the input position in the editor by increasing the block's bottom-padding
|
|
// and setting the control's margin-top, "wrapping" the input with the block.
|
|
useEffect( () => {
|
|
let block = ownerDocument.current.getElementById( id );
|
|
|
|
if ( ! block ) {
|
|
return;
|
|
}
|
|
|
|
if ( ! adjustPosition ) {
|
|
return;
|
|
}
|
|
|
|
// Once when the AI Control is displayed
|
|
if ( showAiControl && ! controlObserver.current && controlRef.current ) {
|
|
// Save the block bottom padding to reset it later.
|
|
blockOriginalPaddingBottom.current = block.style.paddingBottom;
|
|
|
|
// Observe the control's height to adjust the block's bottom padding.
|
|
controlObserver.current = new ResizeObserver( ( [ entry ] ) => {
|
|
// The block element can be replaced or changed, so we need to get it again.
|
|
block = ownerDocument.current.getElementById( id );
|
|
controlHeight.current = entry.contentRect.height;
|
|
|
|
if ( block && controlRef.current && controlHeight.current > 0 ) {
|
|
adjustBlockPadding( block );
|
|
|
|
const { marginBottom } = getComputedStyle( block );
|
|
const bottom = parseFloat( marginBottom );
|
|
|
|
// The control's margin-top is the negative of the control's height plus the block's bottom margin, to end up with the intended gap.
|
|
// P2 uses "!important", so we need to add it to override the theme's styles.
|
|
controlRef.current.style.setProperty(
|
|
'margin-top',
|
|
`-${ controlHeight.current + bottom }px`,
|
|
'important'
|
|
);
|
|
|
|
// The control's bottom margin is set to at least the same value as the block's bottom margin, to keep the distance to the next block.
|
|
// The gap height is added for a bit more space on themes with a smaller bottom margin.
|
|
controlRef.current.style.setProperty(
|
|
'margin-bottom',
|
|
`${ bottom + BLOCK_INPUT_GAP }px`,
|
|
'important'
|
|
);
|
|
}
|
|
} );
|
|
|
|
controlObserver.current.observe( controlRef.current );
|
|
} else if ( controlObserver.current ) {
|
|
block.style.paddingBottom = blockOriginalPaddingBottom.current;
|
|
|
|
controlObserver.current.disconnect();
|
|
controlObserver.current = null;
|
|
controlHeight.current = 0;
|
|
}
|
|
|
|
return () => {
|
|
if ( controlObserver.current ) {
|
|
controlObserver.current.disconnect();
|
|
}
|
|
};
|
|
}, [ adjustBlockPadding, adjustPosition, clientId, controlObserver, id, showAiControl ] );
|
|
|
|
// Hide the AI Control when the block is focused.
|
|
useEffect( () => {
|
|
if ( ! hideOnBlockFocus ) {
|
|
return;
|
|
}
|
|
|
|
if ( showAiControl ) {
|
|
const element = ownerDocument.current.getElementById( id );
|
|
|
|
const handleFocusInBlock = () => {
|
|
// If the AI Control is requesting or suggesting, don't hide it, as the block focus is programmatic.
|
|
if ( [ 'requesting', 'suggesting' ].includes( requestingStateRef.current as string ) ) {
|
|
return;
|
|
}
|
|
|
|
setShowAiControl( false );
|
|
element?.removeEventListener( 'focusin', handleFocusInBlock );
|
|
};
|
|
|
|
element?.addEventListener( 'focusin', handleFocusInBlock );
|
|
|
|
return () => {
|
|
element?.removeEventListener( 'focusin', handleFocusInBlock );
|
|
};
|
|
}
|
|
}, [ hideOnBlockFocus, showAiControl, id ] );
|
|
|
|
const aiInlineExtensionContent = (
|
|
<>
|
|
<BlockEdit { ...props } />
|
|
|
|
{ showAiControl && (
|
|
<AiAssistantInput
|
|
className={ className }
|
|
requestingState={ requestingState }
|
|
requestingError={ error }
|
|
wrapperRef={ controlRef }
|
|
inputRef={ inputRef }
|
|
action={ action }
|
|
blockType={ blockName }
|
|
request={ handleUserRequest }
|
|
stopSuggestion={ handleStopSuggestion }
|
|
close={ handleClose }
|
|
undo={ handleUndo }
|
|
tryAgain={ handleTryAgain }
|
|
/>
|
|
) }
|
|
|
|
<BlockControls { ...blockControlsProps }>
|
|
<AiAssistantExtensionToolbarDropdown
|
|
blockType={ blockName }
|
|
onAskAiAssistant={ handleAskAiAssistant }
|
|
onRequestSuggestion={ handleRequestSuggestion }
|
|
behavior={ behavior }
|
|
/>
|
|
</BlockControls>
|
|
</>
|
|
);
|
|
|
|
if ( isChildBlock ) {
|
|
return aiInlineExtensionContent;
|
|
}
|
|
|
|
const ProviderProps = {
|
|
value: { [ blockName ]: { handleAskAiAssistant, handleRequestSuggestion } },
|
|
};
|
|
|
|
return (
|
|
<InlineExtensionsContext.Provider { ...ProviderProps }>
|
|
{ aiInlineExtensionContent }
|
|
</InlineExtensionsContext.Provider>
|
|
);
|
|
};
|
|
}, 'blockEditWithAiComponents' );
|
|
|
|
/**
|
|
* Function used to extend the registerBlockType settings.
|
|
* Populates the block edit component with the AI Assistant bar and button.
|
|
* @param {object} settings - The block settings.
|
|
* @param {string} name - The block name.
|
|
* @returns {object} The extended block settings.
|
|
*/
|
|
function blockWithInlineExtension( settings, name: ExtendedInlineBlockProp ) {
|
|
// Only extend the allowed block types and when AI is enabled
|
|
const possibleToExtendBlock = isPossibleToExtendBlock( name );
|
|
|
|
if ( ! possibleToExtendBlock ) {
|
|
return settings;
|
|
}
|
|
|
|
return {
|
|
...settings,
|
|
edit: blockEditWithAiComponents( settings.edit ),
|
|
};
|
|
}
|
|
|
|
addFilter(
|
|
'blocks.registerBlockType',
|
|
'jetpack/ai-assistant-support/with-ai-extension',
|
|
blockWithInlineExtension,
|
|
100
|
|
);
|