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:
parent
4569fda5c1
commit
8098c35588
|
@ -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/
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
*.min.js
|
||||
build
|
||||
build-module
|
||||
node_modules
|
||||
vendor
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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/
|
|
@ -0,0 +1 @@
|
|||
16
|
|
@ -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");
|
|
@ -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
|
|
@ -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.
|
|
@ -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.
|
|
@ -0,0 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
---
|
||||
|
||||
[See changelogs for previous versions](https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/plugins/woocommerce-beta-tester/readme.txt).
|
|
@ -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.
|
|
@ -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' );
|
|
@ -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();
|
|
@ -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 |
|
@ -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 |
|
@ -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!"
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Initial release of WooAI plugin.
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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();
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 );
|
||||
}
|
||||
|
||||
}
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 );
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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' ) }
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
export * from './random-loading-message';
|
||||
export * from './description-completion-buttons';
|
|
@ -0,0 +1 @@
|
|||
export * from './random-loading-message';
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.svg' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export * from './useTinyEditor';
|
||||
export * from './useCompletion';
|
||||
export * from './useFeedbackSnackbar';
|
|
@ -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' ),
|
||||
};
|
||||
};
|
|
@ -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 );
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { setTinyContent, getTinyContent } from '../utils/tiny-tools';
|
||||
|
||||
export const useTinyEditor = () => {
|
||||
return { setContent: setTinyContent, getContent: getTinyContent };
|
||||
};
|
|
@ -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 );
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
@import 'product-description/product-description.scss';
|
||||
@import 'product-name/product-name.scss';
|
|
@ -0,0 +1 @@
|
|||
export * from './product-description-button-container';
|
|
@ -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 }
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './product-name-suggestions';
|
||||
export * from './powered-by-link';
|
||||
export * from './suggestion-item';
|
||||
export * from './name-utils';
|
|
@ -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(),
|
||||
} )
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -0,0 +1,4 @@
|
|||
export const getPostId = () =>
|
||||
Number(
|
||||
( document.querySelector( '#post_ID' ) as HTMLInputElement )?.value
|
||||
);
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
} );
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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();
|
||||
};
|
|
@ -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;
|
|
@ -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",
|
||||
},
|
||||
}
|
|
@ -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(),
|
||||
],
|
||||
};
|
|
@ -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' );
|
844
pnpm-lock.yaml
844
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue