From 3c73e547b992e6d4b1ce67647ae056bbd05b43b3 Mon Sep 17 00:00:00 2001 From: Swapnil M Mane Date: Thu, 2 Jan 2025 17:45:53 +0530 Subject: [PATCH] feat: improve the Smart SEO AI code and fix the issue where the content input field was flickering (#172) Thanks to Pavel! --- .../smartSeoOpenAi/admin/package.json | 10 +- .../src/DecorateContentEntryFormBind.tsx | 8 +- .../smartSeoOpenAi/admin/src/FieldTracker.tsx | 7 +- .../smartSeoOpenAi/admin/src/SmartSeo.tsx | 120 ++++++++++-------- .../smartSeoOpenAi/api/package.json | 4 +- .../smartSeoOpenAi/api/src/Article.ts | 18 ++- .../smartSeoOpenAi/api/src/generateSeo.ts | 3 +- 7 files changed, 99 insertions(+), 71 deletions(-) diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/package.json b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/package.json index 87b46d4..120df28 100644 --- a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/package.json +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/package.json @@ -7,7 +7,13 @@ "webiny-extension-type:admin" ], "dependencies": { - "openai": "^4.33.0", - "react": "18.2.0" + "react": "18.2.0", + "graphql-tag": "2.12.6", + "@material-design-icons/svg": "0.14.13", + "@webiny/app-admin": "5.41.4", + "@webiny/app-headless-cms": "5.41.4", + "@webiny/form": "5.41.4", + "@webiny/ui": "5.41.4", + "@webiny/lexical-converter": "5.41.4" } } diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/DecorateContentEntryFormBind.tsx b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/DecorateContentEntryFormBind.tsx index eae2c70..58d69c3 100644 --- a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/DecorateContentEntryFormBind.tsx +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/DecorateContentEntryFormBind.tsx @@ -33,17 +33,17 @@ export const DecorateContentEntryFormBind = useBind.createDecorator(baseHook => useEffect(() => { // Track rich-text fields and SEO fields if (field.type === "rich-text") { - trackField(field.label, field.type, params.name, bind.value, bind.onChange); + trackField(field.label, field.type, params.name, bind.value); } if (seoFields.includes(field.fieldId) && !parent) { - trackField(field.label, field.fieldId, params.name, bind.value, bind.onChange); + trackField(field.label, field.fieldId, params.name, bind.value); } - }, [bind.value]); + }, [JSON.stringify(bind.value)]); return bind; } catch { return baseHook(params); } }; -}); +}); \ No newline at end of file diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/FieldTracker.tsx b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/FieldTracker.tsx index dc6a3ce..63d9475 100644 --- a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/FieldTracker.tsx +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/FieldTracker.tsx @@ -9,7 +9,6 @@ export interface FieldWithValue { path: string; type: string; label: string; - onChange: (value: any) => void; } interface FieldTrackerContext { @@ -20,7 +19,6 @@ interface FieldTrackerContext { type: string, path: string, value: any, - onChange: (value: any) => void ) => void; } @@ -33,14 +31,13 @@ const FieldTrackerContext = React.createContext export const FieldTracker = ({ children }: FieldTrackerProps) => { const [fields, setFields] = useState([]); - const trackField = useCallback((label:string, type:string, path:string, value:any, onChange: any) => { + const trackField = useCallback((label:string, type:string, path:string, value:any) => { setFields(fields => { const newValue: FieldWithValue = { label, type, path, value, - onChange }; const index = fields.findIndex(trackedField => trackedField.path === path); @@ -66,4 +63,4 @@ export const useFieldTracker = () => { } return context; -}; +}; \ No newline at end of file diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/SmartSeo.tsx b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/SmartSeo.tsx index 4eaff14..87d6001 100644 --- a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/SmartSeo.tsx +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/admin/src/SmartSeo.tsx @@ -1,11 +1,12 @@ -import React, { useState, useEffect } from "react"; -import { ContentEntryEditorConfig, useQuery } from "@webiny/app-headless-cms"; +import React, { useState } from "react"; +import gql from "graphql-tag"; +import { useForm } from "@webiny/form"; +import { ContentEntryEditorConfig, useApolloClient } from "@webiny/app-headless-cms"; import { ButtonSecondary, ButtonIcon } from "@webiny/ui/Button"; import { ReactComponent as MagicIcon } from "@material-design-icons/svg/round/school.svg"; import { useSnackbar } from "@webiny/app-admin"; import { FieldWithValue, useFieldTracker } from "./FieldTracker"; import { extractRichTextHtml } from "./extractFromRichText"; -import gql from "graphql-tag"; const { Actions } = ContentEntryEditorConfig; @@ -20,51 +21,42 @@ const GENERATE_SEO_QUERY = gql` `; const GetSeoData = () => { + const client = useApolloClient(); + const form = useForm(); const { showSnackbar } = useSnackbar(); - const [triggerQuery, setTriggerQuery] = useState(false); const [loading, setLoading] = useState(false); const { fields } = useFieldTracker(); - const { data, error, refetch } = useQuery(GENERATE_SEO_QUERY, { - variables: { - input: { - content: extractRichTextHtml(fields).join("\n"), - }, - }, - skip: true, // Prevent automatic execution of the query - }); - - useEffect(() => { - if (triggerQuery) { - setLoading(true); - refetch() - .then(({ data }) => { - const seo = data?.generateSeo; - if (!seo) { - console.error("Invalid response received from AI."); - showSnackbar("No valid data received from AI."); - return; + const askChatGpt = async () => { + setLoading(true); + try { + const { data } = await client.query({ + query: GENERATE_SEO_QUERY, + variables: { + input: { + content: extractRichTextHtml(fields).join("\n") } - - 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((err) => { - console.error("Error during SEO generation:", err); - showSnackbar("We were unable to get a recommendation from AI at this point."); - }) - .finally(() => { - setLoading(false); - setTriggerQuery(false); - }); + } + }); + + const seo = data?.generateSeo; + if (!seo) { + console.error("Invalid response received from AI."); + showSnackbar("No valid data received from AI."); + return; + } + + populateSeoTitle(form, fields, seo.title); + populateSeoDescription(form, fields, seo.description); + populateSeoKeywords(form, fields, seo.keywords); + + showSnackbar("Success! We've populated the SEO fields with the recommended values."); + } catch (err) { + console.error("Error during SEO generation:", err); + showSnackbar("We were unable to get a recommendation from AI at this point."); + } finally { + setLoading(false); } - }, [triggerQuery, refetch, fields, showSnackbar]); - - const askChatGpt = () => { - setTriggerQuery(true); }; return ( @@ -74,14 +66,26 @@ const GetSeoData = () => { ); }; -const populateSeoTitle = (fields: FieldWithValue[], value: string) => { - const field = fields.find((field) => field.type === "seoTitle"); - if (field) field.onChange(value); +const populateSeoTitle = ( + form: ReturnType, + fields: FieldWithValue[], + value: string +) => { + const field = fields.find(field => field.type === "seoTitle"); + if (field) { + form.setValue(field.path, value); + } }; -const populateSeoDescription = (fields: FieldWithValue[], value: string) => { - const field = fields.find((field) => field.type === "seoDescription"); - if (field) field.onChange(value); +const populateSeoDescription = ( + form: ReturnType, + fields: FieldWithValue[], + value: string +) => { + const field = fields.find(field => field.type === "seoDescription"); + if (field) { + form.setValue(field.path, value); + } }; interface Tag { @@ -89,20 +93,26 @@ interface Tag { tagValue: string; } -const populateSeoKeywords = (fields: FieldWithValue[], keywords: string[]) => { - const field = fields.find((field) => field.type === "seoMetaTags"); +const populateSeoKeywords = ( + form: ReturnType, + 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"); + const tagsWithoutKeywords = tags.filter(tag => tag.tagName !== "keywords"); - field.onChange([ - ...tagsWithoutKeywords, - { tagName: "keywords", tagValue: keywords.join(", ") }, - ]); + if (field) { + form.setValue(field.path, [ + ...tagsWithoutKeywords, + { tagName: "keywords", tagValue: keywords.join(", ") } + ]); + } }; export const SmartSeo = () => { diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/package.json b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/package.json index a6da1fd..961f7f9 100644 --- a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/package.json +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/package.json @@ -7,6 +7,8 @@ ], "version": "1.0.0", "dependencies": { - "@webiny/api-headless-cms": "5.41.1" + "@webiny/api-headless-cms": "5.41.4", + "@webiny/api-serverless-cms": "5.41.4", + "openai": "^4.33.0" } } diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/src/Article.ts b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/src/Article.ts index f294bcd..db5337a 100644 --- a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/src/Article.ts +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/src/Article.ts @@ -1,15 +1,26 @@ import { + createCmsGroupPlugin, createCmsModelPlugin, createModelField } from "@webiny/api-headless-cms"; export const Article = () => { return [ + // Defines a new "Smart SEO" content model group. + createCmsGroupPlugin({ + id: "smart-seo", + name: "Smart SEO", + description: "Smart SEO content model group", + slug: "smart-seo", + icon: "fas/magnifying-glass" + }), + + // Defines a new "Article - Smart SEO" content model. createCmsModelPlugin({ name: "Article - Smart SEO", modelId: "article-smart-seo", description: "Article content model for Smart SEO", - group: {id: "", name: ""}, + group: {id: "smart-seo", name: "Smart SEO"}, fields: [ createModelField({ fieldId: "content", @@ -21,7 +32,7 @@ export const Article = () => { fieldId: "seoTitle", type: "text", label: "SEO - Title", - renderer: { name: "text-input" }, + renderer: { name: "text-input" } }), createModelField({ fieldId: "seoDescription", @@ -34,6 +45,7 @@ export const Article = () => { type: "object", label: "SEO - Meta tags", renderer: { name: "objects" }, + multipleValues: true, settings: { fields: [ createModelField({ @@ -56,7 +68,7 @@ export const Article = () => { }) ], layout: [["content"], ["seoTitle"], ["seoDescription"], ["seoMetaTags"]], - titleFieldId: "content" + titleFieldId: "seoTitle" }) ]; }; \ No newline at end of file diff --git a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/src/generateSeo.ts b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/src/generateSeo.ts index 052fa81..fc49aa5 100644 --- a/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/src/generateSeo.ts +++ b/headless-cms/smart-seo-open-ai/5.41.x/extensions/smartSeoOpenAi/api/src/generateSeo.ts @@ -1,5 +1,6 @@ import { CmsGraphQLSchemaPlugin } from "@webiny/api-headless-cms"; import OpenAI from "openai"; +import { Context } from "@webiny/api-serverless-cms" /* * This file adds a GraphQL schema for generating SEO metadata. @@ -15,7 +16,7 @@ const OPENAI_API_KEY = process.env["WEBINY_API_OPEN_AI_API_KEY"]; const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); export const generateSeo = () => [ - new CmsGraphQLSchemaPlugin({ + new CmsGraphQLSchemaPlugin({ typeDefs: ` type SeoData { title: String