+
+
+
+
+
+
![]()
{openGenerationCarousel[tableColumnsIndexes.findIndex(el => el[primaryKey] === item[primaryKey])][n] = true}"
+ />
+
+
+ el[primaryKey] === item[primaryKey])][n] = false"
+ @selectImage="updateSelectedImage"
+ />
+
+
+
+
+
+
+
+
@@ -89,6 +118,7 @@
import { ref, nextTick, watch } from 'vue'
import mediumZoom from 'medium-zoom'
import { Select, Input, Textarea, Table, Checkbox, Skeleton, Toggle } from '@/afcl'
+import GenerationCarousel from './imageGenerationCarousel.vue'
const props = defineProps<{
meta: any,
@@ -97,13 +127,20 @@ const props = defineProps<{
customFieldNames: any,
tableColumnsIndexes: any,
selected: any,
- isAiResponseReceived: boolean[],
- primaryKey: any
+ isAiResponseReceivedAnalize: boolean[],
+ isAiResponseReceivedImage: boolean[],
+ primaryKey: any,
+ openGenerationCarousel: any
+ isError: boolean,
+ errorMessage: string
}>();
+const emit = defineEmits(['error']);
+
const zoomedImage = ref(null)
const zoomedImg = ref(null)
+
function zoomImage(img) {
zoomedImage.value = img
}
@@ -131,6 +168,10 @@ function isInColumnEnum(key: string): boolean {
return true;
}
+function isInColumnImage(key: string): boolean {
+ return props.meta.outputImageFields?.includes(key) || false;
+}
+
function convertColumnEnumToSelectOptions(columnEnumArray: any[], key: string) {
const col = columnEnumArray.find(c => c.name === key);
if (!col) return [];
@@ -140,4 +181,15 @@ function convertColumnEnumToSelectOptions(columnEnumArray: any[], key: string) {
}));
}
+function updateSelectedImage(image: string, id: any, fieldName: string) {
+ props.selected[props.tableColumnsIndexes.findIndex(el => el[props.primaryKey] === id)][fieldName] = image;
+}
+
+function handleError({ isError, errorMessage }) {
+ emit('error', {
+ isError,
+ errorMessage
+ });
+}
+
\ No newline at end of file
diff --git a/index.ts b/index.ts
index e8e0b3e..1faba27 100644
--- a/index.ts
+++ b/index.ts
@@ -2,16 +2,23 @@ import { AdminForthPlugin, Filters } from "adminforth";
import type { IAdminForth, IHttpServer, AdminForthResourcePages, AdminForthResourceColumn, AdminForthDataTypes, AdminForthResource } from "adminforth";
import type { PluginOptions } from './types.js';
import { json } from "stream/consumers";
-import Handlebars from 'handlebars';
+import Handlebars, { compile } from 'handlebars';
+import { RateLimiter } from "adminforth";
export default class BulkAiFlowPlugin extends AdminForthPlugin {
options: PluginOptions;
uploadPlugin: AdminForthPlugin;
+ totalCalls: number;
+ totalDuration: number;
constructor(options: PluginOptions) {
super(options, import.meta.url);
this.options = options;
+
+ // for calculating average time
+ this.totalCalls = 0;
+ this.totalDuration = 0;
}
// Compile Handlebars templates in outputFields using record fields as context
@@ -27,28 +34,114 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
}
return compiled;
}
-
+
+ private compileOutputFieldsTemplatesNoImage(record: any): Record
{
+ const compiled: Record = {};
+ for (const [key, templateStr] of Object.entries(this.options.fillPlainFields)) {
+ try {
+ const tpl = Handlebars.compile(String(templateStr));
+ compiled[key] = tpl(record);
+ } catch {
+ compiled[key] = String(templateStr);
+ }
+ }
+ return compiled;
+ }
+
+ private compileGenerationFieldTemplates(record: any) {
+ const compiled: Record = {};
+ for (const key in this.options.generateImages) {
+ try {
+ const tpl = Handlebars.compile(String(this.options.generateImages[key].prompt));
+ compiled[key] = tpl(record);
+ } catch {
+ compiled[key] = String(this.options.generateImages[key].prompt);
+ }
+ }
+ return compiled;
+ }
+
+ private checkRateLimit(fieldNameRateLimit: string | undefined, headers: Record): { error?: string } | void {
+ if (fieldNameRateLimit) {
+ // rate limit
+ const { error } = RateLimiter.checkRateLimit(
+ this.pluginInstanceId,
+ fieldNameRateLimit,
+ this.adminforth.auth.getClientIp(headers),
+ );
+ if (error) {
+ return { error: "Rate limit exceeded" };
+ }
+ }
+ }
+
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
super.modifyResourceConfig(adminforth, resourceConfig);
//check if options names are provided
const columns = this.resourceConfig.columns;
let columnEnums = [];
- for (const [key, value] of Object.entries(this.options.fillFieldsFromImages)) {
- const column = columns.find(c => c.name.toLowerCase() === key.toLowerCase());
- if (column) {
- if(column.enum){
- (this.options.fillFieldsFromImages as any)[key] = `${value} Select ${key} from the list (USE ONLY VALUE FIELD. USE ONLY VALUES FROM THIS LIST): ${JSON.stringify(column.enum)}`;
- columnEnums.push({
- name: key,
- enum: column.enum,
- });
+ if (this.options.fillFieldsFromImages) {
+ if (!this.options.attachFiles) {
+ throw new Error('⚠️ attachFiles function must be provided in options when fillFieldsFromImages is used');
+ }
+ if (!this.options.visionAdapter) {
+ throw new Error('⚠️ visionAdapter must be provided in options when fillFieldsFromImages is used');
+ }
+
+ for (const [key, value] of Object.entries((this.options.fillFieldsFromImages ))) {
+ const column = columns.find(c => c.name.toLowerCase() === key.toLowerCase());
+ if (column) {
+ if(column.enum){
+ (this.options.fillFieldsFromImages as any)[key] = `${value} Select ${key} from the list (USE ONLY VALUE FIELD. USE ONLY VALUES FROM THIS LIST): ${JSON.stringify(column.enum)}`;
+ columnEnums.push({
+ name: key,
+ enum: column.enum,
+ });
+ }
+ } else {
+ throw new Error(`⚠️ No column found for key "${key}"`);
+ }
+ }
+ }
+
+ if (this.options.fillPlainFields) {
+ if (!this.options.textCompleteAdapter) {
+ throw new Error('⚠️ textCompleteAdapter must be provided in options when fillPlainFields is used');
+ }
+
+ for (const [key, value] of Object.entries((this.options.fillPlainFields))) {
+ const column = columns.find(c => c.name.toLowerCase() === key.toLowerCase());
+ if (column) {
+ if(column.enum){
+ (this.options.fillPlainFields as any)[key] = `${value} Select ${key} from the list (USE ONLY VALUE FIELD. USE ONLY VALUES FROM THIS LIST): ${JSON.stringify(column.enum)}`;
+ columnEnums.push({
+ name: key,
+ enum: column.enum,
+ });
+ }
+ } else {
+ throw new Error(`⚠️ No column found for key "${key}"`);
+ }
+ }
+ }
+
+ if (this.options.generateImages && !this.options.imageGenerationAdapter) {
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
+ if (!this.options.generateImages[key].adapter) {
+ throw new Error(`⚠️ No image generation adapter found for key "${key}"`);
}
- } else {
- throw new Error(`⚠️ No column found for key "${key}"`);
}
}
+
+ const outputImageFields = [];
+ if (this.options.generateImages) {
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
+ outputImageFields.push(key);
+ }
+ }
+ const outputImagesPluginInstanceIds = {};
//check if Upload plugin is installed on all attachment fields
if (this.options.generateImages) {
for (const [key, value] of Object.entries(this.options.generateImages)) {
@@ -69,12 +162,17 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
Please configure adapter in such way that it will store objects publicly (e.g. for S3 use 'public-read' ACL).
`);
}
- this.uploadPlugin = plugin;
- }
-
+ outputImagesPluginInstanceIds[key] = plugin.pluginInstanceId;
+ }
}
+ const outputFields = {
+ ...this.options.fillFieldsFromImages,
+ ...this.options.fillPlainFields,
+ ...(this.options.generateImages || {})
+ };
+
const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
@@ -82,10 +180,16 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
file: this.componentPath('visionAction.vue'),
meta: {
pluginInstanceId: this.pluginInstanceId,
- outputFields: this.options.fillFieldsFromImages,
+ outputFields: outputFields,
actionName: this.options.actionName,
columnEnums: columnEnums,
+ outputImageFields: outputImageFields,
+ outputPlainFields: this.options.fillPlainFields,
primaryKey: primaryKeyColumn.name,
+ outputImagesPluginInstanceIds: outputImagesPluginInstanceIds,
+ isFieldsForAnalizeFromImages: this.options.fillFieldsFromImages ? Object.keys(this.options.fillFieldsFromImages).length > 0 : false,
+ isFieldsForAnalizePlain: this.options.fillPlainFields ? Object.keys(this.options.fillPlainFields).length > 0 : false,
+ isImageGeneration: this.options.generateImages ? Object.keys(this.options.generateImages).length > 0 : false
}
}
@@ -110,6 +214,8 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
}
setupEndpoints(server: IHttpServer) {
+
+
server.endpoint({
method: 'POST',
path: `/plugin/${this.pluginInstanceId}/analyze`,
@@ -154,6 +260,45 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
return { result };
}
});
+
+ server.endpoint({
+ method: 'POST',
+ path: `/plugin/${this.pluginInstanceId}/analyze_no_images`,
+ handler: async ({ body, adminUser, headers }) => {
+ const selectedIds = body.selectedIds || [];
+ const tasks = selectedIds.map(async (ID) => {
+ // Fetch the record using the provided ID
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
+ const record = await this.adminforth.resource(this.resourceConfig.resourceId).get( [Filters.EQ(primaryKeyColumn.name, ID)] );
+
+ //create prompt for OpenAI
+ const compiledOutputFields = this.compileOutputFieldsTemplatesNoImage(record);
+ const prompt = `Analyze the following fields and return a single JSON in format like: {'param1': 'value1', 'param2': 'value2'}.
+ Do NOT return array of objects. Do NOT include any Markdown, code blocks, explanations, or extra text. Only return valid JSON.
+ Each object must contain the following fields: ${JSON.stringify(compiledOutputFields)} Use the exact field names.
+ If it's number field - return only number.`;
+ //send prompt to OpenAI and get response
+ const { content: chatResponse, finishReason } = await this.options.textCompleteAdapter.complete(prompt, [], 500);
+
+ const resp: any = (chatResponse as any).response;
+ const topLevelError = (chatResponse as any).error;
+ if (topLevelError || resp?.error) {
+ throw new Error(`ERROR: ${JSON.stringify(topLevelError || resp?.error)}`);
+ }
+
+ //parse response and update record
+ const resData = JSON.parse(chatResponse);
+
+ return resData;
+ });
+
+ const result = await Promise.all(tasks);
+
+ return { result };
+ }
+ });
+
+
server.endpoint({
method: 'POST',
path: `/plugin/${this.pluginInstanceId}/get_records`,
@@ -169,6 +314,8 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
};
}
});
+
+
server.endpoint({
method: 'POST',
path: `/plugin/${this.pluginInstanceId}/get_images`,
@@ -176,7 +323,9 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
let images = [];
if(body.body.record){
for( const record of body.body.record ) {
- images.push(await this.options.attachFiles({ record: record }));
+ if (this.options.attachFiles) {
+ images.push(await this.options.attachFiles({ record: record }));
+ }
}
}
return {
@@ -184,24 +333,195 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
};
}
});
+
+
server.endpoint({
method: 'POST',
path: `/plugin/${this.pluginInstanceId}/update_fields`,
handler: async ( body ) => {
const selectedIds = body.body.selectedIds || [];
const fieldsToUpdate = body.body.fields || {};
- const updates = selectedIds.map((ID, idx) =>
- this.adminforth
- .resource(this.resourceConfig.resourceId)
- .update(ID, fieldsToUpdate[idx])
+ const outputImageFields = [];
+ if (this.options.generateImages) {
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
+ outputImageFields.push(key);
+ }
+ }
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
+ const updates = selectedIds.map(async (ID, idx) => {
+ const oldRecord = await this.adminforth.resource(this.resourceConfig.resourceId).get( [Filters.EQ(primaryKeyColumn.name, ID)] );
+ for (const [key, value] of Object.entries(outputImageFields)) {
+ const columnPlugin = this.adminforth.activatedPlugins.find(p =>
+ p.resourceConfig!.resourceId === this.resourceConfig.resourceId &&
+ p.pluginOptions.pathColumnName === value
+ );
+ if (columnPlugin) {
+ if(columnPlugin.pluginOptions.storageAdapter.objectCanBeAccesedPublicly()) {
+ if (oldRecord[value]) {
+ // put tag to delete old file
+ try {
+ await columnPlugin.pluginOptions.storageAdapter.markKeyForDeletation(oldRecord[value]);
+ } catch (e) {
+ // file might be e.g. already deleted, so we catch error
+ console.error(`Error setting tag to true for object ${oldRecord[value]}. File will not be auto-cleaned up`, e);
+ }
+ }
+ if (fieldsToUpdate[idx][key] !== null) {
+ // remove tag from new file
+ // in this case we let it crash if it fails: this is a new file which just was uploaded.
+ await columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletation(fieldsToUpdate[idx][value]);
+ }
+ }
+ }
+ }
+ return this.adminforth.resource(this.resourceConfig.resourceId).update(ID, fieldsToUpdate[idx])
+ });
+ await Promise.all(updates);
+ return { ok: true };
+ }
+ });
+
+
+ server.endpoint({
+ method: 'POST',
+ path: `/plugin/${this.pluginInstanceId}/regenerate_images`,
+ handler: async ({ body, headers }) => {
+ const Id = body.recordId || [];
+ const prompt = body.prompt || '';
+ const fieldName = body.fieldName || '';
+ if (this.checkRateLimit(this.options.generateImages[fieldName].rateLimit, headers)) {
+ return { error: "Rate limit exceeded" };
+ }
+ const start = +new Date();
+ const STUB_MODE = false;
+ const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, Id)]);
+ let attachmentFiles
+ if(!this.options.attachFiles){
+ attachmentFiles = [];
+ } else {
+ attachmentFiles = await this.options.attachFiles({ record });
+ }
+ const images = await Promise.all(
+ (new Array(this.options.generateImages[fieldName].countToGenerate)).fill(0).map(async () => {
+
+ if (STUB_MODE) {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ return `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`;
+ }
+
+ let generationAdapter;
+ if (this.options.generateImages[fieldName].adapter) {
+ generationAdapter = this.options.generateImages[fieldName].adapter;
+ } else {
+ generationAdapter = this.options.imageGenerationAdapter;
+ }
+ const resp = await generationAdapter.generate(
+ {
+ prompt,
+ inputFiles: attachmentFiles,
+ n: 1,
+ size: this.options.generateImages[fieldName].outputSize,
+ }
+ )
+ return resp.imageURLs[0]
+ })
);
+ this.totalCalls++;
+ this.totalDuration += (+new Date() - start) / 1000;
+ return { images };
+ }
+ });
- await Promise.all(updates);
- return { ok: true };
+ server.endpoint({
+ method: 'POST',
+ path: `/plugin/${this.pluginInstanceId}/initial_image_generate`,
+ handler: async ({ body, headers }) => {
+ const selectedIds = body.selectedIds || [];
+ const STUB_MODE = false;
+
+ if (this.checkRateLimit(this.options.bulkGenerationRateLimit, headers)) {
+ return { error: "Rate limit exceeded" };
+ }
+ const start = +new Date();
+ const tasks = selectedIds.map(async (ID) => {
+ const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, ID)]);
+ let attachmentFiles
+ if(!this.options.attachFiles){
+ attachmentFiles = [];
+ } else {
+ attachmentFiles = await this.options.attachFiles({ record });
+ }
+ const fieldTasks = Object.keys(this.options?.generateImages || {}).map(async (key) => {
+ const prompt = this.compileGenerationFieldTemplates(record)[key];
+ let images;
+ if (STUB_MODE) {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ images = `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`;
+ } else {
+ let generationAdapter;
+ if (this.options.generateImages[key].adapter) {
+ generationAdapter = this.options.generateImages[key].adapter;
+ } else {
+ generationAdapter = this.options.imageGenerationAdapter;``
+ }
+ const resp = await generationAdapter.generate(
+ {
+ prompt,
+ inputFiles: attachmentFiles,
+ n: 1,
+ size: this.options.generateImages[key].outputSize,
+ }
+ )
+ images = resp.imageURLs[0];
+ }
+ return { key, images };
+ });
+
+ const fieldResults = await Promise.all(fieldTasks);
+ const recordResult: Record = {};
+
+ fieldResults.forEach(({ key, images }) => {
+ recordResult[key] = images;
+ });
+
+ return recordResult;
+ });
+ const result = await Promise.all(tasks);
+
+ this.totalCalls++;
+ this.totalDuration += (+new Date() - start) / 1000;
+
+ return { result };
}
});
- }
-}
+ server.endpoint({
+ method: 'POST',
+ path: `/plugin/${this.pluginInstanceId}/get_generation_prompts`,
+ handler: async ({ body, headers }) => {
+ const Id = body.recordId || [];
+ const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, Id)]);
+ const compiledGenerationOptions = this.compileGenerationFieldTemplates(record);
+ return { generationOptions: compiledGenerationOptions };
+ }
+ });
+
+
+ server.endpoint({
+ method: 'GET',
+ path: `/plugin/${this.pluginInstanceId}/averageDuration`,
+ handler: async () => {
+ return {
+ totalCalls: this.totalCalls,
+ totalDuration: this.totalDuration,
+ averageDuration: this.totalCalls ? this.totalDuration / this.totalCalls : null,
+ };
+ }
+ });
+
+
+
+ }
+}
\ No newline at end of file
diff --git a/types.ts b/types.ts
index cbb06e8..71b8769 100644
--- a/types.ts
+++ b/types.ts
@@ -1,12 +1,50 @@
-import { ImageVisionAdapter, AdminUser, IAdminForth, StorageAdapter } from "adminforth";
+import { ImageVisionAdapter, AdminUser, IAdminForth, StorageAdapter, ImageGenerationAdapter, CompletionAdapter } from "adminforth";
export interface PluginOptions {
actionName: string,
- visionAdapter: ImageVisionAdapter,
+ visionAdapter?: ImageVisionAdapter,
+ textCompleteAdapter?: CompletionAdapter,
+ /**
+ * The adapter to use for image generation.
+ */
+ imageGenerationAdapter?: ImageGenerationAdapter,
fillFieldsFromImages?: Record, // can analyze what is on image and fill fields, typical tasks "find dominant color", "describe what is on image", "clasify to one enum item, e.g. what is on image dog/cat/plant"
- generateImages?: Record, // can generate from images or just from another fields, e.g. "remove text from images", "improve image quality", "turn image into ghibli style"
+ fillPlainFields?: Record,
attachFiles?: ({ record }: {
record: any,
}) => string[] | Promise,
+
+ generateImages?: Record<
+ string, {
+ // can generate from images or just from another fields, e.g. "remove text from images", "improve image quality", "turn image into ghibli style"
+ prompt: string,
+
+ /*
+ * Redefine the adapter for your specific generation task
+ */
+ adapter?: ImageGenerationAdapter,
+
+ /**
+ * The size of the generated image.
+ */
+ outputSize?: string,
+
+ /**
+ * Since AI generation can be expensive, we can limit the number of requests per IP.
+ * E.g. 5/1d - 5 requests per day
+ * 3/1h - 3 requests per hour
+ */
+ rateLimit?: string,
+
+ /**
+ * The number of images to regenerate
+ * in one request
+ */
+ countToGenerate: number,
+ }>,
+ /**
+ * As rateLimit on generateImages, but applied to bulk generations
+ **/
+ bulkGenerationRateLimit?: string,
}