Skip to content

Commit

Permalink
feat: moved OpenAI API call from client to server side by extending t…
Browse files Browse the repository at this point in the history
…he Headless CMS GraphQL API (#171)
  • Loading branch information
swapnilmmane authored Dec 23, 2024
1 parent f303e26 commit 0636eec
Show file tree
Hide file tree
Showing 3 changed files with 161 additions and 88 deletions.
Original file line number Diff line number Diff line change
@@ -1,36 +1,87 @@
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 React, { useState, useEffect } from "react";
import { ContentEntryEditorConfig, useQuery } 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 { useSnackbar } from "@webiny/app-admin";
import gql from "graphql-tag";

const OPENAI_API_KEY = String(process.env["WEBINY_ADMIN_OPEN_AI_API_KEY"]);
const { Actions } = ContentEntryEditorConfig;

const openai = new OpenAI({ apiKey: OPENAI_API_KEY, dangerouslyAllowBrowser: true });
const GENERATE_SEO_QUERY = gql`
query GenerateSeo($input: GenerateSeoInput!) {
generateSeo(input: $input) {
title
description
keywords
}
}
`;

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 GetSeoData = () => {
const { showSnackbar } = useSnackbar();
const [triggerQuery, setTriggerQuery] = useState(false);
const [loading, setLoading] = useState(false);
const { fields } = useFieldTracker();

const { Actions } = ContentEntryEditorConfig;
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 populateSeoTitle = (fields: FieldWithValue[], value: string) => {
const field = fields.find(field => field.type === "seoTitle");
if (!field) {
return;
}
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);
});
}
}, [triggerQuery, refetch, fields, showSnackbar]);

const askChatGpt = () => {
setTriggerQuery(true);
};

field.onChange(value);
return (
<ButtonSecondary onClick={askChatGpt} disabled={loading}>
<ButtonIcon icon={<MagicIcon />} /> AI-optimized SEO
</ButtonSecondary>
);
};

const populateSeoDescription = (fields: FieldWithValue[], value: string) => {
const field = fields.find(field => field.type === "seoDescription");
if (!field) {
return;
}
const populateSeoTitle = (fields: FieldWithValue[], value: string) => {
const field = fields.find((field) => field.type === "seoTitle");
if (field) field.onChange(value);
};

field.onChange(value);
const populateSeoDescription = (fields: FieldWithValue[], value: string) => {
const field = fields.find((field) => field.type === "seoDescription");
if (field) field.onChange(value);
};

interface Tag {
Expand All @@ -39,81 +90,21 @@ interface Tag {
}

const populateSeoKeywords = (fields: FieldWithValue[], keywords: string[]) => {
const field = fields.find(field => field.type === "seoMetaTags");
const field = fields.find((field) => field.type === "seoMetaTags");
if (!field) {
console.warn("no meta tags 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(", ") }
{ 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 (
<ButtonSecondary onClick={() => askChatGpt()} disabled={loading}>
<ButtonIcon icon={<MagicIcon />} /> AI-optimized SEO
</ButtonSecondary>
);
};

export const SmartSeo = () => {
return (
<Actions.ButtonAction
Expand All @@ -123,4 +114,4 @@ export const SmartSeo = () => {
modelIds={["article-smart-seo"]}
/>
);
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { CmsGraphQLSchemaPlugin, ContentContextParams } from "@webiny/api-headless-cms";
import { Context } from "@webiny/api-serverless-cms"
import OpenAI from "openai";

/*
* This file adds a GraphQL schema for generating SEO metadata.
* It defines a generateSeo query to analyze input content.
* The query outputs an SEO title, description, and keywords
* and uses OpenAI’s GPT model to processes the content to generate metadata.
* It uses Webiny’s CmsGraphQLSchemaPlugin to extend the Headless CMS GraphQL API.
*/


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<Context>({
typeDefs: `
type SeoData {
title: String
description: String
keywords: [String]
}
input GenerateSeoInput {
content: String!
}
type Query {
generateSeo(input: GenerateSeoInput!): SeoData
}
`,
resolvers: {
Query: {
generateSeo: async (_, { input }) => {
try {
const { content } = input;

const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo",
messages: [
{
role: "system",
content: `You will be provided with one or more paragraphs of HTML, and you need to extract an 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.`
},
{
role: "user",
content
}
],
temperature: 0.5,
max_tokens: 128,
top_p: 1
});

const messageContent = response?.choices?.[0]?.message?.content;

if (typeof messageContent !== "string") {
console.error("Invalid or null content received from OpenAI.");
throw new Error("Failed to get a valid response from OpenAI.");
}

const seoData = JSON.parse(messageContent);
return {
title: seoData.title,
description: seoData.description,
keywords: seoData.keywords
};
} catch (error) {
console.error("Error generating SEO data:", error);
throw new Error("Failed to generate SEO data.");
}
}
}
}
})
];
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Article } from "./Article"
import { generateSeo } from "./generateSeo";

export const createExtension = () => {
return [
Article
Article,
generateSeo
];
};

0 comments on commit 0636eec

Please sign in to comment.