Plugin development guide
Creating a plugin is a powerful way to extend AdminForth functionality.
Concepts
Every plugin is installed to resource.
Every plugin simply does modification of AdminForth config which developer passed on AdminForth initialization.
Plugin can modify both config of resource where it is installed or whole global config.
To perform modification plugin defines a method modifyResourceConfig
which accepts config
object. The modifyResourceConfig
method called after first config validation and preprocessing.
Also plugins can define custom components and custom APIs.
Boilerplate
Let's create plugin which auto-completes text in strings
mkdir -p af-plugin-chatgpt
cd af-plugin-chatgpt
npm init -y
touch index.ts
npm i --save-dev typescript @types/node
Edit package.json
:
{
...
"main": "index.js",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc && rsync -av --exclude 'node_modules' custom dist/ && npm version patch"
},
}
Install AdminForth for types and classes imports:
npm i adminforth --save
Now create plugin boilerplate in index.ts
:
import { AdminForthPlugin } from "adminforth";
import type { IAdminForth, IHttpServer, AdminForthResourcePages, AdminForthResourceColumn, AdminForthDataTypes, AdminForthResource } from "adminforth";
import type { PluginOptions } from './types.js';
export default class ChatGptPlugin extends AdminForthPlugin {
options: PluginOptions;
constructor(options: PluginOptions) {
super(options, import.meta.url);
this.options = options;
}
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
super.modifyResourceConfig(adminforth, resourceConfig);
// simply modify resourceConfig or adminforth.config. You can get access to plugin options via this.options;
}
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
// optional method where you can safely check field types after database discovery was performed
}
instanceUniqueRepresentation(pluginOptions: any) : string {
// optional method to return unique string representation of plugin instance.
// Needed if plugin can have multiple instances on one resource
return `single`;
}
setupEndpoints(server: IHttpServer) {
server.endpoint({
method: 'POST',
path: `/plugin/${this.pluginInstanceId}/example`,
handler: async ({ body }) => {
const { name } = body;
return { hey: `Hello ${name}` };
}
});
}
}
Create types.ts
file:
export interface PluginOptions {
}
Create ./af-plugin-chatgpt/tsconfig.json
file:
{
"compilerOptions": {
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include*/
"module": "node16", /* Specify what module code is generated. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
"strict": false, /* Enable all strict type-checking options. */
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
},
"exclude": ["node_modules", "dist", "custom"], /* Exclude files from compilation. */
}
This is very important step! (otherwise some features like method redefinition might blindly fail).
Creating plugin logic
In previous section we created boilerplate which is a must for every plugin. Now let's implement plugin logic.
First of all we want one plugin installation to be able to set custom Vue component on create and edit pages.
In plugin options we will pass field name and OPENAI_API_KEY
.
export interface PluginOptions {
/**
* Field where plugin will auto-complete text. Should be string or text field.
*/
fieldName: string;
/**
* OpenAI API key. Go to https://platform.openai.com/, go to Dashboard -> API keys -> Create new secret key
* Paste value in your .env file OPENAI_API_KEY=your_key
* Set openAiApiKey: process.env.OPENAI_API_KEY to access it
*/
openAiApiKey: string;
/**
* Model name. Go to https://platform.openai.com/docs/models, select model and copy name.
* Default is `gpt-4o-mini`. Use e.g. more expensive `gpt-4o` for more powerful model.
*/
model?: string;
/**
* Expert settings
*/
expert?: {
/**
* Number of tokens to generate. Default is 50. 1 token ~= ¾ words
*/
maxTokens?: number;
}
}
Now we have to create custom Vue component which will be used in plugin. To do it create custom folder:
mkdir -p af-plugin-chatgpt/custom
We will use vue-suggestion-input
package in our frontend component.
To install package into frontend component, first of all we have to initialize npm package in custom folder:
cd af-plugin-chatgpt/custom
npm init -y
Now install our dependency:
npm i --save-dev vue-suggestion-input
Create file completionInput.vue
:
<template>
<SuggestionInput
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500
focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400
dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 whitespace-normal"
v-model="currentValue"
:type="column.type"
:completionRequest="complete"
:debounceTime="meta.debounceTime"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, Ref } from 'vue';
import { callAdminForthApi } from '@/utils';
import type { AdminForthColumnCommon } from '@/types/Common';
import SuggestionInput from 'vue-suggestion-input';
import 'vue-suggestion-input/dist/style.css';
const props = defineProps<{
column: AdminForthColumnCommon,
record: any,
meta: any,
}>();
const emit = defineEmits([
'update:value',
]);
const currentValue: Ref<string> = ref('');
onMounted(() => {
currentValue.value = props.record[props.column.name] || '';
});
watch(() => currentValue.value, (value) => {
emit('update:value', value);
});
watch(() => props.record, (value) => {
currentValue.value = value[props.column.name] || '';
});
async function complete(textBeforeCursor: string) {
const res = await callAdminForthApi({
path: `/plugin/${props.meta.pluginInstanceId}/doComplete`,
method: 'POST',
body: {
record: {...props.record, [props.column.name]: textBeforeCursor},
},
});
return res.completion;
}
</script>
As you can see we call API endpoint /plugin/${props.meta.pluginInstanceId}/doComplete
to get completion.
For all your API calls from your own plugins we recommend to use same url format which includes pluginInstanceId. This way you can be sure that your API calls are unique for each plugin installation.
Let's define API endpoint in our plugin:
setupEndpoints(server: IHttpServer) {
server.endpoint({
method: 'POST',
path: `/plugin/${this.pluginInstanceId}/example`,
path: `/plugin/${this.pluginInstanceId}/doComplete`,
handler: async ({ body }) => {
const { name } = body;
return { hey: `Hello ${name}` };
const { record } = body;
let currentVal = record[this.options.fieldName];
const promptLimit = 500;
if (currentVal && currentVal.length > promptLimit) {
currentVal = currentVal.slice(-promptLimit);
}
const resLabel = this.resourceConfig.label;
let content;
if (currentVal) {
content = `Continue writing for text/string field "${this.options.fieldName}" in the table "${resLabel}"\n` +
`Current field value: ${currentVal}\n` +
"Don't talk to me. Just write text. No quotes. Don't repeat current field value, just write completion\n";
} else {
content = `Fill text/string field "${this.options.fieldName}" in the table "${resLabel}"\n` +
"Be short, clear and precise. No quotes. Don't talk to me. Just write text\n";
}
const resp = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.options.openAiApiKey}`
},
body: JSON.stringify({
model: this.options.model || 'gpt-4o-mini',
messages: [{ role: 'user', content, }],
temperature: 0.7,
max_tokens: this.options.expert?.maxTokens || 50,
stop: ['.'],
})
});
const data = await resp.json();
if (!data.choices) {
throw new Error(`Wrong response from OpenAI ${JSON.stringify(data)}`)
}
let suggestion = data.choices[0].message.content + (
data.choices[0].finish_reason === 'stop' ? (
this.columnType === AdminForthDataTypes.TEXT ? '. ' : ''
) : ''
);
if (suggestion.startsWith(currentVal)) {
suggestion = suggestion.slice(currentVal.length);
}
if (suggestion.startsWith(currentVal)) {
suggestion = suggestion.slice(currentVal.length);
}
const wordsList = suggestion.split(' ').map((w, i) => {
return (i === suggestion.split(' ').length - 1) ? w : w + ' ';
});
return { completion: wordsList };
}
})
}
Now we have to set custom input on create and edit pages for field which user defined in fieldName:
export default class ChatGptPlugin extends AdminForthPlugin {
options: PluginOptions;
resourceConfig!: AdminForthResource;
columnType!: AdminForthDataTypes;
...
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
super.modifyResourceConfig(adminforth, resourceConfig);
// ensure that column exists
const column = resourceConfig.columns.find(f => f.name === this.options.fieldName);
if (!column) {
throw new Error(`Field ${this.options.fieldName} not found in resource ${resourceConfig.label}`);
}
if (!column.components) {
column.components = {};
}
const filed = {
file: this.componentPath('completionInput.vue'),
meta: {
pluginInstanceId: this.pluginInstanceId,
fieldName: this.options.fieldName,
debounceTime: 300,
}
}
column.components.create = filed;
column.components.edit = filed;
this.columnType = column.type!;
}
Additionally we should check that column type is string or text, otherwise our input will not work properly. From first sight we can make this validation in modifyResourceConfig method, but it is not good idea because we can't be sure that column type is defined and known at this stage. If user defined it manually then it will be there, but if type is auto-discovered then it will be undefined at this stage.
That is why we will use validateConfigAfterDiscover
method:
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
const column = this.resourceConfig.columns.find(f => f.name === this.options.fieldName);
if (![AdminForthDataTypes.STRING, AdminForthDataTypes.TEXT].includes(column!.type!)) {
throw new Error(`Field ${this.options.fieldName} should be string or text type, but it is ${column!.type}`);
}
// any validation better to do here e.g. because bundleNow might no have enough environment
if (!this.options.openAiApiKey) {
throw new Error('OPENAI_API_KEY is required');
}
}
Finally, since we want to support multiple installations on one resource (e.g. one plugin installation for title
field and another for description
field), we have to define plugin instance unique representation. Best idea in this case to use field name which will be different for each installation:
instanceUniqueRepresentation(pluginOptions: any) : string {
return `${pluginOptions.fieldName}`;
return `single`;
}
Ro compile plugin run:
npm run build
You can also publish your plugin to npm using npm publish
.
Installation of plugin
If you want to test your plugin locally before publishing, enter plugin dir and run:
cd af-plugin-chatgpt
npm link
Then enter your AdminForth project and run:
npm link af-plugin-chatgpt
Now in your app index.ts
file:
import ChatGptPlugin from 'af-plugin-chatgpt';
...
{
resourceId: 'aparts',
...
plugins: [
...
new ChatGptPlugin({
openAiApiKey: process.env.OPENAI_API_KEY as string,
fieldName: 'title',
}),
new ChatGptPlugin({
openAiApiKey: process.env.OPENAI_API_KEY as string,
fieldName: 'description',
}),
]
}
Go to https://platform.openai.com/, go to Dashboard -> API keys -> Create new secret key. Paste value in your .env
file OPENAI_API_KEY=your_key
☝️ Using
npm link
approach still requiresnpm run build
in plugin dir after each change because plugin entry point is defined asdist/index.js
inpackage.json
file. To speed up plugin development you can also don't usenpm link
and just import plugin main file from your demo file:import ChatGptPlugin from '<path to af plugin>/af-plugin-chatgpt/index.js';
🎓 Homework 1: Extend
expert
settings section to include next parameters:temperature
,promptLimit
,debounceTime
,
🎓 Homework 2: Plugin does not pass record other values to Chat GPT which can help to create better prompts with context understanding. Try to adjust prompt to include other record values. Keep in mind that longer prompts can be more expensive and slower, so should smartly limit prompt length.