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
This commit is contained in:
parent
f106ca16b9
commit
1b37042d55
|
@ -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,
|
||||
|
|
|
@ -19,6 +19,8 @@ import {
|
|||
} from './pages';
|
||||
import { customizeStoreStateMachineEvents } from '..';
|
||||
|
||||
import './style.scss';
|
||||
|
||||
export type events = { type: 'THEME_SUGGESTED' };
|
||||
export type DesignWithAiComponent =
|
||||
| typeof BusinessInfoDescription
|
||||
|
|
|
@ -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\\"
|
||||
]
|
||||
}
|
||||
]"
|
||||
` );
|
||||
} );
|
||||
} );
|
|
@ -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,
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export * from './colorChoices';
|
||||
export * from './lookAndTone';
|
|
@ -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' );
|
||||
} );
|
||||
} );
|
|
@ -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'
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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: {},
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
} );
|
||||
|
|
|
@ -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 >;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add customize store AI wizard call for color palette suggestion
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue