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 typescript @types/node -D
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
Also create tsconfig.ts
file so your IDE will be able to resolve adminforth spa imports:
{
"compilerOptions": {
"baseUrl": ".", // This should point to your project root
"paths": {
"@/*": [
// "node_modules/adminforth/dist/spa/src/*"
"../../../spa/src/*"
],
"*": [
// "node_modules/adminforth/dist/spa/node_modules/*"
"../../../spa/node_modules/*"
],
"@@/*": [
// "node_modules/adminforth/dist/spa/src/*"
"."
]
}
}
}
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 vue-suggestion-input -D
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.
Configuring plugin activation order
By default all plugins are activated in random order.
Rarely, it might happen that your plugin somehow depends on other plugins (e.g. default AdminForth rich text editor plugin depends on AdminForth upload file to support images in text).
To control plugin activation order you can set activationOrder
property in plugin class:
export default class ChatGptPlugin extends AdminForthPlugin {
options: PluginOptions;
activationOrder = 100;
...
}
Default value of activationOrder for most plugins is 0
. Plugins with higher activationOrder will be activated later.
To ensure that plugin activates before some other plugins set activationOrder
to negative value.
Splitting frontend logic into multiple files
In case your plugin .vue
files getting too big, you can split them into multiple files (components)
Unfortunately when developing plugin, you should register such components(files) manually by calling componentPath
in modifyResourceConfig
method:
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
super.modifyResourceConfig(adminforth, resourceConfig);
// we just call componentPath method to register component, not using it's result
this.componentPath('subComponent.vue'),
};
}
Now to import subComponent.vue
in completionInput.vue
file you should use @@/plugins/<plugin-class-name>/subComponent.vue
path:
<template>
...
<SubComponent />
...
</template>
<script setup lang="ts">
import SubComponent from '@@/plugins/ChatGptPlugin/subComponent.vue';
</script>
Pay attention that ChatGptPlugin
is a class name of your plugin so it should be used in path.
Using Adapters
There are couple of adapter interfaces in AdminForth like EmailAdapter
for sending emails and CompletionAdapter
.
Adapter is a way to provide same function from different vendors. For example plugin created in this guide uses exactly OpenAI API to get completion.
But in fact OpenAI is not only one API provider for completion, so CompletionAdapter
interface is created to be easily extended.
Here is code from AdminForth:
export interface CompletionAdapter {
validate();
complete(
content: string,
stop: string[],
maxTokens: number,
): Promise<{
content?: string;
finishReason?: string;
error?: string;
}>;
}
To use adapter in plugin you should define it in plugin options:
import { CompletionAdapter } from "adminforth";
export interface PluginOptions {
...
/**
* Adapter for completion
*/
adapter: CompletionAdapter;
}
Then, in your plugin you should call this.options.validate()
, this function will throw error if adminforth app developer did not pass
required parameter (e.g. API token for OpenAI). You can do it in modifyResourceConfig
but, then it will be called at build time (e.g. in Dockerfile). However build time not always has access to all environment variables including OPENAI_API_KEY
.
So we recommend calling validation in validateConfigAfterDiscover
method because it called only in runtime on app start and not in build time.
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
this.options.adapter.validate();
...
}
If some issue with config will happen, validate method will throw error and instance will be crashed so AdminForth app developer will see error message in console and will have to fix it before starting app.
Now you can simply use adapter:
handler: async (a) => {
...
const resp = await this.options.adapter.complete(content, ['.'], this.options.expert?.maxTokens || 50);
...
}