@adminforth/bulk-ai-flow 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -2,16 +2,23 @@ import { AdminForthPlugin, Filters } from "adminforth";
2
2
  import type { IAdminForth, IHttpServer, AdminForthResourcePages, AdminForthResourceColumn, AdminForthDataTypes, AdminForthResource } from "adminforth";
3
3
  import type { PluginOptions } from './types.js';
4
4
  import { json } from "stream/consumers";
5
- import Handlebars from 'handlebars';
5
+ import Handlebars, { compile } from 'handlebars';
6
+ import { RateLimiter } from "adminforth";
6
7
 
7
8
 
8
9
  export default class BulkAiFlowPlugin extends AdminForthPlugin {
9
10
  options: PluginOptions;
10
11
  uploadPlugin: AdminForthPlugin;
12
+ totalCalls: number;
13
+ totalDuration: number;
11
14
 
12
15
  constructor(options: PluginOptions) {
13
16
  super(options, import.meta.url);
14
17
  this.options = options;
18
+
19
+ // for calculating average time
20
+ this.totalCalls = 0;
21
+ this.totalDuration = 0;
15
22
  }
16
23
 
17
24
  // Compile Handlebars templates in outputFields using record fields as context
@@ -27,28 +34,114 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
27
34
  }
28
35
  return compiled;
29
36
  }
30
-
37
+
38
+ private compileOutputFieldsTemplatesNoImage(record: any): Record<string, string> {
39
+ const compiled: Record<string, string> = {};
40
+ for (const [key, templateStr] of Object.entries(this.options.fillPlainFields)) {
41
+ try {
42
+ const tpl = Handlebars.compile(String(templateStr));
43
+ compiled[key] = tpl(record);
44
+ } catch {
45
+ compiled[key] = String(templateStr);
46
+ }
47
+ }
48
+ return compiled;
49
+ }
50
+
51
+ private compileGenerationFieldTemplates(record: any) {
52
+ const compiled: Record<string, any> = {};
53
+ for (const key in this.options.generateImages) {
54
+ try {
55
+ const tpl = Handlebars.compile(String(this.options.generateImages[key].prompt));
56
+ compiled[key] = tpl(record);
57
+ } catch {
58
+ compiled[key] = String(this.options.generateImages[key].prompt);
59
+ }
60
+ }
61
+ return compiled;
62
+ }
63
+
64
+ private checkRateLimit(fieldNameRateLimit: string | undefined, headers: Record<string, string | string[] | undefined>): { error?: string } | void {
65
+ if (fieldNameRateLimit) {
66
+ // rate limit
67
+ const { error } = RateLimiter.checkRateLimit(
68
+ this.pluginInstanceId,
69
+ fieldNameRateLimit,
70
+ this.adminforth.auth.getClientIp(headers),
71
+ );
72
+ if (error) {
73
+ return { error: "Rate limit exceeded" };
74
+ }
75
+ }
76
+ }
77
+
31
78
  async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
32
79
  super.modifyResourceConfig(adminforth, resourceConfig);
33
80
 
34
81
  //check if options names are provided
35
82
  const columns = this.resourceConfig.columns;
36
83
  let columnEnums = [];
37
- for (const [key, value] of Object.entries(this.options.fillFieldsFromImages)) {
38
- const column = columns.find(c => c.name.toLowerCase() === key.toLowerCase());
39
- if (column) {
40
- if(column.enum){
41
- (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)}`;
42
- columnEnums.push({
43
- name: key,
44
- enum: column.enum,
45
- });
84
+ if (this.options.fillFieldsFromImages) {
85
+ if (!this.options.attachFiles) {
86
+ throw new Error('⚠️ attachFiles function must be provided in options when fillFieldsFromImages is used');
87
+ }
88
+ if (!this.options.visionAdapter) {
89
+ throw new Error('⚠️ visionAdapter must be provided in options when fillFieldsFromImages is used');
90
+ }
91
+
92
+ for (const [key, value] of Object.entries((this.options.fillFieldsFromImages ))) {
93
+ const column = columns.find(c => c.name.toLowerCase() === key.toLowerCase());
94
+ if (column) {
95
+ if(column.enum){
96
+ (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)}`;
97
+ columnEnums.push({
98
+ name: key,
99
+ enum: column.enum,
100
+ });
101
+ }
102
+ } else {
103
+ throw new Error(`⚠️ No column found for key "${key}"`);
104
+ }
105
+ }
106
+ }
107
+
108
+ if (this.options.fillPlainFields) {
109
+ if (!this.options.textCompleteAdapter) {
110
+ throw new Error('⚠️ textCompleteAdapter must be provided in options when fillPlainFields is used');
111
+ }
112
+
113
+ for (const [key, value] of Object.entries((this.options.fillPlainFields))) {
114
+ const column = columns.find(c => c.name.toLowerCase() === key.toLowerCase());
115
+ if (column) {
116
+ if(column.enum){
117
+ (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)}`;
118
+ columnEnums.push({
119
+ name: key,
120
+ enum: column.enum,
121
+ });
122
+ }
123
+ } else {
124
+ throw new Error(`⚠️ No column found for key "${key}"`);
125
+ }
126
+ }
127
+ }
128
+
129
+ if (this.options.generateImages && !this.options.imageGenerationAdapter) {
130
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
131
+ if (!this.options.generateImages[key].adapter) {
132
+ throw new Error(`⚠️ No image generation adapter found for key "${key}"`);
46
133
  }
47
- } else {
48
- throw new Error(`⚠️ No column found for key "${key}"`);
49
134
  }
50
135
  }
136
+
51
137
 
138
+ const outputImageFields = [];
139
+ if (this.options.generateImages) {
140
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
141
+ outputImageFields.push(key);
142
+ }
143
+ }
144
+ const outputImagesPluginInstanceIds = {};
52
145
  //check if Upload plugin is installed on all attachment fields
53
146
  if (this.options.generateImages) {
54
147
  for (const [key, value] of Object.entries(this.options.generateImages)) {
@@ -69,12 +162,17 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
69
162
  Please configure adapter in such way that it will store objects publicly (e.g. for S3 use 'public-read' ACL).
70
163
  `);
71
164
  }
72
- this.uploadPlugin = plugin;
73
- }
74
-
75
165
 
166
+ outputImagesPluginInstanceIds[key] = plugin.pluginInstanceId;
167
+ }
76
168
  }
77
169
 
170
+ const outputFields = {
171
+ ...this.options.fillFieldsFromImages,
172
+ ...this.options.fillPlainFields,
173
+ ...(this.options.generateImages || {})
174
+ };
175
+
78
176
 
79
177
  const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
80
178
 
@@ -82,10 +180,16 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
82
180
  file: this.componentPath('visionAction.vue'),
83
181
  meta: {
84
182
  pluginInstanceId: this.pluginInstanceId,
85
- outputFields: this.options.fillFieldsFromImages,
183
+ outputFields: outputFields,
86
184
  actionName: this.options.actionName,
87
185
  columnEnums: columnEnums,
186
+ outputImageFields: outputImageFields,
187
+ outputPlainFields: this.options.fillPlainFields,
88
188
  primaryKey: primaryKeyColumn.name,
189
+ outputImagesPluginInstanceIds: outputImagesPluginInstanceIds,
190
+ isFieldsForAnalizeFromImages: this.options.fillFieldsFromImages ? Object.keys(this.options.fillFieldsFromImages).length > 0 : false,
191
+ isFieldsForAnalizePlain: this.options.fillPlainFields ? Object.keys(this.options.fillPlainFields).length > 0 : false,
192
+ isImageGeneration: this.options.generateImages ? Object.keys(this.options.generateImages).length > 0 : false
89
193
  }
90
194
  }
91
195
 
@@ -110,6 +214,8 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
110
214
  }
111
215
 
112
216
  setupEndpoints(server: IHttpServer) {
217
+
218
+
113
219
  server.endpoint({
114
220
  method: 'POST',
115
221
  path: `/plugin/${this.pluginInstanceId}/analyze`,
@@ -154,6 +260,45 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
154
260
  return { result };
155
261
  }
156
262
  });
263
+
264
+ server.endpoint({
265
+ method: 'POST',
266
+ path: `/plugin/${this.pluginInstanceId}/analyze_no_images`,
267
+ handler: async ({ body, adminUser, headers }) => {
268
+ const selectedIds = body.selectedIds || [];
269
+ const tasks = selectedIds.map(async (ID) => {
270
+ // Fetch the record using the provided ID
271
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
272
+ const record = await this.adminforth.resource(this.resourceConfig.resourceId).get( [Filters.EQ(primaryKeyColumn.name, ID)] );
273
+
274
+ //create prompt for OpenAI
275
+ const compiledOutputFields = this.compileOutputFieldsTemplatesNoImage(record);
276
+ const prompt = `Analyze the following fields and return a single JSON in format like: {'param1': 'value1', 'param2': 'value2'}.
277
+ Do NOT return array of objects. Do NOT include any Markdown, code blocks, explanations, or extra text. Only return valid JSON.
278
+ Each object must contain the following fields: ${JSON.stringify(compiledOutputFields)} Use the exact field names.
279
+ If it's number field - return only number.`;
280
+ //send prompt to OpenAI and get response
281
+ const { content: chatResponse, finishReason } = await this.options.textCompleteAdapter.complete(prompt, [], 500);
282
+
283
+ const resp: any = (chatResponse as any).response;
284
+ const topLevelError = (chatResponse as any).error;
285
+ if (topLevelError || resp?.error) {
286
+ throw new Error(`ERROR: ${JSON.stringify(topLevelError || resp?.error)}`);
287
+ }
288
+
289
+ //parse response and update record
290
+ const resData = JSON.parse(chatResponse);
291
+
292
+ return resData;
293
+ });
294
+
295
+ const result = await Promise.all(tasks);
296
+
297
+ return { result };
298
+ }
299
+ });
300
+
301
+
157
302
  server.endpoint({
158
303
  method: 'POST',
159
304
  path: `/plugin/${this.pluginInstanceId}/get_records`,
@@ -169,6 +314,8 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
169
314
  };
170
315
  }
171
316
  });
317
+
318
+
172
319
  server.endpoint({
173
320
  method: 'POST',
174
321
  path: `/plugin/${this.pluginInstanceId}/get_images`,
@@ -176,7 +323,9 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
176
323
  let images = [];
177
324
  if(body.body.record){
178
325
  for( const record of body.body.record ) {
179
- images.push(await this.options.attachFiles({ record: record }));
326
+ if (this.options.attachFiles) {
327
+ images.push(await this.options.attachFiles({ record: record }));
328
+ }
180
329
  }
181
330
  }
182
331
  return {
@@ -184,24 +333,195 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
184
333
  };
185
334
  }
186
335
  });
336
+
337
+
187
338
  server.endpoint({
188
339
  method: 'POST',
189
340
  path: `/plugin/${this.pluginInstanceId}/update_fields`,
190
341
  handler: async ( body ) => {
191
342
  const selectedIds = body.body.selectedIds || [];
192
343
  const fieldsToUpdate = body.body.fields || {};
193
- const updates = selectedIds.map((ID, idx) =>
194
- this.adminforth
195
- .resource(this.resourceConfig.resourceId)
196
- .update(ID, fieldsToUpdate[idx])
344
+ const outputImageFields = [];
345
+ if (this.options.generateImages) {
346
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
347
+ outputImageFields.push(key);
348
+ }
349
+ }
350
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
351
+ const updates = selectedIds.map(async (ID, idx) => {
352
+ const oldRecord = await this.adminforth.resource(this.resourceConfig.resourceId).get( [Filters.EQ(primaryKeyColumn.name, ID)] );
353
+ for (const [key, value] of Object.entries(outputImageFields)) {
354
+ const columnPlugin = this.adminforth.activatedPlugins.find(p =>
355
+ p.resourceConfig!.resourceId === this.resourceConfig.resourceId &&
356
+ p.pluginOptions.pathColumnName === value
357
+ );
358
+ if (columnPlugin) {
359
+ if(columnPlugin.pluginOptions.storageAdapter.objectCanBeAccesedPublicly()) {
360
+ if (oldRecord[value]) {
361
+ // put tag to delete old file
362
+ try {
363
+ await columnPlugin.pluginOptions.storageAdapter.markKeyForDeletation(oldRecord[value]);
364
+ } catch (e) {
365
+ // file might be e.g. already deleted, so we catch error
366
+ console.error(`Error setting tag to true for object ${oldRecord[value]}. File will not be auto-cleaned up`, e);
367
+ }
368
+ }
369
+ if (fieldsToUpdate[idx][key] !== null) {
370
+ // remove tag from new file
371
+ // in this case we let it crash if it fails: this is a new file which just was uploaded.
372
+ await columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletation(fieldsToUpdate[idx][value]);
373
+ }
374
+ }
375
+ }
376
+ }
377
+ return this.adminforth.resource(this.resourceConfig.resourceId).update(ID, fieldsToUpdate[idx])
378
+ });
379
+ await Promise.all(updates);
380
+ return { ok: true };
381
+ }
382
+ });
383
+
384
+
385
+ server.endpoint({
386
+ method: 'POST',
387
+ path: `/plugin/${this.pluginInstanceId}/regenerate_images`,
388
+ handler: async ({ body, headers }) => {
389
+ const Id = body.recordId || [];
390
+ const prompt = body.prompt || '';
391
+ const fieldName = body.fieldName || '';
392
+ if (this.checkRateLimit(this.options.generateImages[fieldName].rateLimit, headers)) {
393
+ return { error: "Rate limit exceeded" };
394
+ }
395
+ const start = +new Date();
396
+ const STUB_MODE = false;
397
+ const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, Id)]);
398
+ let attachmentFiles
399
+ if(!this.options.attachFiles){
400
+ attachmentFiles = [];
401
+ } else {
402
+ attachmentFiles = await this.options.attachFiles({ record });
403
+ }
404
+ const images = await Promise.all(
405
+ (new Array(this.options.generateImages[fieldName].countToGenerate)).fill(0).map(async () => {
406
+
407
+ if (STUB_MODE) {
408
+ await new Promise((resolve) => setTimeout(resolve, 2000));
409
+ return `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`;
410
+ }
411
+
412
+ let generationAdapter;
413
+ if (this.options.generateImages[fieldName].adapter) {
414
+ generationAdapter = this.options.generateImages[fieldName].adapter;
415
+ } else {
416
+ generationAdapter = this.options.imageGenerationAdapter;
417
+ }
418
+ const resp = await generationAdapter.generate(
419
+ {
420
+ prompt,
421
+ inputFiles: attachmentFiles,
422
+ n: 1,
423
+ size: this.options.generateImages[fieldName].outputSize,
424
+ }
425
+ )
426
+ return resp.imageURLs[0]
427
+ })
197
428
  );
429
+ this.totalCalls++;
430
+ this.totalDuration += (+new Date() - start) / 1000;
431
+ return { images };
432
+ }
433
+ });
198
434
 
199
- await Promise.all(updates);
200
435
 
201
- return { ok: true };
436
+ server.endpoint({
437
+ method: 'POST',
438
+ path: `/plugin/${this.pluginInstanceId}/initial_image_generate`,
439
+ handler: async ({ body, headers }) => {
440
+ const selectedIds = body.selectedIds || [];
441
+ const STUB_MODE = false;
442
+
443
+ if (this.checkRateLimit(this.options.bulkGenerationRateLimit, headers)) {
444
+ return { error: "Rate limit exceeded" };
445
+ }
446
+ const start = +new Date();
447
+ const tasks = selectedIds.map(async (ID) => {
448
+ const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, ID)]);
449
+ let attachmentFiles
450
+ if(!this.options.attachFiles){
451
+ attachmentFiles = [];
452
+ } else {
453
+ attachmentFiles = await this.options.attachFiles({ record });
454
+ }
455
+ const fieldTasks = Object.keys(this.options?.generateImages || {}).map(async (key) => {
456
+ const prompt = this.compileGenerationFieldTemplates(record)[key];
457
+ let images;
458
+ if (STUB_MODE) {
459
+ await new Promise((resolve) => setTimeout(resolve, 2000));
460
+ images = `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`;
461
+ } else {
462
+ let generationAdapter;
463
+ if (this.options.generateImages[key].adapter) {
464
+ generationAdapter = this.options.generateImages[key].adapter;
465
+ } else {
466
+ generationAdapter = this.options.imageGenerationAdapter;``
467
+ }
468
+ const resp = await generationAdapter.generate(
469
+ {
470
+ prompt,
471
+ inputFiles: attachmentFiles,
472
+ n: 1,
473
+ size: this.options.generateImages[key].outputSize,
474
+ }
475
+ )
476
+ images = resp.imageURLs[0];
477
+ }
478
+ return { key, images };
479
+ });
480
+
481
+ const fieldResults = await Promise.all(fieldTasks);
482
+ const recordResult: Record<string, string[]> = {};
483
+
484
+ fieldResults.forEach(({ key, images }) => {
485
+ recordResult[key] = images;
486
+ });
487
+
488
+ return recordResult;
489
+ });
490
+ const result = await Promise.all(tasks);
491
+
492
+ this.totalCalls++;
493
+ this.totalDuration += (+new Date() - start) / 1000;
494
+
495
+ return { result };
202
496
  }
203
497
  });
204
- }
205
- }
206
498
 
207
499
 
500
+ server.endpoint({
501
+ method: 'POST',
502
+ path: `/plugin/${this.pluginInstanceId}/get_generation_prompts`,
503
+ handler: async ({ body, headers }) => {
504
+ const Id = body.recordId || [];
505
+ const record = await this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, Id)]);
506
+ const compiledGenerationOptions = this.compileGenerationFieldTemplates(record);
507
+ return { generationOptions: compiledGenerationOptions };
508
+ }
509
+ });
510
+
511
+
512
+ server.endpoint({
513
+ method: 'GET',
514
+ path: `/plugin/${this.pluginInstanceId}/averageDuration`,
515
+ handler: async () => {
516
+ return {
517
+ totalCalls: this.totalCalls,
518
+ totalDuration: this.totalDuration,
519
+ averageDuration: this.totalCalls ? this.totalDuration / this.totalCalls : null,
520
+ };
521
+ }
522
+ });
523
+
524
+
525
+
526
+ }
527
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/bulk-ai-flow",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/types.ts CHANGED
@@ -1,12 +1,50 @@
1
- import { ImageVisionAdapter, AdminUser, IAdminForth, StorageAdapter } from "adminforth";
1
+ import { ImageVisionAdapter, AdminUser, IAdminForth, StorageAdapter, ImageGenerationAdapter, CompletionAdapter } from "adminforth";
2
2
 
3
3
 
4
4
  export interface PluginOptions {
5
5
  actionName: string,
6
- visionAdapter: ImageVisionAdapter,
6
+ visionAdapter?: ImageVisionAdapter,
7
+ textCompleteAdapter?: CompletionAdapter,
8
+ /**
9
+ * The adapter to use for image generation.
10
+ */
11
+ imageGenerationAdapter?: ImageGenerationAdapter,
7
12
  fillFieldsFromImages?: Record<string, string>, // 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"
8
- generateImages?: Record<string, string>, // can generate from images or just from another fields, e.g. "remove text from images", "improve image quality", "turn image into ghibli style"
13
+ fillPlainFields?: Record<string, string>,
9
14
  attachFiles?: ({ record }: {
10
15
  record: any,
11
16
  }) => string[] | Promise<string[]>,
17
+
18
+ generateImages?: Record<
19
+ string, {
20
+ // can generate from images or just from another fields, e.g. "remove text from images", "improve image quality", "turn image into ghibli style"
21
+ prompt: string,
22
+
23
+ /*
24
+ * Redefine the adapter for your specific generation task
25
+ */
26
+ adapter?: ImageGenerationAdapter,
27
+
28
+ /**
29
+ * The size of the generated image.
30
+ */
31
+ outputSize?: string,
32
+
33
+ /**
34
+ * Since AI generation can be expensive, we can limit the number of requests per IP.
35
+ * E.g. 5/1d - 5 requests per day
36
+ * 3/1h - 3 requests per hour
37
+ */
38
+ rateLimit?: string,
39
+
40
+ /**
41
+ * The number of images to regenerate
42
+ * in one request
43
+ */
44
+ countToGenerate: number,
45
+ }>,
46
+ /**
47
+ * As rateLimit on generateImages, but applied to bulk generations
48
+ **/
49
+ bulkGenerationRateLimit?: string,
12
50
  }