Add Short Description "Write With AI" Button + Inform about automatic generation (#39805)

* Add "Write With AI" button to product short description media buttons area.
This commit is contained in:
Thomas Shellberg 2023-09-06 12:36:14 +02:00 committed by GitHub
parent a66a48108f
commit 8ec91504ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1143 additions and 189 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
[Woo AI] Add a Write with AI button for the short description field in product editor.

View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "f04d60d67b018f03857889eabb4fde67",
"content-hash": "4422aa30be46b03afe33056e2f26359c",
"packages": [
{
"name": "composer/installers",

View File

@ -20,6 +20,7 @@ class Woo_AI_Product_Text_Generation {
public function __construct() {
add_action( 'admin_enqueue_scripts', array( $this, 'add_woo_ai_register_script' ) );
add_action( 'media_buttons', array( $this, 'add_gpt_button' ), 40 );
add_action( 'media_buttons', array( $this, 'add_short_description_gpt_button' ), 50 );
add_action( 'edit_form_before_permalink', array( $this, 'add_name_generation_form' ) );
add_filter( 'the_editor', array( $this, 'add_gpt_form' ), 10, 1 );
}
@ -82,6 +83,19 @@ class Woo_AI_Product_Text_Generation {
echo '<div id="woocommerce-ai-app-product-gpt-button"></div>';
}
/**
* Add gpt button to the editor.
*
* @param String $editor_id Editor Id.
*/
public function add_short_description_gpt_button( $editor_id ) {
if ( 'excerpt' !== $editor_id || ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_pages' ) ) ) {
return;
}
echo '<div id="woocommerce-ai-app-product-short-description-gpt-button"></div>';
}
/**
* Add the form and button for generating product title suggestions to the editor.
*/

View File

@ -10,6 +10,7 @@
"version": "0.2.0",
"homepage": "http://github.com/woocommerce/woo-ai",
"devDependencies": {
"@svgr/webpack": "^8.1.0",
"@types/debug": "^4.1.7",
"@types/jquery": "^3.5.16",
"@types/react": "^17.0.2",
@ -17,6 +18,7 @@
"@types/wordpress__components": "^19.10.3",
"@woocommerce/dependency-extraction-webpack-plugin": "workspace:*",
"@woocommerce/eslint-plugin": "workspace:*",
"@wordpress/data": "wp-6.0",
"@wordpress/env": "^8.2.0",
"@wordpress/prettier-config": "2.17.0",
"@wordpress/scripts": "^19.2.4",
@ -27,6 +29,7 @@
"uglify-js": "^3.5.3"
},
"dependencies": {
"@automattic/tour-kit": "^1.1.1",
"@emotion/react": "^11.10.4",
"@types/prop-types": "^15.7.4",
"@types/react-outside-click-handler": "^1.3.1",
@ -41,6 +44,7 @@
"@wordpress/i18n": "wp-6.0",
"@wordpress/notices": "wp-6.0",
"@wordpress/plugins": "wp-6.0",
"@wordpress/preferences": "wp-6.0",
"debug": "^4.3.3",
"prop-types": "^15.8.1",
"react-query": "^3.39.3"
@ -48,7 +52,6 @@
"peerDependencies": {
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.2",
"@wordpress/data": "wp-6.0",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},

View File

@ -2,13 +2,12 @@
* External dependencies
*/
import React from 'react';
import { __, sprintf } from '@wordpress/i18n';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import MagicIcon from '../../assets/images/icons/magic.svg';
import { MIN_TITLE_LENGTH_FOR_DESCRIPTION } from '../constants';
type MagicButtonProps = {
title?: string;
@ -17,6 +16,10 @@ type MagicButtonProps = {
label: string;
};
type WriteItForMeBtnProps = MagicButtonProps & {
disabledMessage?: string;
};
const MagicButton = ( {
title,
label,
@ -40,24 +43,14 @@ const MagicButton = ( {
export const WriteItForMeBtn = ( {
disabled,
onClick,
}: Omit< MagicButtonProps, 'title' | 'label' > ) => {
disabledMessage,
}: Omit< WriteItForMeBtnProps, 'title' | 'label' > ) => {
return (
<MagicButton
disabled={ disabled }
onClick={ onClick }
label={ __( 'Write with AI', 'woocommerce' ) }
title={
disabled
? sprintf(
/* translators: %d: Minimum characters for product title */
__(
'Please create a product title before generating a description. It must be %d characters or longer.',
'woocommerce'
),
MIN_TITLE_LENGTH_FOR_DESCRIPTION
)
: undefined
}
title={ disabled ? disabledMessage : undefined }
/>
);
};
@ -71,7 +64,7 @@ export const StopCompletionBtn = ( {
disabled={ disabled }
onClick={ onClick }
label={ __( 'Stop writing…', 'woocommerce' ) }
title={ __( 'Stop generating the description.', 'woocommerce' ) }
title={ __( 'Stop generating content.', 'woocommerce' ) }
/>
);
};

View File

@ -1,2 +1,3 @@
export * from './random-loading-message';
export * from './description-completion-buttons';
export * from './info-modal';

View File

@ -0,0 +1 @@
export * from './info-modal';

View File

@ -0,0 +1,34 @@
.shortDescriptionGenerated-spotlight {
max-width: 400px;
padding:20px;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
span {
font-weight: 600;
font-size: 18px;
}
p {
font-size:16px;
margin-bottom: 20px;
}
button {
padding: 10px 15px;
border-radius: 5px;
color:#fff;
background-color: #0074a2;
border: 1px solid lightblue;
float:right;
border:none;
margin-bottom:20px;
&:hover, &:focus {
background-color: darken(#0074a2, 10%); // Slightly darken the blue on hover and focus
}
&:active {
background-color: darken(#0074a2, 15%); // Darken a bit more on active state
}
}
}

View File

@ -0,0 +1,48 @@
/**
* External dependencies
*/
import { useDispatch, select } from '@wordpress/data';
import { store as preferencesStore } from '@wordpress/preferences';
import React from 'react';
/**
* Internal dependencies
*/
import TourSpotlight from '../tour-spotlight/tour-spotlight';
import './info-modal.scss';
interface InfoModalProps {
id: string;
message: string;
title: string;
}
export const InfoModal: React.FC< InfoModalProps > = ( {
id,
message,
title,
} ) => {
const anchorElement = document.querySelector( '#postexcerpt' );
const hasBeenDismissedBefore = select( preferencesStore ).get(
'woo-ai-plugin',
`modalDismissed-${ id }`
);
const { set } = useDispatch( preferencesStore );
if ( ! anchorElement || hasBeenDismissedBefore ) return null;
const closeTour = () => {
set( 'woo-ai-plugin', `modalDismissed-${ id }`, true );
};
return (
<TourSpotlight
title={ title }
description={ message }
onDismiss={ closeTour }
reference={ '#postexcerpt' }
className={ `${ id }-spotlight` }
/>
);
};

View File

@ -0,0 +1 @@
export * from './tour-spotlight';

View File

@ -0,0 +1,71 @@
/**
* External dependencies
*/
import Tour, { TourStepRendererProps } from '@automattic/tour-kit';
import { Button } from '@wordpress/components';
import { useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import React from 'react';
type TourSpotlightProps = {
onDismiss: () => void;
title: string;
description: string;
reference: string;
className?: string;
};
export default function TourSpotlight( {
onDismiss,
title,
description,
reference,
className,
}: TourSpotlightProps ) {
const [ showTour, setShowTour ] = useState( true );
const handleDismiss = () => {
setShowTour( false );
onDismiss();
};
// Define a configuration for the tour, passing along a handler for closing.
const config = {
steps: [
{
referenceElements: {
desktop: reference,
},
meta: {
description,
},
},
],
renderers: {
tourStep: ( { currentStepIndex }: TourStepRendererProps ) => {
return (
<div className={ className }>
<h3>{ title }</h3>
<p>
{
config.steps[ currentStepIndex ].meta
.description
}
</p>
<Button onClick={ handleDismiss }>
{ __( 'Got it', 'woocommerce' ) }
</Button>
</div>
);
},
tourMinimized: () => <div />,
},
closeHandler: () => handleDismiss(),
options: {},
};
if ( ! showTour ) {
return null;
}
return <Tour config={ config } />;
}

View File

@ -1,3 +1,4 @@
export const WOO_AI_PLUGIN_FEATURE_NAME = 'woo_ai_plugin';
export const MAX_TITLE_LENGTH = 200;
export const MIN_TITLE_LENGTH_FOR_DESCRIPTION = 15;
export const MIN_DESC_LENGTH_FOR_SHORT_DESC = 100;

View File

@ -1,2 +1,8 @@
declare module '@wordpress/notices';
declare module '@wordpress/data';
declare module '*.svg' {
const content: string;
export default content;
}
declare const wp: any;
declare module '@wordpress/data';
declare module '@wordpress/preferences';
declare module '@wordpress/notices';

View File

@ -1,6 +0,0 @@
declare module '*.svg' {
const content: string;
export default content;
}
declare module '@wordpress/data';

View File

@ -1,11 +1,39 @@
/**
* External dependencies
*/
import { useState, useEffect } from 'react';
/**
* Internal dependencies
*/
import { setTinyContent, getTinyContent } from '../utils/tiny-tools';
import {
getTinyContentObject,
setTinyContent,
getTinyContent,
TinyContent,
} from '../utils/tiny-tools';
export const useTinyEditor = ( editorId?: string ) => {
const [ editor, setEditor ] = useState< TinyContent | null >( null );
useEffect( () => {
const fetchEditor = () => {
const editorInstance = getTinyContentObject( editorId );
if ( editorInstance ) {
setEditor( editorInstance );
}
};
window.addEventListener( 'load', fetchEditor );
return () => {
document.removeEventListener( 'load', fetchEditor );
};
}, [ editorId ] );
return {
setContent: ( str: string ) => setTinyContent( str, editorId ),
getContent: () => getTinyContent( editorId ),
getEditorObject: () => editor,
};
};

View File

@ -9,9 +9,14 @@ import { QueryClient, QueryClientProvider } from 'react-query';
*/
import { WriteItForMeButtonContainer } from './product-description';
import { ProductNameSuggestions } from './product-name';
import { WriteShortDescriptionButtonContainer } from './product-short-description';
import setPreferencesPersistence from './utils/preferencesPersistence';
import './index.scss';
// This sets up loading and saving the plugin's preferences.
setPreferencesPersistence();
const queryClient = new QueryClient();
const renderComponent = ( Component, rootElement ) => {
@ -39,7 +44,15 @@ const nameSuggestionsRoot = document.getElementById(
'woocommerce-ai-app-product-name-suggestions'
);
const shortDescriptionButtonRoot = document.getElementById(
'woocommerce-ai-app-product-short-description-gpt-button'
);
if ( window.JP_CONNECTION_INITIAL_STATE?.connectionStatus?.isActive ) {
renderComponent( WriteItForMeButtonContainer, descriptionButtonRoot );
renderComponent( ProductNameSuggestions, nameSuggestionsRoot );
renderComponent(
WriteShortDescriptionButtonContainer,
shortDescriptionButtonRoot
);
}

View File

@ -1,2 +1,3 @@
@import "product-description/product-description.scss";
@import "product-name/product-name.scss";
@import 'product-description/product-description.scss';
@import 'product-name/product-name.scss';
@import 'product-short-description/product-short-description.scss';

View File

@ -3,7 +3,7 @@
*/
import { useDispatch } from '@wordpress/data';
import { store as noticesStore } from '@wordpress/notices';
import { __ } from '@wordpress/i18n';
import { __, sprintf } from '@wordpress/i18n';
import { useState, useEffect, useRef } from '@wordpress/element';
import {
__experimentalUseCompletion as useCompletion,
@ -18,7 +18,7 @@ import {
MIN_TITLE_LENGTH_FOR_DESCRIPTION,
WOO_AI_PLUGIN_FEATURE_NAME,
} from '../constants';
import { StopCompletionBtn, WriteItForMeBtn } from '../components';
import { InfoModal, StopCompletionBtn, WriteItForMeBtn } from '../components';
import { useFeedbackSnackbar, useStoreBranding, useTinyEditor } from '../hooks';
import {
getProductName,
@ -29,24 +29,11 @@ import {
recordTracksFactory,
} from '../utils';
import { Attribute } from '../utils/types';
import { translateApiErrors as getApiError } from '../utils/apiErrors';
import { buildShortDescriptionPrompt } from '../product-short-description/product-short-description-button-container';
const DESCRIPTION_MAX_LENGTH = 300;
const getApiError = ( error?: string ) => {
switch ( error ) {
case 'connection_error':
return __(
'❗ We were unable to reach the experimental service. Please check back in shortly.',
'woocommerce'
);
default:
return __(
`❗ We encountered an issue with this experimental feature. Please check back in shortly.`,
'woocommerce'
);
}
};
const recordDescriptionTracks = recordTracksFactory(
'description_completion',
() => ( {
@ -161,12 +148,16 @@ export function WriteItForMeButtonContainer() {
);
};
title?.addEventListener( 'keyup', updateTitleHandler );
title?.addEventListener( 'change', updateTitleHandler );
// We have to keep track of manually typing, pasting, undo/redo, and when description is generated.
const eventsToTrack = [ 'keyup', 'change', 'undo', 'redo', 'paste' ];
for ( const event of eventsToTrack ) {
title?.addEventListener( event, updateTitleHandler );
}
return () => {
title?.removeEventListener( 'keyup', updateTitleHandler );
title?.removeEventListener( 'change', updateTitleHandler );
for ( const event of eventsToTrack ) {
title?.removeEventListener( event, updateTitleHandler );
}
};
}, [ titleEl ] );
@ -257,19 +248,11 @@ export function WriteItForMeButtonContainer() {
try {
await requestCompletion( prompt );
if ( ! shortTinyEditor.getContent() || shortDescriptionGenerated ) {
await requestShortCompletion(
[
'Please write a high-converting Meta Description for the WooCommerce product description below.',
'It should strictly adhere to the following guidelines:',
'It should entice someone from a search results page to click on the product link.',
'It should be no more than 155 characters so that the entire meta description fits within the space provided by the search engine result without being cut off or truncated.',
'It should explain what users will see if they click on the product page link.',
'Do not wrap in double quotes or use any other special characters.',
`It should include the target keyword for the product.`,
`Here is the full product description: \n${ tinyEditor.getContent() }`,
].join( '\n' )
);
const longDescription = tinyEditor.getContent();
if ( ! longDescription || shortDescriptionGenerated ) {
const shortDescriptionPrompt =
buildShortDescriptionPrompt( longDescription );
await requestShortCompletion( shortDescriptionPrompt );
setShortDescriptionGenerated( true );
}
} catch ( err ) {
@ -280,9 +263,31 @@ export function WriteItForMeButtonContainer() {
return completionActive ? (
<StopCompletionBtn onClick={ stopCompletion } />
) : (
<WriteItForMeBtn
disabled={ ! writeItForMeEnabled }
onClick={ onWriteItForMeClick }
/>
<>
<WriteItForMeBtn
disabled={ ! writeItForMeEnabled }
onClick={ onWriteItForMeClick }
disabledMessage={ sprintf(
/* translators: %d: Message shown when short description button is disabled because of a minimum description length */
__(
'Please create a product title before generating a description. It must be at least %d characters long.',
'woocommerce'
),
MIN_TITLE_LENGTH_FOR_DESCRIPTION
) }
/>
{ shortDescriptionGenerated && (
<InfoModal
id="shortDescriptionGenerated"
// message should be translatable.
message={ __(
'The short description was automatically generated by AI using the long description. This normally appears at the top of your product pages.',
'woocommerce'
) }
// title should also be translatable.
title={ __( 'Short Description Generated', 'woocommerce' ) }
/>
) }
</>
);
}

View File

@ -0,0 +1 @@
export * from './product-short-description-button-container';

View File

@ -0,0 +1,132 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useState, useEffect } from '@wordpress/element';
import {
__experimentalUseCompletion as useCompletion,
UseCompletionError,
} from '@woocommerce/ai';
import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import {
MIN_DESC_LENGTH_FOR_SHORT_DESC,
WOO_AI_PLUGIN_FEATURE_NAME,
} from '../constants';
import { StopCompletionBtn, WriteItForMeBtn } from '../components';
import { useTinyEditor } from '../hooks';
import { getPostId, recordTracksFactory } from '../utils';
import { translateApiErrors as getApiError } from '../utils/apiErrors';
const recordShortDescriptionTracks = recordTracksFactory(
'short_description_completion',
() => ( {
post_id: getPostId(),
} )
);
export function buildShortDescriptionPrompt( longDesc: string ) {
return [
'Please write a high-converting Meta Description for the WooCommerce product description below.',
'It should strictly adhere to the following guidelines:',
'It should entice someone from a search results page to click on the product link.',
'It should be no more than 155 characters so that the entire meta description fits within the space provided by the search engine result without being cut off or truncated.',
'It should explain what users will see if they click on the product page link.',
'Do not wrap in double quotes or use any other special characters.',
'It should include the target keyword for the product.',
`Here is the full product description: \n${ longDesc }`,
].join( '\n' );
}
export function WriteShortDescriptionButtonContainer() {
const { createWarningNotice } = useDispatch( 'core/notices' );
const [ fetching, setFetching ] = useState< boolean >( false );
const tinyEditor = useTinyEditor();
const shortTinyEditor = useTinyEditor( 'excerpt' );
const [ postContent, setPostContent ] = useState( '' );
const handleUseCompletionError = ( err: UseCompletionError ) => {
createWarningNotice( getApiError( err.code ?? '' ) );
setFetching( false );
// eslint-disable-next-line no-console
console.error( err );
};
const { requestCompletion, completionActive, stopCompletion } =
useCompletion( {
feature: WOO_AI_PLUGIN_FEATURE_NAME,
onStreamMessage: ( content ) => {
shortTinyEditor.setContent( content );
},
onStreamError: handleUseCompletionError,
onCompletionFinished: ( reason, content ) => {
recordShortDescriptionTracks( 'stop', {
reason,
character_count: content.length,
} );
setFetching( false );
},
} );
useEffect( () => {
recordShortDescriptionTracks( 'view_button' );
}, [] );
// This effect sets up the 'init' listener to update the 'isEditorReady' state
useEffect( () => {
const editor = tinyEditor.getEditorObject();
if ( editor ) {
// Set the content on initial page load.
setPostContent( tinyEditor.getContent() );
// Register a listener for the tinyMCE editor to update the postContent state.
const changeHandler = () => {
setPostContent( tinyEditor.getContent() );
};
editor.on( 'input', changeHandler );
editor.on( 'change', changeHandler );
return () => {
editor.off( 'input', changeHandler );
editor.off( 'change', changeHandler );
};
}
}, [ tinyEditor ] );
const writeItForMeEnabled =
! fetching && postContent.length >= MIN_DESC_LENGTH_FOR_SHORT_DESC;
const onWriteItForMeClick = async () => {
setFetching( true );
const prompt = buildShortDescriptionPrompt( tinyEditor.getContent() );
recordShortDescriptionTracks( 'start', {
prompt,
} );
try {
await requestCompletion( prompt );
} catch ( err ) {
handleUseCompletionError( err as UseCompletionError );
}
};
return completionActive ? (
<StopCompletionBtn onClick={ stopCompletion } />
) : (
<WriteItForMeBtn
disabled={ ! writeItForMeEnabled }
onClick={ onWriteItForMeClick }
disabledMessage={ sprintf(
/* translators: %d: Message shown when short description button is disabled because of a minimum description length */
__(
'Please write a product description before generating a short description. It must be at least %d characters long.',
'woocommerce'
),
MIN_DESC_LENGTH_FOR_SHORT_DESC
) }
/>
);
}

View File

@ -0,0 +1,4 @@
// Why do we need this but the other button works without it?
div#woocommerce-ai-app-product-short-description-gpt-button {
display: inline-block;
}

View File

@ -0,0 +1,19 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export const translateApiErrors = ( error?: string ) => {
switch ( error ) {
case 'connection_error':
return __(
'❗ We were unable to reach the experimental service. Please check back in shortly.',
'woocommerce'
);
default:
return __(
`❗ We encountered an issue with this experimental feature. Please check back in shortly.`,
'woocommerce'
);
}
};

View File

@ -0,0 +1,23 @@
/**
* External dependencies
*/
import { dispatch } from '@wordpress/data';
import { store as preferencesStore } from '@wordpress/preferences';
const setPreferencesPersistence = () =>
dispatch( preferencesStore ).setPersistenceLayer( {
get: async () => {
const savedPreferences = window.localStorage.getItem(
'woo-ai-plugin-prefs'
);
return savedPreferences ? JSON.parse( savedPreferences ) : {};
},
set: ( preferences: object ) => {
window.localStorage.setItem(
'woo-ai-plugin-prefs',
JSON.stringify( preferences )
);
},
} );
export default setPreferencesPersistence;

View File

@ -1,7 +1,9 @@
type TinyContent = {
export type TinyContent = {
getContent: () => string;
setContent: ( str: string ) => void;
id: string;
on: ( event: string, callback: ( event: Event ) => void ) => void;
off: ( event: string, callback: ( event: Event ) => void ) => void;
};
declare const tinymce: {
@ -9,7 +11,7 @@ declare const tinymce: {
editors: TinyContent[];
};
const getTinyContentObject = ( editorId = 'content' ) =>
export const getTinyContentObject = ( editorId = 'content' ) =>
typeof tinymce === 'object'
? tinymce.editors.find(
( editor: { id: string } ) => editor.id === editorId
@ -38,5 +40,5 @@ export const setTinyContent = ( str: string, editorId?: string ) => {
};
export const getTinyContent = ( editorId?: string ) => {
return getTinyContentObject( editorId )?.getContent();
return getTinyContentObject( editorId )?.getContent() ?? '';
};

View File

@ -23,6 +23,11 @@ module.exports = {
},
resolve: {
extensions: [ '.js', '.jsx', '.tsx', '.ts' ],
fallback: {
stream: false,
path: false,
fs: false,
},
},
plugins: [
...defaultConfig.plugins.filter(

File diff suppressed because it is too large Load Diff