From 1b37042d5541970996bf576e7a2bd60c3b20fe53 Mon Sep 17 00:00:00 2001 From: RJ <27843274+rjchow@users.noreply.github.com> Date: Tue, 19 Sep 2023 18:41:52 +1000 Subject: [PATCH] add: color palette ai text completion call (#40237) * add: color palette ai text completion call * reorganised for allowing more prompts * lint fix * moved tests and fixed version --- .../customize-store/design-with-ai/actions.ts | 26 +- .../customize-store/design-with-ai/index.tsx | 2 + .../prompts/colorChoices.test.ts | 142 ++++++++++ .../design-with-ai/prompts/colorChoices.ts | 240 ++++++++++++++++ .../design-with-ai/prompts/index.ts | 2 + .../prompts/lookAndTone.test.ts | 28 ++ .../design-with-ai/prompts/lookAndTone.ts | 51 ++++ .../design-with-ai/services.ts | 266 ++++++++++++------ .../design-with-ai/state-machine.tsx | 30 +- .../customize-store/design-with-ai/style.scss | 28 ++ .../design-with-ai/tests/services.test.ts | 160 ++++++++--- .../customize-store/design-with-ai/types.ts | 19 ++ plugins/woocommerce-admin/package.json | 3 +- .../add-customize-store-color-palette | 4 + pnpm-lock.yaml | 7 + 15 files changed, 870 insertions(+), 138 deletions(-) create mode 100644 plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/colorChoices.test.ts create mode 100644 plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/colorChoices.ts create mode 100644 plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/index.ts create mode 100644 plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/lookAndTone.test.ts create mode 100644 plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/lookAndTone.ts create mode 100644 plugins/woocommerce-admin/client/customize-store/design-with-ai/style.scss create mode 100644 plugins/woocommerce/changelog/add-customize-store-color-palette diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/actions.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/actions.ts index 88851668c7a..661504275c1 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-with-ai/actions.ts +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/actions.ts @@ -9,8 +9,10 @@ import { recordEvent } from '@woocommerce/tracks'; * Internal dependencies */ import { + ColorPalette, designWithAiStateMachineContext, designWithAiStateMachineEvents, + LookAndToneCompletionResponse, } from './types'; import { aiWizardClosedBeforeCompletionEvent } from './events'; import { @@ -18,7 +20,6 @@ import { lookAndFeelCompleteEvent, toneOfVoiceCompleteEvent, } from './pages'; -import { LookAndToneCompletionResponse } from './services'; const assignBusinessInfoDescription = assign< designWithAiStateMachineContext, @@ -72,14 +73,28 @@ const assignLookAndTone = assign< }, } ); +const assignDefaultColorPalette = assign< + designWithAiStateMachineContext, + designWithAiStateMachineEvents +>( { + aiSuggestions: ( context, event: unknown ) => { + return { + ...context.aiSuggestions, + defaultColorPalette: ( + event as { + data: { + response: ColorPalette; + }; + } + ).data.response, + }; + }, +} ); + const logAIAPIRequestError = () => { // log AI API request error // eslint-disable-next-line no-console console.log( 'API Request error' ); - recordEvent( - 'customize_your_store_look_and_tone_ai_completion_response_error', - { error_type: 'http_network_error' } - ); }; const updateQueryStep = ( @@ -142,6 +157,7 @@ export const actions = { assignLookAndFeel, assignToneOfVoice, assignLookAndTone, + assignDefaultColorPalette, logAIAPIRequestError, updateQueryStep, recordTracksStepViewed, diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/index.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/index.tsx index 45c07440480..7a6f8d0cc7e 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-with-ai/index.tsx +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/index.tsx @@ -19,6 +19,8 @@ import { } from './pages'; import { customizeStoreStateMachineEvents } from '..'; +import './style.scss'; + export type events = { type: 'THEME_SUGGESTED' }; export type DesignWithAiComponent = | typeof BusinessInfoDescription diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/colorChoices.test.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/colorChoices.test.ts new file mode 100644 index 00000000000..91c7d0df06e --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/colorChoices.test.ts @@ -0,0 +1,142 @@ +/** + * Internal dependencies + */ +import { defaultColorPalette } from '.'; + +describe( 'colorPairing.responseValidation', () => { + it( 'should validate a correct color palette', () => { + const validPalette = { + name: 'Ancient Bronze', + primary: '#11163d', + secondary: '#8C8369', + foreground: '#11163d', + background: '#ffffff', + }; + + const parsedResult = + defaultColorPalette.responseValidation( validPalette ); + expect( parsedResult ).toEqual( validPalette ); + } ); + + it( 'should fail for an incorrect name', () => { + const invalidPalette = { + name: 'Invalid Name', + primary: '#11163d', + secondary: '#8C8369', + foreground: '#11163d', + background: '#ffffff', + }; + expect( () => defaultColorPalette.responseValidation( invalidPalette ) ) + .toThrowErrorMatchingInlineSnapshot( ` + "[ + { + \\"code\\": \\"custom\\", + \\"message\\": \\"Color palette not part of allowed list\\", + \\"path\\": [ + \\"name\\" + ] + } + ]" + ` ); + } ); + + it( 'should fail for an invalid primary color', () => { + const invalidPalette = { + name: 'Ancient Bronze', + primary: 'invalidColor', + secondary: '#8C8369', + foreground: '#11163d', + background: '#ffffff', + }; + expect( () => defaultColorPalette.responseValidation( invalidPalette ) ) + .toThrowErrorMatchingInlineSnapshot( ` + "[ + { + \\"validation\\": \\"regex\\", + \\"code\\": \\"invalid_string\\", + \\"message\\": \\"Invalid primary color\\", + \\"path\\": [ + \\"primary\\" + ] + } + ]" + ` ); + } ); + + it( 'should fail for an invalid secondary color', () => { + const invalidPalette = { + name: 'Ancient Bronze', + primary: '#11163d', + secondary: 'invalidColor', + foreground: '#11163d', + background: '#ffffff', + }; + expect( () => defaultColorPalette.responseValidation( invalidPalette ) ) + .toThrowErrorMatchingInlineSnapshot( ` + "[ + { + \\"validation\\": \\"regex\\", + \\"code\\": \\"invalid_string\\", + \\"message\\": \\"Invalid secondary color\\", + \\"path\\": [ + \\"secondary\\" + ] + } + ]" + ` ); + } ); + + it( 'should fail for an invalid foreground color', () => { + const invalidPalette = { + name: 'Ancient Bronze', + primary: '#11163d', + secondary: '11163d', + foreground: '#invalid_color', + background: '#ffffff', + }; + expect( () => defaultColorPalette.responseValidation( invalidPalette ) ) + .toThrowErrorMatchingInlineSnapshot( ` + "[ + { + \\"validation\\": \\"regex\\", + \\"code\\": \\"invalid_string\\", + \\"message\\": \\"Invalid secondary color\\", + \\"path\\": [ + \\"secondary\\" + ] + }, + { + \\"validation\\": \\"regex\\", + \\"code\\": \\"invalid_string\\", + \\"message\\": \\"Invalid foreground color\\", + \\"path\\": [ + \\"foreground\\" + ] + } + ]" + ` ); + } ); + + it( 'should fail for an invalid background color', () => { + const invalidPalette = { + name: 'Ancient Bronze', + primary: '#11163d', + secondary: '#11163d', + foreground: '#11163d', + background: '#fffff', + }; + expect( () => defaultColorPalette.responseValidation( invalidPalette ) ) + .toThrowErrorMatchingInlineSnapshot( ` + "[ + { + \\"validation\\": \\"regex\\", + \\"code\\": \\"invalid_string\\", + \\"message\\": \\"Invalid background color\\", + \\"path\\": [ + \\"background\\" + ] + } + ]" + ` ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/colorChoices.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/colorChoices.ts new file mode 100644 index 00000000000..5fa682b0aee --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/colorChoices.ts @@ -0,0 +1,240 @@ +/** + * External dependencies + */ +import { z } from 'zod'; +/** + * Internal dependencies + */ +import { ColorPalette } from '../types'; + +const colorChoices: ColorPalette[] = [ + { + name: 'Ancient Bronze', + primary: '#11163d', + secondary: '#8C8369', + foreground: '#11163d', + background: '#ffffff', + }, + { + name: 'Crimson Tide', + primary: '#A02040', + secondary: '#234B57', + foreground: '#871C37', + background: '#ffffff', + }, + { + name: 'Purple Twilight', + primary: '#301834', + secondary: '#6a5eb7', + foreground: '#090909', + background: '#fefbff', + }, + { + name: 'Midnight Citrus', + primary: '#1B1736', + secondary: '#7E76A3', + foreground: '#1B1736', + background: '#ffffff', + }, + { + name: 'Lemon Myrtle', + primary: '#3E7172', + secondary: '#FC9B00', + foreground: '#325C5D', + background: '#ffffff', + }, + { + name: 'Green Thumb', + primary: '#164A41', + secondary: '#4B7B4D', + foreground: '#164A41', + background: '#ffffff', + }, + { + name: 'Golden Haze', + primary: '#232224', + secondary: '#EBB54F', + foreground: '#515151', + background: '#ffffff', + }, + { + name: 'Golden Indigo', + primary: '#4866C0', + secondary: '#C09F50', + foreground: '#405AA7', + background: '#ffffff', + }, + { + name: 'Arctic Dawn', + primary: '#243156', + secondary: '#DE5853', + foreground: '#243156', + background: '#ffffff', + }, + { + name: 'Jungle Sunrise', + primary: '#1a4435', + secondary: '#ed774e', + foreground: '#0a271d', + background: '#fefbec', + }, + { + name: 'Berry Grove', + primary: '#1F351A', + secondary: '#DE76DE', + foreground: '#1f351a', + background: '#fdfaf1', + }, + { + name: 'Fuchsia', + primary: '#b7127f', + secondary: '#18020C', + foreground: '#b7127f', + background: '#f7edf6', + }, + { + name: 'Raspberry Chocolate', + primary: '#42332e', + secondary: '#d64d68', + foreground: '#241d1a', + background: '#eeeae6', + }, + { + name: 'Canary', + primary: '#0F0F05', + secondary: '#353535', + foreground: '#0F0F05', + background: '#FCFF9B', + }, + { + name: 'Gumtree Sunset', + primary: '#476C77', + secondary: '#EFB071', + foreground: '#476C77', + background: '#edf4f4', + }, + { + name: 'Ice', + primary: '#12123F', + secondary: '#3473FE', + foreground: '#12123F', + background: '#F1F4FA', + }, + { + name: 'Cinder', + primary: '#c14420', + secondary: '#2F2D2D', + foreground: '#863119', + background: '#f1f2f2', + }, + { + name: 'Blue Lagoon', + primary: '#004DE5', + secondary: '#0496FF', + foreground: '#0036A3', + background: '#FEFDF8', + }, + { + name: 'Sandalwood Oasis', + primary: '#F0EBE3', + secondary: '#DF9785', + foreground: '#ffffff', + background: '#2a2a16', + }, + { + name: 'Rustic Rosewood', + primary: '#F4F4F2', + secondary: '#EE797C', + foreground: '#ffffff', + background: '#1A1A1A', + }, + { + name: 'Cinnamon Latte', + primary: '#D9CAB3', + secondary: '#BC8034', + foreground: '#FFFFFF', + background: '#3C3F4D', + }, + { + name: 'Lilac Nightshade', + primary: '#f5d6ff', + secondary: '#C48DDA', + foreground: '#ffffff', + background: '#000000', + }, + { + name: 'Lightning', + primary: '#ebffd2', + secondary: '#fefefe', + foreground: '#ebffd2', + background: '#0e1fb5', + }, + { + name: 'Aquamarine Night', + primary: '#deffef', + secondary: '#56fbb9', + foreground: '#ffffff', + background: '#091C48', + }, + { + name: 'Charcoal', + primary: '#dbdbdb', + secondary: '#efefef', + foreground: '#dbdbdb', + background: '#1e1e1e', + }, + { + name: 'Evergreen Twilight', + primary: '#ffffff', + secondary: '#8EE978', + foreground: '#ffffff', + background: '#181818', + }, + { + name: 'Slate', + primary: '#FFFFFF', + secondary: '#FFDF6D', + foreground: '#EFF2F9', + background: '#13161E', + }, +]; +const allowedNames: string[] = colorChoices.map( ( palette ) => palette.name ); +const hexColorRegex = /^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/; + +export const colorPaletteValidator = z.object( { + name: z.string().refine( ( name ) => allowedNames.includes( name ), { + message: 'Color palette not part of allowed list', + } ), + primary: z + .string() + .regex( hexColorRegex, { message: 'Invalid primary color' } ), + secondary: z + .string() + .regex( hexColorRegex, { message: 'Invalid secondary color' } ), + foreground: z + .string() + .regex( hexColorRegex, { message: 'Invalid foreground color' } ), + background: z + .string() + .regex( hexColorRegex, { message: 'Invalid background color' } ), +} ); + +export const defaultColorPalette = { + queryId: 'default_color_palette', + + // make sure version is updated every time the prompt is changed + version: '2023-09-18', + prompt: ( businessDescription: string, look: string, tone: string ) => { + return ` + You are a WordPress theme expert. Analyse the following store description, merchant's chosen look and tone, and determine the most appropriate color scheme. + Respond only with one color scheme and only its JSON. + + Chosen look and tone: ${ look } look, ${ tone } tone. + Business description: ${ businessDescription } + + Colors to choose from: + ${ JSON.stringify( colorChoices ) } + `; + }, + responseValidation: colorPaletteValidator.parse, +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/index.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/index.ts new file mode 100644 index 00000000000..ee134ed5941 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/index.ts @@ -0,0 +1,2 @@ +export * from './colorChoices'; +export * from './lookAndTone'; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/lookAndTone.test.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/lookAndTone.test.ts new file mode 100644 index 00000000000..cea15cc91b2 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/lookAndTone.test.ts @@ -0,0 +1,28 @@ +/** + * Internal dependencies + */ +import { LookAndToneCompletionResponse } from '../types'; +import { lookAndTone } from '.'; + +describe( 'parseLookAndToneCompletionResponse', () => { + it( 'should return a valid object when given valid JSON', () => { + const validObj = JSON.parse( + '{"look": "Contemporary", "tone": "Neutral"}' + ); + const result = lookAndTone.responseValidation( validObj ); + const expected: LookAndToneCompletionResponse = { + look: 'Contemporary', + tone: 'Neutral', + }; + expect( result ).toEqual( expected ); + } ); + + it( 'should throw an error and record an event for valid JSON but invalid values', () => { + const invalidValuesObj = { + completion: '{"look": "Invalid", "tone": "Invalid"}', + }; + expect( () => + lookAndTone.responseValidation( invalidValuesObj ) + ).toThrow( 'Invalid values in Look and Tone completion response' ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/lookAndTone.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/lookAndTone.ts new file mode 100644 index 00000000000..5b230eccbda --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/prompts/lookAndTone.ts @@ -0,0 +1,51 @@ +/** + * Internal dependencies + */ +import { + Look, + Tone, + VALID_LOOKS, + VALID_TONES, + LookAndToneCompletionResponse, +} from '../types'; + +export const isLookAndToneCompletionResponse = ( + obj: unknown +): obj is LookAndToneCompletionResponse => { + return ( + obj !== undefined && + obj !== null && + typeof obj === 'object' && + 'look' in obj && + VALID_LOOKS.includes( obj.look as Look ) && + 'tone' in obj && + VALID_TONES.includes( obj.tone as Tone ) + ); +}; + +export const lookAndTone = { + queryId: 'look_and_tone', + // make sure version is updated every time the prompt is changed + version: '2023-09-18', + prompt: ( businessInfoDescription: string ) => { + return [ + 'You are a WordPress theme expert.', + 'Analyze the following store description and determine the look and tone of the theme.', + `For look, you can choose between ${ VALID_LOOKS.join( ',' ) }.`, + `For tone of the description, you can choose between ${ VALID_TONES.join( + ',' + ) }.`, + 'Your response should be in json with look and tone values.', + '\n', + businessInfoDescription, + ].join( '\n' ); + }, + responseValidation: ( response: unknown ) => { + if ( isLookAndToneCompletionResponse( response ) ) { + return response; + } + throw new Error( + 'Invalid values in Look and Tone completion response' + ); + }, +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/services.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/services.ts index bab237fb6d7..f8b7cc1671b 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-with-ai/services.ts +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/services.ts @@ -4,103 +4,13 @@ import { __experimentalRequestJetpackToken as requestJetpackToken } from '@woocommerce/ai'; import apiFetch from '@wordpress/api-fetch'; import { recordEvent } from '@woocommerce/tracks'; -import { Sender } from 'xstate'; +import { Sender, assign, createMachine } from 'xstate'; /** * Internal dependencies */ -import { - Look, - Tone, - VALID_LOOKS, - VALID_TONES, - designWithAiStateMachineContext, -} from './types'; - -export interface LookAndToneCompletionResponse { - look: Look; - tone: Tone; -} - -interface MaybeLookAndToneCompletionResponse { - completion: string; -} - -export const isLookAndToneCompletionResponse = ( - obj: unknown -): obj is LookAndToneCompletionResponse => { - return ( - obj !== undefined && - obj !== null && - typeof obj === 'object' && - 'look' in obj && - VALID_LOOKS.includes( obj.look as Look ) && - 'tone' in obj && - VALID_TONES.includes( obj.tone as Tone ) - ); -}; - -export const parseLookAndToneCompletionResponse = ( - obj: MaybeLookAndToneCompletionResponse -): LookAndToneCompletionResponse => { - try { - const o = JSON.parse( obj.completion ); - if ( isLookAndToneCompletionResponse( o ) ) { - return o; - } - } catch { - recordEvent( - 'customize_your_store_look_and_tone_ai_completion_response_error', - { error_type: 'json_parse_error', response: JSON.stringify( obj ) } - ); - } - recordEvent( - 'customize_your_store_look_and_tone_ai_completion_response_error', - { - error_type: 'valid_json_invalid_values', - response: JSON.stringify( obj ), - } - ); - throw new Error( 'Could not parse Look and Tone completion response.' ); -}; - -export const getLookAndTone = async ( - context: designWithAiStateMachineContext -) => { - const prompt = [ - 'You are a WordPress theme expert.', - 'Analyze the following store description and determine the look and tone of the theme.', - `For look, you can choose between ${ VALID_LOOKS.join( ',' ) }.`, - `For tone of the description, you can choose between ${ VALID_TONES.join( - ',' - ) }.`, - 'Your response should be in json with look and tone values.', - '\n', - context.businessInfoDescription.descriptionText, - ]; - - const { token } = await requestJetpackToken(); - - const url = new URL( - 'https://public-api.wordpress.com/wpcom/v2/text-completion' - ); - - url.searchParams.append( 'feature', 'woo_cys' ); - - const data: { - completion: string; - } = await apiFetch( { - url: url.toString(), - method: 'POST', - data: { - token, - prompt: prompt.join( '\n' ), - _fields: 'completion', - }, - } ); - - return parseLookAndToneCompletionResponse( data ); -}; +import { designWithAiStateMachineContext } from './types'; +import { lookAndTone } from './prompts'; const browserPopstateHandler = () => ( sendBack: Sender< { type: 'EXTERNAL_URL_UPDATE' } > ) => { @@ -113,7 +23,177 @@ const browserPopstateHandler = }; }; +export const getCompletion = async < ValidResponseObject >( { + queryId, + prompt, + version, + responseValidation, + retryCount, +}: { + queryId: string; + prompt: string; + version: string; + responseValidation: ( arg0: string ) => ValidResponseObject; + retryCount: number; +} ) => { + const { token } = await requestJetpackToken(); + let data: { + completion: string; + }; + let parsedCompletionJson; + try { + const url = new URL( + 'https://public-api.wordpress.com/wpcom/v2/text-completion' + ); + + url.searchParams.append( 'feature', 'woo_cys' ); + + data = await apiFetch( { + url: url.toString(), + method: 'POST', + data: { + token, + prompt, + _fields: 'completion', + }, + } ); + } catch ( error ) { + recordEvent( 'customize_your_store_ai_completion_api_error', { + query_id: queryId, + version, + retry_count: retryCount, + error_type: 'api_request_error', + } ); + throw error; + } + + try { + parsedCompletionJson = JSON.parse( data.completion ); + } catch { + recordEvent( 'customize_your_store_ai_completion_response_error', { + query_id: queryId, + version, + retry_count: retryCount, + error_type: 'json_parse_error', + response: data.completion, + } ); + throw new Error( + `Error validating Jetpack AI text completions response for ${ queryId }` + ); + } + + try { + const validatedResponse = responseValidation( parsedCompletionJson ); + recordEvent( 'customize_your_store_ai_completion_success', { + query_id: queryId, + version, + retry_count: retryCount, + } ); + return validatedResponse; + } catch ( error ) { + recordEvent( 'customize_your_store_ai_completion_response_error', { + query_id: queryId, + version, + retry_count: retryCount, + error_type: 'valid_json_invalid_values', + response: data.completion, + } ); + throw error; + } +}; + +export const getLookAndTone = async ( + context: designWithAiStateMachineContext +) => { + return getCompletion( { + ...lookAndTone, + prompt: lookAndTone.prompt( + context.businessInfoDescription.descriptionText + ), + retryCount: 0, + } ); +}; + +export const queryAiEndpoint = createMachine( + { + id: 'query-ai-endpoint', + predictableActionArguments: true, + initial: 'init', + context: { + // these values are all overwritten by incoming parameters + prompt: '', + queryId: '', + version: '', + responseValidation: () => true, + retryCount: 0, + validatedResponse: {} as unknown, + }, + states: { + init: { + always: 'querying', + entry: [ 'setRetryCount' ], + }, + querying: { + invoke: { + src: 'getCompletion', + onDone: { + target: 'success', + actions: [ 'handleAiResponse' ], + }, + onError: { + target: 'error', + }, + }, + }, + error: { + always: [ + { + cond: ( context ) => context.retryCount >= 3, + target: 'failed', + }, + { + target: 'querying', + actions: assign( { + retryCount: ( context ) => context.retryCount + 1, + } ), + }, + ], + }, + failed: { + type: 'final', + data: { + result: 'failed', + }, + }, + success: { + type: 'final', + data: ( context ) => { + return { + result: 'success', + response: context.validatedResponse, + }; + }, + }, + }, + }, + { + actions: { + handleAiResponse: assign( { + validatedResponse: ( _context, event: unknown ) => + ( event as { data: unknown } ).data, + } ), + setRetryCount: assign( { + retryCount: 0, + } ), + }, + services: { + getCompletion, + }, + } +); + export const services = { getLookAndTone, browserPopstateHandler, + queryAiEndpoint, }; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/state-machine.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/state-machine.tsx index 7dcaf78435a..58dd0367583 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-with-ai/state-machine.tsx +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/state-machine.tsx @@ -10,6 +10,7 @@ import { getQuery } from '@woocommerce/navigation'; import { designWithAiStateMachineContext, designWithAiStateMachineEvents, + ColorPalette, } from './types'; import { BusinessInfoDescription, @@ -19,6 +20,7 @@ import { } from './pages'; import { actions } from './actions'; import { services } from './services'; +import { defaultColorPalette } from './prompts'; export const hasStepInUrl = ( _ctx: unknown, @@ -60,13 +62,15 @@ export const designWithAiStateMachineDefinition = createMachine( businessInfoDescription: { descriptionText: '', }, - lookAndFeel: { choice: '', }, toneOfVoice: { choice: '', }, + aiSuggestions: { + defaultColorPalette: {} as ColorPalette, + }, }, initial: 'navigate', states: { @@ -264,6 +268,30 @@ export const designWithAiStateMachineDefinition = createMachine( step: 'api-call-loader', }, ], + type: 'parallel', + states: { + chooseColorPairing: { + invoke: { + src: 'queryAiEndpoint', + data: ( context ) => { + return { + ...defaultColorPalette, + prompt: defaultColorPalette.prompt( + context.businessInfoDescription + .descriptionText, + context.lookAndFeel.choice, + context.toneOfVoice.choice + ), + }; + }, + onDone: { + actions: [ + 'assignDefaultColorPalette', + ], + }, + }, + }, + }, }, postApiCallLoader: {}, }, diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/style.scss b/plugins/woocommerce-admin/client/customize-store/design-with-ai/style.scss new file mode 100644 index 00000000000..6f849a7d06a --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/style.scss @@ -0,0 +1,28 @@ +.woocommerce-onboarding-loader { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + background-color: #fff; + + .woocommerce-onboarding-loader-wrapper { + align-items: flex-start; + display: flex; + flex-direction: column; + justify-content: center; + max-width: 520px; + min-height: 400px; + + .woocommerce-onboarding-loader-container { + text-align: center; + min-height: 400px; + } + + .loader-hearticon { + position: relative; + top: 2px; + left: 2px; + } + } +} diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/tests/services.test.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/tests/services.test.ts index dc619a3ac72..0810302dfbb 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-with-ai/tests/services.test.ts +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/tests/services.test.ts @@ -2,63 +2,147 @@ * External dependencies */ import { recordEvent } from '@woocommerce/tracks'; - +import { __experimentalRequestJetpackToken as requestJetpackToken } from '@woocommerce/ai'; +import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies */ -import { - parseLookAndToneCompletionResponse, - LookAndToneCompletionResponse, -} from '../services'; +import { getCompletion } from '../services'; jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn(), } ) ); -describe( 'parseLookAndToneCompletionResponse', () => { +jest.mock( '@woocommerce/ai', () => ( { + __experimentalRequestJetpackToken: jest.fn(), +} ) ); + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +describe( 'getCompletion', () => { beforeEach( () => { - ( recordEvent as jest.Mock ).mockClear(); + jest.clearAllMocks(); } ); - it( 'should return a valid object when given valid JSON', () => { - const validObj = { - completion: '{"look": "Contemporary", "tone": "Neutral"}', - }; - const result = parseLookAndToneCompletionResponse( validObj ); - const expected: LookAndToneCompletionResponse = { - look: 'Contemporary', - tone: 'Neutral', - }; - expect( result ).toEqual( expected ); - expect( recordEvent ).not.toHaveBeenCalled(); - } ); + it( 'should successfully get completion', async () => { + ( requestJetpackToken as jest.Mock ).mockResolvedValue( { + token: 'fake_token', + } ); + ( apiFetch as unknown as jest.Mock ).mockResolvedValue( { + completion: JSON.stringify( { key: 'value' } ), + } ); + const responseValidation = jest.fn( ( json ) => json ); - it( 'should throw an error and record an event for JSON parse error', () => { - const invalidObj = { completion: 'invalid JSON' }; - expect( () => - parseLookAndToneCompletionResponse( invalidObj ) - ).toThrow( 'Could not parse Look and Tone completion response.' ); - expect( recordEvent ).toHaveBeenCalledWith( - 'customize_your_store_look_and_tone_ai_completion_response_error', + const result = await getCompletion( { + queryId: 'query1', + prompt: 'test prompt', + responseValidation, + retryCount: 0, + version: '1', + } ); + + expect( result ).toEqual( { key: 'value' } ); + expect( responseValidation ).toBeCalledWith( { key: 'value' } ); + expect( recordEvent ).toBeCalledWith( + 'customize_your_store_ai_completion_success', { - error_type: 'json_parse_error', - response: JSON.stringify( invalidObj ), + query_id: 'query1', + retry_count: 0, + version: '1', } ); } ); - it( 'should throw an error and record an event for valid JSON but invalid values', () => { - const invalidValuesObj = { - completion: '{"look": "Invalid", "tone": "Invalid"}', - }; - expect( () => - parseLookAndToneCompletionResponse( invalidValuesObj ) - ).toThrow( 'Could not parse Look and Tone completion response.' ); - expect( recordEvent ).toHaveBeenCalledWith( - 'customize_your_store_look_and_tone_ai_completion_response_error', + it( 'should handle API fetch error', async () => { + ( requestJetpackToken as jest.Mock ).mockResolvedValue( { + token: 'fake_token', + } ); + ( apiFetch as unknown as jest.Mock ).mockRejectedValue( + new Error( 'API error' ) + ); + + await expect( + getCompletion( { + queryId: 'query1', + prompt: 'test prompt', + responseValidation: () => {}, + retryCount: 0, + version: '1', + } ) + ).rejects.toThrow( 'API error' ); + + expect( recordEvent ).toBeCalledWith( + 'customize_your_store_ai_completion_api_error', { + query_id: 'query1', + retry_count: 0, + error_type: 'api_request_error', + version: '1', + } + ); + } ); + + it( 'should handle JSON parse error', async () => { + ( requestJetpackToken as jest.Mock ).mockResolvedValue( { + token: 'fake_token', + } ); + ( apiFetch as unknown as jest.Mock ).mockResolvedValue( { + completion: 'invalid json', + } ); + + await expect( + getCompletion( { + queryId: 'query1', + prompt: 'test prompt', + responseValidation: () => {}, + retryCount: 0, + version: '1', + } ) + ).rejects.toThrow( + `Error validating Jetpack AI text completions response for query1` + ); + + expect( recordEvent ).toBeCalledWith( + 'customize_your_store_ai_completion_response_error', + { + query_id: 'query1', + retry_count: 0, + error_type: 'json_parse_error', + response: 'invalid json', + version: '1', + } + ); + } ); + + it( 'should handle validation error', async () => { + ( requestJetpackToken as jest.Mock ).mockResolvedValue( { + token: 'fake_token', + } ); + ( apiFetch as unknown as jest.Mock ).mockResolvedValue( { + completion: JSON.stringify( { key: 'invalid value' } ), + } ); + const responseValidation = jest.fn( () => { + throw new Error( 'Validation error' ); + } ); + + await expect( + getCompletion( { + queryId: 'query1', + prompt: 'test prompt', + responseValidation, + retryCount: 0, + version: '1', + } ) + ).rejects.toThrow( 'Validation error' ); + + expect( recordEvent ).toBeCalledWith( + 'customize_your_store_ai_completion_response_error', + { + query_id: 'query1', + retry_count: 0, error_type: 'valid_json_invalid_values', - response: JSON.stringify( invalidValuesObj ), + response: JSON.stringify( { key: 'invalid value' } ), + version: '1', } ); } ); diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/types.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/types.ts index d7f8bbe7492..743d094d1c4 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-with-ai/types.ts +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/types.ts @@ -1,3 +1,12 @@ +/** + * External dependencies + */ +import { z } from 'zod'; +/** + * Internal dependencies + */ +import { colorPaletteValidator } from './prompts'; + export type designWithAiStateMachineContext = { businessInfoDescription: { descriptionText: string; @@ -8,6 +17,9 @@ export type designWithAiStateMachineContext = { toneOfVoice: { choice: Tone | ''; }; + aiSuggestions: { + defaultColorPalette: ColorPalette; + }; // If we require more data from options, previously provided core profiler details, // we can retrieve them in preBusinessInfoDescription and then assign them here }; @@ -31,3 +43,10 @@ export const VALID_LOOKS = [ 'Contemporary', 'Classic', 'Bold' ] as const; export const VALID_TONES = [ 'Informal', 'Neutral', 'Formal' ] as const; export type Look = ( typeof VALID_LOOKS )[ number ]; export type Tone = ( typeof VALID_TONES )[ number ]; + +export interface LookAndToneCompletionResponse { + look: Look; + tone: Tone; +} + +export type ColorPalette = z.infer< typeof colorPaletteValidator >; diff --git a/plugins/woocommerce-admin/package.json b/plugins/woocommerce-admin/package.json index 116cb546087..662cfbf7386 100644 --- a/plugins/woocommerce-admin/package.json +++ b/plugins/woocommerce-admin/package.json @@ -101,7 +101,8 @@ "react-transition-group": "^4.4.2", "react-visibility-sensor": "^5.1.1", "redux": "^4.1.2", - "xstate": "4.37.1" + "xstate": "4.37.1", + "zod": "^3.22.2" }, "devDependencies": { "@automattic/color-studio": "^2.5.0", diff --git a/plugins/woocommerce/changelog/add-customize-store-color-palette b/plugins/woocommerce/changelog/add-customize-store-color-palette new file mode 100644 index 00000000000..e65132fbdf5 --- /dev/null +++ b/plugins/woocommerce/changelog/add-customize-store-color-palette @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add customize store AI wizard call for color palette suggestion diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89f5ee3ea0e..b4060d0f393 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2876,6 +2876,9 @@ importers: xstate: specifier: 4.37.1 version: 4.37.1 + zod: + specifier: ^3.22.2 + version: 3.22.2 devDependencies: '@automattic/color-studio': specifier: ^2.5.0 @@ -49480,6 +49483,10 @@ packages: wrap-ansi: 2.1.0 dev: true + /zod@3.22.2: + resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==} + dev: false + /zwitch@1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} dev: true