From 05fedf4dbb06d852b8ea23c6421f2b9a88c47598 Mon Sep 17 00:00:00 2001 From: Swapnil M Mane Date: Tue, 3 Dec 2024 16:05:28 +0530 Subject: [PATCH 1/3] docs: added Smart SEO: Webiny + Open AI integration example --- .../extensions/smartSeoOpenAi/package.json | 13 ++ .../src/DecorateContentEntryForm.tsx | 19 +++ .../src/DecorateContentEntryFormBind.tsx | 49 +++++++ .../smartSeoOpenAi/src/FieldTracker.tsx | 69 ++++++++++ .../smartSeoOpenAi/src/SmartSeo.tsx | 126 ++++++++++++++++++ .../smartSeoOpenAi/src/extractFromRichText.ts | 14 ++ .../extensions/smartSeoOpenAi/src/index.tsx | 15 +++ .../extensions/smartSeoOpenAi/tsconfig.json | 4 + 8 files changed, 309 insertions(+) create mode 100644 headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/package.json create mode 100644 headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/DecorateContentEntryForm.tsx create mode 100644 headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/DecorateContentEntryFormBind.tsx create mode 100644 headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/FieldTracker.tsx create mode 100644 headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/SmartSeo.tsx create mode 100644 headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/extractFromRichText.ts create mode 100644 headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/index.tsx create mode 100644 headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/tsconfig.json diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/package.json b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/package.json new file mode 100644 index 00000000..5f4b852d --- /dev/null +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/package.json @@ -0,0 +1,13 @@ +{ + "name": "smart-seo-open-ai", + "main": "src/index.tsx", + "version": "1.0.0", + "keywords": [ + "webiny-extension", + "webiny-extension-type:admin" + ], + "dependencies": { + "openai": "^4.33.0", + "react": "18.2.0" + } +} diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/DecorateContentEntryForm.tsx b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/DecorateContentEntryForm.tsx new file mode 100644 index 00000000..4994de43 --- /dev/null +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/DecorateContentEntryForm.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { ContentEntryForm } from "@webiny/app-headless-cms/admin/components/ContentEntryForm/ContentEntryForm"; +import { FieldTracker } from "./FieldTracker"; + +/** + * Decorate the ContentEntryForm with a FieldTracker. + * FieldTracker monitors changes in form fields, enabling dynamic updates + * and interaction with external services like OpenAI for SEO recommendations. + */ +export const DecorateContentEntryForm = ContentEntryForm.createDecorator(Original => { + return function ContentEntryForm(props) { + return ( + // Use the FieldTracker component to track changes in the form fields + + + + ); + }; +}); diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/DecorateContentEntryFormBind.tsx b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/DecorateContentEntryFormBind.tsx new file mode 100644 index 00000000..5c7e0047 --- /dev/null +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/DecorateContentEntryFormBind.tsx @@ -0,0 +1,49 @@ +import { useBind } from "@webiny/form"; +import { useModel, useModelField, useParentField } from "@webiny/app-headless-cms"; +import { useFieldTracker } from "./FieldTracker"; +import { useEffect } from "react"; + +/** + * Decorator `useBind` hook that integrates field tracking for models. + * In this example below we track rich-text fields and SEO-specific fields + * ("seoTitle", "seoDescription", "seoMetaTags") of article model. + * to trigger dynamic updates and interaction with external services. + * + * You can customise this decorator to track fields of other models or field types. + */ +export const DecorateContentEntryFormBind = useBind.createDecorator(baseHook => { + const seoFields = ["seoTitle", "seoDescription", "seoMetaTags"]; + + return params => { + try { + const { trackField } = useFieldTracker(); + + const { field } = useModelField(); + const parent = useParentField(); + + const { model } = useModel(); + + // Skip tracking for non-article models + if (model.modelId !== "article") { + return baseHook(params); + } + + const bind = baseHook(params); + + useEffect(() => { + // Track rich-text fields and SEO fields + if (field.type === "rich-text") { + trackField(field.label, field.type, params.name, bind.value, bind.onChange); + } + + if (seoFields.includes(field.fieldId) && !parent) { + trackField(field.label, field.fieldId, params.name, bind.value, bind.onChange); + } + }, [bind.value]); + + return bind; + } catch { + return baseHook(params); + } + }; +}); diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/FieldTracker.tsx b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/FieldTracker.tsx new file mode 100644 index 00000000..dc6a3ceb --- /dev/null +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/FieldTracker.tsx @@ -0,0 +1,69 @@ +import React, { Dispatch, SetStateAction, useCallback, useMemo, useState } from "react"; + +interface FieldTrackerProps { + children: React.ReactNode; +} + +export interface FieldWithValue { + value: any; + path: string; + type: string; + label: string; + onChange: (value: any) => void; +} + +interface FieldTrackerContext { + fields: FieldWithValue[]; + setFields: Dispatch>; + trackField: ( + label: string, + type: string, + path: string, + value: any, + onChange: (value: any) => void + ) => void; +} + +/** + * Context and provider for tracking changes in content entry fields. + * Enables monitoring and managing form field state for dynamic behavior. + */ +const FieldTrackerContext = React.createContext(undefined); + +export const FieldTracker = ({ children }: FieldTrackerProps) => { + const [fields, setFields] = useState([]); + + const trackField = useCallback((label:string, type:string, path:string, value:any, onChange: any) => { + setFields(fields => { + const newValue: FieldWithValue = { + label, + type, + path, + value, + onChange + }; + + const index = fields.findIndex(trackedField => trackedField.path === path); + + if (index > -1) { + return [...fields.slice(0, index), newValue, ...fields.slice(index + 1)]; + } + + return [...fields, newValue]; + }); + }, []); + + const context = useMemo(() => ({ fields, setFields, trackField }), [fields]); + + return {children}; +}; + +export const useFieldTracker = () => { + const context = React.useContext(FieldTrackerContext); + + if (!context) { + throw new Error(`FieldTracker context is missing in the component hierarchy!`); + } + + return context; +}; diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/SmartSeo.tsx b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/SmartSeo.tsx new file mode 100644 index 00000000..b6cb9cf4 --- /dev/null +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/SmartSeo.tsx @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import OpenAI from "openai"; +import { ReactComponent as MagicIcon } from "@material-design-icons/svg/round/school.svg"; +import { ContentEntryEditorConfig } from "@webiny/app-headless-cms"; +import { ButtonSecondary, ButtonIcon } from "@webiny/ui/Button"; +import { FieldWithValue, useFieldTracker } from "./FieldTracker"; +import { extractRichTextHtml } from "./extractFromRichText"; +import { useSnackbar } from "@webiny/app-admin"; + +const OPENAI_API_KEY = String(process.env["REACT_APP_OPEN_AI_API_KEY"]); + +const openai = new OpenAI({ apiKey: OPENAI_API_KEY, dangerouslyAllowBrowser: true }); + +const prompt = `You will be provided with one or more paragraphs of HTML, and you need to extract a SEO optimized page title, a page summary, and up to 5 keywords. Response should be returned as a plain JSON object, with "title" field for the page title, "description" field for page summary, and "keywords" field as an array of keywords.`; + +const { Actions } = ContentEntryEditorConfig; + +const populateSeoTitle = (fields: FieldWithValue[], value: string) => { + const field = fields.find(field => field.type === "seoTitle"); + if (!field) { + return; + } + + field.onChange(value); +}; + +const populateSeoDescription = (fields: FieldWithValue[], value: string) => { + const field = fields.find(field => field.type === "seoDescription"); + if (!field) { + return; + } + + field.onChange(value); +}; + +interface Tag { + tagName: string; + tagValue: string; +} + +const populateSeoKeywords = (fields: FieldWithValue[], keywords: string[]) => { + const field = fields.find(field => field.type === "seoMetaTags"); + if (!field) { + console.warn("no meta tags field!"); + return; + } + const tags: Tag[] = Array.isArray(field.value) ? field.value : []; + const tagsWithoutKeywords = tags.filter(tag => tag.tagName !== "keywords"); + + field.onChange([ + ...tagsWithoutKeywords, + { tagName: "keywords", tagValue: keywords.join(", ") } + ]); +}; + +/** + * A button component to trigger OpenAI's GPT model to generate SEO fields. + * Extracts rich-text content, sends it to GPT, and updates the form fields with the AI's suggestions. + */ +const GetSeoData = () => { + const { showSnackbar } = useSnackbar(); + const [loading, setLoading] = useState(false); + const { fields } = useFieldTracker(); + + const askChatGpt = async () => { + let response; + setLoading(true); + try { + response = await openai.chat.completions.create({ + model: "gpt-3.5-turbo", + messages: [ + { + role: "system", + content: prompt + }, + { + role: "user", + content: extractRichTextHtml(fields).join("\n") + } + ], + temperature: 0.5, + max_tokens: 128, + top_p: 1 + }); + } catch (e) { + console.log(e); + } + setLoading(false); + + console.log("ChatGPT response", response); + try { + // const seo = { + // title: "Node.js, Yarn, and AWS Setup Guide for Webiny", + // description: + // "Learn how to set up Node.js, yarn, and AWS account and user credentials for deploying Webiny. Make sure you have the required versions installed.", + // keywords: ["Node.js", "Yarn", "AWS", "Webiny", "setup"] + // }; + const seo = JSON.parse(response?.choices[0].message.content as string); + console.log("parsed response", seo); + populateSeoTitle(fields, seo.title); + populateSeoDescription(fields, seo.description); + populateSeoKeywords(fields, seo.keywords); + showSnackbar("Success! We've populated the SEO fields with the recommended values."); + } catch (e) { + console.log(e); + showSnackbar("We were unable to get a recommendation from AI at this point."); + } + }; + + return ( + askChatGpt()} disabled={loading}> + } /> AI-optimized SEO + + ); +}; + +export const SmartSeo = () => { + return ( + } + modelIds={["article"]} + /> + ); +}; diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/extractFromRichText.ts b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/extractFromRichText.ts new file mode 100644 index 00000000..190108a9 --- /dev/null +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/extractFromRichText.ts @@ -0,0 +1,14 @@ +import { createLexicalStateTransformer } from "@webiny/lexical-converter"; +import { FieldWithValue } from "./FieldTracker"; + +const transformer = createLexicalStateTransformer(); + +/** + * Extracts HTML from rich-text (lexical content) fields. + * Filters fields of type "rich-text" and converts their content to HTML for further processing. We will pass this HTML content to OpenAI. + */ +export const extractRichTextHtml = (fields: FieldWithValue[]) => { + return fields + .filter(field => field.type === "rich-text" && !!field.value) + .map(field => transformer.toHtml(field.value)); +}; \ No newline at end of file diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/index.tsx b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/index.tsx new file mode 100644 index 00000000..b68e2632 --- /dev/null +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/src/index.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { ContentEntryEditorConfig } from "@webiny/app-headless-cms"; +import { DecorateContentEntryForm } from "./DecorateContentEntryForm"; +import { DecorateContentEntryFormBind} from "./DecorateContentEntryFormBind"; +import { SmartSeo } from "./SmartSeo"; + +export const Extension = () => { + return <>{/* Your code here. */} + + + + + + ; +}; diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/tsconfig.json b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/tsconfig.json new file mode 100644 index 00000000..596e2cf7 --- /dev/null +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} From 63c125f10a1dfc3097e2d9da2c5b0d8c903c15ae Mon Sep 17 00:00:00 2001 From: Swapnil M Mane Date: Tue, 3 Dec 2024 17:05:17 +0530 Subject: [PATCH 2/3] docs: added article.json model for importing the SmartSEO OpenAI article model --- headless-cms/smart-seo-open-ai/article.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 headless-cms/smart-seo-open-ai/article.json diff --git a/headless-cms/smart-seo-open-ai/article.json b/headless-cms/smart-seo-open-ai/article.json new file mode 100644 index 00000000..11b4bd43 --- /dev/null +++ b/headless-cms/smart-seo-open-ai/article.json @@ -0,0 +1 @@ +{"groups":[{"id":"673b06847c123c0002c9cfdc","name":"Ungrouped","slug":"ungrouped","description":"A generic content model group","icon":"fas/star"}],"models":[{"modelId":"article","name":"Article","group":"673b06847c123c0002c9cfdc","singularApiName":"Article","pluralApiName":"Articles","fields":[{"multipleValues":false,"listValidation":[],"settings":{},"renderer":{"name":"text-input","settings":{}},"helpText":null,"predefinedValues":{"enabled":false,"values":[]},"label":"SEO - Title","type":"text","tags":[],"placeholderText":null,"id":"fatgp2e8","validation":[],"storageId":"text@fatgp2e8","fieldId":"seoTitle"},{"multipleValues":false,"listValidation":[],"settings":{},"renderer":{"name":"long-text-text-area","settings":{}},"helpText":null,"predefinedValues":{"enabled":false,"values":[]},"label":"SEO - Description","type":"long-text","tags":[],"placeholderText":null,"id":"hmm8rn13","validation":[],"storageId":"long-text@hmm8rn13","fieldId":"seoDescription"},{"multipleValues":false,"listValidation":[],"settings":{},"renderer":{"name":"lexical-text-input","settings":{}},"helpText":null,"predefinedValues":{"enabled":false,"values":[]},"label":"Content","type":"rich-text","tags":[],"placeholderText":null,"id":"fpdv3c7m","validation":[],"storageId":"rich-text@fpdv3c7m","fieldId":"content"},{"multipleValues":true,"listValidation":[],"settings":{"fields":[{"renderer":{"name":"text-input"},"label":"Tag Name","id":"5u81n61u","type":"text","validation":[],"fieldId":"tagName","storageId":"text@5u81n61u"},{"renderer":{"name":"text-input"},"label":"Tag Value","id":"4jhcwcq2","type":"text","validation":[],"fieldId":"tagValue","storageId":"text@4jhcwcq2"}],"layout":[["5u81n61u"],["4jhcwcq2"]]},"renderer":{"name":"objects","settings":{}},"helpText":null,"predefinedValues":{"enabled":false,"values":[]},"label":"SEO - Meta tags","type":"object","tags":[],"placeholderText":null,"id":"ttwf5mz2","validation":[],"storageId":"object@ttwf5mz2","fieldId":"seoMetaTags"}],"layout":[["fpdv3c7m"],["fatgp2e8"],["hmm8rn13"],["ttwf5mz2"]],"titleFieldId":"seoTitle","descriptionFieldId":"seoDescription","tags":["type:model"]}]} From 0d8b5196bfc1a967196e525074ddba4a4f544fca Mon Sep 17 00:00:00 2001 From: Swapnil M Mane Date: Wed, 4 Dec 2024 17:13:03 +0530 Subject: [PATCH 3/3] docs: testing api and admin plugin together --- .../testPluginsAi/admin/package.json | 13 ++ .../admin/src/DecorateContentEntryForm.tsx | 19 +++ .../src/DecorateContentEntryFormBind.tsx | 49 +++++++ .../testPluginsAi/admin/src/FieldTracker.tsx | 69 ++++++++++ .../testPluginsAi/admin/src/SmartSeo.tsx | 126 ++++++++++++++++++ .../admin/src/extractFromRichText.ts | 14 ++ .../testPluginsAi/admin/src/index.tsx | 15 +++ .../testPluginsAi/admin/tsconfig.json | 4 + .../extensions/testPluginsAi/api/package.json | 12 ++ .../testPluginsAi/api/src/Article.ts | 66 +++++++++ .../extensions/testPluginsAi/api/src/index.ts | 6 + .../testPluginsAi/api/tsconfig.json | 4 + 12 files changed, 397 insertions(+) create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/package.json create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/DecorateContentEntryForm.tsx create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/DecorateContentEntryFormBind.tsx create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/FieldTracker.tsx create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/SmartSeo.tsx create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/extractFromRichText.ts create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/index.tsx create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/tsconfig.json create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/package.json create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/src/Article.ts create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/src/index.ts create mode 100644 headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/tsconfig.json diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/package.json b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/package.json new file mode 100644 index 00000000..76698309 --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/package.json @@ -0,0 +1,13 @@ +{ + "name": "test-plugin-ai-admin", + "main": "src/index.tsx", + "version": "1.0.0", + "keywords": [ + "webiny-extension", + "webiny-extension-type:admin" + ], + "dependencies": { + "openai": "^4.33.0", + "react": "18.2.0" + } +} diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/DecorateContentEntryForm.tsx b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/DecorateContentEntryForm.tsx new file mode 100644 index 00000000..4994de43 --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/DecorateContentEntryForm.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { ContentEntryForm } from "@webiny/app-headless-cms/admin/components/ContentEntryForm/ContentEntryForm"; +import { FieldTracker } from "./FieldTracker"; + +/** + * Decorate the ContentEntryForm with a FieldTracker. + * FieldTracker monitors changes in form fields, enabling dynamic updates + * and interaction with external services like OpenAI for SEO recommendations. + */ +export const DecorateContentEntryForm = ContentEntryForm.createDecorator(Original => { + return function ContentEntryForm(props) { + return ( + // Use the FieldTracker component to track changes in the form fields + + + + ); + }; +}); diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/DecorateContentEntryFormBind.tsx b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/DecorateContentEntryFormBind.tsx new file mode 100644 index 00000000..5c7e0047 --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/DecorateContentEntryFormBind.tsx @@ -0,0 +1,49 @@ +import { useBind } from "@webiny/form"; +import { useModel, useModelField, useParentField } from "@webiny/app-headless-cms"; +import { useFieldTracker } from "./FieldTracker"; +import { useEffect } from "react"; + +/** + * Decorator `useBind` hook that integrates field tracking for models. + * In this example below we track rich-text fields and SEO-specific fields + * ("seoTitle", "seoDescription", "seoMetaTags") of article model. + * to trigger dynamic updates and interaction with external services. + * + * You can customise this decorator to track fields of other models or field types. + */ +export const DecorateContentEntryFormBind = useBind.createDecorator(baseHook => { + const seoFields = ["seoTitle", "seoDescription", "seoMetaTags"]; + + return params => { + try { + const { trackField } = useFieldTracker(); + + const { field } = useModelField(); + const parent = useParentField(); + + const { model } = useModel(); + + // Skip tracking for non-article models + if (model.modelId !== "article") { + return baseHook(params); + } + + const bind = baseHook(params); + + useEffect(() => { + // Track rich-text fields and SEO fields + if (field.type === "rich-text") { + trackField(field.label, field.type, params.name, bind.value, bind.onChange); + } + + if (seoFields.includes(field.fieldId) && !parent) { + trackField(field.label, field.fieldId, params.name, bind.value, bind.onChange); + } + }, [bind.value]); + + return bind; + } catch { + return baseHook(params); + } + }; +}); diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/FieldTracker.tsx b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/FieldTracker.tsx new file mode 100644 index 00000000..dc6a3ceb --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/FieldTracker.tsx @@ -0,0 +1,69 @@ +import React, { Dispatch, SetStateAction, useCallback, useMemo, useState } from "react"; + +interface FieldTrackerProps { + children: React.ReactNode; +} + +export interface FieldWithValue { + value: any; + path: string; + type: string; + label: string; + onChange: (value: any) => void; +} + +interface FieldTrackerContext { + fields: FieldWithValue[]; + setFields: Dispatch>; + trackField: ( + label: string, + type: string, + path: string, + value: any, + onChange: (value: any) => void + ) => void; +} + +/** + * Context and provider for tracking changes in content entry fields. + * Enables monitoring and managing form field state for dynamic behavior. + */ +const FieldTrackerContext = React.createContext(undefined); + +export const FieldTracker = ({ children }: FieldTrackerProps) => { + const [fields, setFields] = useState([]); + + const trackField = useCallback((label:string, type:string, path:string, value:any, onChange: any) => { + setFields(fields => { + const newValue: FieldWithValue = { + label, + type, + path, + value, + onChange + }; + + const index = fields.findIndex(trackedField => trackedField.path === path); + + if (index > -1) { + return [...fields.slice(0, index), newValue, ...fields.slice(index + 1)]; + } + + return [...fields, newValue]; + }); + }, []); + + const context = useMemo(() => ({ fields, setFields, trackField }), [fields]); + + return {children}; +}; + +export const useFieldTracker = () => { + const context = React.useContext(FieldTrackerContext); + + if (!context) { + throw new Error(`FieldTracker context is missing in the component hierarchy!`); + } + + return context; +}; diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/SmartSeo.tsx b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/SmartSeo.tsx new file mode 100644 index 00000000..b6cb9cf4 --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/SmartSeo.tsx @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import OpenAI from "openai"; +import { ReactComponent as MagicIcon } from "@material-design-icons/svg/round/school.svg"; +import { ContentEntryEditorConfig } from "@webiny/app-headless-cms"; +import { ButtonSecondary, ButtonIcon } from "@webiny/ui/Button"; +import { FieldWithValue, useFieldTracker } from "./FieldTracker"; +import { extractRichTextHtml } from "./extractFromRichText"; +import { useSnackbar } from "@webiny/app-admin"; + +const OPENAI_API_KEY = String(process.env["REACT_APP_OPEN_AI_API_KEY"]); + +const openai = new OpenAI({ apiKey: OPENAI_API_KEY, dangerouslyAllowBrowser: true }); + +const prompt = `You will be provided with one or more paragraphs of HTML, and you need to extract a SEO optimized page title, a page summary, and up to 5 keywords. Response should be returned as a plain JSON object, with "title" field for the page title, "description" field for page summary, and "keywords" field as an array of keywords.`; + +const { Actions } = ContentEntryEditorConfig; + +const populateSeoTitle = (fields: FieldWithValue[], value: string) => { + const field = fields.find(field => field.type === "seoTitle"); + if (!field) { + return; + } + + field.onChange(value); +}; + +const populateSeoDescription = (fields: FieldWithValue[], value: string) => { + const field = fields.find(field => field.type === "seoDescription"); + if (!field) { + return; + } + + field.onChange(value); +}; + +interface Tag { + tagName: string; + tagValue: string; +} + +const populateSeoKeywords = (fields: FieldWithValue[], keywords: string[]) => { + const field = fields.find(field => field.type === "seoMetaTags"); + if (!field) { + console.warn("no meta tags field!"); + return; + } + const tags: Tag[] = Array.isArray(field.value) ? field.value : []; + const tagsWithoutKeywords = tags.filter(tag => tag.tagName !== "keywords"); + + field.onChange([ + ...tagsWithoutKeywords, + { tagName: "keywords", tagValue: keywords.join(", ") } + ]); +}; + +/** + * A button component to trigger OpenAI's GPT model to generate SEO fields. + * Extracts rich-text content, sends it to GPT, and updates the form fields with the AI's suggestions. + */ +const GetSeoData = () => { + const { showSnackbar } = useSnackbar(); + const [loading, setLoading] = useState(false); + const { fields } = useFieldTracker(); + + const askChatGpt = async () => { + let response; + setLoading(true); + try { + response = await openai.chat.completions.create({ + model: "gpt-3.5-turbo", + messages: [ + { + role: "system", + content: prompt + }, + { + role: "user", + content: extractRichTextHtml(fields).join("\n") + } + ], + temperature: 0.5, + max_tokens: 128, + top_p: 1 + }); + } catch (e) { + console.log(e); + } + setLoading(false); + + console.log("ChatGPT response", response); + try { + // const seo = { + // title: "Node.js, Yarn, and AWS Setup Guide for Webiny", + // description: + // "Learn how to set up Node.js, yarn, and AWS account and user credentials for deploying Webiny. Make sure you have the required versions installed.", + // keywords: ["Node.js", "Yarn", "AWS", "Webiny", "setup"] + // }; + const seo = JSON.parse(response?.choices[0].message.content as string); + console.log("parsed response", seo); + populateSeoTitle(fields, seo.title); + populateSeoDescription(fields, seo.description); + populateSeoKeywords(fields, seo.keywords); + showSnackbar("Success! We've populated the SEO fields with the recommended values."); + } catch (e) { + console.log(e); + showSnackbar("We were unable to get a recommendation from AI at this point."); + } + }; + + return ( + askChatGpt()} disabled={loading}> + } /> AI-optimized SEO + + ); +}; + +export const SmartSeo = () => { + return ( + } + modelIds={["article"]} + /> + ); +}; diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/extractFromRichText.ts b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/extractFromRichText.ts new file mode 100644 index 00000000..190108a9 --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/extractFromRichText.ts @@ -0,0 +1,14 @@ +import { createLexicalStateTransformer } from "@webiny/lexical-converter"; +import { FieldWithValue } from "./FieldTracker"; + +const transformer = createLexicalStateTransformer(); + +/** + * Extracts HTML from rich-text (lexical content) fields. + * Filters fields of type "rich-text" and converts their content to HTML for further processing. We will pass this HTML content to OpenAI. + */ +export const extractRichTextHtml = (fields: FieldWithValue[]) => { + return fields + .filter(field => field.type === "rich-text" && !!field.value) + .map(field => transformer.toHtml(field.value)); +}; \ No newline at end of file diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/index.tsx b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/index.tsx new file mode 100644 index 00000000..b68e2632 --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/index.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { ContentEntryEditorConfig } from "@webiny/app-headless-cms"; +import { DecorateContentEntryForm } from "./DecorateContentEntryForm"; +import { DecorateContentEntryFormBind} from "./DecorateContentEntryFormBind"; +import { SmartSeo } from "./SmartSeo"; + +export const Extension = () => { + return <>{/* Your code here. */} + + + + + + ; +}; diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/tsconfig.json b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/tsconfig.json new file mode 100644 index 00000000..596e2cf7 --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/package.json b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/package.json new file mode 100644 index 00000000..bec1dd73 --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/package.json @@ -0,0 +1,12 @@ +{ + "name": "test-plugin-ai-api", + "main": "src/index.ts", + "keywords": [ + "webiny-extension", + "webiny-extension-type:api" + ], + "version": "1.0.0", + "dependencies": { + "@webiny/api-headless-cms": "5.41.1" + } +} diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/src/Article.ts b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/src/Article.ts new file mode 100644 index 00000000..a313c05e --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/src/Article.ts @@ -0,0 +1,66 @@ +import { + createCmsModelPlugin, + createModelField +} from "@webiny/api-headless-cms"; + +export const Article = () => { + return [ + // Defines a new Ungrouped content models group. + createCmsModelPlugin({ + name: "Article", + modelId: "article", + description: "Article content model for Smart SEO", + group: { + id: "673b06847c123c0002c9cfdc", + name: "Ungrouped AI 2" + }, + fields: [ + createModelField({ + fieldId: "content", + type: "rich-text", + label: "Content", + renderer: { name: "lexical-text-input" }, + }), + createModelField({ + fieldId: "seoTitle", + type: "text", + label: "SEO - Title", + renderer: { name: "text-input" }, + }), + createModelField({ + fieldId: "seoDescription", + type: "long-text", + label: "SEO - Description", + renderer: { name: "long-text-text-area" }, + }), + createModelField({ + fieldId: "seoMetaTags", + type: "object", + label: "SEO - Meta tags", + renderer: { name: "objects" }, + settings: { + fields: [ + createModelField({ + id: "tagName", + fieldId: "tagName", + type: "text", + label: "Tag Name", + renderer: { name: "text-input" } + }), + createModelField({ + id: "tagValue", + fieldId: "tagValue", + type: "text", + label: "Tag Value", + renderer: { name: "text-input" } + }) + ], + layout: [["tagName"], ["tagValue"]] + } + }) + ], + layout: [["content"], ["seoTitle"], ["seoDescription"], ["seoMetaTags"]], + titleFieldId: "content" + }) + ]; +}; \ No newline at end of file diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/src/index.ts b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/src/index.ts new file mode 100644 index 00000000..1744361a --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/src/index.ts @@ -0,0 +1,6 @@ +import { Article } from "./Article" +export const createExtension = () => { + return [ + Article + ]; +}; diff --git a/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/tsconfig.json b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/tsconfig.json new file mode 100644 index 00000000..596e2cf7 --- /dev/null +++ b/headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +}