-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #168 from webiny/test-api-admin-extension
docs: testing api and admin plugin together
- Loading branch information
Showing
12 changed files
with
397 additions
and
0 deletions.
There are no files selected for viewing
13 changes: 13 additions & 0 deletions
13
headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/package.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
...ms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/DecorateContentEntryForm.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; | ||
}); |
49 changes: 49 additions & 0 deletions
49
...est-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/DecorateContentEntryFormBind.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}; | ||
}); |
69 changes: 69 additions & 0 deletions
69
headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/FieldTracker.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
126 changes: 126 additions & 0 deletions
126
headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/SmartSeo.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"]} | ||
/> | ||
); | ||
}; |
14 changes: 14 additions & 0 deletions
14
...less-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/extractFromRichText.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
}; |
15 changes: 15 additions & 0 deletions
15
headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/src/index.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</>; | ||
}; |
4 changes: 4 additions & 0 deletions
4
headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/admin/tsconfig.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
{ | ||
"extends": "../../tsconfig.json", | ||
"include": ["src"] | ||
} |
12 changes: 12 additions & 0 deletions
12
headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/package.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
66 changes: 66 additions & 0 deletions
66
headless-cms/test-plugins-ai/5.41.x/extensions/testPluginsAi/api/src/Article.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
}) | ||
]; | ||
}; |
Oops, something went wrong.