@adminforth/bulk-ai-flow 1.21.9 → 1.22.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.
@@ -20,7 +20,7 @@
20
20
  {
21
21
  label: checkedCount > 1 ? t('Save fields') : t('Save field'),
22
22
  options: {
23
- disabled: isLoading || checkedCount < 1 || isCriticalError || isFetchingRecords || isGeneratingImages || isAnalizingFields || isAnalizingImages,
23
+ disabled: isLoading || checkedCount < 1 || isFetchingRecords || isProcessingAny || isGenerationPaused,
24
24
  loader: isLoading, class: 'w-fit'
25
25
  },
26
26
  onclick: async (dialog) => { await saveData(); dialog.hide(); }
@@ -67,46 +67,71 @@
67
67
  :click-to-close-outside="false"
68
68
  >
69
69
  <div class="bulk-vision-table flex flex-col items-center gap-3 md:gap-4 overflow-y-auto">
70
- <template v-if="records && props.checkboxes.length && popupMode === 'generation'" >
70
+ <template v-if="recordsList.length && popupMode === 'generation'" >
71
+
72
+
73
+ <div class="w-full">
74
+ <div v-if="isGenerationPaused" class="flex flex-col gap-2 mb-2">
75
+ <p class="text-sm font-semibold text-yellow-800">{{ t(`Generated ${startedRecordCount} records. `) + t('Generation on pause. Resume generation?') }}</p>
76
+ <div class="flex items-center gap-2">
77
+ <button
78
+ class="px-3 py-1.5 text-sm rounded-md bg-gradient-to-r from-purple-500 via-purple-600 to-purple-700 text-white"
79
+ @click="resumeGeneration"
80
+ >
81
+ {{ t('Resume generation') }}
82
+ </button>
83
+ <button
84
+ class="px-3 py-1.5 text-sm rounded-md bg-white hover:bg-gray-100 text-gray-900 border border-gray-200"
85
+ @click="cancelGeneration"
86
+ >
87
+ {{ t('Cancel generation') }}
88
+ </button>
89
+ </div>
90
+ </div>
91
+ <div
92
+ class="w-full h-[30px] rounded-2xl bg-gray-200 dark:bg-gray-700 overflow-hidden relative"
93
+ :class="isGenerationPaused ? 'opacity-80' : ''"
94
+ role="progressbar"
95
+ :aria-valuenow="displayedProcessedCount"
96
+ :aria-valuemin="0"
97
+ :aria-valuemax="totalRecords"
98
+ >
99
+ <div
100
+ class="h-full bg-gradient-to-r from-purple-500 via-purple-600 to-purple-700 transition-all duration-200 "
101
+ :style="{ width: `${displayedProgressPercent}%` }"
102
+ ></div>
103
+ <div class="absolute inset-0 flex items-center justify-center text-sm font-medium text-white drop-shadow">
104
+ <template v-if="isProcessingAny || isGenerationPaused">
105
+ {{ Math.floor((displayedProcessedCount / totalRecords) * 100) }}%
106
+ </template>
107
+ <template v-else-if="isGenerationCancelled">
108
+ {{ t('Generation cancelled') }}
109
+ </template>
110
+ <template v-else>
111
+ {{ t('Processed') }}
112
+ </template>
113
+ </div>
114
+ </div>
115
+ </div>
116
+
117
+
71
118
  <VisionTable
72
- class="md:max-h-[75vh] max-w-[1560px] w-full h-full"
73
- :checkbox="props.checkboxes"
74
- :records="records"
119
+ class="md:max-h-[75vh] max-w-[1560px] w-full h-full"
120
+ ref="tableRef"
121
+ :records="recordsList"
75
122
  :meta="props.meta"
76
- :images="images"
77
123
  :tableHeaders="tableHeaders"
78
- :tableColumns="tableColumns"
79
124
  :customFieldNames="customFieldNames"
80
- :tableColumnsIndexes="tableColumnsIndexes"
81
- :selected="selected"
82
- :oldData="oldData"
83
125
  :isError="isError"
84
126
  :errorMessage="errorMessage"
85
- :isAiResponseReceivedAnalizeImage="isAiResponseReceivedAnalizeImage"
86
- :isAiResponseReceivedAnalizeNoImage="isAiResponseReceivedAnalizeNoImage"
87
- :isAiResponseReceivedImage="isAiResponseReceivedImage"
88
- :primaryKey="primaryKey"
89
- :openGenerationCarousel="openGenerationCarousel"
90
- :openImageCompare="openImageCompare"
91
- @error="handleTableError"
92
- :carouselSaveImages="carouselSaveImages"
93
- :carouselImageIndex="carouselImageIndex"
94
127
  :regenerateImagesRefreshRate="props.meta.refreshRates?.regenerateImages"
95
- :isAiGenerationError="isAiGenerationError"
96
- :aiGenerationErrorMessage="aiGenerationErrorMessage"
97
- :isAiImageGenerationError="isAiImageGenerationError"
98
- :imageGenerationErrorMessage="imageGenerationErrorMessage"
99
- @regenerate-images="regenerateImages"
100
128
  :isImageHasPreviewUrl="isImageHasPreviewUrl"
101
129
  :imageGenerationPrompts="generationPrompts.generateImages"
102
- :isImageToTextGenerationError="isImageToTextGenerationError"
103
- :imageToTextErrorMessages="imageToTextErrorMessages"
104
- :isTextToTextGenerationError="isTextToTextGenerationError"
105
- :textToTextErrorMessages="textToTextErrorMessages"
106
130
  :outputImageFields="props.meta.outputImageFields"
107
131
  :outputFieldsForAnalizeFromImages="props.meta.outputFieldsForAnalizeFromImages"
108
132
  :outputPlainFields="props.meta.outputPlainFields"
109
- :regeneratingFieldsStatus="regeneratingFieldsStatus"
133
+ @error="handleTableError"
134
+ @regenerate-images="regenerateImages"
110
135
  @regenerate-cell="regenerateCell"
111
136
  />
112
137
  <div class="text-red-600 flex items-center w-full">
@@ -165,8 +190,9 @@
165
190
 
166
191
  <script lang="ts" setup>
167
192
  import { callAdminForthApi } from '@/utils';
168
- import { Ref, ref, watch } from 'vue'
169
- import { Dialog, Button, Textarea, Toggle, Tooltip } from '@/afcl';
193
+ import { ref, computed, reactive } from 'vue'
194
+ import pLimit from 'p-limit';
195
+ import { Dialog, Textarea, Toggle, Tooltip } from '@/afcl';
170
196
  import VisionTable from './VisionTable.vue'
171
197
  import adminforth from '@/adminforth';
172
198
  import { useI18n } from 'vue-i18n';
@@ -174,9 +200,11 @@ import { AdminUser, type AdminForthResourceCommon } from '@/types/Common';
174
200
  import { useCoreStore } from '@/stores/core';
175
201
  import { IconShieldSolid, IconInfoCircleSolid } from '@iconify-prerendered/vue-flowbite';
176
202
  import { IconExclamationTriangle } from '@iconify-prerendered/vue-humbleicons';
203
+ import { useFiltersStore } from '@/stores/filters';
177
204
 
178
205
 
179
206
  const coreStore = useCoreStore();
207
+ const filtersStore = useFiltersStore();
180
208
 
181
209
  const { t } = useI18n();
182
210
  const props = defineProps<{
@@ -188,58 +216,110 @@ const props = defineProps<{
188
216
  clearCheckboxes?: () => any,
189
217
  }>();
190
218
 
219
+ type RecordStatus = 'pending' | 'processing' | 'completed' | 'failed';
220
+
221
+ type RecordState = {
222
+ id: string | number;
223
+ status: RecordStatus;
224
+ isChecked: boolean;
225
+ label: string;
226
+ data: Record<string, any>;
227
+ oldData: Record<string, any>;
228
+ images: any[];
229
+ aiStatus: {
230
+ generatedImages: boolean;
231
+ analyzedImages: boolean;
232
+ analyzedNoImages: boolean;
233
+ };
234
+ openGenerationCarousel: Record<string, boolean>;
235
+ openImageCompare: Record<string, boolean>;
236
+ carouselSaveImages: Record<string, any[]>;
237
+ carouselImageIndex: Record<string, number>;
238
+ imageGenerationErrorMessage: string;
239
+ imageGenerationFailed: boolean;
240
+ imageToTextErrorMessages: Record<string, string>;
241
+ textToTextErrorMessages: Record<string, string>;
242
+ regeneratingFieldsStatus: Record<string, boolean>;
243
+ listOfImageNotGenerated: Record<string, any>;
244
+ };
245
+
246
+ const recordIds = ref<Array<string | number>>([]);
247
+ const recordsById = new Map<string, RecordState>();
248
+ const uncheckedRecordIds = new Set<string>();
249
+
191
250
  defineExpose({
192
251
  click
193
252
  });
194
253
 
195
- const confirmDialog = ref(null);
196
- const records = ref<any[]>([]);
197
- const images = ref<any[]>([]);
198
- const tableHeaders = ref([]);
199
- const tableColumns = ref([]);
200
- const tableColumnsIndexes = ref([]);
201
- const customFieldNames = ref([]);
202
- const selected = ref<any[]>([]);
203
- const oldData = ref<any[]>([]);
204
- const carouselSaveImages = ref<any[]>([]);
205
- const carouselImageIndex = ref<any[]>([]);
206
- const isAiResponseReceivedAnalizeImage = ref([]);
207
- const isAiResponseReceivedAnalizeNoImage = ref([]);
208
- const isAiResponseReceivedImage = ref([]);
254
+ const confirmDialog = ref<any>(null);
209
255
  const primaryKey = props.meta.primaryKey;
210
- const openGenerationCarousel = ref([]);
211
- const openImageCompare = ref([]);
212
256
  const isLoading = ref(false);
213
257
  const isFetchingRecords = ref(false);
214
258
  const isError = ref(false);
215
- const isCriticalError = ref(false);
216
- const isImageGenerationError = ref(false);
217
259
  const errorMessage = ref('');
218
- const checkedCount = ref(0);
219
- const isGeneratingImages = ref(false);
220
- const isAnalizingFields = ref(false);
221
- const isAnalizingImages = ref(false);
222
260
  const isDialogOpen = ref(false);
223
- const isAiGenerationError = ref<boolean[]>([false]);
224
- const aiGenerationErrorMessage = ref<string[]>([]);
225
- const isAiImageGenerationError = ref<boolean[]>([false]);
226
-
227
- const isImageToTextGenerationError = ref<boolean[]>([false]);
228
- const imageToTextErrorMessages = ref<Record<string, string>[]>([]);
229
-
230
- const isTextToTextGenerationError = ref<boolean[]>([false]);
231
- const textToTextErrorMessages = ref<Record<string, string>[]>([]);
232
-
233
- const imageGenerationErrorMessage = ref<string[]>([]);
234
261
  const isImageHasPreviewUrl = ref<Record<string, boolean>>({});
235
262
  const popupMode = ref<'generation' | 'confirmation' | 'settings'>('confirmation');
236
263
  const generationPrompts = ref<any>({});
237
264
  const isDataSaved = ref(false);
238
-
239
- const regeneratingFieldsStatus = ref<Record<string, Record<string, boolean>>>({});
240
265
  const overwriteExistingValues = ref<boolean>(false);
241
266
 
242
- const listOfImageThatWasNotGeneratedPerRecord = ref<Record<string, string[]>>({});
267
+ const checkedCount = computed(() => recordIds.value.length - uncheckedRecordIds.size);
268
+ const totalRecords = computed(() => recordIds.value.length);
269
+ const isGenerationPaused = ref(false);
270
+ const isGenerationCancelled = ref(false);
271
+ const pendingResumeResolver = ref<null | (() => void)>(null);
272
+ const completedRecordIds = ref<Set<string>>(new Set());
273
+ const isActiveGeneration = ref(false);
274
+ const pauseBreakpoints = computed(() => props.meta.askConfirmation || []);
275
+ const startedRecordCount = ref(0);
276
+ let startGate = Promise.resolve();
277
+ const tableRef = ref<any>(null);
278
+ const processedCount = computed(() => {
279
+ recordsVersion.value;
280
+ return Array.from(recordsById.values()).filter(record => record.status === 'completed' || record.status === 'failed').length;
281
+ });
282
+ const progressStep = computed(() => {
283
+ if (!totalRecords.value || totalRecords.value < 100) {
284
+ return 1;
285
+ }
286
+ return Math.max(1, Math.floor(totalRecords.value / 100));
287
+ });
288
+ const displayedProcessedCount = computed(() => {
289
+ const step = progressStep.value;
290
+ if (step <= 1) {
291
+ return processedCount.value;
292
+ }
293
+ if (processedCount.value >= totalRecords.value) {
294
+ return totalRecords.value;
295
+ }
296
+ return Math.floor(processedCount.value / step) * step;
297
+ });
298
+ const displayedProgressPercent = computed(() => {
299
+ if (!totalRecords.value) {
300
+ return 0;
301
+ }
302
+ return Math.min(100, Math.round((displayedProcessedCount.value / totalRecords.value) * 100));
303
+ });
304
+ const isProcessingAny = computed(() => {
305
+ recordsVersion.value;
306
+ return Array.from(recordsById.values()).some(record => record.status === 'processing');
307
+ });
308
+
309
+ const tableHeaders = computed(() => generateTableHeaders(props.meta.outputFields));
310
+ const customFieldNames = computed(() => tableHeaders.value.slice((props.meta.isAttachFiles) ? 3 : 2).map(h => h.fieldName));
311
+ const recordsVersion = ref(0);
312
+ const recordsList = computed(() => {
313
+ recordsVersion.value;
314
+ const ids = isGenerationCancelled.value
315
+ ? recordIds.value.filter(id => completedRecordIds.value.has(String(id)))
316
+ : recordIds.value;
317
+ return ids.map(id => getOrCreateRecord(id));
318
+ });
319
+
320
+ function checkIfDialogOpen() {
321
+ return isDialogOpen.value === true;
322
+ }
243
323
 
244
324
  const openDialog = async () => {
245
325
  window.addEventListener('beforeunload', beforeUnloadHandler);
@@ -251,32 +331,9 @@ const openDialog = async () => {
251
331
  isDialogOpen.value = true;
252
332
  confirmDialog.value.open();
253
333
  isFetchingRecords.value = true;
254
- await getRecords();
255
- if (props.meta.isAttachFiles) {
256
- await getImages();
257
- }
334
+ await initializeGlobalState();
258
335
  await findPreviewURLForImages();
259
- tableHeaders.value = generateTableHeaders(props.meta.outputFields);
260
- const result = generateTableColumns();
261
- tableColumns.value = result.tableData;
262
- tableColumnsIndexes.value = result.indexes;
263
- customFieldNames.value = tableHeaders.value.slice((props.meta.isAttachFiles) ? 3 : 2).map(h => h.fieldName);
264
- setSelected();
265
- if (props.meta.isImageGeneration) {
266
- fillCarouselSaveImages();
267
- }
268
- for (let i = 0; i < selected.value?.length; i++) {
269
- openGenerationCarousel.value[i] = props.meta.outputImageFields?.reduce((acc,key) =>{
270
- acc[key] = false;
271
- return acc;
272
- },{[primaryKey]: records.value[i][primaryKey]} as Record<string, boolean>);
273
- openImageCompare.value[i] = props.meta.outputImageFields?.reduce((acc,key) =>{
274
- acc[key] = false;
275
- return acc;
276
- },{[primaryKey]: records.value[i][primaryKey]} as Record<string, boolean>);
277
- }
278
336
  isFetchingRecords.value = false;
279
- // Ensure prompts are loaded before any automatic AI action run
280
337
  if (!generationPrompts.value || Object.keys(generationPrompts.value).length === 0) {
281
338
  await getGenerationPrompts();
282
339
  }
@@ -284,196 +341,544 @@ const openDialog = async () => {
284
341
  runAiActions();
285
342
  }
286
343
  }
344
+
345
+ async function getListOfIds() {
346
+ if ( props.meta.recordSelector === 'filtered' ) {
347
+ const filters = filtersStore.getFilters(props.resource.resourceId);
348
+ let res;
349
+ try {
350
+ res = await callAdminForthApi({
351
+ path: `/plugin/${props.meta.pluginInstanceId}/get_filtered_ids`,
352
+ method: 'POST',
353
+ body: { filters },
354
+ silentError: true,
355
+ });
356
+ } catch (e) {
357
+ console.error('Failed to get records for filtered selector:', e);
358
+ isError.value = true;
359
+ errorMessage.value = t(`Failed to fetch records. Please, try to re-run the action.`);
360
+ return [];
361
+ }
362
+ if (!res?.ok || !res?.recordIds) {
363
+ console.error('Failed to get records for filtered selector, response error:', res);
364
+ isError.value = true;
365
+ errorMessage.value = t(`Failed to fetch records. Please, try to re-run the action.`);
366
+ return [];
367
+ }
368
+ return res.recordIds;
369
+ } else {
370
+ return props.checkboxes;
371
+ }
372
+ }
287
373
 
288
374
 
289
- function runAiActions() {
375
+ async function runAiActions() {
290
376
  popupMode.value = 'generation';
291
-
292
- if (props.meta.isImageGeneration) {
293
- isGeneratingImages.value = true;
294
- runAiAction({
295
- endpoint: 'initial_image_generate',
296
- actionType: 'generate_images',
297
- responseFlag: isAiResponseReceivedImage,
298
- });
299
- }
300
- if (props.meta.isFieldsForAnalizeFromImages) {
301
- isAnalizingImages.value = true;
302
- runAiAction({
303
- endpoint: 'analyze',
304
- actionType: 'analyze',
305
- responseFlag: isAiResponseReceivedAnalizeImage,
306
- });
307
- }
308
- if (props.meta.isFieldsForAnalizePlain) {
309
- isAnalizingFields.value = true;
310
- runAiAction({
311
- endpoint: 'analyze_no_images',
312
- actionType: 'analyze_no_images',
313
- responseFlag: isAiResponseReceivedAnalizeNoImage,
314
- });
377
+ if (!await checkRateLimits()) {
378
+ return;
315
379
  }
380
+ isGenerationCancelled.value = false;
381
+ isGenerationPaused.value = false;
382
+ isActiveGeneration.value = true;
383
+ completedRecordIds.value = new Set();
384
+ startedRecordCount.value = 0;
385
+ const limit = pLimit(props.meta.concurrencyLimit || 10);
386
+ const tasks = recordIds.value
387
+ .map(id => limit(() => processOneRecord(String(id))));
388
+ await Promise.all(tasks);
389
+ isActiveGeneration.value = false;
316
390
  }
317
391
 
318
392
  const closeDialog = () => {
319
393
  window.removeEventListener('beforeunload', beforeUnloadHandler);
320
- isAiResponseReceivedAnalizeImage.value = [];
321
- isAiResponseReceivedAnalizeNoImage.value = [];
322
- isAiResponseReceivedImage.value = [];
323
-
324
- imageToTextErrorMessages.value = [];
325
- textToTextErrorMessages.value = [];
326
- imageGenerationErrorMessage.value = [];
327
- regeneratingFieldsStatus.value = {};
328
-
329
- records.value = [];
330
- images.value = [];
331
- selected.value = [];
332
- tableColumns.value = [];
333
- tableColumnsIndexes.value = [];
394
+ resetGlobalState();
334
395
  isError.value = false;
335
- isCriticalError.value = false;
336
- isImageGenerationError.value = false;
337
396
  errorMessage.value = '';
338
397
  isDialogOpen.value = false;
339
398
  popupMode.value = 'confirmation';
340
399
  isDataSaved.value = false;
341
400
  }
342
401
 
343
- watch(selected, (val) => {
344
- checkedCount.value = val.filter(item => item.isChecked === true).length;
345
- }, { deep: true });
402
+ async function initializeGlobalState() {
403
+ const ids = await getListOfIds();
404
+ recordIds.value = ids;
405
+ recordsById.clear();
406
+ uncheckedRecordIds.clear();
407
+ }
346
408
 
347
- function fillCarouselSaveImages() {
348
- for (const item of selected.value) {
349
- const tempItem: any = {};
350
- const tempItemIndex: any = {};
351
- for (const [key, value] of Object.entries(item)) {
352
- if (props.meta.outputImageFields?.includes(key)) {
353
- tempItem[key] = [];
354
- tempItemIndex[key] = 0;
355
- }
409
+ function resetGlobalState() {
410
+ recordIds.value = [];
411
+ recordsById.clear();
412
+ uncheckedRecordIds.clear();
413
+ }
414
+
415
+ function getOrCreateRecord(recordId: string | number): RecordState {
416
+ const key = String(recordId);
417
+ let record = recordsById.get(key);
418
+ if (!record) {
419
+ record = createEmptyRecord(recordId);
420
+ record.isChecked = !uncheckedRecordIds.has(key);
421
+ recordsById.set(key, record);
422
+ }
423
+ return record;
424
+ }
425
+
426
+ function touchRecords() {
427
+ recordsVersion.value += 1;
428
+ }
429
+
430
+ function waitForResumeIfPaused() {
431
+ if (!isGenerationPaused.value) {
432
+ return Promise.resolve();
433
+ }
434
+ return new Promise<void>(resolve => {
435
+ pendingResumeResolver.value = resolve;
436
+ });
437
+ }
438
+
439
+ function resolvePause() {
440
+ if (pendingResumeResolver.value) {
441
+ pendingResumeResolver.value();
442
+ pendingResumeResolver.value = null;
443
+ }
444
+ }
445
+
446
+ function shouldPauseAfterRecords(processed: number) {
447
+ if (!pauseBreakpoints.value?.length) {
448
+ return false;
449
+ }
450
+ return pauseBreakpoints.value.some((rule: any) => {
451
+ if (typeof rule?.afterRecords === 'number' && processed === rule.afterRecords) {
452
+ return true;
356
453
  }
357
- carouselSaveImages.value.push(tempItem);
358
- carouselImageIndex.value.push(tempItemIndex);
454
+ if (typeof rule?.everyRecords === 'number' && rule.everyRecords > 0 && processed % rule.everyRecords === 0) {
455
+ return true;
456
+ }
457
+ return false;
458
+ });
459
+ }
460
+
461
+ function resumeGeneration() {
462
+ if (!isGenerationPaused.value) {
463
+ return;
359
464
  }
465
+ isGenerationPaused.value = false;
466
+ resolvePause();
360
467
  }
361
468
 
469
+ function cancelGeneration() {
470
+ if (isGenerationCancelled.value) {
471
+ return;
472
+ }
473
+ isGenerationCancelled.value = true;
474
+ isGenerationPaused.value = false;
475
+ resolvePause();
476
+ const generatedIds = new Set(completedRecordIds.value);
477
+ recordIds.value = recordIds.value.filter(id => generatedIds.has(String(id)));
478
+ for (const key of Array.from(recordsById.keys())) {
479
+ if (!generatedIds.has(key)) {
480
+ recordsById.delete(key);
481
+ }
482
+ }
483
+ for (const key of Array.from(uncheckedRecordIds)) {
484
+ if (!generatedIds.has(key)) {
485
+ uncheckedRecordIds.delete(key);
486
+ }
487
+ }
488
+ touchRecords();
489
+ tableRef.value?.refresh();
490
+ }
362
491
 
363
- function formatLabel(str) {
364
- const labelsMap = props.meta?.columnLabels || {};
365
- let labelFromMeta = labelsMap[str];
366
- if (!labelFromMeta) {
367
- const match = Object.keys(labelsMap).find(k => k.toLowerCase() === String(str).toLowerCase());
368
- if (match) labelFromMeta = labelsMap[match];
492
+ async function withStartGate<T>(fn: () => Promise<T>) {
493
+ const previousGate = startGate;
494
+ let releaseGate: () => void;
495
+ startGate = new Promise<void>(resolve => {
496
+ releaseGate = resolve;
497
+ });
498
+ await previousGate;
499
+ try {
500
+ return await fn();
501
+ } finally {
502
+ releaseGate!();
369
503
  }
370
- if (labelFromMeta) return labelFromMeta;
371
- return str
372
- .split('_')
373
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
374
- .join(' ');
375
504
  }
376
505
 
377
- function generateTableHeaders(outputFields) {
378
- const headers = [];
506
+ function createImageFieldMap<T>(factory: () => T): Record<string, T> {
507
+ const result: Record<string, T> = {};
508
+ for (const field of props.meta.outputImageFields || []) {
509
+ result[field] = factory();
510
+ }
511
+ return result;
512
+ }
379
513
 
380
- headers.push({ label: 'Checkboxes', fieldName: 'checkboxes' });
381
- headers.push({ label: 'Field name', fieldName: 'label' });
514
+ function createEmptyRecord(recordId: string | number): RecordState {
515
+ return {
516
+ id: recordId,
517
+ status: 'pending',
518
+ isChecked: true,
519
+ label: '',
520
+ data: {},
521
+ oldData: {},
522
+ images: [],
523
+ aiStatus: {
524
+ generatedImages: !props.meta.isImageGeneration,
525
+ analyzedImages: !props.meta.isFieldsForAnalizeFromImages,
526
+ analyzedNoImages: !props.meta.isFieldsForAnalizePlain,
527
+ },
528
+ openGenerationCarousel: createImageFieldMap(() => false),
529
+ openImageCompare: createImageFieldMap(() => false),
530
+ carouselSaveImages: createImageFieldMap(() => []),
531
+ carouselImageIndex: createImageFieldMap(() => 0),
532
+ imageGenerationErrorMessage: '',
533
+ imageGenerationFailed: false,
534
+ imageToTextErrorMessages: {},
535
+ textToTextErrorMessages: {},
536
+ regeneratingFieldsStatus: {},
537
+ listOfImageNotGenerated: {},
538
+ };
539
+ }
540
+
541
+ async function processOneRecord(recordId: string) {
542
+ if (!checkIfDialogOpen()) {
543
+ return;
544
+ }
545
+ if (isGenerationCancelled.value) {
546
+ return;
547
+ }
548
+ await withStartGate(async () => {
549
+ while (true) {
550
+ if (!checkIfDialogOpen() || isGenerationCancelled.value) {
551
+ return;
552
+ }
553
+ if (isGenerationPaused.value) {
554
+ await waitForResumeIfPaused();
555
+ continue;
556
+ }
557
+ const nextStarted = startedRecordCount.value + 1;
558
+ startedRecordCount.value = nextStarted;
559
+ if (isActiveGeneration.value && shouldPauseAfterRecords(nextStarted)) {
560
+ isGenerationPaused.value = true;
561
+ }
562
+ break;
563
+ }
564
+ });
565
+ if (!checkIfDialogOpen() || isGenerationCancelled.value) {
566
+ return;
567
+ }
568
+ const record = getOrCreateRecord(recordId);
569
+ if (!record || !record.isChecked) {
570
+ return;
571
+ }
572
+ record.status = 'processing';
573
+ touchRecords();
574
+ record.imageGenerationFailed = false;
575
+ record.imageGenerationErrorMessage = '';
576
+ record.imageToTextErrorMessages = {};
577
+ record.textToTextErrorMessages = {};
578
+ record.aiStatus.generatedImages = !props.meta.isImageGeneration;
579
+ record.aiStatus.analyzedImages = !props.meta.isFieldsForAnalizeFromImages;
580
+ record.aiStatus.analyzedNoImages = !props.meta.isFieldsForAnalizePlain;
581
+
582
+ const oldDataResult = await fetchOldData(recordId);
583
+ if (!checkIfDialogOpen()) {
584
+ return;
585
+ }
586
+ if (!oldDataResult) {
587
+ record.status = 'failed';
588
+ record.aiStatus.generatedImages = true;
589
+ record.aiStatus.analyzedImages = true;
590
+ record.aiStatus.analyzedNoImages = true;
591
+ touchRecords();
592
+ return;
593
+ }
594
+ record.label = oldDataResult._label || record.label;
595
+ initializeRecordData(record, oldDataResult);
382
596
  if (props.meta.isAttachFiles) {
383
- headers.push({ label: 'Source Images', fieldName: 'images' });
597
+ await fetchImages(record, oldDataResult);
598
+ if (!checkIfDialogOpen()) {
599
+ return;
600
+ }
384
601
  }
385
- for (const key in outputFields) {
386
- headers.push({
387
- label: formatLabel(key),
388
- fieldName: key,
389
- });
602
+
603
+ const actions: Array<'generate_images' | 'analyze' | 'analyze_no_images'> = [];
604
+ if (props.meta.isImageGeneration) {
605
+ actions.push('generate_images');
390
606
  }
391
- return headers;
607
+ if (props.meta.isFieldsForAnalizeFromImages) {
608
+ actions.push('analyze');
609
+ }
610
+ if (props.meta.isFieldsForAnalizePlain) {
611
+ actions.push('analyze_no_images');
612
+ }
613
+
614
+ const results = await Promise.allSettled(actions.map(actionType => runActionForRecord(record, actionType)));
615
+ if (!checkIfDialogOpen()) {
616
+ return;
617
+ }
618
+ const hasError = results.some(result => result.status === 'rejected');
619
+ record.status = hasError ? 'failed' : 'completed';
620
+ completedRecordIds.value.add(String(recordId));
621
+ touchRecords();
622
+ }
623
+
624
+ async function checkRateLimits() {
625
+ const actionsToCheck: Array<'generate_images' | 'analyze' | 'analyze_no_images'> = [];
626
+ if (props.meta.isImageGeneration) {
627
+ actionsToCheck.push('generate_images');
628
+ }
629
+ if (props.meta.isFieldsForAnalizeFromImages) {
630
+ actionsToCheck.push('analyze');
631
+ }
632
+ if (props.meta.isFieldsForAnalizePlain) {
633
+ actionsToCheck.push('analyze_no_images');
634
+ }
635
+ for (const actionType of actionsToCheck) {
636
+ try {
637
+ const rateLimitRes = await callAdminForthApi({
638
+ path: `/plugin/${props.meta.pluginInstanceId}/update-rate-limits`,
639
+ method: 'POST',
640
+ body: { actionType },
641
+ });
642
+ if (rateLimitRes?.error || rateLimitRes?.ok === false) {
643
+ adminforth.alert({
644
+ message: t(`Rate limit exceeded for "${actionType.replace('_', ' ')}" action. Please try again later.`),
645
+ variant: 'danger',
646
+ timeout: 'unlimited',
647
+ });
648
+ return false;
649
+ }
650
+ } catch (e) {
651
+ adminforth.alert({
652
+ message: t(`Error checking rate limit for "${actionType.replace('_', ' ')}" action.`),
653
+ variant: 'danger',
654
+ timeout: 'unlimited',
655
+ });
656
+ return false;
657
+ }
658
+ }
659
+ return true;
392
660
  }
393
661
 
394
- function generateTableColumns() {
395
- const fields = [];
396
- const tableData = [];
397
- const indexes = [];
398
- for (const field of tableHeaders.value) {
399
- fields.push( field.fieldName );
400
- }
401
- for (const [index, checkbox] of props.checkboxes.entries()) {
402
- const record = records.value[index];
403
- let reqFields: any = {};
404
- for (const field of fields) {
405
- reqFields[field] = record[field] || '';
662
+ async function runActionForRecord(record: RecordState, actionType: 'analyze' | 'analyze_no_images' | 'generate_images') {
663
+ if (!checkIfDialogOpen()) {
664
+ return;
665
+ }
666
+ const responseFlag = actionType === 'generate_images'
667
+ ? 'generatedImages'
668
+ : actionType === 'analyze'
669
+ ? 'analyzedImages'
670
+ : 'analyzedNoImages';
671
+ record.aiStatus[responseFlag] = false;
672
+
673
+ let customPrompt;
674
+ if (actionType === 'generate_images') {
675
+ customPrompt = generationPrompts.value.imageGenerationPrompts || generationPrompts.value.generateImages;
676
+ } else if (actionType === 'analyze') {
677
+ customPrompt = generationPrompts.value.imageFieldsPrompts;
678
+ } else if (actionType === 'analyze_no_images') {
679
+ customPrompt = generationPrompts.value.plainFieldsPrompts;
680
+ }
681
+
682
+ let createJobResponse;
683
+ try {
684
+ if (!checkIfDialogOpen()) {
685
+ return;
406
686
  }
407
- reqFields.label = record._label;
408
- reqFields.images = images.value[index];
409
- reqFields[primaryKey] = record[primaryKey];
410
- indexes.push({
411
- [primaryKey]: record[primaryKey],
412
- label: record._label,
687
+ createJobResponse = await callAdminForthApi({
688
+ path: `/plugin/${props.meta.pluginInstanceId}/create-job`,
689
+ method: 'POST',
690
+ body: {
691
+ actionType,
692
+ recordId: record.id,
693
+ ...(customPrompt !== undefined ? { customPrompt: JSON.stringify(customPrompt) } : {}),
694
+ filterFilledFields: !overwriteExistingValues.value,
695
+ },
696
+ silentError: true,
697
+ });
698
+ } catch (e) {
699
+ record.aiStatus[responseFlag] = true;
700
+ throw e;
701
+ }
702
+
703
+ if (!checkIfDialogOpen()) {
704
+ return;
705
+ }
706
+
707
+ if (createJobResponse?.error || !createJobResponse?.jobId) {
708
+ record.aiStatus[responseFlag] = true;
709
+ adminforth.alert({
710
+ message: t(`Failed to ${actionType.replace('_', ' ')}. Please, try to re-run the action.`),
711
+ variant: 'danger',
712
+ timeout: 'unlimited',
413
713
  });
414
- tableData.push(reqFields);
714
+ throw new Error(createJobResponse?.error || 'Failed to create job');
415
715
  }
416
- return { tableData, indexes };
716
+
717
+ await pollJob(record, createJobResponse.jobId, actionType, responseFlag);
417
718
  }
418
719
 
419
- function setSelected() {
420
- selected.value = records.value.map(() => ({}));
421
- records.value.forEach((record, index) => {
422
- for (const key in props.meta.outputFields) {
423
- if (isInColumnEnum(key)) {
424
- const colEnum = props.meta.columnEnums.find(c => c.name === key);
425
- const object = colEnum.enum.find(item => item.value === record[key]);
426
- selected.value[index][key] = object ? record[key] : null;
427
- } else {
428
- selected.value[index][key] = record[key];
720
+ async function pollJob(
721
+ record: RecordState,
722
+ jobId: string,
723
+ actionType: 'analyze' | 'analyze_no_images' | 'generate_images',
724
+ responseFlag: keyof RecordState['aiStatus']
725
+ ) {
726
+ let isInProgress = true;
727
+ while (isInProgress && isDialogOpen.value) {
728
+ const jobResponse = await callAdminForthApi({
729
+ path: `/plugin/${props.meta.pluginInstanceId}/get-job-status`,
730
+ method: 'POST',
731
+ body: { jobId },
732
+ silentError: true,
733
+ });
734
+ if (!jobResponse) {
735
+ await waitForRefresh(actionType);
736
+ continue;
737
+ }
738
+ if (jobResponse?.error) {
739
+ record.aiStatus[responseFlag] = true;
740
+ throw new Error(jobResponse.error);
741
+ }
742
+ const jobStatus = jobResponse?.job?.status;
743
+ if (jobStatus === 'in_progress') {
744
+ await waitForRefresh(actionType);
745
+ continue;
746
+ }
747
+ if (jobStatus === 'completed') {
748
+ applyJobResult(record, jobResponse.job, actionType);
749
+ record.aiStatus[responseFlag] = true;
750
+ isInProgress = false;
751
+ continue;
752
+ }
753
+ if (jobStatus === 'failed') {
754
+ applyJobFailure(record, jobResponse.job, actionType);
755
+ record.aiStatus[responseFlag] = true;
756
+ throw new Error(jobResponse.job?.error || 'Job failed');
757
+ }
758
+ }
759
+ }
760
+
761
+ function applyJobResult(record: RecordState, job: any, actionType: 'analyze' | 'analyze_no_images' | 'generate_images') {
762
+ if (actionType === 'generate_images') {
763
+ for (const fieldName of props.meta.outputImageFields || []) {
764
+ const resultValue = job?.result?.[fieldName];
765
+ if (resultValue !== undefined) {
766
+ record.data[fieldName] = resultValue;
767
+ }
768
+ record.carouselSaveImages[fieldName] = resultValue ? [resultValue] : [];
769
+ if (job?.recordMeta?.[`${fieldName}_meta`]) {
770
+ record.carouselSaveImages[fieldName] = [job.recordMeta[`${fieldName}_meta`].originalImage];
771
+ record.listOfImageNotGenerated[fieldName] = job.recordMeta[`${fieldName}_meta`];
429
772
  }
430
773
  }
431
- selected.value[index].isChecked = true;
432
- selected.value[index][primaryKey] = record[primaryKey];
433
- oldData.value[index] = { ...selected.value[index] };
434
- });
774
+ } else {
775
+ record.data = {
776
+ ...record.data,
777
+ ...(job?.result || {}),
778
+ };
779
+ }
780
+ touchRecords();
435
781
  }
436
782
 
437
- function isInColumnEnum(key: string): boolean {
438
- const colEnum = props.meta.columnEnums?.find(c => c.name === key);
439
- if (!colEnum) {
440
- return false;
783
+ function applyJobFailure(record: RecordState, job: any, actionType: 'analyze' | 'analyze_no_images' | 'generate_images') {
784
+ adminforth.alert({
785
+ message: t(`Generation action "${actionType.replace('_', ' ')}" failed for record: ${record.id}. Error: ${job?.error || 'Unknown error'}`),
786
+ variant: 'danger',
787
+ timeout: 'unlimited',
788
+ });
789
+ if (actionType === 'generate_images') {
790
+ record.imageGenerationFailed = true;
791
+ record.imageGenerationErrorMessage = job?.error || 'Unknown error';
792
+ } else if (actionType === 'analyze') {
793
+ for (const field of Object.keys(props.meta.outputFieldsForAnalizeFromImages || {})) {
794
+ record.imageToTextErrorMessages[props.meta.outputFieldsForAnalizeFromImages[field]] = job?.error || 'Unknown error';
795
+ }
796
+ } else if (actionType === 'analyze_no_images') {
797
+ for (const field of Object.keys(props.meta.outputPlainFields || {})) {
798
+ record.textToTextErrorMessages[props.meta.outputPlainFields[field]] = job?.error || 'Unknown error';
799
+ }
441
800
  }
442
- return true;
801
+ touchRecords();
443
802
  }
444
803
 
445
- function handleTableError(errorData) {
446
- isError.value = errorData.isError;
447
- errorMessage.value = errorData.errorMessage;
804
+ async function waitForRefresh(actionType: 'analyze' | 'analyze_no_images' | 'generate_images') {
805
+ if (actionType === 'generate_images') {
806
+ await new Promise(resolve => setTimeout(resolve, props.meta.refreshRates?.generateImages));
807
+ } else if (actionType === 'analyze') {
808
+ await new Promise(resolve => setTimeout(resolve, props.meta.refreshRates?.fillFieldsFromImages));
809
+ } else if (actionType === 'analyze_no_images') {
810
+ await new Promise(resolve => setTimeout(resolve, props.meta.refreshRates?.fillPlainFields));
811
+ }
448
812
  }
449
813
 
450
- async function getRecords() {
814
+ async function fetchOldData(recordId: string) {
451
815
  try {
452
816
  const res = await callAdminForthApi({
453
- path: `/plugin/${props.meta.pluginInstanceId}/get_records`,
817
+ path: `/plugin/${props.meta.pluginInstanceId}/get_old_data`,
454
818
  method: 'POST',
455
- body: {
456
- record: props.checkboxes,
457
- },
819
+ body: { recordId },
820
+ silentError: true,
458
821
  });
459
- records.value = res.records;
822
+ if (!res?.ok || !res?.record) {
823
+ adminforth.alert({
824
+ message: res?.error || t('Failed to fetch old data. Please, try to re-run the action.'),
825
+ variant: 'danger',
826
+ timeout: 'unlimited',
827
+ });
828
+ return null;
829
+ }
830
+ return res.record;
460
831
  } catch (error) {
461
- console.error('Failed to get records:', error);
832
+ console.error('Failed to get old record:', error);
462
833
  isError.value = true;
463
834
  errorMessage.value = t(`Failed to fetch records. Please, try to re-run the action.`);
835
+ return null;
464
836
  }
465
837
  }
466
838
 
467
- async function getImages() {
839
+ function initializeRecordData(record: RecordState, oldRecord: Record<string, any>) {
840
+ const newData: Record<string, any> = {};
841
+ const newOldData: Record<string, any> = {};
842
+ for (const key in props.meta.outputFields || {}) {
843
+ const normalizedValue = normalizeEnumValue(key, oldRecord[key] ?? null);
844
+ newData[key] = normalizedValue;
845
+ newOldData[key] = normalizedValue;
846
+ }
847
+ if (props.meta.outputImageFields) {
848
+ for (const key of props.meta.outputImageFields) {
849
+ const normalizedValue = normalizeEnumValue(key, oldRecord[key] ?? null);
850
+ newData[key] = normalizedValue;
851
+ newOldData[key] = normalizedValue;
852
+ }
853
+ }
854
+ newData[primaryKey] = oldRecord[primaryKey] ?? record.id;
855
+ newOldData[primaryKey] = oldRecord[primaryKey] ?? record.id;
856
+ newOldData._label = oldRecord._label;
857
+ record.data = newData;
858
+ record.oldData = newOldData;
859
+ touchRecords();
860
+ }
861
+
862
+ function normalizeEnumValue(key: string, value: any) {
863
+ const colEnum = props.meta.columnEnums?.find(c => c.name === key);
864
+ if (!colEnum) {
865
+ return value;
866
+ }
867
+ const match = colEnum.enum.find(item => item.value === value);
868
+ return match ? value : null;
869
+ }
870
+
871
+ async function fetchImages(record: RecordState, oldRecord: Record<string, any>) {
468
872
  try {
469
873
  const res = await callAdminForthApi({
470
874
  path: `/plugin/${props.meta.pluginInstanceId}/get_images`,
471
875
  method: 'POST',
472
876
  body: {
473
- record: records.value,
877
+ record: [oldRecord],
474
878
  },
475
879
  });
476
- images.value = res.images;
880
+ record.images = res.images?.[0] || [];
881
+ touchRecords();
477
882
  } catch (error) {
478
883
  console.error('Failed to get images:', error);
479
884
  isError.value = true;
@@ -481,33 +886,66 @@ async function getImages() {
481
886
  }
482
887
  }
483
888
 
484
- async function prepareDataForSave() {
485
- const checkedItems = selected.value
486
- .filter(item => item.isChecked === true)
487
- .map(item => {
488
- const { isChecked, primaryKey, ...itemWithoutIsCheckedAndId } = item;
489
- return itemWithoutIsCheckedAndId;
889
+ function formatLabel(str) {
890
+ const labelsMap = props.meta?.columnLabels || {};
891
+ let labelFromMeta = labelsMap[str];
892
+ if (!labelFromMeta) {
893
+ const match = Object.keys(labelsMap).find(k => k.toLowerCase() === String(str).toLowerCase());
894
+ if (match) labelFromMeta = labelsMap[match];
895
+ }
896
+ if (labelFromMeta) return labelFromMeta;
897
+ return str
898
+ .split('_')
899
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
900
+ .join(' ');
901
+ }
902
+
903
+ function generateTableHeaders(outputFields) {
904
+ const headers = [];
905
+
906
+ headers.push({ label: 'Checkboxes', fieldName: 'checkboxes' });
907
+ headers.push({ label: 'Field name', fieldName: 'label' });
908
+ if (props.meta.isAttachFiles) {
909
+ headers.push({ label: 'Source Images', fieldName: 'images' });
910
+ }
911
+ for (const key in outputFields) {
912
+ headers.push({
913
+ label: formatLabel(key),
914
+ fieldName: key,
490
915
  });
491
- const checkedItemsIDs = selected.value
492
- .filter(item => item.isChecked === true)
493
- .map(item => item[primaryKey]);
494
-
495
- if (isImageGenerationError.value !== true) {
496
- const promises = [];
497
- for (const item of checkedItems) {
498
- for (const [key, value] of Object.entries(item)) {
499
- if(props.meta.outputImageFields?.includes(key)) {
500
- const p = convertImages(key, value).then(result => {
501
- item[key] = result;
502
- });
503
-
504
- promises.push(p);
505
- }
916
+ }
917
+ return headers;
918
+ }
919
+ function handleTableError(errorData) {
920
+ isError.value = errorData.isError;
921
+ errorMessage.value = errorData.errorMessage;
922
+ }
923
+ async function prepareDataForSave() {
924
+ const checkedRecords = recordIds.value
925
+ .map(id => getOrCreateRecord(id))
926
+ .filter(record => record.isChecked === true);
927
+ const checkedItems = checkedRecords.map(record => ({
928
+ ...record.data,
929
+ [primaryKey]: record.id,
930
+ }));
931
+
932
+ const promises: Promise<void>[] = [];
933
+ checkedRecords.forEach((record, index) => {
934
+ if (record.imageGenerationFailed) {
935
+ return;
936
+ }
937
+ for (const [key, value] of Object.entries(checkedItems[index])) {
938
+ if (props.meta.outputImageFields?.includes(key)) {
939
+ const p = convertImages(key, value).then(result => {
940
+ checkedItems[index][key] = result;
941
+ });
942
+ promises.push(p);
506
943
  }
507
944
  }
508
- await Promise.all(promises);
509
- }
510
- return [checkedItemsIDs, checkedItems];
945
+ });
946
+
947
+ await Promise.all(promises);
948
+ return [checkedItems, checkedRecords] as const;
511
949
  }
512
950
 
513
951
  async function convertImages(fieldName, img) {
@@ -532,36 +970,34 @@ async function convertImages(fieldName, img) {
532
970
 
533
971
 
534
972
  async function saveData() {
535
- if (!selected.value?.length) {
536
- adminforth.alert({ message: t('No items selected'), variant: 'warning' });
537
- return;
538
- }
973
+ const errorText = 'Failed to save some records. Not all data may be saved'
539
974
  try {
540
975
  isLoading.value = true;
541
- const [checkedItemsIDs, reqData] = await prepareDataForSave();
542
- if (isImageGenerationError.value === false) {
976
+ const [reqData, checkedRecords] = await prepareDataForSave();
977
+ if (checkedRecords.length < 1) {
978
+ adminforth.alert({ message: t('No items selected'), variant: 'warning' });
979
+ return;
980
+ }
981
+ if (!checkedRecords.some(record => record.imageGenerationFailed)) {
543
982
  const imagesToUpload = [];
544
- for (const item of reqData) {
983
+ for (let index = 0; index < reqData.length; index++) {
984
+ const item = reqData[index];
985
+ const record = checkedRecords[index];
545
986
  for (const [key, value] of Object.entries(item)) {
546
- if(props.meta.outputImageFields?.includes(key)) {
987
+ if (props.meta.outputImageFields?.includes(key)) {
547
988
  if (!value) {
548
989
  continue;
549
990
  }
550
991
  if (!overwriteExistingValues.value) {
551
- const imageURL = selected.value.find(rec => rec[primaryKey] === item[primaryKey])[key];
552
- let originalImageUrl = ''
553
- try {
554
- originalImageUrl = listOfImageThatWasNotGeneratedPerRecord.value[item[primaryKey]][key].originalImage;
555
- } catch (error) {
556
- originalImageUrl = '';
557
- }
992
+ const imageURL = record.data[key];
993
+ const originalImageUrl = record.listOfImageNotGenerated?.[key]?.originalImage || '';
558
994
  if (originalImageUrl === imageURL) {
559
- reqData.find(rec => rec[primaryKey] === item[primaryKey])[key] = undefined;
995
+ reqData[index][key] = undefined;
560
996
  continue;
561
997
  }
562
998
  }
563
- const p = uploadImage(value, item[primaryKey], key).then(result => {
564
- item[key] = result;
999
+ const p = uploadImage(value, record.id, key).then(result => {
1000
+ reqData[index][key] = result;
565
1001
  });
566
1002
  imagesToUpload.push(p);
567
1003
  }
@@ -569,287 +1005,53 @@ async function saveData() {
569
1005
  }
570
1006
  await Promise.all(imagesToUpload);
571
1007
  }
572
- const res = await callAdminForthApi({
573
- path: `/plugin/${props.meta.pluginInstanceId}/update_fields`,
574
- method: 'POST',
575
- body: {
576
- selectedIds: checkedItemsIDs,
577
- fields: reqData,
578
- saveImages: !isImageGenerationError.value
579
- },
580
- });
1008
+ const limit = pLimit(props.meta.concurrencyLimit || 10);
1009
+ const saveTasks = checkedRecords.map((record, index) =>
1010
+ limit(async () => {
1011
+ return await callAdminForthApi({
1012
+ path: `/plugin/${props.meta.pluginInstanceId}/update_fields`,
1013
+ method: 'POST',
1014
+ body: {
1015
+ selectedIds: [record.id],
1016
+ fields: [reqData[index]],
1017
+ saveImages: !record.imageGenerationFailed,
1018
+ },
1019
+ });
1020
+ })
1021
+ );
581
1022
 
582
- if(res.ok === true) {
583
- confirmDialog.value.close();
584
- props.updateList();
585
- props.clearCheckboxes();
586
- } else if (res.ok === false) {
587
- adminforth.alert({
588
- message: res.error,
589
- variant: 'danger',
590
- timeout: 'unlimited',
1023
+ const saveResults = await Promise.all(saveTasks);
1024
+ const failedResult = saveResults.find(res => res?.ok === false || res?.error);
1025
+
1026
+ if (failedResult && failedResult.ok === false) {
1027
+ saveResults.filter(res => res?.ok === false).forEach(res => {
1028
+ adminforth.alert({
1029
+ message: res.error || t(errorText),
1030
+ variant: 'danger',
1031
+ timeout: 'unlimited',
1032
+ });
1033
+ console.error('Error saving data:', res.error);
591
1034
  });
592
1035
  isError.value = true;
593
- errorMessage.value = t(`Failed to save data. You are not allowed to save.`);
594
- } else {
595
- console.error('Error saving data:', res);
1036
+ errorMessage.value = t(errorText);
1037
+ } else if ( failedResult ) {
1038
+ console.error('Error saving data:', failedResult);
596
1039
  isError.value = true;
597
- errorMessage.value = t(`Failed to save data. Please, try to re-run the action.`);
1040
+ errorMessage.value = t(errorText);
598
1041
  }
599
1042
  } catch (error) {
600
1043
  console.error('Error saving data:', error);
601
1044
  isError.value = true;
602
- errorMessage.value = t(`Failed to save data. Please, try to re-run the action.`);
1045
+ errorMessage.value = t(errorText);
603
1046
  } finally {
1047
+ confirmDialog.value.close();
1048
+ props.updateList();
1049
+ props.clearCheckboxes?.();
604
1050
  isLoading.value = false;
605
1051
  isDataSaved.value = true;
606
1052
  window.removeEventListener('beforeunload', beforeUnloadHandler);
607
1053
  }
608
1054
  }
609
-
610
-
611
- async function runAiAction({
612
- endpoint,
613
- actionType,
614
- responseFlag,
615
- updateOnSuccess = true,
616
- recordsIds = props.checkboxes,
617
- disableRateLimitCheck = false,
618
- }: {
619
- endpoint: string;
620
- actionType: 'analyze' | 'analyze_no_images' | 'generate_images';
621
- responseFlag: Ref<boolean[]>;
622
- updateOnSuccess?: boolean;
623
- recordsIds?: any[];
624
- disableRateLimitCheck?: boolean;
625
- }) {
626
- let hasError = false;
627
- let errorMessage = '';
628
- const jobsIds: { jobId: any; recordId: any; }[] = [];
629
- // responseFlag.value = props.checkboxes.map(() => false);
630
- for (let i = 0; i < recordsIds.length; i++) {
631
- const index = props.checkboxes.findIndex(item => String(item) === String(recordsIds[i]));
632
- if (index !== -1) {
633
- responseFlag.value[index] = false;
634
- }
635
- }
636
- let isRateLimitExceeded = false;
637
- if (!disableRateLimitCheck){
638
- try {
639
- const rateLimitRes = await callAdminForthApi({
640
- path: `/plugin/${props.meta.pluginInstanceId}/update-rate-limits`,
641
- method: 'POST',
642
- body: {
643
- actionType: actionType,
644
- },
645
- });
646
- if (rateLimitRes?.error) {
647
- isRateLimitExceeded = true;
648
- adminforth.alert({
649
- message: t(`Rate limit exceeded for "${actionType.replace('_', ' ')}" action. Please try again later.`),
650
- variant: 'danger',
651
- timeout: 'unlimited',
652
- });
653
- return;
654
- }
655
- } catch (e) {
656
- adminforth.alert({
657
- message: t(`Error checking rate limit for "${actionType.replace('_', ' ')}" action.`),
658
- variant: 'danger',
659
- timeout: 'unlimited',
660
- });
661
- isRateLimitExceeded = true;
662
- }
663
- if (isRateLimitExceeded) {
664
- return;
665
- };
666
- }
667
-
668
- let customPrompt;
669
- if (actionType === 'generate_images') {
670
- customPrompt = generationPrompts.value.imageGenerationPrompts || generationPrompts.value.generateImages;
671
- } else if (actionType === 'analyze') {
672
- customPrompt = generationPrompts.value.imageFieldsPrompts;
673
- } else if (actionType === 'analyze_no_images') {
674
- customPrompt = generationPrompts.value.plainFieldsPrompts;
675
- }
676
- //creating jobs
677
- const tasks = recordsIds.map(async (checkbox, i) => {
678
- try {
679
- const res = await callAdminForthApi({
680
- path: `/plugin/${props.meta.pluginInstanceId}/create-job`,
681
- method: 'POST',
682
- body: {
683
- actionType: actionType,
684
- recordId: checkbox,
685
- ...(customPrompt !== undefined ? { customPrompt: JSON.stringify(customPrompt) } : {}),
686
- filterFilledFields: !overwriteExistingValues.value,
687
- },
688
- silentError: true,
689
- });
690
-
691
- if (res?.error) {
692
- throw new Error(res.error);
693
- }
694
-
695
- if (!res) {
696
- throw new Error(`${actionType} request returned empty response.`);
697
- }
698
-
699
- jobsIds.push({ jobId: res.jobId, recordId: checkbox });
700
- } catch (e) {
701
- console.error(`Error during ${actionType} for item ${i}:`, e);
702
- hasError = true;
703
- errorMessage = t(`Failed to ${actionType.replace('_', ' ')}. Please, try to re-run the action.`);
704
- return { success: false, index: i, error: e };
705
- }
706
- });
707
- await Promise.all(tasks);
708
-
709
- //polling jobs
710
- let isInProgress = true;
711
- //if no jobs were created, skip polling
712
- while (isInProgress && isDialogOpen.value) {
713
- //check if at least one job is still in progress
714
- let isAtLeastOneInProgress = false;
715
- //checking status of each job
716
- for (const { jobId, recordId } of jobsIds) {
717
- //check job status
718
- const jobResponse = await callAdminForthApi({
719
- path: `/plugin/${props.meta.pluginInstanceId}/get-job-status`,
720
- method: 'POST',
721
- body: { jobId },
722
- silentError: true,
723
- });
724
- //check for errors
725
- if (!jobResponse) {
726
- isAtLeastOneInProgress = true;
727
- continue;
728
- }
729
- if (jobResponse?.error) {
730
- console.error(`Error during ${actionType}:`, jobResponse.error);
731
- break;
732
- };
733
- // extract job status
734
- let jobStatus = jobResponse?.job?.status;
735
- // check if job is still in progress. If in progress - skip to next job
736
- if (jobStatus === 'in_progress') {
737
- isAtLeastOneInProgress = true;
738
- //if job is completed - update record data
739
- } else if (jobStatus === 'completed') {
740
- // finding index of the record in selected array
741
- const index = selected.value.findIndex(item => String(item[primaryKey]) === String(recordId));
742
- //if we are generating images - update carouselSaveImages with new image
743
- if (actionType === 'generate_images') {
744
- for (const [key, value] of Object.entries(carouselSaveImages.value[index])) {
745
- if (props.meta.outputImageFields?.includes(key)) {
746
- carouselSaveImages.value[index][key] = [jobResponse.job.result[key]];
747
- if (jobResponse.job.recordMeta?.[`${key}_meta`]) {
748
- carouselSaveImages.value[index][key] = [jobResponse.job.recordMeta[`${key}_meta`].originalImage];
749
- if (!listOfImageThatWasNotGeneratedPerRecord.value[recordId]) {
750
- listOfImageThatWasNotGeneratedPerRecord.value[recordId] = [];
751
- }
752
- listOfImageThatWasNotGeneratedPerRecord.value[recordId][key] = jobResponse.job.recordMeta[`${key}_meta`];
753
- }
754
- }
755
- }
756
- }
757
- //marking that we received response for this record
758
- //if (actionType !== 'analyze_no_images' || !props.meta.isFieldsForAnalizeFromImages) {
759
- responseFlag.value[index] = true;
760
- //}
761
- //updating selected with new data from AI
762
- const pk = selected.value[index]?.[primaryKey];
763
- if (pk) {
764
- selected.value[index] = {
765
- ...selected.value[index],
766
- ...jobResponse.job.result,
767
- isChecked: true,
768
- [primaryKey]: pk,
769
- };
770
- }
771
- //removing job from jobsIds
772
- if (index !== -1) {
773
- jobsIds.splice(jobsIds.findIndex(j => j.jobId === jobId), 1);
774
- }
775
- // checking one more time if we have in progress jobs
776
- isAtLeastOneInProgress = true;
777
- // if job is failed - set error
778
- } else if (jobStatus === 'failed') {
779
- const index = selected.value.findIndex(item => String(item[primaryKey]) === String(recordId));
780
- //if (actionType !== 'analyze_no_images' || !props.meta.isFieldsForAnalizeFromImages) {
781
- responseFlag.value[index] = true;
782
- //}
783
- if (index !== -1) {
784
- jobsIds.splice(jobsIds.findIndex(j => j.jobId === jobId), 1);
785
- } else {
786
- jobsIds.splice(0, jobsIds.length);
787
- }
788
- isAtLeastOneInProgress = true;
789
- adminforth.alert({
790
- message: t(`Generation action "${actionType.replace('_', ' ')}" failed for record: ${recordId}. Error: ${jobResponse.job?.error || 'Unknown error'}`),
791
- variant: 'danger',
792
- timeout: 'unlimited',
793
- });
794
- if (actionType === 'generate_images') {
795
- isAiImageGenerationError.value[index] = true;
796
- imageGenerationErrorMessage.value[index] = jobResponse.job?.error || 'Unknown error';
797
- } else if (actionType === 'analyze') {
798
- for (const field of Object.keys(props.meta.outputFieldsForAnalizeFromImages ) ) {
799
- if (!imageToTextErrorMessages.value[index]) {
800
- imageToTextErrorMessages.value[index] = {};
801
- }
802
- imageToTextErrorMessages.value[index][props.meta.outputFieldsForAnalizeFromImages[field]] = jobResponse.job?.error || 'Unknown error';
803
- }
804
- } else if (actionType === 'analyze_no_images') {
805
- for( const field of Object.keys(props.meta.outputPlainFields ) ) {
806
- if (!textToTextErrorMessages.value[index]) {
807
- textToTextErrorMessages.value[index] = {};
808
- }
809
- textToTextErrorMessages.value[index][props.meta.outputPlainFields[field]] = jobResponse.job?.error || 'Unknown error';
810
- }
811
- }
812
- }
813
- }
814
- if (!isAtLeastOneInProgress) {
815
- isInProgress = false;
816
- }
817
- if (jobsIds.length > 0) {
818
- if (actionType === 'generate_images') {
819
- await new Promise(resolve => setTimeout(resolve, props.meta.refreshRates?.generateImages));
820
- } else if (actionType === 'analyze') {
821
- await new Promise(resolve => setTimeout(resolve, props.meta.refreshRates?.fillFieldsFromImages));
822
- } else if (actionType === 'analyze_no_images') {
823
- await new Promise(resolve => setTimeout(resolve, props.meta.refreshRates?.fillPlainFields));
824
- } else {
825
- await new Promise(resolve => setTimeout(resolve, 2000));
826
- }
827
- }
828
- }
829
-
830
- if (hasError) {
831
- adminforth.alert({
832
- message: errorMessage,
833
- variant: 'danger',
834
- timeout: 'unlimited',
835
- });
836
- isError.value = true;
837
- if (actionType === 'generate_images') {
838
- isImageGenerationError.value = true;
839
- }
840
- this.errorMessage.value = errorMessage;
841
- return;
842
- }
843
-
844
- if (actionType === 'generate_images') {
845
- isGeneratingImages.value = false;
846
- } else if (actionType === 'analyze') {
847
- isAnalizingImages.value = false;
848
- } else if (actionType === 'analyze_no_images') {
849
- isAnalizingFields.value = false;
850
- }
851
- }
852
-
853
1055
  async function uploadImage(imgBlob, id, fieldName) {
854
1056
  const file = new File([imgBlob], `generated_${fieldName}_${id}.${imgBlob.type.split('/').pop()}`, { type: imgBlob.type });
855
1057
  const { name, size, type } = file;
@@ -921,7 +1123,7 @@ async function uploadImage(imgBlob, id, fieldName) {
921
1123
  }
922
1124
  }
923
1125
 
924
- function regenerateImages(recordInfo: any) {
1126
+ function regenerateImages({ recordId }: { recordId: any }) {
925
1127
  if (coreStore.isInternetError) {
926
1128
  adminforth.alert({
927
1129
  message: t('Cannot regenerate images while internet connection is lost. Please check your connection and try again.'),
@@ -930,13 +1132,15 @@ function regenerateImages(recordInfo: any) {
930
1132
  });
931
1133
  return;
932
1134
  }
933
- isGeneratingImages.value = true;
934
- runAiAction({
935
- endpoint: 'initial_image_generate',
936
- actionType: 'generate_images',
937
- responseFlag: isAiResponseReceivedImage,
938
- recordsIds: [recordInfo.recordInfo],
939
- disableRateLimitCheck: true,
1135
+ const record = recordsById.get(String(recordId));
1136
+ if (!record) {
1137
+ return;
1138
+ }
1139
+ record.aiStatus.generatedImages = false;
1140
+ touchRecords();
1141
+ runActionForRecord(record, 'generate_images').catch(() => {
1142
+ record.aiStatus.generatedImages = true;
1143
+ touchRecords();
940
1144
  });
941
1145
  }
942
1146
 
@@ -1078,10 +1282,15 @@ async function regenerateCell(recordInfo: any) {
1078
1282
  });
1079
1283
  return;
1080
1284
  }
1081
- if (!regeneratingFieldsStatus.value[recordInfo.recordId]) {
1082
- regeneratingFieldsStatus.value[recordInfo.recordId] = {};
1285
+ const record = recordsById.get(String(recordInfo.recordId));
1286
+ if (!record) {
1287
+ return;
1083
1288
  }
1084
- regeneratingFieldsStatus.value[recordInfo.recordId][recordInfo.fieldName] = true;
1289
+ if (!record.regeneratingFieldsStatus[recordInfo.fieldName]) {
1290
+ record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1291
+ }
1292
+ record.regeneratingFieldsStatus[recordInfo.fieldName] = true;
1293
+ touchRecords();
1085
1294
  const actionType = props.meta.outputFieldsForAnalizeFromImages?.includes(recordInfo.fieldName)
1086
1295
  ? 'analyze'
1087
1296
  : props.meta.outputPlainFields?.includes(recordInfo.fieldName)
@@ -1089,16 +1298,18 @@ async function regenerateCell(recordInfo: any) {
1089
1298
  : null;
1090
1299
  if (!actionType) {
1091
1300
  console.error(`Field ${recordInfo.fieldName} is not configured for analysis.`);
1301
+ record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1302
+ touchRecords();
1092
1303
  return;
1093
1304
  }
1094
1305
 
1095
1306
  let generationPromptsForField = {};
1096
1307
  if (actionType === 'analyze') {
1097
1308
  generationPromptsForField = generationPrompts.value.imageFieldsPrompts || {};
1098
- isAnalizingFields.value = true;
1309
+ record.aiStatus.analyzedImages = false;
1099
1310
  } else if (actionType === 'analyze_no_images') {
1100
1311
  generationPromptsForField = generationPrompts.value.plainFieldsPrompts || {};
1101
- isAnalizingImages.value = true;
1312
+ record.aiStatus.analyzedNoImages = false;
1102
1313
  }
1103
1314
 
1104
1315
  let jobId;
@@ -1117,7 +1328,7 @@ async function regenerateCell(recordInfo: any) {
1117
1328
  silentError: true,
1118
1329
  });
1119
1330
  } catch (e) {
1120
- regeneratingFieldsStatus.value[recordInfo.recordId][recordInfo.fieldName] = false;
1331
+ record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1121
1332
  console.error(`Error during cell regeneration for record ${recordInfo.recordId}, field ${recordInfo.fieldName}:`, e);
1122
1333
  }
1123
1334
  if ( res.ok === false) {
@@ -1127,7 +1338,7 @@ async function regenerateCell(recordInfo: any) {
1127
1338
  });
1128
1339
  isError.value = true;
1129
1340
  errorMessage.value = t(`Failed to regenerate field`);
1130
- regeneratingFieldsStatus.value[recordInfo.recordId][recordInfo.fieldName] = false;
1341
+ record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1131
1342
  return;
1132
1343
  }
1133
1344
  jobId = res.jobId;
@@ -1152,38 +1363,29 @@ async function regenerateCell(recordInfo: any) {
1152
1363
  timeout: 'unlimited',
1153
1364
  });
1154
1365
  if (actionType === 'analyze') {
1155
- imageToTextErrorMessages.value[recordInfo.recordId][recordInfo.fieldName] = res.job?.error || 'Unknown error';
1156
- isAnalizingFields.value = false;
1366
+ record.imageToTextErrorMessages[recordInfo.fieldName] = res.job?.error || 'Unknown error';
1367
+ record.aiStatus.analyzedImages = true;
1157
1368
  } else if (actionType === 'analyze_no_images') {
1158
- textToTextErrorMessages.value[recordInfo.recordId][recordInfo.fieldName] = res.job?.error || 'Unknown error';
1159
- isAnalizingImages.value = false;
1369
+ record.textToTextErrorMessages[recordInfo.fieldName] = res.job?.error || 'Unknown error';
1370
+ record.aiStatus.analyzedNoImages = true;
1160
1371
  }
1161
- regeneratingFieldsStatus.value[recordInfo.recordId][recordInfo.fieldName] = false;
1372
+ record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1373
+ touchRecords();
1162
1374
  return;
1163
1375
  } else if (res.job?.status === 'completed') {
1164
- const index = selected.value.findIndex(item => String(item[primaryKey]) === String(recordInfo.recordId));
1165
-
1166
- const pk = selected.value[index]?.[primaryKey];
1167
- if (pk) {
1168
- selected.value[index] = {
1169
- ...selected.value[index],
1170
- ...res.job.result,
1171
- isChecked: true,
1172
- [primaryKey]: pk,
1173
- };
1174
- }
1376
+ record.data = {
1377
+ ...record.data,
1378
+ ...res.job.result,
1379
+ };
1175
1380
  if (actionType === 'analyze') {
1176
- if (imageToTextErrorMessages.value[index]) {
1177
- imageToTextErrorMessages.value[index][recordInfo.fieldName] = '';
1178
- }
1179
- isAnalizingFields.value = false;
1381
+ record.imageToTextErrorMessages[recordInfo.fieldName] = '';
1382
+ record.aiStatus.analyzedImages = true;
1180
1383
  } else if (actionType === 'analyze_no_images') {
1181
- if (textToTextErrorMessages.value[index]) {
1182
- textToTextErrorMessages.value[index][recordInfo.fieldName] = '';
1183
- }
1184
- isAnalizingImages.value = false;
1384
+ record.textToTextErrorMessages[recordInfo.fieldName] = '';
1385
+ record.aiStatus.analyzedNoImages = true;
1185
1386
  }
1186
- regeneratingFieldsStatus.value[recordInfo.recordId][recordInfo.fieldName] = false;
1387
+ record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1388
+ touchRecords();
1187
1389
  }
1188
1390
  }
1189
1391