Add initial release of WooAI plugin (#38610)

Co-authored-by: Joel Thiessen <444632+joelclimbsthings@users.noreply.github.com>
Co-authored-by: Nima Karimi <73110514+nima-karimi@users.noreply.github.com>
Co-authored-by: Thomas Shellberg <tommy.shellberg@automattic.com>
This commit is contained in:
Joel Thiessen 2023-06-08 13:20:05 -07:00 committed by GitHub
parent 4569fda5c1
commit 8098c35588
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 6863 additions and 400 deletions

View File

@ -0,0 +1,28 @@
.distignore
.editorconfig
.gitignore
.travis.yml
.git/
.eslintrc
.eslintignore
.wordpress-org/
.turbo/
composer.json
tsconfig.json
composer.lock
package-lock.json
package.json
webpack.config.js
phpcs.xml
NEXT_CHANGELOG.md
DEVELOPMENT.md
changelog/
# build files
woo-ai.zip
node_modules/
bin/
vendor/
woo-ai/
src/

View File

@ -0,0 +1,24 @@
# This file is for unifying the coding style for different editors and IDEs
# editorconfig.org
# WordPress Coding Standards
# https://make.wordpress.org/core/handbook/coding-standards/
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
tab_width = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
[*.txt]
trim_trailing_whitespace = false
[*.{md,json,yml}]
trim_trailing_whitespace = false
indent_style = space
indent_size = 2

View File

@ -0,0 +1,5 @@
*.min.js
build
build-module
node_modules
vendor

13
plugins/woo-ai/.eslintrc Normal file
View File

@ -0,0 +1,13 @@
{
"root": true,
"extends": [ "plugin:@woocommerce/eslint-plugin/recommended" ],
"env": {
"browser": true,
"node": true
},
"rules": {
"camelcase": 0,
"react/react-in-jsx-scope": "off",
"no-alert": "off"
}
}

26
plugins/woo-ai/.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Operating System files
.DS_Store
Thumbs.db
# IDE files
.idea
.vscode/
project.xml
project.properties
.project
.settings*
*.sublime-project
*.sublime-workspace
.sublimelinterrc
# Dependencies
vendor/
node_modules/
# Built assets
build/
woo-ai.zip
build
build-module
build-style
woo-ai/

1
plugins/woo-ai/.nvmrc Normal file
View File

@ -0,0 +1 @@
16

View File

@ -0,0 +1,3 @@
// Import the default config file and expose it in the project root.
// Useful for editor integrations.
module.exports = require("@wordpress/prettier-config");

View File

@ -0,0 +1,19 @@
language: php
before_script:
- |
# Remove Xdebug for a huge performance increase:
if [ -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini ]; then
phpenv config-rm xdebug.ini
else
echo "xdebug.ini does not exist"
fi
- composer install
script:
- ./vendor/bin/phpcs -s -n -p *
# Specifies that Travis should create branch builds only for master.
branches:
only:
- master

View File

@ -0,0 +1,5 @@
## [0.1.0](https://github.com/woocommerce/woocommerce/releases/tag/0.1.0) - 2023-06-05
- Patch - Unhook a duplicate Jetpack Contact Form button hook.
- Minor - Declare HPOS compatibility.
- Minor - Truncating product title before sending.

View File

@ -0,0 +1,52 @@
# Woo AI
Woo AI is a WooCommerce plugin that utilizes the power of artificial intelligence to enhance your eCommerce experience. With features like AI-powered product title optimization and automated product description generation, Woo AI is designed to boost your store's efficiency and sales potential.
## Getting Started
Please refer to [the Getting Started section of the WooCommerce Core `README.md`](https://github.com/woocommerce/woocommerce/blob/trunk/README.md) for a general-purpose guide on getting started. The rest of this document will assume that you've installed all of the prequisites and setup described there.
## Plugin Development Environments
The plugin makes use of [the `@wordpress/env` package](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/).
This supplies convenient commands for creating, destroying, cleaning, and testing WordPress environments.
```bash
# Make sure you are in the working directory of the plugin you are interested in setting up the environment for
cd plugins/woo-ai
# Start will create the environment if necessary or start an existing one
pnpm -- wp-env start
# Stop will, well, stop the environment
pnpm -- wp-env stop
# Destroy will remove all of the environment's files.
pnpm -- wp-env destroy
```
## Development
To enable Live(Hot) Reload when code is changed, run the following commands:
```text
pnpm install
pnpm run start
```
To build the /woo-ai/ plugin directory (when loading the plugin via symlink), run:
```text
pnpm install
pnpm run build
```
To build the plugin ZIP file, run:
```text
pnpm install
pnpm run build:zip
```
See [wp-scripts](https://github.com/WordPress/gutenberg/tree/master/packages/scripts) for more usage information.
## License
This plugin is licensed under the GPL v3 or later.

View File

@ -0,0 +1,5 @@
# Changelog
---
[See changelogs for previous versions](https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/plugins/woocommerce-beta-tester/readme.txt).

30
plugins/woo-ai/README.md Normal file
View File

@ -0,0 +1,30 @@
# Woo AI
Woo AI is a WooCommerce plugin that utilizes the power of artificial intelligence to enhance your eCommerce experience. With features like AI-powered product title optimization and automated product description generation, Woo AI is designed to boost your store's efficiency and sales potential.
## Installation
1. Download the Woo AI plugin zip file.
2. Go to your WordPress Dashboard, then navigate to `Plugins > Add New > Upload Plugin`.
3. Select the downloaded Woo AI zip file to upload.
4. After uploading, click on `Activate Plugin`.
## Usage
**Improve Product Titles using AI Recommendations**
1. Go to the WooCommerce `Products` page.
2. Select a product and click `Edit` or `Add New` to create a new product.
3. Start typing a product title.
4. Review and apply AI-generated title recommendations.
**Generate Product Descriptions based on existing product data**
1. Go to the WooCommerce `Products` page.
2. Select a product and click `Edit`.
3. Make sure the product has a sufficient title length and click on the `Write It For Me` button.
4. Review and apply the AI-generated product description.
## License
This plugin is licensed under the GPL v3 or later.

View File

@ -0,0 +1,23 @@
<?php
/**
* API initialization for ai plugin.
*
* @package Woo_AI
*/
defined( 'ABSPATH' ) || exit;
/**
* API initialization for ai plugin.
*
* @package Woo_AI
*/
/**
* Register the Woo AI route.
*/
function woo_ai_rest_api_init() {
require_once dirname( __FILE__ ) . '/product-data-suggestion/class-product-data-suggestion-api.php';
}
add_action( 'rest_api_init', 'woo_ai_rest_api_init' );

View File

@ -0,0 +1,198 @@
<?php
/**
* REST API Attribute Suggestion Controller
*
* Handles requests to /wc-admin/wooai/product-data-suggestions
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\AI\Completion\Jetpack_Completion_Service;
use Automattic\WooCommerce\AI\ProductDataSuggestion\Product_Data_Suggestion_Exception;
use Automattic\WooCommerce\AI\ProductDataSuggestion\Product_Data_Suggestion_Prompt_Generator;
use Automattic\WooCommerce\AI\ProductDataSuggestion\Product_Data_Suggestion_Request;
use Automattic\WooCommerce\AI\ProductDataSuggestion\Product_Data_Suggestion_Service;
use Automattic\WooCommerce\AI\PromptFormatter\Json_Request_Formatter;
use Automattic\WooCommerce\AI\PromptFormatter\Product_Attribute_Formatter;
use Automattic\WooCommerce\AI\PromptFormatter\Product_Category_Formatter;
use WC_REST_Data_Controller;
use WP_Error;
use WP_HTTP_Response;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
defined( 'ABSPATH' ) || exit;
/**
* Attribute Suggestion API controller.
*
* @internal
* @extends WC_REST_Data_Controller
*/
class Product_Data_Suggestion_API extends WC_REST_Data_Controller {
/**
* The product data suggestion service.
*
* @var Product_Data_Suggestion_Service
*/
protected $product_data_suggestion_service;
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'wooai';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'product-data-suggestions';
/**
* Constructor
*/
public function __construct() {
$json_request_formatter = new Json_Request_Formatter();
$product_category_formatter = new Product_Category_Formatter();
$product_attribute_formatter = new Product_Attribute_Formatter();
$prompt_generator = new Product_Data_Suggestion_Prompt_Generator( $product_category_formatter, $product_attribute_formatter, $json_request_formatter );
$completion_service = new Jetpack_Completion_Service();
$this->product_data_suggestion_service = new Product_Data_Suggestion_Service( $prompt_generator, $completion_service );
$this->register_routes();
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'get_response' ),
'permission_callback' => array( $this, 'get_response_permission_check' ),
'args' => array(
'requested_data' => array(
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_text_field',
'required' => true,
),
'name' => array(
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
),
'description' => array(
'type' => 'string',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
),
'categories' => array(
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'wp_parse_id_list',
'items' => array(
'type' => 'integer',
),
'default' => array(),
),
'tags' => array(
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'items' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'default' => array(),
),
'attributes' => array(
'type' => 'array',
'validate_callback' => 'rest_validate_request_arg',
'default' => array(),
'items' => array(
'type' => 'object',
'properties' => array(
'key' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
'value' => array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
),
),
),
),
),
),
)
);
}
/**
* Check if a given request has access to create a product.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_Error|boolean
*/
public function get_response_permission_check( WP_REST_Request $request ) {
if ( ! wc_rest_check_post_permissions( 'product', 'create' ) ) {
return new WP_Error( 'woocommerce_rest_cannot_view', __( 'Sorry, you cannot view this resource.', 'woocommerce' ), array( 'status' => rest_authorization_required_code() ) );
}
return true;
}
/**
* Get the product-data suggestions.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_Error|WP_HTTP_Response|WP_REST_Response
*/
public function get_response( WP_REST_Request $request ) {
$requested_data = $request->get_param( 'requested_data' );
$name = $request->get_param( 'name' );
$description = $request->get_param( 'description' );
$categories = $request->get_param( 'categories' );
$tags = $request->get_param( 'tags' );
$attributes = $request->get_param( 'attributes' );
// Strip HTML tags from the description.
if ( ! empty( $description ) ) {
$description = wp_strip_all_tags( $request->get_param( 'description' ) );
}
// Check if enough data is provided in the name and description to get suggestions.
if ( strlen( $name ) < 10 && strlen( $description ) < 50 ) {
return new WP_Error( 'error', __( 'Enter a few descriptive words or add product description, tags, or attributes to generate name ideas.', 'woocommerce' ), array( 'status' => 400 ) );
}
try {
$product_data_request = new Product_Data_Suggestion_Request( $requested_data, $name, $description, $tags, $categories, $attributes );
$suggestions = $this->product_data_suggestion_service->get_suggestions( $product_data_request );
} catch ( Product_Data_Suggestion_Exception $e ) {
return new WP_Error( 'error', $e->getMessage(), array( 'status' => $e->getCode() ) );
}
return rest_ensure_response( $suggestions );
}
}
new Product_Data_Suggestion_API();

View File

@ -0,0 +1,15 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z"
stroke="#CC1818"
stroke-width="1.5"
/>
<path d="M13 7H11V13H13V7Z" fill="#CC1818" />
<path d="M13 15H11V17H13V15Z" fill="#CC1818" />
</svg>

After

Width:  |  Height:  |  Size: 376 B

View File

@ -0,0 +1,27 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1714_12055)">
<path
d="M13.3235 6.76562L14.8726 10.9519L19.0588 12.5009L14.8726 14.05L13.3235 18.2362L11.7745 14.05L7.58823 12.5009L11.7745 10.9519L13.3235 6.76562Z"
fill="#1B71B1"
/>
<path
d="M7.58824 5L8.30318 6.93211L10.2353 7.64706L8.30318 8.362L7.58824 10.2941L6.87329 8.362L4.94118 7.64706L6.87329 6.93211L7.58824 5Z"
fill="#1B71B1"
/>
<path
d="M7.58824 14.7051L8.30318 16.6372L10.2353 17.3521L8.30318 18.0671L7.58824 19.9992L6.87329 18.0671L4.94118 17.3521L6.87329 16.6372L7.58824 14.7051Z"
fill="#1B71B1"
/>
</g>
<defs>
<clipPath id="clip0_1714_12055">
<rect width="24" height="24" rx="2" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 808 B

28
plugins/woo-ai/bin/build-zip.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/sh
PLUGIN_SLUG="woo-ai"
PROJECT_PATH=$(pwd)
BUILD_PATH="${PROJECT_PATH}/build"
DEST_PATH="$BUILD_PATH/$PLUGIN_SLUG"
echo "Generating build directory..."
rm -rf "$BUILD_PATH"
mkdir -p "$DEST_PATH"
echo "Installing PHP and JS dependencies..."
pnpm install
echo "Running JS Build..."
pnpm -w run build --filter=woo-ai || exit "$?"
echo "Syncing files..."
rsync -rc --exclude-from="$PROJECT_PATH/.distignore" "$PROJECT_PATH/" "$DEST_PATH/" --delete --delete-excluded
echo "Generating zip file..."
cd "$BUILD_PATH" || exit
zip -q -r "${PLUGIN_SLUG}.zip" "$PLUGIN_SLUG/"
cd "$PROJECT_PATH" || exit
mv "$BUILD_PATH/${PLUGIN_SLUG}.zip" "$PROJECT_PATH"
echo "${PLUGIN_SLUG}.zip file generated!"
echo "Build done!"

View File

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Initial release of WooAI plugin.

View File

@ -0,0 +1,65 @@
{
"name": "woocommerce/woo-ai",
"description": "Run AI experiments in WooCommerce.",
"homepage": "https://woocommerce.com/",
"type": "wordpress-plugin",
"license": "GPL-3.0-or-later",
"prefer-stable": true,
"minimum-stability": "dev",
"version": "0.1.0",
"require": {
"composer/installers": "~1.7",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^6.5 || ^7.5",
"woocommerce/woocommerce-sniffs": "^0.1.3",
"automattic/jetpack-changelogger": "3.3.0"
},
"scripts": {
"test": [
"phpunit"
],
"phpcs": [
"phpcs -s -p"
],
"phpcs-pre-commit": [
"phpcs -s -p -n"
],
"phpcbf": [
"phpcbf -p"
]
},
"extra": {
"scripts-description": {
"test": "Run unit tests",
"phpcs": "Analyze code against the WordPress coding standards with PHP_CodeSniffer",
"phpcbf": "Fix coding standards warnings/errors automatically with PHP Code Beautifier"
},
"changelogger": {
"formatter": {
"filename": "../../tools/changelogger/class-plugin-formatter.php"
},
"types": {
"fix": "Fixes an existing bug",
"add": "Adds functionality",
"update": "Update existing functionality",
"dev": "Development related task",
"tweak": "A minor adjustment to the codebase",
"performance": "Address performance issues",
"enhancement": "Improve existing functionality"
},
"versioning": "wordpress",
"changelog": "CHANGELOG.md"
}
},
"config": {
"platform": {
"php": "7.2"
},
"allow-plugins": {
"composer/installers": true,
"dealerdirect/phpcodesniffer-composer-installer": true
}
}
}

2654
plugins/woo-ai/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
<?php
/**
* Admin notices
*
* @package Woo_AI\Admin
*/
defined( 'ABSPATH' ) || exit;
/**
* Admin notices class.
*/
class Woo_AI_Admin_Notices {
/**
* WooCommerce not installed notice.
*/
public function woocoommerce_not_installed() {
include_once dirname( __FILE__ ) . '/views/html-admin-missing-woocommerce.php';
}
/**
* Jetpack not installed notice.
*/
public function jetpack_not_installed() {
include_once dirname( __FILE__ ) . '/views/html-admin-missing-jetpack.php';
}
}

View File

@ -0,0 +1,130 @@
<?php
/**
* Woo AI Product Text Generation Class
*
* @package Woo_AI
*/
defined( 'ABSPATH' ) || exit;
use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
/**
* Settings Class.
*/
class Woo_AI_Product_Text_Generation {
/**
* Constructor
*/
public function __construct() {
add_action( 'admin_enqueue_scripts', array( $this, 'add_woo_ai_register_script' ) );
add_action( 'media_buttons', array( $this, 'add_gpt_button' ), 40 );
add_action( 'edit_form_before_permalink', array( $this, 'add_name_generation_form' ) );
add_filter( 'the_editor', array( $this, 'add_gpt_form' ), 10, 1 );
}
/**
* Enqueue the styles and JS
*/
public function add_woo_ai_register_script() {
$script_path = '/../build/index.js';
$script_asset_path = dirname( __FILE__ ) . '/../build/index.asset.php';
$script_asset = file_exists( $script_asset_path )
? require $script_asset_path
: array(
'dependencies' => array(),
'version' => filemtime( $script_path ),
);
$script_url = plugins_url( $script_path, __FILE__ );
$script_asset['dependencies'][] = WC_ADMIN_APP; // Add WCA as a dependency to ensure it loads first.
wp_register_script(
'woo-ai',
$script_url,
$script_asset['dependencies'],
$script_asset['version'],
true
);
wp_enqueue_script( 'woo-ai' );
if ( class_exists( '\Automattic\Jetpack\Connection\Initial_State' ) ) {
wp_add_inline_script( 'woo-ai', Connection_Initial_State::render(), 'before' );
}
$css_file_version = filemtime( dirname( __FILE__ ) . '/../build/index.css' );
wp_register_style(
'wp-components',
plugins_url( 'dist/components/style.css', __FILE__ ),
array(),
$css_file_version
);
wp_register_style(
'woo-ai',
plugins_url( '/../build/index.css', __FILE__ ),
// Add any dependencies styles may have, such as wp-components.
array(
'wp-components',
),
$css_file_version
);
wp_enqueue_style( 'woo-ai' );
}
/**
* Add gpt button to the editor.
*
* @param String $editor_id Editor Id.
*/
public function add_gpt_button( $editor_id ) {
if ( 'content' !== $editor_id || ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_pages' ) ) ) {
return;
}
echo '<div id="woocommerce-ai-app-product-gpt-button"></div>';
}
/**
* Add the form and button for generating product title suggestions to the editor.
*/
public function add_name_generation_form() {
if ( ! current_user_can( 'edit_posts' ) && ! current_user_can( 'edit_pages' ) ) {
return;
}
echo '<div id="woocommerce-ai-app-product-name-suggestions"></div>';
}
/**
* Add gpt form to the editor.
*
* @param String $content Gpt form content.
*/
public function add_gpt_form( $content ) {
global $post;
// Check if the current post type is 'product'.
if ( 'product' === $post->post_type ) {
// Check if the content contains the specific editor ID.
$editor_container_id = 'wp-content-editor-container';
$editor_container_position = strpos( $content, $editor_container_id );
if ( false !== $editor_container_position ) {
$gpt_form =
'<div id="woocommerce-ai-app-product-gpt-form"></div>';
$content = $gpt_form . $content;
}
}
return $content;
}
}
new Woo_AI_Product_Text_Generation();

View File

@ -0,0 +1,204 @@
<?php
/**
* Woo AI plugin main class
*
* @package Woo_AI
*/
defined( 'ABSPATH' ) || exit;
/**
* Woo_AI Main Class.
*/
class Woo_AI {
/**
* Config
*
* @var array
*/
private $plugin_config;
/**
* Plugin instance.
*
* @var Woo_AI
*/
protected static $instance = null;
/**
* Main Instance.
*/
public static function instance() {
self::$instance = is_null( self::$instance ) ? new self() : self::$instance;
return self::$instance;
}
/**
* Ran on activation to flush update cache
*/
public static function activate() {
delete_site_transient( 'update_plugins' );
delete_site_transient( 'woocommerce_latest_tag' );
}
/**
* Get the plugin url.
*
* @return string
*/
public function plugin_url() {
return untrailingslashit( plugins_url( '/', WOO_AI_FILE ) );
}
/**
* Constructor
*/
public function __construct() {
$this->plugin_name = plugin_basename( WOO_AI_FILE );
$this->plugin_config = array(
'plugin_file' => 'woocommerce/woocommerce.php',
'slug' => 'woocommerce',
'proper_folder_name' => 'woocommerce',
'api_url' => 'https://K.wordpress.org/plugins/info/1.0/woocommerce.json',
'repo_url' => 'https://wordpress.org/plugins/woocommerce/',
);
add_filter( 'jetpack_offline_mode', '__return_false' );
add_action( 'current_screen', array( $this, 'includes' ) );
}
/**
* Include any classes we need within admin.
*/
public function includes() {
$current_screen = get_current_screen();
if ( 'post' === $current_screen->base && 'product' === $current_screen->post_type ) {
include_once dirname( __FILE__ ) . '/class-woo-ai-product-text-generation.php';
}
}
/**
* Get plugin download URL.
*
* @since 1.0
* @param string $version The version.
* @return string
*/
public function get_download_url( $version ) {
$data = $this->get_wporg_data();
if ( empty( $data->versions->$version ) ) {
return false;
}
return $data->versions->$version;
}
/**
* Get Plugin data.
*
* @since 1.0
* @return object $data The data.
*/
public function get_plugin_data() {
return get_plugin_data( WP_PLUGIN_DIR . '/' . $this->plugin_config['plugin_file'] );
}
/**
* Return true if version string is a beta version.
*
* @param string $version_str Version string.
* @return bool
*/
protected static function is_beta_version( $version_str ) {
return strpos( $version_str, 'beta' ) !== false;
}
/**
* Return true if version string is a Release Candidate.
*
* @param string $version_str Version string.
* @return bool
*/
protected static function is_rc_version( $version_str ) {
return strpos( $version_str, 'rc' ) !== false;
}
/**
* Return true if version string is a stable version.
*
* @param string $version_str Version string.
* @return bool
*/
protected static function is_stable_version( $version_str ) {
return ! self::is_beta_version( $version_str ) && ! self::is_rc_version( $version_str );
}
/**
* Return true if release's version string belongs to beta channel, i.e.
* if it's beta, rc or stable release.
*
* @param string $version_str Version string of the release.
* @return bool
*/
protected static function is_in_beta_channel( $version_str ) {
return self::is_beta_version( $version_str ) || self::is_rc_version( $version_str ) || self::is_stable_version( $version_str );
}
/**
* Return true if release's version string belongs to release candidate channel, i.e.
* if it's rc or stable release.
*
* @param string $version_str Version string of the release.
* @return bool
*/
protected static function is_in_rc_channel( $version_str ) {
return self::is_rc_version( $version_str ) || self::is_stable_version( $version_str );
}
/**
* Return true if release's version string belongs to stable channel, i.e.
* if it's stable release and not a beta or rc.
*
* @param string $version_str Version string of the release.
* @return bool
*/
protected static function is_in_stable_channel( $version_str ) {
return self::is_stable_version( $version_str );
}
/**
* Return available versions from wp.org tags belonging to selected channel.
*
* @param string $channel Filter versions by channel: all|beta|rc|stable.
* @return array(string)
*/
public function get_tags( $channel = 'all' ) {
$data = $this->get_wporg_data();
$releases = (array) $data->versions;
unset( $releases['trunk'] );
$releases = array_keys( $releases );
foreach ( $releases as $index => $version ) {
if ( version_compare( $version, '3.6', '<' ) ) {
unset( $releases[ $index ] );
}
}
if ( 'beta' === $channel ) {
$releases = array_filter( $releases, array( __CLASS__, 'is_in_beta_channel' ) );
} elseif ( 'rc' === $channel ) {
$releases = array_filter( $releases, array( __CLASS__, 'is_in_rc_channel' ) );
} elseif ( 'stable' === $channel ) {
$releases = array_filter( $releases, array( __CLASS__, 'is_in_stable_channel' ) );
}
return $releases;
}
}

View File

@ -0,0 +1,17 @@
<?php
/**
* Completion Exception Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\Completion;
use Automattic\WooCommerce\AI\Exception\Woo_AI_Exception;
defined( 'ABSPATH' ) || exit;
/**
* Completion Exception Class
*/
class Completion_Exception extends Woo_AI_Exception {}

View File

@ -0,0 +1,188 @@
<?php
/**
* Jetpack Completion Service Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\Completion;
use Automattic\Jetpack\Connection\Client;
use Jetpack;
use Jetpack_Options;
use JsonException;
use WP_Error;
defined( 'ABSPATH' ) || exit;
/**
* Jetpack Service class.
*/
class Jetpack_Completion_Service implements Completion_Service_Interface {
/**
* The timeout for the completion request.
*/
private const COMPLETION_TIMEOUT = 60;
/**
* Gets the completion from the API.
*
* @param array $arguments An array of arguments to send to the API.
*
* @return string The completion response.
*
* @throws Completion_Exception If the request fails.
*/
public function get_completion( array $arguments ): string {
$this->validate_jetpack_connection();
$site_id = $this->get_site_id();
$response = $this->send_request_to_api( $site_id, $arguments );
return $this->process_response( $response );
}
/**
* Validates the Jetpack connection.
*
* @throws Completion_Exception If Jetpack connection is not ready.
*/
private function validate_jetpack_connection(): void {
if ( ! $this->is_jetpack_ready() ) {
throw new Completion_Exception( __( 'Not connected to Jetpack. Please make sure that Jetpack is active and connected.', 'woocommerce' ), 400 );
}
}
/**
* Returns whether Jetpack connection is ready.
*
* @return bool True if Jetpack connection is ready, false otherwise.
*/
private function is_jetpack_ready(): bool {
return Jetpack::connection()->has_connected_owner() && Jetpack::is_connection_ready();
}
/**
* Gets the Jetpack Site ID.
*
* @return string The Jetpack Site ID.
*
* @throws Completion_Exception If no Jetpack Site ID is found.
*/
private function get_site_id(): string {
$site_id = Jetpack_Options::get_option( 'id' );
if ( ! $site_id ) {
throw new Completion_Exception( __( 'No Jetpack Site ID found. Please make sure that Jetpack is active and connected.', 'woocommerce' ), 400 );
}
return (string) $site_id;
}
/**
* Sends request to the API and gets the response.
*
* @param string $site_id The site ID.
* @param array $arguments An array of arguments to send to the API.
*
* @return array|WP_Error The response from the API.
*/
private function send_request_to_api( string $site_id, array $arguments ) {
return Client::wpcom_json_api_request_as_user(
"/sites/{$site_id}/jetpack-ai/completions",
'2',
array(
'method' => 'POST',
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ),
'timeout' => self::COMPLETION_TIMEOUT,
),
$arguments
);
}
/**
* Processes the API response.
*
* @param array|WP_Error $response The response from the API.
*
* @return string The completion response.
*
* @throws Completion_Exception If there's an error in the response.
*/
private function process_response( $response ): string {
if ( is_wp_error( $response ) ) {
$this->handle_wp_error( $response );
}
if ( ! isset( $response['response']['code'] ) || 200 !== $response['response']['code'] ) {
/* translators: %s: The error message. */
throw new Completion_Exception( sprintf( __( 'Failed to get completion ( reason: %s )', 'woocommerce' ), $response['response']['message'] ), 400 );
}
$response_body = wp_remote_retrieve_body( $response );
try {
// Extract the string from the response. Response might be wrapped in quotes and escaped. E.g. "{ \n \"foo\": \"bar\" \n }".
$decoded = json_decode( $response_body, true, 512, JSON_THROW_ON_ERROR );
} catch ( JsonException $e ) {
$this->handle_json_exception( $e );
}
return $this->get_completion_string_from_decoded_response( $decoded, $response_body );
}
/**
* Handles WP_Error response.
*
* @param WP_Error $response The WP_Error response.
*
* @throws Completion_Exception With the error message from the response.
*/
private function handle_wp_error( WP_Error $response ): void {
/* translators: %s: The error message. */
throw new Completion_Exception( sprintf( __( 'Failed to get completion ( reason: %s )', 'woocommerce' ), $response->get_error_message() ), 400 );
}
/**
* Handles JSON parsing exceptions.
*
* @param JsonException $e The JSON exception.
*
* @throws Completion_Exception With the error message from the JSON exception.
*/
private function handle_json_exception( JsonException $e ): void {
/* translators: %s: The error message. */
throw new Completion_Exception( sprintf( __( 'Failed to decode completion response ( reason: %s )', 'woocommerce' ), $e->getMessage() ), 500, $e );
}
/**
* Retrieves the completion string from the decoded response.
*
* @param mixed $decoded The decoded JSON response.
* @param string $response_body The original response body.
*
* @return string The completion string.
*
* @throws Completion_Exception If the decoded response is invalid or empty.
*/
private function get_completion_string_from_decoded_response( $decoded, string $response_body ): string {
if ( ! is_string( $decoded ) ) {
// Check if the response is an error.
if ( isset( $decoded['code'] ) ) {
$error_message = $decoded['message'] ?? $decoded['code'];
/* translators: %s: The error message. */
throw new Completion_Exception( sprintf( __( 'Failed to get completion ( reason: %s )', 'woocommerce' ), $error_message ), 400 );
}
// If the decoded response is not an error, it means that the response was not wrapped in quotes and escaped, so we can use it as is.
$decoded = $response_body;
}
if ( empty( $decoded ) || ! is_string( $decoded ) ) {
/* translators: %s: The response body. */
throw new Completion_Exception( sprintf( __( 'Invalid or empty completion response: %s', 'woocommerce' ), $response_body ), 500 );
}
return $decoded;
}
}

View File

@ -0,0 +1,26 @@
<?php
/**
* Completion Service Interface
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\Completion;
defined( 'ABSPATH' ) || exit;
/**
* Completion Service Interface.
*/
interface Completion_Service_Interface {
/**
* Gets the completion from the API.
*
* @param array $arguments An array of arguments to send to the API.
*
* @return string The completion response.
*
* @throws Completion_Exception If the request fails.
*/
public function get_completion( array $arguments ): string;
}

View File

@ -0,0 +1,17 @@
<?php
/**
* Woo AI Exception Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\Exception;
use Exception;
defined( 'ABSPATH' ) || exit;
/**
* Woo AI Exception Class
*/
class Woo_AI_Exception extends Exception {}

View File

@ -0,0 +1,17 @@
<?php
/**
* Product Data Suggestion Exception Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\ProductDataSuggestion;
use Automattic\WooCommerce\AI\Exception\Woo_AI_Exception;
defined( 'ABSPATH' ) || exit;
/**
* Product Data Suggestion Exception Class
*/
class Product_Data_Suggestion_Exception extends Woo_AI_Exception {}

View File

@ -0,0 +1,163 @@
<?php
/**
* Woo AI Attribute Suggestion Prompt Generator Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\ProductDataSuggestion;
use Automattic\WooCommerce\AI\PromptFormatter\Json_Request_Formatter;
use Automattic\WooCommerce\AI\PromptFormatter\Product_Attribute_Formatter;
use Automattic\WooCommerce\AI\PromptFormatter\Product_Category_Formatter;
defined( 'ABSPATH' ) || exit;
/**
* Attribute Suggestion Prompt Generator Class
*/
class Product_Data_Suggestion_Prompt_Generator {
private const PROMPT_TEMPLATE = <<<PROMPT_TEMPLATE
You are a WooCommerce SEO and marketing expert.
Using the product's name, description, tags, categories, and other attributes,
provide three optimized alternatives to the product's %s to enhance the store's SEO performance and sales.
Provide the best option for the product's %s based on the product properties.
Identify the language used in the given title and use the same language in your response.
Return only the alternative value for product's %s in the "content" part of your response.
Product titles should contain at least 20 characters.
Return a short and concise reason for each suggestion in seven words in the "reason" part of your response.
The product's properties are:
%s
PROMPT_TEMPLATE;
/**
* The product category formatter.
*
* @var Product_Category_Formatter
*/
protected $product_category_formatter;
/**
* The JSON request formatter.
*
* @var Json_Request_Formatter
*/
protected $json_request_formatter;
/**
* The product attribute formatter.
*
* @var Product_Attribute_Formatter
*/
protected $product_attribute_formatter;
/**
* Product_Data_Suggestion_Prompt_Generator constructor.
*
* @param Product_Category_Formatter $product_category_formatter The product category formatter.
* @param Product_Attribute_Formatter $product_attribute_formatter The product attribute formatter.
* @param Json_Request_Formatter $json_request_formatter The JSON request formatter.
*/
public function __construct( Product_Category_Formatter $product_category_formatter, Product_Attribute_Formatter $product_attribute_formatter, Json_Request_Formatter $json_request_formatter ) {
$this->product_category_formatter = $product_category_formatter;
$this->product_attribute_formatter = $product_attribute_formatter;
$this->json_request_formatter = $json_request_formatter;
}
/**
* Build the user prompt based on the request.
*
* @param Product_Data_Suggestion_Request $request The request to build the prompt for.
*
* @return string
*/
public function get_user_prompt( Product_Data_Suggestion_Request $request ): string {
$request_prompt = $this->get_request_prompt( $request );
$prompt = sprintf(
self::PROMPT_TEMPLATE,
$request->requested_data,
$request->requested_data,
$request->requested_data,
$request_prompt
);
// Append the JSON request prompt.
$prompt .= "\n" . $this->get_example_response( $request );
return $prompt;
}
/**
* Build a prompt for the request.
*
* @param Product_Data_Suggestion_Request $request The request to build the prompt for.
*
* @return string
*/
private function get_request_prompt( Product_Data_Suggestion_Request $request ): string {
$request_prompt = '';
if ( ! empty( $request->name ) ) {
$request_prompt .= sprintf(
"\nName: %s",
$request->name
);
}
if ( ! empty( $request->description ) ) {
$request_prompt .= sprintf(
"\nDescription: %s",
$request->description
);
}
if ( ! empty( $request->tags ) ) {
$request_prompt .= sprintf(
"\nTags (comma separated): %s",
implode( ', ', $request->tags )
);
}
if ( ! empty( $request->categories ) ) {
$request_prompt .= sprintf(
"\nCategories (comma separated, child categories separated with >): %s",
$this->product_category_formatter->format( $request->categories )
);
}
if ( ! empty( $request->attributes ) ) {
$request_prompt .= sprintf(
"\n%s",
$this->product_attribute_formatter->format( $request->attributes )
);
}
return $request_prompt;
}
/**
* Get an example response for the request.
*
* @param Product_Data_Suggestion_Request $request The request to build the example response for.
*
* @return string
*/
private function get_example_response( Product_Data_Suggestion_Request $request ): string {
$response_array = array(
'suggestions' => array(
array(
'content' => sprintf( 'An improved alternative to the product\'s %s', $request->requested_data ),
'reason' => sprintf( 'First concise reason why this %s helps the SEO and sales of the product.', $request->requested_data ),
),
array(
'content' => sprintf( 'Another improved alternative to the product\'s %s', $request->requested_data ),
'reason' => sprintf( 'Second concise reason this %s helps the SEO and sales of the product.', $request->requested_data ),
),
),
);
return $this->json_request_formatter->format( $response_array );
}
}

View File

@ -0,0 +1,109 @@
<?php
/**
* Woo AI Attribute Suggestion Request Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\ProductDataSuggestion;
defined( 'ABSPATH' ) || exit;
/**
* Attribute Suggestion Request class.
*/
class Product_Data_Suggestion_Request {
const REQUESTED_DATA_NAME = 'name';
const REQUESTED_DATA_DESCRIPTION = 'description';
const REQUESTED_DATA_TAGS = 'tags';
const REQUESTED_DATA_CATEGORIES = 'categories';
/**
* Name of the product data that is being requested (e.g. title, description, tags, etc.).
*
* @var string
*/
public $requested_data;
/**
* The name of the product.
*
* @var string
*/
public $name;
/**
* The description of the product.
*
* @var string
*/
public $description;
/**
* The product tags.
*
* @var string[]
*/
public $tags;
/**
* Categories of the product as an associative array.
*
* @var string[]
*/
public $categories;
/**
* Other attributes of the product as an associative array.
*
* @var array[] Associative array of attributes. Each attribute is an associative array with the following keys:
* - name: The name of the attribute.
* - value: The value of the attribute.
*/
public $attributes;
/**
* Constructor
*
* @param string $requested_data The key for the product data that suggestions are being requested for.
* @param string $name The name of the product.
* @param string $description The description of the product.
* @param string[] $tags The product tags.
* @param integer[] $categories Category IDs of the product as an associative array.
* @param array[] $attributes Other attributes of the product as an associative array.
*
* @throws Product_Data_Suggestion_Exception If the requested attribute is invalid.
*/
public function __construct( string $requested_data, string $name, string $description, array $tags = array(), array $categories = array(), array $attributes = array() ) {
$this->validate_requested_data( $requested_data );
$this->requested_data = $requested_data;
$this->name = $name;
$this->description = $description;
$this->tags = $tags;
$this->categories = $categories;
$this->attributes = $attributes;
}
/**
* Validates the requested attribute.
*
* @param string $requested_data The attribute that suggestions are being requested for.
*
* @return void
*
* @throws Product_Data_Suggestion_Exception If the requested data is invalid.
*/
private function validate_requested_data( string $requested_data ): void {
$valid_requested_data_keys = array(
self::REQUESTED_DATA_NAME,
self::REQUESTED_DATA_DESCRIPTION,
self::REQUESTED_DATA_TAGS,
self::REQUESTED_DATA_CATEGORIES,
);
if ( ! in_array( $requested_data, $valid_requested_data_keys, true ) ) {
throw new Product_Data_Suggestion_Exception( 'Invalid requested data.', 400 );
}
}
}

View File

@ -0,0 +1,78 @@
<?php
/**
* Woo AI Attribute Suggestion Service Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\ProductDataSuggestion;
use Automattic\WooCommerce\AI\Completion\Completion_Exception;
use Automattic\WooCommerce\AI\Completion\Completion_Service_Interface;
use JsonException;
defined( 'ABSPATH' ) || exit;
/**
* Attribute Suggestion Service class.
*/
class Product_Data_Suggestion_Service {
/**
* The prompt generator.
*
* @var Product_Data_Suggestion_Prompt_Generator
*/
protected $prompt_generator;
/**
* The completion service.
*
* @var Completion_Service_Interface
*/
protected $completion_service;
/**
* Constructor
*
* @param Product_Data_Suggestion_Prompt_Generator $prompt_generator The prompt generator.
* @param Completion_Service_Interface $completion_service The completion service.
*/
public function __construct( Product_Data_Suggestion_Prompt_Generator $prompt_generator, Completion_Service_Interface $completion_service ) {
$this->prompt_generator = $prompt_generator;
$this->completion_service = $completion_service;
}
/**
* Get suggestions for the given request.
*
* @param Product_Data_Suggestion_Request $request The request.
*
* @return array An array of suggestions. Each suggestion is an associative array with the following keys:
* - content: The suggested content.
* - reason: The reason for the suggestion.
*
* @throws Product_Data_Suggestion_Exception If If getting the suggestions fails or the suggestions cannot be decoded from JSON.
*/
public function get_suggestions( Product_Data_Suggestion_Request $request ): array {
$arguments = array(
'content' => $this->prompt_generator->get_user_prompt( $request ),
'skip_cache' => true,
'feature' => 'woo_ai_plugin',
);
try {
$completion = $this->completion_service->get_completion( $arguments );
} catch ( Completion_Exception $e ) {
/* translators: %s: The error message. */
throw new Product_Data_Suggestion_Exception( sprintf( __( 'Failed to fetch the suggestions: %s', 'woocommerce' ), $e->getMessage() ), $e->getCode(), $e );
}
try {
return json_decode( $completion, true, 512, JSON_THROW_ON_ERROR );
} catch ( JsonException $e ) {
throw new Product_Data_Suggestion_Exception( 'Failed to decode the suggestions. Please try again.', 400, $e );
}
}
}

View File

@ -0,0 +1,57 @@
<?php
/**
* JSON Request Formatter class.
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\PromptFormatter;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* JSON Request Formatter class.
*/
class Json_Request_Formatter implements Prompt_Formatter_Interface {
const JSON_REQUEST_PROMPT = <<<JSON_REQUEST_PROMPT
Structure your response as JSON (RFC 8259), similar to this example:
%s
You speak only JSON (RFC 8259). Output only the JSON response. Do not say anything else or use normal text.
JSON_REQUEST_PROMPT;
/**
* Generates a prompt so that we can get a JSON response from the completion API.
*
* @param array $data An example array of data to include in the request as a possible JSON response.
*
* @return string
*
* @throws InvalidArgumentException If the input data is not valid.
*/
public function format( $data ): string {
if ( ! $this->validate_data( $data ) ) {
throw new InvalidArgumentException( 'Invalid input data. Provide an array.' );
}
return sprintf(
self::JSON_REQUEST_PROMPT,
wp_json_encode( $data )
);
}
/**
* Validates the data to make sure it can be formatted.
*
* @param mixed $data The data to format.
*
* @return bool True if the data is valid, false otherwise.
*/
public function validate_data( $data ): bool {
return ! empty( $data ) && is_array( $data );
}
}

View File

@ -0,0 +1,82 @@
<?php
/**
* Product Attribute Formatter Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\PromptFormatter;
use InvalidArgumentException;
defined( 'ABSPATH' ) || exit;
/**
* Product Attribute Formatter class.
*/
class Product_Attribute_Formatter implements Prompt_Formatter_Interface {
/**
* The attribute labels.
*
* @var array
*/
private $attribute_labels;
/**
* Product_Attribute_Formatter constructor.
*/
public function __construct() {
$this->attribute_labels = wc_get_attribute_taxonomy_labels();
}
/**
* Format attributes for the prompt
*
* @param array $data An associative array of attributes with the format { "name": "name", "value": "value" }.
*
* @return string A string containing the formatted attributes.
*
* @throws InvalidArgumentException If the input data is not valid.
*/
public function format( $data ): string {
if ( ! $this->validate_data( $data ) ) {
throw new InvalidArgumentException( 'Invalid input data. Provide an array of attributes.' );
}
$formatted_attributes = '';
foreach ( $data as $attribute ) {
// Skip if the attribute value is empty or if the attribute label is empty.
if ( empty( $attribute['value'] ) || empty( $this->attribute_labels[ $attribute['name'] ] ) ) {
continue;
}
$label = $this->attribute_labels[ $attribute['name'] ];
$formatted_attributes .= sprintf( "%s: \"%s\"\n", $label, $attribute['value'] );
}
return $formatted_attributes;
}
/**
* Validates the data to make sure it can be formatted.
*
* @param mixed $data The data to format.
*
* @return bool True if the data is valid, false otherwise.
*/
public function validate_data( $data ): bool {
if ( empty( $data ) || ! is_array( $data ) ) {
return false;
}
foreach ( $data as $attribute ) {
if ( empty( $attribute['name'] ) ) {
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,104 @@
<?php
/**
* Product Category Formatter Class
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\PromptFormatter;
use Exception;
use InvalidArgumentException;
use WP_Term;
defined( 'ABSPATH' ) || exit;
/**
* Product Category Formatter class.
*/
class Product_Category_Formatter implements Prompt_Formatter_Interface {
private const CATEGORY_TAXONOMY = 'product_cat';
private const PARENT_CATEGORY_SEPARATOR = ' > ';
private const UNCATEGORIZED_SLUG = 'uncategorized';
/**
* Get the category names from the category ids from WooCommerce and recursively get all the parent categories and prepend them to the category names separated by >.
*
* @param array $data The category ids.
*
* @return string A string containing the formatted categories. E.g., "Books > Fiction, Books > Novels > Fiction"
*
* @throws InvalidArgumentException If the input data is not an array.
*/
public function format( $data ): string {
if ( ! $this->validate_data( $data ) ) {
throw new InvalidArgumentException( 'Invalid input data. Provide an array of category ids.' );
}
$categories = array();
foreach ( $data as $category_id ) {
$category = get_term( $category_id, self::CATEGORY_TAXONOMY );
// If the category is not found, or it is the uncategorized category, skip it.
if ( ! $category instanceof WP_Term || self::UNCATEGORIZED_SLUG === $category->slug ) {
continue;
}
$categories[] = $this->format_category_name( $category );
}
return implode( ', ', $categories );
}
/**
* Validates the data to make sure it can be formatted.
*
* @param mixed $data The data to format.
*
* @return bool True if the data is valid, false otherwise.
*/
public function validate_data( $data ): bool {
return ! empty( $data ) && is_array( $data );
}
/**
* Get formatted category name with parent categories prepended separated by >.
*
* @param WP_Term $category The category as a WP_Term object.
*
* @return string The formatted category name.
*/
private function format_category_name( WP_Term $category ): string {
$parent_categories = $this->get_parent_categories( $category->term_id );
$parent_categories[] = $category->name;
return implode( self::PARENT_CATEGORY_SEPARATOR, $parent_categories );
}
/**
* Get parent categories for the given category id.
*
* @param int $category_id The category id.
*
* @return array An array of names of the parent categories in the order of the hierarchy.
*/
private function get_parent_categories( int $category_id ): array {
$parent_category_ids = get_ancestors( $category_id, self::CATEGORY_TAXONOMY );
if ( empty( $parent_category_ids ) ) {
return array();
}
$parent_category_ids = array_reverse( $parent_category_ids );
return array_map(
function ( $parent_category_id ) {
$parent_category = get_term( $parent_category_id, self::CATEGORY_TAXONOMY );
return $parent_category->name;
},
$parent_category_ids
);
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Prompt Formatter Interface.
*
* @package Woo_AI
*/
namespace Automattic\WooCommerce\AI\PromptFormatter;
defined( 'ABSPATH' ) || exit;
/**
* Prompt Formatter Interface.
*/
interface Prompt_Formatter_Interface {
/**
* Formats the data into a prompt.
*
* @param mixed $data The data to format.
*
* @return string The formatted prompt.
*/
public function format( $data ): string;
/**
* Validates the data to make sure it can be formatted.
*
* @param mixed $data The data to format.
*
* @return bool True if the data is valid, false otherwise.
*/
public function validate_data( $data ): bool;
}

View File

@ -0,0 +1,47 @@
<?php
/**
* Missing Jetpack notice.
*
* @package Woo_AI\Admin\Views
*/
defined( 'ABSPATH' ) || exit;
?>
<div class="notice notice-error">
<p>
<?php
// Translators: %s Plugin name.
echo sprintf( esc_html__( '%s requires Jetpack to be installed and activated in order to serve updates.', 'woocommerce' ), '<strong>' . esc_html__( 'Woo AI', 'woocommerce' ) . '</strong>' );
?>
</p>
<?php if ( ! is_plugin_active( 'jetpack/jetpack.php' ) && current_user_can( 'activate_plugin', 'jetpack/jetpack.php' ) ) : ?>
<p>
<?php
$installed_plugins = get_plugins();
if ( isset( $installed_plugins['jetpack/jetpack.php'] ) ) :
?>
<a href="<?php echo esc_url( wp_nonce_url( self_admin_url( 'plugins.php?action=activate&plugin=jetpack/jetpack.php&plugin_status=active' ), 'activate-plugin_jetpack/jetpack.php' ) ); ?>" class="button button-primary"><?php esc_html_e( 'Activate Jetpack', 'woocommerce' ); ?></a>
<?php endif; ?>
<?php if ( current_user_can( 'deactivate_plugin', 'woo-ai/woo-ai.php' ) ) : ?>
<a href="<?php echo esc_url( wp_nonce_url( 'plugins.php?action=deactivate&plugin=woo-ai/woo-ai.php&plugin_status=inactive', 'deactivate-plugin_woo-ai/woo-ai.php' ) ); ?>" class="button button-secondary"><?php esc_html_e( 'Turn off Woo AI plugin', 'woocommerce' ); ?></a>
<?php endif; ?>
</p>
<?php else : ?>
<?php
if ( current_user_can( 'install_plugins' ) ) {
$url = wp_nonce_url( self_admin_url( 'update.php?action=install-plugin&plugin=jetpack' ), 'install-plugin_jetpack' );
} else {
$url = 'http://wordpress.org/plugins/jetpack/';
}
?>
<p>
<a href="<?php echo esc_url( $url ); ?>" class="button button-primary"><?php esc_html_e( 'Install Jetpack', 'woocommerce' ); ?></a>
<?php if ( current_user_can( 'deactivate_plugin', 'woo-ai/woo-ai.php' ) ) : ?>
<a href="<?php echo esc_url( wp_nonce_url( 'plugins.php?action=deactivate&plugin=woo-ai/woo-ai.php&plugin_status=inactive', 'deactivate-plugin_woo-ai/woo-ai.php' ) ); ?>" class="button button-secondary"><?php esc_html_e( 'Turn off Woo AI plugin', 'woocommerce' ); ?></a>
<?php endif; ?>
</p>
<?php endif; ?>
</div>

View File

@ -0,0 +1,47 @@
<?php
/**
* Missing WooCommerce notice.
*
* @package Woo_AI\Admin\Views
*/
defined( 'ABSPATH' ) || exit;
?>
<div class="notice notice-error">
<p>
<?php
// Translators: %s Plugin name.
echo sprintf( esc_html__( '%s requires WooCommerce to be installed and activated in order to serve updates.', 'woocommerce' ), '<strong>' . esc_html__( 'Woo AI', 'woocommerce' ) . '</strong>' );
?>
</p>
<?php if ( ! is_plugin_active( 'woocommerce/woocommerce.php' ) && current_user_can( 'activate_plugin', 'woocommerce/woocommerce.php' ) ) : ?>
<p>
<?php
$installed_plugins = get_plugins();
if ( isset( $installed_plugins['woocommerce/woocommerce.php'] ) ) :
?>
<a href="<?php echo esc_url( wp_nonce_url( self_admin_url( 'plugins.php?action=activate&plugin=woocommerce/woocommerce.php&plugin_status=active' ), 'activate-plugin_woocommerce/woocommerce.php' ) ); ?>" class="button button-primary"><?php esc_html_e( 'Activate WooCommerce', 'woocommerce' ); ?></a>
<?php endif; ?>
<?php if ( current_user_can( 'deactivate_plugin', 'woo-ai/woo-ai.php' ) ) : ?>
<a href="<?php echo esc_url( wp_nonce_url( 'plugins.php?action=deactivate&plugin=woo-ai/woo-ai.php&plugin_status=inactive', 'deactivate-plugin_woo-ai/woo-ai.php' ) ); ?>" class="button button-secondary"><?php esc_html_e( 'Turn off Woo AI plugin', 'woocommerce' ); ?></a>
<?php endif; ?>
</p>
<?php else : ?>
<?php
if ( current_user_can( 'install_plugins' ) ) {
$url = wp_nonce_url( self_admin_url( 'update.php?action=install-plugin&plugin=woocommerce' ), 'install-plugin_woocommerce' );
} else {
$url = 'http://wordpress.org/plugins/woocommerce/';
}
?>
<p>
<a href="<?php echo esc_url( $url ); ?>" class="button button-primary"><?php esc_html_e( 'Install WooCommerce', 'woocommerce' ); ?></a>
<?php if ( current_user_can( 'deactivate_plugin', 'woo-ai/woo-ai.php' ) ) : ?>
<a href="<?php echo esc_url( wp_nonce_url( 'plugins.php?action=deactivate&plugin=woo-ai/woo-ai.php&plugin_status=inactive', 'deactivate-plugin_woo-ai/woo-ai.php' ) ); ?>" class="button button-secondary"><?php esc_html_e( 'Turn off Woo AI plugin', 'woocommerce' ); ?></a>
<?php endif; ?>
</p>
<?php endif; ?>
</div>

View File

@ -0,0 +1,92 @@
{
"name": "woo-ai",
"description": "Implementing WooCommerce AI Experiments.",
"license": "GPL-2.0",
"repository": {
"type": "git",
"url": "git://github.com/woocommerce/woo-ai.git"
},
"title": "Woo AI",
"version": "0.1.0",
"homepage": "http://github.com/woocommerce/woo-ai",
"devDependencies": {
"@types/debug": "^4.1.7",
"@types/jquery": "^3.5.16",
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.2",
"@types/wordpress__components": "^19.10.3",
"@woocommerce/dependency-extraction-webpack-plugin": "workspace:*",
"@woocommerce/eslint-plugin": "workspace:*",
"@wordpress/env": "^8.0.0",
"@wordpress/prettier-config": "2.17.0",
"@wordpress/scripts": "^19.2.4",
"eslint": "^8.32.0",
"prettier": "npm:wp-prettier@^2.6.2",
"ts-loader": "^9.4.1",
"typescript": "^4.9.5",
"uglify-js": "^3.5.3"
},
"dependencies": {
"@emotion/react": "^11.10.4",
"@types/prop-types": "^15.7.4",
"@types/react-outside-click-handler": "^1.3.1",
"@woocommerce/components": "workspace:*",
"@woocommerce/tracks": "workspace:*",
"@wordpress/api-fetch": "wp-6.0",
"@wordpress/components": "wp-6.0",
"@wordpress/compose": "wp-6.0",
"@wordpress/element": "wp-6.0",
"@wordpress/hooks": "wp-6.0",
"@wordpress/i18n": "wp-6.0",
"@wordpress/plugins": "wp-6.0",
"debug": "^4.3.3",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"@wordpress/data": "wp-6.0"
},
"scripts": {
"postinstall": "composer install",
"changelog": "composer exec -- changelogger",
"build": "pnpm -w exec turbo run turbo:build --filter=$npm_package_name",
"turbo:build": "pnpm run build:admin && pnpm run uglify",
"build:admin": "wp-scripts build",
"build:zip": "./bin/build-zip.sh",
"build:dev": "pnpm run lint:js && pnpm run build",
"uglify": "rm -f $npm_package_assets_js_min && for f in $npm_package_assets_js_js; do file=${f%.js}; node_modules/.bin/uglifyjs $f -c -m > $file.min.js; done",
"check-engines": "wp-scripts check-engines",
"check-licenses": "wp-scripts check-licenses",
"format:js": "wp-scripts format-js",
"lint:css": "wp-scripts lint-style",
"lint:css:fix": "wp-scripts lint-style --fix",
"lint:js": "wp-scripts lint-js",
"lint:js:fix": "wp-scripts lint-js --fix",
"lint:md:docs": "wp-scripts lint-md-docs",
"lint:md:js": "wp-scripts lint-md-js",
"lint:pkg-json": "wp-scripts lint-pkg-json",
"packages-update": "wp-scripts packages-update",
"start": "wp-scripts start",
"test:e2e": "wp-scripts test-e2e",
"test:unit": "wp-scripts test-unit-js"
},
"engines": {
"node": "^16.14.1",
"pnpm": "^8.3.1"
},
"lint-staged": {
"*.php": [
"php -d display_errors=1 -l",
"composer run-script phpcs-pre-commit"
],
"*.(t|j)s?(x)": [
"npm run lint:js:fix"
],
"*.scss": [
"npm run lint:css:fix"
]
}
}

View File

@ -0,0 +1,77 @@
/**
* External dependencies
*/
import React from 'react';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import MagicIcon from '../../assets/images/icons/magic.svg';
import { MIN_TITLE_LENGTH_FOR_DESCRIPTION } from '../constants';
type MagicButtonProps = {
title?: string;
disabled?: boolean;
onClick: () => void;
label: string;
};
const MagicButton = ( {
title,
label,
onClick,
disabled = false,
}: MagicButtonProps ) => {
return (
<button
className="button wp-media-button woo-ai-write-it-for-me-btn"
type="button"
disabled={ disabled }
title={ title }
onClick={ onClick }
>
<img src={ MagicIcon } alt="" />
{ label }
</button>
);
};
export const WriteItForMeBtn = ( {
disabled,
onClick,
}: Omit< MagicButtonProps, 'title' | 'label' > ) => {
return (
<MagicButton
disabled={ disabled }
onClick={ onClick }
label={ __( 'Write with AI', 'woocommerce' ) }
title={
disabled
? sprintf(
/* translators: %d: Minimum characters for product title */
__(
'Please create a product title before generating a description. It must be %d characters or longer.',
'woocommerce'
),
MIN_TITLE_LENGTH_FOR_DESCRIPTION
)
: undefined
}
/>
);
};
export const StopCompletionBtn = ( {
disabled,
onClick,
}: Omit< MagicButtonProps, 'title' | 'label' > ) => {
return (
<MagicButton
disabled={ disabled }
onClick={ onClick }
label={ __( 'Stop writing…', 'woocommerce' ) }
title={ __( 'Stop generating the description.', 'woocommerce' ) }
/>
);
};

View File

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

View File

@ -0,0 +1 @@
export * from './random-loading-message';

View File

@ -0,0 +1,137 @@
/**
* External dependencies
*/
import { useState, useEffect, useRef } from '@wordpress/element';
import React from 'react';
import { __ } from '@wordpress/i18n';
import { Spinner } from '@woocommerce/components';
/**
* Internal dependencies
*/
import { shuffleArray } from '../../utils';
// Define the Property types for the RandomLoadingMessage component
type RandomLoadingMessageProps = {
isLoading: boolean;
};
const tipsAndTricksPhrases = [
__(
'Make your product title descriptive for better results.',
'woocommerce'
),
__( 'Tailor your product names to your target audience.', 'woocommerce' ),
__(
"Focus on your product's unique features and benefits in descriptions.",
'woocommerce'
),
__(
'Add relevant categories and tags to make products easy to find.',
'woocommerce'
),
__(
'Including precise product attributes helps us provide better suggestions.',
'woocommerce'
),
__(
'Know your audience and speak their language in descriptions.',
'woocommerce'
),
__(
'Get creative with product titles, but stay on topic for the best suggestions.',
'woocommerce'
),
__(
'Enhance your suggestions further by adding important features to your product titles.',
'woocommerce'
),
__(
'Balance accurate information & creativity for optimal titles…',
'woocommerce'
),
__(
'Keep refining your product information for better suggestions…',
'woocommerce'
),
__(
'Remember to showcase the benefits of your products in descriptions…',
'woocommerce'
),
__(
'Consider your target audience while crafting product names…',
'woocommerce'
),
__(
'Use keywords in titles and descriptions that customers search for…',
'woocommerce'
),
__(
'Highlight unique features of your product for better suggestions…',
'woocommerce'
),
__(
'Optimize descriptions and titles for mobile devices too…',
'woocommerce'
),
__(
'Create catchy titles, but keep the focus on your product…',
'woocommerce'
),
];
const getRandomLoadingPhrase = ( phrasesStack: string[] ): string => {
// Pop the first message from the stack and push it back in
const poppedMessage = phrasesStack.shift();
if ( ! poppedMessage ) {
return '';
}
phrasesStack.push( poppedMessage );
return poppedMessage;
};
export const RandomLoadingMessage: React.FC< RandomLoadingMessageProps > = ( {
isLoading,
} ) => {
const messageUpdateTimeout = useRef< number >();
const [ currentMessage, setCurrentMessage ] = useState(
__( 'Brainstorming ideas… hold on tight.', 'woocommerce' )
);
useEffect( () => {
const phrasesStack = shuffleArray( tipsAndTricksPhrases );
// Recursive function to update the message on an increasing time interval
const updateMessage = ( delay: number ) => {
clearTimeout( messageUpdateTimeout.current );
messageUpdateTimeout.current = window.setTimeout( () => {
setCurrentMessage( getRandomLoadingPhrase( phrasesStack ) );
// Updates the message after an increasing delay. It will update every 3s, 4.5s, 6.75s, 10.125s, etc.
updateMessage( delay * 1.5 );
}, delay );
};
if ( isLoading ) {
updateMessage( 3000 );
} else {
clearTimeout( messageUpdateTimeout.current );
}
return () => {
clearTimeout( messageUpdateTimeout.current );
};
}, [ isLoading ] );
return (
<>
<span className="woo-ai-loading-message_spinner">
<Spinner />
</span>
<span>{ currentMessage }</span>
</>
);
};

View File

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

4
plugins/woo-ai/src/customer.d.ts vendored Normal file
View File

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

View File

@ -0,0 +1,3 @@
export * from './useTinyEditor';
export * from './useCompletion';
export * from './useFeedbackSnackbar';

View File

@ -0,0 +1,87 @@
/**
* External dependencies
*/
import { useRef, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { askQuestion } from '../utils';
type StopReason = 'abort' | 'finished' | 'error' | 'interrupted';
type UseCompletionProps = {
onStreamMessage: ( message: string, chunk: string ) => void;
onCompletionFinished?: (
reason: StopReason,
previousContent: string
) => void;
onStreamError?: ( event: Event ) => void;
};
export const useCompletion = ( {
onStreamMessage,
onCompletionFinished = () => {},
onStreamError = () => {},
}: UseCompletionProps ) => {
const completionSource = useRef< EventSource | null >( null );
const previousContent = useRef< string >( '' );
const [ completionActive, setCompletionActive ] = useState( false );
const stopCompletion = ( reason: StopReason ) => {
if ( completionSource.current?.close ) {
completionSource.current.close();
}
onCompletionFinished( reason, previousContent.current );
completionSource.current = null;
setCompletionActive( false );
};
const onMessage = ( event: MessageEvent ) => {
if ( event.data === '[DONE]' ) {
stopCompletion( 'finished' );
return;
}
const data = JSON.parse( event.data );
const chunk = data.choices[ 0 ].delta.content;
if ( chunk ) {
previousContent.current += chunk;
onStreamMessage( previousContent.current, chunk );
}
};
const onError = ( event: Event ) => {
// eslint-disable-next-line no-console
console.debug( 'Streaming error encountered', event );
stopCompletion( 'error' );
onStreamError( event );
};
const requestCompletion = async ( question: string ) => {
if ( completionSource.current ) {
stopCompletion( 'interrupted' );
}
previousContent.current = '';
const suggestionsSource = await askQuestion( question );
setCompletionActive( true );
suggestionsSource.addEventListener( 'message', ( e ) =>
onMessage( e )
);
suggestionsSource.addEventListener( 'error', ( event ) =>
onError( event )
);
completionSource.current = suggestionsSource;
return suggestionsSource;
};
return {
requestCompletion,
completionActive,
stopCompletion: stopCompletion.bind( null, 'abort' ),
};
};

View File

@ -0,0 +1,92 @@
/**
* External dependencies
*/
import React from 'react';
import { createInterpolateElement, useState } from '@wordpress/element';
// TODO: Re-add "@types/wordpress__data" package to resolve this, causing other issues until pnpm 8.6.0 is usable
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line @woocommerce/dependency-group
import { useDispatch } from '@wordpress/data';
type ShowSnackbarProps = {
label: string;
onPositiveResponse: () => void;
onNegativeResponse: () => void;
};
type NoticeItem = {
notice: { id: string };
};
export const useFeedbackSnackbar = () => {
const { createNotice, removeNotice } = useDispatch( 'core/notices' );
const [ noticeId, setNoticeId ] = useState< string | null >( null );
const showSnackbar = async ( {
label,
onPositiveResponse,
onNegativeResponse,
}: ShowSnackbarProps ) => {
const noticePromise: unknown = createNotice( 'info', label, {
type: 'snackbar',
explicitDismiss: true,
actions: [
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
label: createInterpolateElement(
'<ThumbsUp /> <ThumbsDown />',
{
ThumbsUp: (
<span
className="woo-ai-feedback-snackbar-action"
data-response="positive"
>
👍
</span>
),
ThumbsDown: (
<span
className="woo-ai-feedback-snackbar-action"
data-response="negative"
>
👎
</span>
),
}
),
onClick: ( e: React.MouseEvent< HTMLButtonElement > ) => {
const response = (
e.target as HTMLSpanElement
).getAttribute( 'data-response' );
if ( response === 'positive' ) {
onPositiveResponse();
}
if ( response === 'negative' ) {
onNegativeResponse();
}
},
},
],
} );
( noticePromise as Promise< NoticeItem > ).then(
( item: NoticeItem ) => {
setNoticeId( item.notice.id );
}
);
return noticePromise as Promise< NoticeItem >;
};
return {
showSnackbar,
removeSnackbar: () => {
if ( noticeId ) {
removeNotice( noticeId );
}
},
};
};

View File

@ -0,0 +1,64 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import {
ProductDataSuggestion,
ProductDataSuggestionRequest,
ApiErrorResponse,
} from '../utils/types';
type ProductDataSuggestionSuccessResponse = {
suggestions: ProductDataSuggestion[];
};
type ProductDataSuggestionErrorResponse = ApiErrorResponse;
export const useProductDataSuggestions = () => {
const fetchSuggestions = async (
request: ProductDataSuggestionRequest
): Promise< ProductDataSuggestion[] > => {
try {
const response =
await apiFetch< ProductDataSuggestionSuccessResponse >( {
path: '/wooai/product-data-suggestions',
method: 'POST',
data: request,
} );
return response.suggestions;
} catch ( error ) {
/* eslint-disable-next-line no-console */
console.error( error );
const errorResponse = error as ProductDataSuggestionErrorResponse;
const hasStatus = errorResponse?.data?.status;
const hasMessage = errorResponse?.message;
// Check if the status is 500 or greater.
const isStatusGte500 =
errorResponse?.data?.status && errorResponse.data.status >= 500;
// If the error response doesn't have a status or message, or if the status is 500 or greater, throw a generic error.
if ( ! hasStatus || ! hasMessage || isStatusGte500 ) {
throw new Error(
__(
`Apologies, this is an experimental feature and there was an error with this service. Please try again.`,
'woocommerce'
)
);
}
throw new Error( errorResponse.message );
}
};
return {
fetchSuggestions,
} as const;
};

View File

@ -0,0 +1,8 @@
/**
* Internal dependencies
*/
import { setTinyContent, getTinyContent } from '../utils/tiny-tools';
export const useTinyEditor = () => {
return { setContent: setTinyContent, getContent: getTinyContent };
};

View File

@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { render, createRoot } from '@wordpress/element';
/**
* Internal dependencies
*/
import { WriteItForMeButtonContainer } from './product-description';
import { ProductNameSuggestions } from './product-name';
import './index.scss';
const renderComponent = ( Component, rootElement ) => {
if ( ! rootElement ) {
return;
}
if ( createRoot ) {
createRoot( rootElement ).render( <Component /> );
} else {
render( <Component />, rootElement );
}
};
const descriptionButtonRoot = document.getElementById(
'woocommerce-ai-app-product-gpt-button'
);
const nameSuggestionsRoot = document.getElementById(
'woocommerce-ai-app-product-name-suggestions'
);
if ( window.JP_CONNECTION_INITIAL_STATE?.connectionStatus?.isActive ) {
renderComponent( WriteItForMeButtonContainer, descriptionButtonRoot );
renderComponent( ProductNameSuggestions, nameSuggestionsRoot );
}

View File

@ -0,0 +1,2 @@
@import 'product-description/product-description.scss';
@import 'product-name/product-name.scss';

View File

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

View File

@ -0,0 +1,148 @@
/**
* External dependencies
*/
import React from 'react';
import { __ } from '@wordpress/i18n';
import { useState, useEffect, useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import {
MAX_TITLE_LENGTH,
MIN_TITLE_LENGTH_FOR_DESCRIPTION,
} from '../constants';
import { WriteItForMeBtn, StopCompletionBtn } from '../components';
import { useTinyEditor, useCompletion, useFeedbackSnackbar } from '../hooks';
import { recordTracksFactory, getPostId } from '../utils';
const DESCRIPTION_MAX_LENGTH = 300;
const getApiError = () => {
return __(
`❗ We're currently experiencing high demand for our experimental feature. Please check back in shortly.`,
'woocommerce'
);
};
const recordDescriptionTracks = recordTracksFactory(
'description_completion',
() => ( {
post_id: getPostId(),
} )
);
export function WriteItForMeButtonContainer() {
const titleEl = useRef< HTMLInputElement >(
document.querySelector( '#title' )
);
const [ fetching, setFetching ] = useState< boolean >( false );
const [ productTitle, setProductTitle ] = useState< string >(
titleEl.current?.value || ''
);
const tinyEditor = useTinyEditor();
const { showSnackbar, removeSnackbar } = useFeedbackSnackbar();
const { requestCompletion, completionActive, stopCompletion } =
useCompletion( {
onStreamMessage: ( content ) => {
// This prevents printing out incomplete HTML tags.
const ignoreRegex = new RegExp( /<\/?\w*[^>]*$/g );
if ( ! ignoreRegex.test( content ) ) {
tinyEditor.setContent( content );
}
},
onStreamError: ( error ) => {
// eslint-disable-next-line no-console
console.debug( 'Streaming error encountered', error );
tinyEditor.setContent( getApiError() );
},
onCompletionFinished: ( reason, content ) => {
recordDescriptionTracks( 'stop', {
reason,
character_count: content.length,
current_title: productTitle,
} );
setFetching( false );
if ( reason === 'finished' ) {
showSnackbar( {
label: __(
'Was the AI-generated description helpful?',
'woocommerce'
),
onPositiveResponse: () => {
recordDescriptionTracks( 'feedback', {
response: 'positive',
} );
},
onNegativeResponse: () => {
recordDescriptionTracks( 'feedback', {
response: 'negative',
} );
},
} );
}
},
} );
useEffect( () => {
const title = titleEl.current;
const updateTitleHandler = ( e: Event ) => {
setProductTitle(
( e.target as HTMLInputElement ).value.trim() || ''
);
};
title?.addEventListener( 'keyup', updateTitleHandler );
title?.addEventListener( 'change', updateTitleHandler );
return () => {
title?.removeEventListener( 'keyup', updateTitleHandler );
title?.removeEventListener( 'change', updateTitleHandler );
};
}, [ titleEl ] );
const writeItForMeEnabled =
! fetching && productTitle.length >= MIN_TITLE_LENGTH_FOR_DESCRIPTION;
const buildPrompt = () => {
const instructions = [
`Write a product description with the following product title: "${ productTitle.slice(
0,
MAX_TITLE_LENGTH
) }."`,
'Identify the language used in this product title and use the same language in your response.',
'Use a 9th grade reading level.',
`Make the description ${ DESCRIPTION_MAX_LENGTH } words or less.`,
'Structure the description into paragraphs using standard HTML <p> tags.',
'Only if appropriate, use <ul> and <li> tags to list product features.',
'When appropriate, use <strong> and <em> tags to emphasize text.',
'Do not include a top-level heading at the beginning description.',
];
return instructions.join( '\n' );
};
const onWriteItForMeClick = () => {
setFetching( true );
removeSnackbar();
const prompt = buildPrompt();
recordDescriptionTracks( 'start', {
prompt,
} );
requestCompletion( prompt );
};
return completionActive ? (
<StopCompletionBtn onClick={ stopCompletion } />
) : (
<WriteItForMeBtn
disabled={ ! writeItForMeEnabled }
onClick={ onWriteItForMeClick }
/>
);
}

View File

@ -0,0 +1,45 @@
#postdivrich.woocommerce-product-description {
// hacks to fix visuals with "write it for me" button present
.wp-editor-tools {
top: -1px !important;
border-bottom: 0 !important;
}
.wp-switch-editor
{
position: relative;
z-index: 1;
}
}
#wpcontent {
.woo-ai-feedback-snackbar-action {
font-size: 1.4em;
}
.woo-ai-feedback-snackbar-action + .woo-ai-feedback-snackbar-action {
margin-left: 10px;
}
.components-snackbar__action:has( .woo-ai-feedback-snackbar-action) {
text-decoration: none;
}
}
.wp-media-buttons {
.woo-ai-write-it-for-me-btn {
min-width: 126px;
&[disabled] {
img {
opacity: 0.2;
}
}
img {
filter: invert(32%) sepia(36%) saturate(2913%) hue-rotate(161deg) brightness(87%) contrast(91%);
padding: 0;
}
}
}

View File

@ -0,0 +1,4 @@
export * from './product-name-suggestions';
export * from './powered-by-link';
export * from './suggestion-item';
export * from './name-utils';

View File

@ -0,0 +1,16 @@
/**
* Internal dependencies
*/
import { recordTracksFactory, getPostId } from '../utils';
type TracksData = Record<
string,
string | number | Array< Record< string, string | number > >
>;
export const recordNameTracks = recordTracksFactory< TracksData >(
'name_completion',
() => ( {
post_id: getPostId(),
} )
);

View File

@ -0,0 +1,33 @@
/**
* External dependencies
*/
import { createInterpolateElement } from '@wordpress/element';
import { Link } from '@woocommerce/components';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { recordNameTracks } from './index';
export const PoweredByLink = () => (
<span className="woo-ai-get-suggestions-powered_by">
{ createInterpolateElement(
__( 'Powered by experimental AI. <link/>', 'woocommerce' ),
{
link: (
<Link
href="https://automattic.com/ai-guidelines"
target="_blank"
type="external"
onClick={ () => {
recordNameTracks( 'learn_more_click' );
} }
>
{ __( 'Learn more', 'woocommerce' ) }
</Link>
),
}
) }
</span>
);

View File

@ -0,0 +1,274 @@
/**
* External dependencies
*/
import React from 'react';
import { __ } from '@wordpress/i18n';
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import MagicIcon from '../../assets/images/icons/magic.svg';
import AlertIcon from '../../assets/images/icons/alert.svg';
import { productData } from '../utils';
import { useProductDataSuggestions } from '../hooks/useProductDataSuggestions';
import {
ProductDataSuggestion,
ProductDataSuggestionRequest,
} from '../utils/types';
import { SuggestionItem, PoweredByLink, recordNameTracks } from './index';
import { RandomLoadingMessage } from '../components';
const MIN_TITLE_LENGTH = 10;
enum SuggestionsState {
Fetching = 'fetching',
Failed = 'failed',
None = 'none',
}
type TinyEditor = {
on: ( eventName: string, handler: () => void ) => void;
};
declare const tinymce: {
on: (
eventName: 'addeditor',
handler: ( event: Event & { editor: TinyEditor } ) => void,
thing?: boolean
) => void;
};
const MagicImage = () => (
<img
className="wc-product-name-suggestions__magic-image"
src={ MagicIcon }
alt=""
/>
);
export const ProductNameSuggestions = () => {
const [ suggestionsState, setSuggestionsState ] =
useState< SuggestionsState >( SuggestionsState.None );
const [ isFirstLoad, setIsFirstLoad ] = useState< boolean >( true );
const [ visible, setVisible ] = useState< boolean >( false );
const [ suggestions, setSuggestions ] = useState< ProductDataSuggestion[] >(
[]
);
const { fetchSuggestions } = useProductDataSuggestions();
const nameInputRef = useRef< HTMLInputElement >(
document.querySelector( '#title' )
);
const [ productName, setProductName ] = useState< string >(
nameInputRef.current?.value || ''
);
useEffect( () => {
const nameInput = nameInputRef.current;
const onFocus = () => {
setVisible( true );
};
const onKeyUp = ( e: KeyboardEvent ) => {
if ( e.key === 'Escape' ) {
setVisible( false );
}
setSuggestions( [] );
setProductName( ( e.target as HTMLInputElement ).value || '' );
};
const onChange = ( e: Event ) => {
setProductName( ( e.target as HTMLInputElement ).value || '' );
};
const onBodyClick = ( e: MouseEvent ) => {
const target = e.target as HTMLElement;
if (
! (
nameInput?.ownerDocument.activeElement === nameInput ||
// Need to capture errant handlediv click that happens on load as well
Boolean( target.querySelector( ':scope > .handlediv' ) ) ||
target?.matches(
'#woocommerce-ai-app-product-name-suggestions *, #title'
)
)
) {
setVisible( false );
}
};
// Necessary since tinymce does not bubble click events.
const onDOMLoad = () => {
tinymce.on(
'addeditor',
( event ) =>
event.editor.on( 'click', () => setVisible( false ) ),
true
);
};
if ( nameInput ) {
nameInput.addEventListener( 'focus', onFocus );
nameInput.addEventListener( 'keyup', onKeyUp );
nameInput.addEventListener( 'change', onChange );
}
document.body.addEventListener( 'click', onBodyClick );
document.addEventListener( 'DOMContentLoaded', onDOMLoad );
return () => {
if ( nameInput ) {
nameInput.removeEventListener( 'focus', onFocus );
nameInput.removeEventListener( 'keyup', onKeyUp );
nameInput.removeEventListener( 'change', onChange );
}
document.body.removeEventListener( 'click', onBodyClick );
document.removeEventListener( 'DOMContentLoaded', onDOMLoad );
};
}, [] );
const updateProductName = ( newName: string ) => {
if ( ! nameInputRef.current || ! newName.length ) {
return;
}
nameInputRef.current.value = newName;
nameInputRef.current.setAttribute( 'value', newName );
// Ensure change event is fired for other interactions.
nameInputRef.current.dispatchEvent( new Event( 'change' ) );
setProductName( newName );
};
const handleSuggestionClick = ( suggestion: ProductDataSuggestion ) => {
recordNameTracks( 'select', {
selected_title: suggestion.content,
} );
updateProductName( suggestion.content );
setSuggestions( [] );
};
const fetchProductSuggestions = async (
event: React.MouseEvent< HTMLElement >
) => {
if ( ( event.target as Element )?.closest( 'a' ) ) {
return;
}
setSuggestions( [] );
setSuggestionsState( SuggestionsState.Fetching );
try {
const currentProductData = productData();
recordNameTracks( 'start', {
current_title: currentProductData.name,
} );
const request: ProductDataSuggestionRequest = {
requested_data: 'name',
...currentProductData,
};
const suggestionResults = await fetchSuggestions( request );
recordNameTracks( 'stop', {
reason: 'finished',
suggestions: suggestionResults,
} );
setSuggestions( suggestionResults );
setSuggestionsState( SuggestionsState.None );
setIsFirstLoad( false );
} catch ( e ) {
recordNameTracks( 'stop', {
reason: 'error',
error: ( e as { message?: string } )?.message || '',
} );
setSuggestionsState( SuggestionsState.Failed );
}
};
const shouldRenderSuggestionsButton = useCallback( () => {
return (
productName.length >= MIN_TITLE_LENGTH &&
suggestionsState !== SuggestionsState.Fetching
);
}, [ productName, suggestionsState ] );
const getSuggestionsButtonLabel = useCallback( () => {
return isFirstLoad
? __( 'Generate name ideas with AI', 'woocommerce' )
: __( 'Get more ideas', 'woocommerce' );
}, [ isFirstLoad ] );
return (
<div
className="wc-product-name-suggestions-container"
hidden={ ! visible }
>
{ suggestions.length > 0 &&
suggestionsState !== SuggestionsState.Fetching && (
<ul className="wc-product-name-suggestions__suggested-names">
{ suggestions.map( ( suggestion, index ) => (
<SuggestionItem
key={ index }
suggestion={ suggestion }
onSuggestionClick={ handleSuggestionClick }
/>
) ) }
</ul>
) }
{ productName.length < MIN_TITLE_LENGTH &&
suggestionsState === SuggestionsState.None && (
<>
<div className="wc-product-name-suggestions__tip-message">
<div>
<MagicImage />
{ __(
'Enter a few descriptive words to generate product name.',
'woocommerce'
) }
</div>
<PoweredByLink />
</div>
</>
) }
{ suggestionsState !== SuggestionsState.Failed && (
<button
className="button woo-ai-get-suggestions-btn"
type="button"
onClick={ fetchProductSuggestions }
style={ {
display: shouldRenderSuggestionsButton()
? 'flex'
: 'none',
} }
>
<div className="woo-ai-get-suggestions-btn__content">
<MagicImage />
{ getSuggestionsButtonLabel() }
</div>
<PoweredByLink />
</button>
) }
{ suggestionsState === SuggestionsState.Fetching && (
<p className="wc-product-name-suggestions__loading-message">
<RandomLoadingMessage
isLoading={
suggestionsState === SuggestionsState.Fetching
}
/>
</p>
) }
{ suggestionsState === SuggestionsState.Failed && (
<p className="wc-product-name-suggestions__error-message">
<img src={ AlertIcon } alt="" />
{ __(
`We're currently experiencing high demand for our experimental feature. Please check back in shortly!`,
'woocommerce'
) }
</p>
) }
</div>
);
};

View File

@ -0,0 +1,183 @@
$border-color: #8c8f94;
$base-bg-color: #fff;
$hover-bg-color: #f0f0f1;
$base-text-color: #757575;
$z-index-container: 1001;
$z-index-header: 1002;
$border-radius: 4px;
#woocommerce-ai-app-product-name-suggestions {
position: relative;
}
.wc-product-name-suggestions-container {
border: 1px solid $border-color;
border-radius: $border-radius;
overflow: visible;
position: absolute;
top: 0;
left: 0;
width: 100%;
background: $base-bg-color;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.15);
z-index: $z-index-container;
box-sizing: border-box;
.wc-product-name-suggestions__magic-image {
height: 24px;
width: 24px;
}
.woo-ai-get-suggestions-btn {
border: none;
width: 100%;
text-align: left;
background-color: $base-bg-color;
padding: 10px;
border-radius: $border-radius;
display: flex;
align-items: center;
justify-content: space-between;
margin: 0;
line-height: 18px;
img {
vertical-align: middle;
}
&__content {
display: flex;
align-items: center;
}
.woocommerce-pill {
display: inline-block;
font-size: .8em;
color: $base-text-color;
padding: 2px 8px;
}
}
.woo-ai-get-suggestions-btn:hover {
background-color: $hover-bg-color;
}
.wc-product-name-suggestions__suggested-names {
list-style: none;
padding: 0;
margin: 0;
border-bottom: 1px solid #dcdcde;
.suggestion-item {
background-color: $base-bg-color;
margin-bottom: 0;
cursor: pointer;
.suggestion-content-container {
display: flex;
flex-direction: column;
.suggestion-content {
margin: .8em 0 .25em;
}
.suggestion-reason {
color: grey;
margin: .25em 0 .8em;
}
}
button.select-suggestion {
border: none;
width: 100%;
text-align: left;
background-color: $base-bg-color;
color: inherit;
display: flex;
justify-content: space-between;
align-items: center;
.button.use-suggestion {
border: none;
background: initial;
visibility: hidden;
&:hover {
visibility: visible;
}
}
&:hover {
background-color: $hover-bg-color;
.button.use-suggestion {
visibility: visible;
}
}
}
}
}
.wc-product-name-suggestions__loading-message {
border-radius: $border-radius;
margin: 0;
background-color: $hover-bg-color;
padding: 10px;
display: flex;
align-items: center;
.woo-ai-loading-message_spinner {
svg.woocommerce-spinner {
width: 28px;
height: 28px;
vertical-align: middle;
margin-right: 0.5em;
}
}
}
.wc-product-name-suggestions__error-message {
display: flex;
align-items: center;
margin: 0;
padding: 10px;
img {
margin-right: 10px;
}
}
.wc-product-name-suggestions__tip-message {
margin: 0;
padding: 10px;
display: flex;
align-items: center;
justify-content: space-between;
img {
vertical-align: middle;
}
}
}
.woocommerce-layout > .woocommerce-layout__header {
z-index: $z-index-header;
}
.woo-ai-get-suggestions-powered_by {
color: #8d8b8b;
a {
text-decoration: none;
padding: 0 3px 3px 3px;
&:after {
font: 16px/11px dashicons;
content: "\f504";
font-weight: normal;
top: 2px;
position: relative;
margin-left: 3px;
}
}
}

View File

@ -0,0 +1,26 @@
/**
* Internal dependencies
*/
import { ProductDataSuggestion } from '../utils/types';
export const SuggestionItem = ( {
suggestion,
onSuggestionClick,
}: {
suggestion: ProductDataSuggestion;
onSuggestionClick: ( suggestion: ProductDataSuggestion ) => void;
} ) => (
<li className="suggestion-item">
<button
className="button select-suggestion"
type="button"
onClick={ () => onSuggestionClick( suggestion ) }
>
<div className="suggestion-content-container">
<p className="suggestion-content">{ suggestion.content }</p>
<p className="suggestion-reason">{ suggestion.reason }</p>
</div>
<div className="button use-suggestion">Use</div>
</button>
</li>
);

View File

@ -0,0 +1,4 @@
export const getPostId = () =>
Number(
( document.querySelector( '#post_ID' ) as HTMLInputElement )?.value
);

View File

@ -0,0 +1,6 @@
export * from './productData';
export * from './shuffleArray';
export * from './jetpack-completion';
export * from './recordTracksFactory';
export * from './get-post-id';
export * from './tiny-tools';

View File

@ -0,0 +1,100 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import debugFactory from 'debug';
import { WOO_AI_PLUGIN_FEATURE_NAME } from '../constants';
const debugToken = debugFactory( 'jetpack-ai-assistant:token' );
const JWT_TOKEN_ID = 'jetpack-ai-jwt-token';
const JWT_TOKEN_EXPIRATION_TIME = 2 * 60 * 1000;
declare global {
interface Window {
JP_CONNECTION_INITIAL_STATE: {
apiNonce: string;
siteSuffix: string;
};
}
}
/**
* Request a token from the Jetpack site to use with the API
*
* @return {Promise<{token: string, blogId: string}>} The token and the blogId
*/
async function requestToken() {
// Trying to pick the token from localStorage
const token = localStorage.getItem( JWT_TOKEN_ID );
let tokenData;
if ( token ) {
try {
tokenData = JSON.parse( token );
} catch ( err ) {
debugToken( 'Error parsing token', err );
}
}
if ( tokenData && tokenData?.expire > Date.now() ) {
debugToken( 'Using cached token' );
return tokenData;
}
const apiNonce = window.JP_CONNECTION_INITIAL_STATE?.apiNonce;
const siteSuffix = window.JP_CONNECTION_INITIAL_STATE?.siteSuffix;
const isJetpackSite = false; //! window.wpcomFetch;
const data: { token: string; blog_id: string } = await apiFetch( {
path: '/jetpack/v4/jetpack-ai-jwt?_cacheBuster=' + Date.now(),
credentials: 'same-origin',
headers: {
'X-WP-Nonce': apiNonce,
},
method: 'POST',
} );
const newTokenData = {
token: data.token,
/**
* TODO: make sure we return id from the .com token acquisition endpoint too
*/
blogId: isJetpackSite ? data.blog_id : siteSuffix,
/**
* Let's expire the token in 2 minutes
*/
expire: Date.now() + JWT_TOKEN_EXPIRATION_TIME,
};
// Store the token in localStorage
debugToken( 'Storing new token' );
localStorage.setItem( JWT_TOKEN_ID, JSON.stringify( newTokenData ) );
return newTokenData;
}
/**
* Leaving this here to make it easier to debug the streaming API calls for now
*
* @param {string} question - The query to send to the API
* @param {number} postId - The post where this completion is being requested, if available
*/
export async function askQuestion( question: string, postId = null ) {
const { token } = await requestToken();
const url = new URL(
'https://public-api.wordpress.com/wpcom/v2/jetpack-ai-query'
);
url.searchParams.append( 'question', question );
url.searchParams.append( 'token', token );
url.searchParams.append( 'feature', WOO_AI_PLUGIN_FEATURE_NAME );
if ( postId ) {
url.searchParams.append( 'post_id', postId );
}
const source = new EventSource( url.toString() );
return source;
}

View File

@ -0,0 +1,124 @@
/**
* Internal dependencies
*/
import { Attribute, ProductData } from './types';
import { getTinyContent } from '../utils/tiny-tools';
const isElementVisible = ( element: HTMLElement ) =>
! ( window.getComputedStyle( element ).display === 'none' );
const getCategories = (): string[] => {
const categoryCheckboxEls: NodeListOf< HTMLInputElement > =
document.querySelectorAll(
'#taxonomy-product_cat input[name="tax_input[product_cat][]"]:checked'
);
const tempCategories: string[] = [];
categoryCheckboxEls.forEach( ( el ) => {
if ( ! el.value.length ) {
return;
}
tempCategories.push( el.value );
} );
return tempCategories;
};
const getTags = (): string[] => {
const tagsEl: HTMLTextAreaElement | null = document.querySelector(
'textarea[name="tax_input[product_tag]"]'
);
const tags = tagsEl ? tagsEl.value.split( ',' ) : [];
return tags.filter( ( tag ) => tag !== '' );
};
const getAttributes = (): Attribute[] => {
const attributeSelectEls: NodeListOf< HTMLSelectElement > =
document.querySelectorAll(
"#product_attributes select[name^='attribute_values']"
);
const tempAttributes: Attribute[] = [];
attributeSelectEls.forEach( ( el: HTMLSelectElement ) => {
const attributeName =
el.getAttribute( 'data-taxonomy' )?.replace( 'pa_', '' ) || '';
const attributeValues = Array.from( el.selectedOptions )
.map( ( option ) => option.text )
.join( ',' );
if ( ! attributeValues || ! attributeName ) {
return;
}
tempAttributes.push( {
name: attributeName,
value: attributeValues,
} );
} );
return tempAttributes;
};
const getDescription = (): string => {
const isBlockEditor =
document.querySelectorAll( '.block-editor' ).length > 0;
if ( ! isBlockEditor ) {
const content = document.querySelector(
'#content'
) as HTMLInputElement;
const tinyContent = getTinyContent();
if ( content && isElementVisible( content ) ) {
return content.value;
} else if ( tinyContent ) {
return tinyContent;
}
}
return (
document.querySelector(
'.block-editor-rich-text__editable'
) as HTMLInputElement
)?.value;
};
const getProductName = (): string => {
const productNameEl: HTMLInputElement | null =
document.querySelector( '#title' );
return productNameEl ? productNameEl.value : '';
};
const getProductType = () => {
const productTypeEl: HTMLInputElement | null =
document.querySelector( '#product-type' );
return productTypeEl ? productTypeEl.value : '';
};
export const productData = (): ProductData => {
return {
name: getProductName(),
categories: getCategories(),
tags: getTags(),
attributes: getAttributes(),
description: getDescription(),
product_type: getProductType(),
is_downloadable: (
document.querySelector( '#_downloadable' ) as HTMLInputElement
)?.checked
? 'Yes'
: 'No',
is_virtual: (
document.querySelector( '#_virtual' ) as HTMLInputElement
)?.checked
? 'Yes'
: 'No',
};
};

View File

@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { recordEvent } from '@woocommerce/tracks';
export const recordTracksFactory = < T = Record< string, string | number > >(
feature: string,
propertiesCallback: () => Record< string, string | number >
) => {
return ( name: string, properties?: T ) =>
recordEvent( `woo_ai_product_${ feature }_${ name }`, {
...propertiesCallback(),
...properties,
} );
};

View File

@ -0,0 +1,16 @@
export const shuffleArray = ( array: string[] ) => {
let currentIndex = array.length;
let temporaryValue: string;
let randomIndex: number;
while ( currentIndex !== 0 ) {
randomIndex = Math.floor( Math.random() * currentIndex );
currentIndex -= 1;
temporaryValue = array[ currentIndex ];
array[ currentIndex ] = array[ randomIndex ];
array[ randomIndex ] = temporaryValue;
}
return array;
};

View File

@ -0,0 +1,33 @@
type TinyContent = {
getContent: () => string;
setContent: ( str: string ) => void;
};
declare const tinymce: { get: ( str: string ) => TinyContent };
const getTinyContentObject = () =>
typeof tinymce === 'object' ? tinymce.get( 'content' ) : null;
export const setTinyContent = ( str: string ) => {
if ( ! str.length ) {
return;
}
const contentTinyMCE = getTinyContentObject();
if ( contentTinyMCE ) {
contentTinyMCE.setContent( str );
} else {
{
const el: HTMLInputElement | null = document.querySelector(
'#wp-content-editor-container .wp-editor-area'
);
if ( el ) {
el.value = str;
}
}
}
};
export const getTinyContent = () => {
return getTinyContentObject()?.getContent();
};

View File

@ -0,0 +1,41 @@
export type Attribute = {
name: string;
value: string;
};
export type ProductData = {
name: string;
description: string;
categories: string[];
tags: string[];
attributes: Attribute[];
product_type: string;
is_downloadable: string;
is_virtual: string;
};
export type ProductDataSuggestion = {
reason: string;
content: string;
};
export type ProductDataSuggestionRequest = {
requested_data: string;
name: string;
description: string;
categories: string[];
tags: string[];
attributes: Attribute[];
};
// This is the standard API response data when an error is returned.
export type ApiErrorResponse = {
code: string;
message: string;
data?: ApiErrorResponseData | undefined;
};
// API errors contain data with the status, and more in-depth error details. This may be null.
export type ApiErrorResponseData = {
status: number;
} | null;

View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
// Target latest version of ECMAScript.
"target": "esnext",
// Make sure we're not pulling types from external projects.
"typeRoots": [
"./typings",
"./node_modules/@types",
"src/custom.d.ts",
],
// Search under node_modules for non-relative imports.
"moduleResolution": "node",
"jsx": "react-jsx",
// Temporary for resolving the test data json, remove when moving to using the API
"resolveJsonModule": true,
"jsxImportSource": "@emotion/react",
// Enable strictest settings like strictNullChecks & noImplicitAny.
"strict": true,
// Import non-ES modules as default imports.
"esModuleInterop": true,
// Skip type checking of declaration files because some libraries define two copies of the same type in an inconsistent way
"skipLibCheck": true,
"module": "esnext",
},
}

View File

@ -0,0 +1,34 @@
const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
const WooCommerceDependencyExtractionWebpackPlugin = require( '@woocommerce/dependency-extraction-webpack-plugin' );
module.exports = {
...defaultConfig,
entry: {
...defaultConfig.entry,
},
module: {
...defaultConfig.module,
rules: [
...defaultConfig.module.rules,
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.(png|jp(e*)g|svg|gif)$/,
type: 'asset/resource',
},
],
},
resolve: {
extensions: [ '.js', '.jsx', '.tsx', '.ts' ],
},
plugins: [
...defaultConfig.plugins.filter(
( plugin ) =>
plugin.constructor.name !== 'DependencyExtractionWebpackPlugin'
),
new WooCommerceDependencyExtractionWebpackPlugin(),
],
};

103
plugins/woo-ai/woo-ai.php Normal file
View File

@ -0,0 +1,103 @@
<?php
/**
* Plugin Name: Woo AI
* Plugin URI: https://github.com/woocommerce/woocommerce/
* Description: Enable AI experiments within the WooCommerce experience. <a href="https://automattic.com/ai-guidelines" target="_blank" rel="noopener noreferrer">Learn more</a>.
* Version: 0.1.0
* Author: WooCommerce
* Author URI: http://woocommerce.com/
* Requires at least: 5.8
* Tested up to: 6.0
* WC requires at least: 6.7
* WC tested up to: 7.0
* Text Domain: woo-ai
*
* @package Woo_AI
*/
defined( 'ABSPATH' ) || exit;
// Define WOO_AI_FILE.
if ( ! defined( 'WOO_AI_FILE' ) ) {
define( 'WOO_AI_FILE', __FILE__ );
}
/**
* Load text domain before all other code.
*/
function _woo_ai_load_textdomain(): void {
load_plugin_textdomain( 'woo-ai', false, basename( dirname( __FILE__ ) ) . '/languages' );
}
add_action( 'plugins_loaded', '_woo_ai_load_textdomain' );
/**
* Bootstrap plugin.
*/
function _woo_ai_bootstrap(): void {
// Check if WooCommerce is enabled.
if ( ! class_exists( 'WooCommerce' ) ) {
include dirname( __FILE__ ) . '/includes/class-woo-ai-admin-notices.php';
$notices = new Woo_AI_Admin_Notices();
add_action( 'admin_notices', array( $notices, 'woocoommerce_not_installed' ) );
// Stop here.
return;
}
add_action(
'before_woocommerce_init',
function() {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
}
}
);
if ( class_exists( 'Automattic\Jetpack\Forms\ContactForm\Admin' ) ) {
remove_action( 'media_buttons', array( Automattic\Jetpack\Forms\ContactForm\Admin::init(), 'grunion_media_button' ), 999 );
}
// Check if Jetpack is enabled.
if ( ! class_exists( 'Jetpack' ) ) {
include dirname( __FILE__ ) . '/includes/class-woo-ai-admin-notices.php';
$notices = new Woo_AI_Admin_Notices();
add_action( 'admin_notices', array( $notices, 'jetpack_not_installed' ) );
// Stop here.
return;
}
if ( ! class_exists( 'Woo_AI' ) ) {
include dirname( __FILE__ ) . '/includes/class-woo-ai.php';
register_activation_hook( __FILE__, array( 'Woo_AI', 'activate' ) );
add_action( 'admin_init', array( 'Woo_AI', 'instance' ) );
}
}
add_action(
'wp_loaded',
function () {
require 'api/api.php';
require_once dirname( __FILE__ ) . '/includes/exception/class-woo-ai-exception.php';
require_once dirname( __FILE__ ) . '/includes/completion/class-completion-exception.php';
require_once dirname( __FILE__ ) . '/includes/completion/interface-completion-service.php';
require_once dirname( __FILE__ ) . '/includes/completion/class-jetpack-completion-service.php';
require_once dirname( __FILE__ ) . '/includes/prompt-formatter/interface-prompt-formatter.php';
require_once dirname( __FILE__ ) . '/includes/prompt-formatter/class-product-category-formatter.php';
require_once dirname( __FILE__ ) . '/includes/prompt-formatter/class-product-attribute-formatter.php';
require_once dirname( __FILE__ ) . '/includes/prompt-formatter/class-json-request-formatter.php';
require_once dirname( __FILE__ ) . '/includes/product-data-suggestion/class-product-data-suggestion-exception.php';
require_once dirname( __FILE__ ) . '/includes/product-data-suggestion/class-product-data-suggestion-request.php';
require_once dirname( __FILE__ ) . '/includes/product-data-suggestion/class-product-data-suggestion-prompt-generator.php';
require_once dirname( __FILE__ ) . '/includes/product-data-suggestion/class-product-data-suggestion-service.php';
}
);
add_action( 'plugins_loaded', '_woo_ai_bootstrap' );

File diff suppressed because it is too large Load Diff