Skip to content

Commit

Permalink
[chore] refactor
Browse files Browse the repository at this point in the history
- replace hardcoded value to defaults
- fix lexical editor to html handlebarsjs helper
- Add lexical AI feature to import separately
  • Loading branch information
ash committed Aug 18, 2024
1 parent 601227c commit 6df56b1
Show file tree
Hide file tree
Showing 17 changed files with 261 additions and 124 deletions.
25 changes: 9 additions & 16 deletions src/ai/models/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@ export const OpenAIConfig: GenerationConfig = {
prompt: string,
options: { locale: string; model: string; system: string },
) => {
// const finalPrompt = `Output language code: ${options.locale}
// Prompt: ${prompt}
// Output:
// `

// console.log('finalPrompt: ', finalPrompt)
const streamTextResult = await streamText({
model: openai(options.model),
prompt,
Expand Down Expand Up @@ -192,7 +186,6 @@ export const OpenAIConfig: GenerationConfig = {
name: 'OpenAI GPT',
fields: ['richText'],
handler: (text: string, options) => {
//TODO: change it to open ai text to speech api
return generateRichText(text, options)
},
output: 'text',
Expand All @@ -216,10 +209,10 @@ export const OpenAIConfig: GenerationConfig = {
name: 'system',
type: 'textarea',
defaultValue: `INSTRUCTIONS:
You are a highly skilled and professional blog writer,
renowned for crafting engaging and well-organized articles.
When given a title, you meticulously create blogs that are not only
informative and accurate but also captivating and beautifully structured.`,
You are a highly skilled and professional blog writer,
renowned for crafting engaging and well-organized articles.
When given a title, you meticulously create blogs that are not only
informative and accurate but also captivating and beautifully structured.`,
label: 'System prompt',
},
{
Expand All @@ -231,11 +224,11 @@ export const OpenAIConfig: GenerationConfig = {
name: 'layout',
type: 'textarea',
defaultValue: `[paragraph] - A short introduction to the topic.
[horizontalrule]
[list] - A section with headings and a paragraph.
[horizontalrule]
[paragraph] - A short conclusion.
[quote] - A quote from a famous person based on the topic.
[horizontalrule]
[list] - A section with headings and a paragraph.
[horizontalrule]
[paragraph] - A short conclusion.
[quote] - A quote from a famous person based on the topic.
`,
label: 'Layout',
},
Expand Down
8 changes: 7 additions & 1 deletion src/collections/Instructions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { CollectionConfig, GroupField } from 'payload'

import { GenerationModels } from '../ai/models/index.js'
import { PLUGIN_INSTRUCTIONS_TABLE } from '../defaults.js'
import { PromptEditorField } from '../fields/PromptEditorField/PromptEditorField.js'
import { SelectField } from '../fields/SelectField/SelectField.js'

Expand All @@ -20,12 +21,17 @@ const modelOptions = GenerationModels.map((model) => {
})

export const Instructions: CollectionConfig = {
slug: 'instructions',
slug: PLUGIN_INSTRUCTIONS_TABLE,

// TODO: Revisit permissions, better if end user can provide this
access: {
create: () => true,
read: () => true,
update: () => true,
},
admin: {
hidden: true,
},
fields: [
{
name: 'schema-path',
Expand Down
5 changes: 3 additions & 2 deletions src/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const PLUGIN_NAME = 'plugin-ai'
export const PLUGIN_INSTRUCTIONS_TABLE = 'instructions'
export const PLUGIN_INSTRUCTIONS_MAP_GLOBAL = `${PLUGIN_NAME}__${PLUGIN_INSTRUCTIONS_TABLE}_map`
export const PLUGIN_INSTRUCTIONS_TABLE = `${PLUGIN_NAME}-instructions`
export const PLUGIN_INSTRUCTIONS_MAP_GLOBAL = `${PLUGIN_NAME}-${PLUGIN_INSTRUCTIONS_TABLE}-map`
export const PLUGIN_LEXICAL_EDITOR_FEATURE = `${PLUGIN_NAME}-actions-feature`

// Endpoint defaults
export const PLUGIN_API_ENDPOINT_BASE = '/plugin-ai'
Expand Down
82 changes: 51 additions & 31 deletions src/endpoints/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import type { SerializedEditorState } from 'lexical'
import type { PayloadRequest } from 'payload'
import type { BasePayload, PayloadRequest } from 'payload'

import Handlebars from 'handlebars'
import asyncHelpers from 'handlebars-async-helpers'
import { flattenTopLevelFields } from 'payload'

import type { Endpoints, MenuItems } from '../types.js'

import { GenerationModels } from '../ai/models/index.js'
import { PLUGIN_API_ENDPOINT_GENERATE, PLUGIN_API_ENDPOINT_GENERATE_UPLOAD } from '../defaults.js'
import {
PLUGIN_API_ENDPOINT_GENERATE,
PLUGIN_API_ENDPOINT_GENERATE_UPLOAD,
PLUGIN_INSTRUCTIONS_TABLE,
} from '../defaults.js'
import { getFieldBySchemaPath } from '../utilities/getFieldBySchemaPath.js'
import { lexicalToHTML } from '../utilities/lexicalToHTML.js'

const asyncHandlebars = asyncHelpers(Handlebars)

const replacePlaceholders = (prompt: string, values: object) => {
return asyncHandlebars.compile(prompt)(values)
return asyncHandlebars.compile(prompt, { trackIds: true })(values)
}

const assignPrompt = async (
Expand Down Expand Up @@ -123,14 +127,46 @@ const assignPrompt = async (
}
}

const registerEditorHelper = (payload, schemaPath) => {
//TODO: add autocomplete ability using handlebars template on PromptEditorField and include custom helpers in dropdown

let fieldInfo = getFieldInfo(payload.collections, schemaPath)
const schemaPathChunks = schemaPath.split('.')

asyncHandlebars.registerHelper(
'toLexicalHTML',
async function (content: SerializedEditorState, options) {
const collectionSlug = schemaPathChunks[0]
const { ids } = options
for (const id of ids) {
//TODO: Find a better to get schemaPath of defined field in prompt editor
const path = `${collectionSlug}.${id}`
fieldInfo = getFieldInfo(payload.collections, path)
}

const html = await lexicalToHTML(content, fieldInfo.editor?.editorConfig)
return new asyncHandlebars.SafeString(html)
},
)
}

const getFieldInfo = (collections: BasePayload['collections'], schemaPath: string) => {
let fieldInfo = null
//TODO: Only run below for enabled collections
for (const collectionsKey in collections) {
const collection = collections[collectionsKey]
fieldInfo = getFieldBySchemaPath(collection.config, schemaPath)
if (fieldInfo) {
return fieldInfo
}
}
}

export const endpoints: Endpoints = {
textarea: {
handler: async (req: PayloadRequest) => {
const data = await req.json?.()

// console.log('req.payload.config.editor : ', req.payload.config.editor.editorConfig)

console.log('incoming data -----> ', JSON.stringify(data, null, 2))
const { locale = 'en', options } = data
const { action, instructionId } = options
const contextData = data.doc
Expand All @@ -141,13 +177,16 @@ export const endpoints: Endpoints = {
// @ts-expect-error
instructions = await req.payload.findByID({
id: instructionId,
collection: 'instructions',
collection: PLUGIN_INSTRUCTIONS_TABLE,
})
}

const { prompt: promptTemplate = '' } = instructions

const fieldName = instructions['schema-path']?.split('.').pop()
const schemaPath = instructions['schema-path']
const fieldName = schemaPath?.split('.').pop()

registerEditorHelper(req.payload, schemaPath)

const prompts = await assignPrompt(action, {
context: contextData,
Expand Down Expand Up @@ -191,19 +230,6 @@ export const endpoints: Endpoints = {
handler: async (req: PayloadRequest) => {
const data = await req.json?.()

const postsCollection = req.payload.collections['posts']
const flattenFields = flattenTopLevelFields(postsCollection.config.fields)

//TODO: Important find a way to use lexcial editor in more generic way
const fieldConfig = flattenFields.find((f) => {
return f.name === 'content'
})

//TODO: Important
// @ts-expect-error
const { editor } = fieldConfig || { editor: {} }

// console.log('fieldConfig : ', fieldConfig)
const { options } = data
const { instructionId, uploadCollectionSlug } = options
const contextData = data.doc
Expand All @@ -214,20 +240,14 @@ export const endpoints: Endpoints = {
// @ts-expect-error
instructions = await req.payload.findByID({
id: instructionId,
collection: 'instructions',
collection: PLUGIN_INSTRUCTIONS_TABLE,
})
}

const { prompt: promptTemplate = '' } = instructions
const schemaPath = instructions['schema-path']

//TODO: add autocomplete ability using handlebars template on PromptEditorField and include custom helpers in dropdown
asyncHandlebars.registerHelper(
'toLexicalHTML',
async function (content: SerializedEditorState) {
const html = await lexicalToHTML(content, editor.editorConfig)
return new asyncHandlebars.SafeString(html)
},
)
registerEditorHelper(req.payload, schemaPath)

const text = await replacePlaceholders(promptTemplate, contextData)
const modelId = instructions['model-id']
Expand Down
14 changes: 14 additions & 0 deletions src/fields/LexicalEditor/ActionsFeatureComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useFieldProps } from '@payloadcms/ui'

import { useInstructions } from '../../providers/InstructionsProvider/hook.js'
import { Actions } from '../../ui/Actions/Actions.js'

export const ActionsFeatureComponent = () => {
const { schemaPath } = useFieldProps()

const { id: instructionId } = useInstructions({
path: schemaPath,
})

return <Actions instructionId={instructionId} />
}
14 changes: 14 additions & 0 deletions src/fields/LexicalEditor/feature.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client'

import { createClientFeature } from '@payloadcms/richtext-lexical/client'

import { ActionsFeatureComponent } from './ActionsFeatureComponent.js'

export const LexicalEditorFeatureClient = createClientFeature({
plugins: [
{
Component: ActionsFeatureComponent,
position: 'belowContainer',
},
],
})
23 changes: 23 additions & 0 deletions src/fields/LexicalEditor/feature.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createServerFeature } from '@payloadcms/richtext-lexical'

import { PLUGIN_LEXICAL_EDITOR_FEATURE } from '../../defaults.js'
import { LexicalEditorFeatureClient } from './feature.client.js'

export const PayloadAiPluginLexicalEditorFeature = createServerFeature({
feature: ({ props }: Record<any, any>) => {
const sanitizedProps = {
applyToFocusedEditor:
props?.applyToFocusedEditor === undefined ? false : props.applyToFocusedEditor,
disableIfParentHasFixedToolbar:
props?.disableIfParentHasFixedToolbar === undefined
? false
: props.disableIfParentHasFixedToolbar,
}
return {
ClientFeature: LexicalEditorFeatureClient,
clientFeatureProps: sanitizedProps,
sanitizedServerFeatureProps: sanitizedProps,
}
},
key: PLUGIN_LEXICAL_EDITOR_FEATURE,
})
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
* without appropriate licensing is prohibited.
*/

export { PayloadAiPluginLexicalEditorFeature } from './fields/LexicalEditor/feature.server.js'
export { payloadAiPlugin } from './plugin.js'
15 changes: 11 additions & 4 deletions src/init.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export const init = async (payload, fieldSchemaPaths) => {
import type { Payload } from 'payload'

import { PLUGIN_INSTRUCTIONS_MAP_GLOBAL, PLUGIN_INSTRUCTIONS_TABLE } from './defaults.js'

export const init = async (payload: Payload, fieldSchemaPaths) => {
payload.logger.info(`— AI Plugin: Initializing...`)

const paths = Object.keys(fieldSchemaPaths)
Expand All @@ -8,7 +12,7 @@ export const init = async (payload, fieldSchemaPaths) => {
const path = paths[i]
const fieldType = fieldSchemaPaths[path]
const entry = await payload.find({
collection: 'instructions',
collection: PLUGIN_INSTRUCTIONS_TABLE,
where: {
'field-type': {
equals: fieldType,
Expand All @@ -21,7 +25,7 @@ export const init = async (payload, fieldSchemaPaths) => {

if (!entry?.docs?.length) {
const instructions = await payload.create({
collection: 'instructions',
collection: PLUGIN_INSTRUCTIONS_TABLE,
data: {
'field-type': fieldType,
'schema-path': path,
Expand All @@ -34,8 +38,11 @@ export const init = async (payload, fieldSchemaPaths) => {
}
}

payload.logger.info(
`— AI Plugin: Enabled fieldMap: ${JSON.stringify(fieldInstructionsMap, null, 2)}`,
)
await payload.updateGlobal({
slug: 'ai-plugin__instructions_map', // required
slug: PLUGIN_INSTRUCTIONS_MAP_GLOBAL, // required
data: {
map: fieldInstructionsMap,
},
Expand Down
5 changes: 3 additions & 2 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { deepMerge } from 'payload/shared'
import type { PluginConfig } from './types.js'

import { Instructions } from './collections/Instructions.js'
import { PLUGIN_INSTRUCTIONS_MAP_GLOBAL } from './defaults.js'
import { endpoints } from './endpoints/index.js'
import { init } from './init.js'
import { InstructionsProvider } from './providers/InstructionsProvider/index.js'
Expand Down Expand Up @@ -42,12 +43,12 @@ const payloadAiPlugin =
globals: [
...incomingConfig.globals,
{
slug: 'ai-plugin__instructions_map',
slug: PLUGIN_INSTRUCTIONS_MAP_GLOBAL,
access: {
read: () => true,
},
admin: {
hidden: true,
hidden: false,
},
fields: [
{
Expand Down
4 changes: 3 additions & 1 deletion src/providers/InstructionsProvider/InstructionsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import React, { createContext, useEffect, useState } from 'react'

import { PLUGIN_INSTRUCTIONS_MAP_GLOBAL } from '../../defaults.js'

const initialContext = {
instructions: undefined,
}
Expand All @@ -15,7 +17,7 @@ export const InstructionsProvider = ({ children }: { children: React.ReactNode }
// their ID is needed to edit them for Drawer, so instead of fetching it
// one by one its map is saved in globals during build
useEffect(() => {
fetch('/api/globals/ai-plugin__instructions_map')
fetch(`/api/globals/${PLUGIN_INSTRUCTIONS_MAP_GLOBAL}`)
.then((res) => {
res.json().then((data) => {
setInstructionsState(data.map)
Expand Down
Loading

0 comments on commit 6df56b1

Please sign in to comment.