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:
RJ 2023-09-19 18:41:52 +10:00 committed by GitHub
parent f106ca16b9
commit 1b37042d55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 870 additions and 138 deletions

View File

@ -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,

View File

@ -19,6 +19,8 @@ import {
} from './pages';
import { customizeStoreStateMachineEvents } from '..';
import './style.scss';
export type events = { type: 'THEME_SUGGESTED' };
export type DesignWithAiComponent =
| typeof BusinessInfoDescription

View File

@ -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\\"
]
}
]"
` );
} );
} );

View File

@ -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,
};

View File

@ -0,0 +1,2 @@
export * from './colorChoices';
export * from './lookAndTone';

View File

@ -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' );
} );
} );

View File

@ -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'
);
},
};

View File

@ -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,
};

View File

@ -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: {},
},

View File

@ -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;
}
}
}

View File

@ -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',
}
);
} );

View File

@ -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 >;

View File

@ -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",

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add customize store AI wizard call for color palette suggestion

View File

@ -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