Skip to content

Commit

Permalink
Merge pull request #168 from webiny/test-api-admin-extension
Browse files Browse the repository at this point in the history
docs: testing api and admin plugin together
  • Loading branch information
swapnilmmane authored Dec 4, 2024
2 parents 63c125f + 0d8b519 commit 2e1075d
Show file tree
Hide file tree
Showing 12 changed files with 397 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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
<FieldTracker>
<Original {...props} />
</FieldTracker>
);
};
});
Original file line number Diff line number Diff line change
@@ -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);
}
};
});
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<FieldWithValue[]>>;
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<FieldTrackerContext | undefined>(undefined);

export const FieldTracker = ({ children }: FieldTrackerProps) => {
const [fields, setFields] = useState<FieldWithValue[]>([]);

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 <FieldTrackerContext.Provider value={context}>{children}</FieldTrackerContext.Provider>;
};

export const useFieldTracker = () => {
const context = React.useContext(FieldTrackerContext);

if (!context) {
throw new Error(`FieldTracker context is missing in the component hierarchy!`);
}

return context;
};
Original file line number Diff line number Diff line change
@@ -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 (
<ButtonSecondary onClick={() => askChatGpt()} disabled={loading}>
<ButtonIcon icon={<MagicIcon />} /> AI-optimized SEO
</ButtonSecondary>
);
};

export const SmartSeo = () => {
return (
<Actions.ButtonAction
name={"askAi"}
before={"save"}
element={<GetSeoData />}
modelIds={["article"]}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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));
};
Original file line number Diff line number Diff line change
@@ -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. */}
<DecorateContentEntryForm />
<ContentEntryEditorConfig>
<SmartSeo />
<DecorateContentEntryFormBind />
</ContentEntryEditorConfig>
</>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
})
];
};
Loading

0 comments on commit 2e1075d

Please sign in to comment.