@adminforth/bulk-ai-flow 2.0.4 → 2.1.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.
@@ -1,333 +1,85 @@
1
1
  <template>
2
- <div class="flex items-end justify-start gap-2 cursor-pointer">
3
- <div class="flex items-center justify-center text-white bg-gradient-to-r h-[18px] from-lightPrimary via-lightPrimary/90 to-lightPrimary/80 hover:opacity-90 focus:ring-4 focus:outline-none focus:ring-lightPrimary/30 dark:focus:ring-darkPrimary/30 font-medium rounded-md text-sm px-1 text-center dark:bg-none dark:bg-darkPrimary/10 dark:text-darkPrimary dark:brightness-200">
4
- {{t('AI')}}
5
- </div>
6
- <p class="text-justify max-h-[18px] truncate max-w-[60vw] md:max-w-none">{{ props.meta.actionName }}</p>
7
- </div>
2
+ <span class="flex items-center gap-2">
3
+ <IconSparkles class="w-4 h-4 shrink-0 text-lightInputIconColor dark:text-darkInputIconColor" aria-hidden="true" />
4
+ <span class="truncate">{{ props.meta.actionName }}</span>
5
+ </span>
8
6
  <Dialog
9
7
  ref="confirmDialog"
10
- :header="t('Bulk AI Generation')"
11
- class="[scrollbar-gutter:stable] max-w-full h-fit"
8
+ :header="props.meta.actionName"
9
+ class="w-11/12"
10
+ :class="popupMode === 'generation' ? 'max-w-6xl' : 'max-w-2xl'"
11
+ :buttons="popupMode === 'generation' ? generationModeButtons : confirmationModeButtons"
12
12
  :beforeCancelFunction="handleBeforeCancel"
13
- :class="popupMode === 'generation' ? 'lg:w-auto !lg:max-w-[1600px]'
14
- : popupMode === 'settings' ? 'lg:w-[1000px] !lg:max-w-[1000px]'
15
- : 'lg:w-[500px] !lg:max-w-[500px]'"
16
- :buttons="popupMode === 'generation' ? generationModeButtons : popupMode === 'settings' ? [
17
- {
18
- label: t('Cancel'),
19
- options: {
20
- class: 'afcl-button w-1/6',
21
- variant: 'secondary'
22
- },
23
- onclick: () => { popupMode = 'confirmation'; }
24
- },
25
- {
26
- label: t('Save settings'),
27
- options: {
28
- class: 'afcl-button w-1/6',
29
- },
30
- onclick: (dialog) => { saveSettings(); }
31
- },
32
- ] :
33
- [
34
- {
35
- label: t('Cancel'),
36
- options: {
37
- class: 'afcl-button w-1/2',
38
- variant: 'secondary'
39
- },
40
- onclick: (dialog) => confirmDialog.tryToHideModal()
41
- },
42
- {
43
- label: t('Start generation'),
44
- options: {
45
- class: 'afcl-button w-1/2',
46
- loader: isCheckingRateLimits
47
- },
48
- onclick: async () => { await runAiActions(); }
49
- }
50
- ]"
51
- :close-by-click-outside="false"
13
+ :closeByClickOutside="false"
14
+ :closeByEsc="!zoomedImage"
52
15
  >
53
- <div
54
- class="bulk-vision-table flex flex-col gap-3 w-full"
55
- :class="{
56
- 'h-[calc(100vh_-_200px)] max-h-[830px]': popupMode === 'generation',
57
- 'h-auto': popupMode !== 'generation'
58
- }"
59
- :style="popupMode !== 'generation' ? { maxHeight: 'min(720px, calc(100vh - 200px))' } : {}"
60
- >
61
- <template v-if="recordsList.length && popupMode === 'generation'" >
62
- <div class="w-full min-w-[980px]">
63
- <div v-if="isGenerationPaused" class="flex flex-col gap-2 mb-2">
64
- <p class="text-sm font-semibold text-yellow-800">{{ t(`Generated ${startedRecordCount} records. `) + t('Generation on pause. Resume generation?') }}</p>
65
- <div class="flex items-center gap-2">
66
- <button
67
- class="h-8 px-3 py-1.5 text-sm rounded-md bg-gradient-to-r from-lightPrimary to-lightPrimary/80 text-white border-none ml-2"
68
- @click="resumeGeneration"
69
- >
70
- {{ t('Resume generation') }}
71
- </button>
72
- <button
73
- class="h-8 px-3 py-1.5 text-sm rounded-md bg-white hover:bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:border-gray-600"
74
- @click="cancelGeneration"
75
- >
76
- {{ t('Cancel generation') }}
77
- </button>
78
- </div>
79
- </div>
80
-
81
- <div class="generation-progress-panel w-full bg-white dark:bg-gray-900 border border-gray-200/80 dark:border-gray-800 p-5 m-1 rounded-2xl shadow-sm flex flex-col gap-4">
82
- <div class="generation-status-header flex items-center justify-between">
83
- <div class="flex items-center gap-3">
84
- <div
85
- class="p-2 rounded-xl shrink-0"
86
- :class="generationStatusClass"
87
- >
88
- <IconShieldCheckOutline v-if="displayedProcessedCount === totalRecords" class="w-5 h-5" />
89
- <IconRefreshOutline v-else :class="isProcessingAny ? 'animate-spin' : ''" class="w-5 h-5" />
90
- </div>
91
-
92
- <div>
93
- <h4 class="text-sm font-bold text-gray-800 dark:text-gray-200 tracking-tight">
94
- {{ displayedProcessedCount }} {{ t('of') }} {{ totalRecords }} {{ t('fields ready to save') }}
95
- </h4>
96
- </div>
97
- </div>
98
-
99
- <span
100
- class="px-2.5 py-1 text-xs rounded-default border flex items-center gap-1.5"
101
- :class="generationStatusClass"
102
- >
103
- <span
104
- class="w-1.5 h-1.5 rounded-full"
105
- :class="isGenerationPaused ? 'bg-yellow-500' : displayedProcessedCount === totalRecords ? 'bg-green-500' : 'bg-blue-500'"
106
- ></span>
107
- {{
108
- isGenerationPaused ? t('On pause')
109
- : displayedProcessedCount === totalRecords ? t('Ready')
110
- : t('Processing')
111
- }}
112
- </span>
113
- </div>
114
-
115
- <ProgressBar
116
- class="w-full"
117
- :class="isGenerationPaused ? 'opacity-80' : ''"
118
- :current-value="Math.floor((displayedProcessedCount / totalRecords) * 100)"
119
- :max-value="100"
120
- :min-value="0"
121
- :showAnimation="isProcessingAny && !isGenerationPaused"
122
- :showLabels="false"
123
- :showValues="false"
124
- :show-progress="false"
125
- />
126
-
127
- </div>
128
- </div>
129
-
130
-
131
- <VisionTable
132
- class="w-full flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable] [mask-image:linear-gradient(to_bottom,transparent_0px,black_20px)]"
133
- ref="tableRef"
16
+ <GenerationSetup
17
+ v-if="popupMode === 'confirmation'"
18
+ v-model:prompts="generationPrompts"
19
+ v-model:overwrite="overwriteExistingValues"
20
+ :meta="props.meta"
21
+ :defaultPrompts="defaultPrompts"
22
+ :savedPrompts="savedPrompts"
23
+ @save="savePrompt"
24
+ />
25
+ <div v-else class="bulk-vision-table flex flex-col gap-3">
26
+ <template v-if="recordsList.length">
27
+ <ReviewQueue
134
28
  :records="recordsList"
135
29
  :meta="props.meta"
136
- :tableHeaders="tableHeaders"
137
- :customFieldNames="customFieldNames"
138
- :isError="isError"
139
- :errorMessage="errorMessage"
140
- :regenerateImagesRefreshRate="props.meta.refreshRates?.regenerateImages"
141
- :isImageHasPreviewUrl="isImageHasPreviewUrl"
142
- :imageGenerationPrompts="generationPrompts.generateImages"
143
- :outputImageFields="props.meta.outputImageFields"
144
- :outputFieldsForAnalizeFromImages="props.meta.outputFieldsForAnalizeFromImages"
145
- :outputPlainFields="props.meta.outputPlainFields"
146
- :overwriteExistingValues="overwriteExistingValues"
147
- @error="handleTableError"
148
- @regenerate-images="regenerateImages"
149
- @regenerate-record="regenerateRecord"
30
+ :processed="displayedProcessedCount"
31
+ :total="totalRecords"
32
+ :isProcessing="isProcessingAny"
33
+ :isPaused="isGenerationPaused"
34
+ @regenerateRecord="regenerateRecord"
35
+ @regenerateImages="regenerateImages"
36
+ @regenerateCell="regenerateCell"
37
+ @zoom="(url) => zoomedImage = url"
38
+ @stop="cancelGeneration"
39
+ @resume="resumeGeneration"
150
40
  />
151
- <div class="mt-2 flex items-center text-lightPrimary dark:text-darkPrimary bg-lightPrimary/10 dark:bg-darkPrimary/10 px-4 py-3 rounded-default border border-lightPrimary/20 dark:border-darkPrimary/20 text-sm">
152
- <IconInfoCircleSolid class="w-5 h-5 me-2 shrink-0 text-lightPrimary dark:text-darkPrimary dark:brightness-200" />
153
- <p class="text-lightPrimary dark:text-darkPrimary dark:brightness-200">
154
- <span class="font-semibold text-lightPrimary dark:text-darkPrimary dark:brightness-200">{{ t('Only checked fields will be saved.') }}</span>
155
- {{ t('You can regenerate fields before saving if needed.') }}
156
- </p>
157
- </div>
158
- <div class="af-error-message flex items-center w-full text-red-600">
159
- <p v-if="isError === true">{{ errorMessage }}</p>
160
- </div>
161
- </template>
162
- <template v-else-if="!recordsList.length && popupMode === 'generation'">
163
- <p>{{ t('No data to save. Feel free to click "Cancel"') }}</p>
41
+ <p class="flex items-start gap-2 text-xs text-lightInputIconColor dark:text-darkInputIconColor">
42
+ <IconInfo class="mt-px w-3.5 h-3.5 shrink-0" aria-hidden="true" />
43
+ {{ t('Only checked records are saved. You can edit or regenerate values before saving.') }}
44
+ </p>
164
45
  </template>
165
- <div v-else-if="popupMode === 'settings'" class="af-settings-panel w-full flex flex-col gap-6 overflow-y-auto overflow-x-hidden pr-2 max-h-[70vh]">
166
- <template v-for="(promptsCategory, key) in generationPrompts" :key="key">
167
- <div v-if="Object.keys(promptsCategory).length > 0" class="w-full flex flex-col">
168
- <div class="flex items-start gap-3.5 mb-6">
169
-
170
- <div class="flex-1 min-w-0">
171
- <h3 class="text-base font-bold text-gray-900 dark:text-white tracking-tight">
172
- {{
173
- key === "plainFieldsPrompts" ? t("Prompts for non-image fields")
174
- : key === "generateImages" ? t("Prompts for image fields")
175
- : t("Prompts for image analysis")
176
- }}
177
- </h3>
178
- <p class="text-xs text-gray-400 mt-0.5">
179
- {{
180
- key === "plainFieldsPrompts" ? t("Define how AI should generate content for text-based fields.")
181
- : key === "generateImages" ? t("Configure prompt rules for generating media and images.")
182
- : t("Instructions for extracting text specifications from images.")
183
- }}
184
- </p>
185
- </div>
186
- </div>
187
-
188
- <div class="grid gap-6" :class="Object.keys(promptsCategory).length === 1 ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2'">
189
- <div
190
- v-for="(prompt, promptKey) in promptsCategory"
191
- :key="promptKey"
192
- class="
193
- flex flex-col bg-white dark:bg-gray-900 border border-gray-200/80 p-5 rounded-default shadow-sm transition-all duration-200
194
- hover:shadow-md hover:border-gray-300
195
- dark:border-gray-800
196
- dark:hover:border-gray-700"
197
- >
198
- <div class="flex items-center justify-between mb-4">
199
- <div class="flex items-center gap-3">
200
- <div class="p-2 bg-lightPrimary/10 text-lightPrimary rounded-lg shrink-0
201
- dark:bg-darkPrimary/20
202
- dark:text-darkPrimary ">
203
- <IconImageSolid
204
- v-if="key === 'generateImages'"
205
- class="w-5 h-5 dark:brightness-200"
206
- />
207
- <IconFileLinesOutline
208
- v-else
209
- class="w-5 h-5 dark:brightness-200"
210
- />
211
- </div>
212
- <div>
213
- <h4 class="text-sm font-bold text-gray-800 dark:text-gray-200 capitalize tracking-tight">
214
- {{ formatLabel(promptKey) }}
215
- </h4>
216
- <p class="text-[11px] text-gray-400 dark:text-gray-500">
217
- {{ key === 'generateImages' ? t('Generate source media') : t('Generate a detailed content') }}
218
- </p>
219
- </div>
220
- </div>
221
-
222
- <span class="px-2 py-0.5 text-[10px] font-semibold tracking-wide rounded-md bg-lightPrimary/10 text-lightPrimary dark:bg-darkPrimary/10 dark:text-darkPrimary dark:brightness-200">
223
- {{ key === 'generateImages' ? t('Image field') : t('Text field') }}
224
- </span>
225
- </div>
226
-
227
- <div class="relative flex-1">
228
- <Textarea
229
- v-model="generationPrompts[key][promptKey]"
230
- class="w-full h-56 p-3.5 text-sm leading-relaxed border border-gray-200 bg-gray-50/30 text-gray-800 shadow-sm resize-none font-sans
231
- dark:border-gray-700
232
- dark:bg-gray-800/40
233
- dark:text-gray-100 rounded-xl
234
- dark:focus:border-gray-700
235
- focus:outline-none
236
- focus:border-gray-200"
237
- :placeholder="t('Enter prompt instructions...')"
238
- ></Textarea>
239
- </div>
240
-
241
- <button
242
- type="button"
243
- mode="secondary"
244
- @click="resetPromptToDefault(key, promptKey)"
245
- class="
246
- mt-4 flex items-center gap-1.5 text-xs font-semibold bg-transparent px-3 py-1 w-fit rounded-default
247
- border border-red-200 hover:border-red-600
248
- text-red-600 hover:text-red-700
249
- dark:border-red-900/50 dark:hover:border-red-400
250
- dark:text-red-500 dark:hover:text-red-400
251
- transition-colors duration-150"
252
- >
253
- <IconUndoOutline class="w-3.5 h-3.5" />
254
- {{ t('Reset') }}
255
- </button>
256
-
257
- </div>
258
- </div>
259
- </div>
260
- </template>
261
- </div>
262
- <div v-else class="af-confirmation-panel flex flex-col gap-4 w-full h-full">
263
- <div class="af-overwrite-settings-card flex flex-col p-5 rounded-default border border-gray-100 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900/40">
264
- <div class="flex items-start justify-between gap-4">
265
- <div class="flex items-start gap-3 min-w-0">
266
- <div class="p-2 bg-lightPrimary/10 dark:bg-darkPrimary/10 rounded-xl text-lightPrimary dark:text-darkPrimary shrink-0 mt-0.5">
267
- <IconShieldCheckOutline class="w-5 h-5 dark:brightness-200" />
268
- </div>
269
- <div class="min-w-0">
270
- <p class="text-base font-semibold text-gray-900 dark:text-white leading-tight">{{ t('Overwrite existing values') }}</p>
271
- <p class="text-sm text-gray-500 dark:text-gray-400 mt-1 break-words pr-2">{{ t('AI-generated data will replace existing values.') }}</p>
272
- </div>
273
- </div>
274
- <div class="shrink-0 pt-1">
275
- <Toggle v-model="overwriteExistingValues" />
276
- </div>
277
- </div>
278
-
279
- <div v-if="overwriteExistingValues" class="mt-4 flex items-start text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 px-3 py-2.5 rounded-xl border border-amber-200 dark:border-amber-900/50 text-sm">
280
- <IconInfoCircleSolid class="w-4 h-4 me-2 shrink-0 mt-0.5 " />
281
- <p>{{ t('Warning: Existing values will be overwritten.') }}</p>
282
- </div>
283
- </div>
284
-
285
- <div
286
- @click="clickSettingsButton()"
287
- class="af-configure-prompts-card
288
- flex items-center justify-between p-5 rounded-default border border-gray-100 bg-white shadow-sm cursor-pointer transition-all duration-150
289
- dark:border-gray-800 dark:bg-gray-900/40 "
290
- >
291
- <div class="flex items-start gap-3 min-w-0">
292
- <div class="p-2 bg-lightPrimary/10 dark:bg-darkPrimary/10 rounded-xl text-lightPrimary dark:text-darkPrimary shrink-0 mt-0.5">
293
- <IconMessageCaptionOutline class="w-5 h-5 dark:brightness-200" />
294
- </div>
295
- <div class="min-w-0">
296
- <p class="text-base font-semibold text-gray-900 dark:text-white leading-tight">{{ t('Configure prompts')}}</p>
297
- <p class="text-sm text-gray-500 dark:text-gray-400 mt-1 break-words">{{ t('Change the instructions for AI to generate actual data.')}}</p>
298
- </div>
299
- </div>
300
- </div>
301
-
302
- <div class="flex items-center gap-2 px-1 text-xs font-medium text-gray-400 dark:text-gray-500 dark:brightness-200">
303
- <IconInfoCircleSolid class="w-4 h-4 " />
304
- <p>{{ t('This action will only apply to the fields included in your prompts.') }}</p>
305
- </div>
306
-
46
+ <Skeleton v-else-if="isFetchingRecords" type="input" class="h-[20rem] w-full rounded-lg" />
47
+ <div v-else class="flex flex-col items-center gap-2 rounded-lg border border-dashed border-lightFormBorder px-6 py-10 text-center dark:border-darkFormBorder">
48
+ <IconInbox class="w-6 h-6 text-lightInputPlaceholderText dark:text-darkInputPlaceholderText" aria-hidden="true" />
49
+ <p class="text-sm text-lightInputIconColor dark:text-darkInputIconColor">{{ t('No data to save. Feel free to click "Cancel"') }}</p>
307
50
  </div>
51
+
52
+ <p v-if="isError" class="af-error-message flex items-start gap-1.5 text-sm text-lightDanger dark:text-darkDanger">
53
+ <IconCircleAlert class="mt-0.5 w-4 h-4 shrink-0" aria-hidden="true" />
54
+ {{ errorMessage }}
55
+ </p>
308
56
  </div>
309
57
  </Dialog>
58
+ <ImageLightbox v-model="zoomedImage" />
310
59
  </template>
311
60
 
312
61
  <script lang="ts" setup>
313
62
  import { callAdminForthApi } from '@/utils';
314
- import { ref, computed, nextTick } from 'vue'
63
+ import { ref, reactive, computed } from 'vue'
315
64
  import pLimit from 'p-limit';
316
- import { Dialog, Textarea, Toggle, Tooltip } from '@/afcl';
317
- import VisionTable from './VisionTable.vue'
65
+ import { Dialog, Skeleton } from '@/afcl';
66
+ import { IconCircleAlert, IconInbox, IconInfo, IconSparkles } from '@iconify-prerendered/vue-lucide';
67
+ import GenerationSetup from './GenerationSetup.vue';
68
+ import ReviewQueue from './ReviewQueue.vue';
69
+ import ImageLightbox from './ImageLightbox.vue';
318
70
  import adminforth from '@/adminforth';
319
71
  import { useI18n } from 'vue-i18n';
320
72
  import { AdminUser, type AdminForthResourceCommon } from '@/types/Common';
321
73
  import { useCoreStore } from '@/stores/core';
322
- import { IconInfoCircleSolid } from '@iconify-prerendered/vue-flowbite';
323
74
  import { useFiltersStore } from '@/stores/filters';
324
- import { IconMessageCaptionOutline, IconShieldCheckOutline, IconFileLinesOutline, IconUndoOutline, IconImageSolid, IconRefreshOutline } from '@iconify-prerendered/vue-flowbite';
325
- import { ProgressBar } from '@/afcl';
75
+ import { PROMPT_CATEGORY, isRegenerating, recordError, type GenerationAction, type PromptCategory, type RecordState } from './reviewModel';
326
76
 
77
+ const DURATION_RE = /\b\d+\.\d+s\b/g;
78
+ const RETRY_IN_RE = /Please retry in [\d.]+s\.?/g;
79
+ const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
327
80
 
328
81
  const coreStore = useCoreStore();
329
82
  const filtersStore = useFiltersStore();
330
- const showCloseConfirmModal = ref(false);
331
83
 
332
84
  const { t } = useI18n();
333
85
  const props = defineProps<{
@@ -339,37 +91,10 @@ const props = defineProps<{
339
91
  clearCheckboxes?: () => any,
340
92
  }>();
341
93
 
342
- type RecordStatus = 'pending' | 'processing' | 'completed' | 'failed';
343
- type GenerationAction = 'analyze' | 'analyze_no_images' | 'generate_images';
344
-
345
- type RecordState = {
346
- id: string | number;
347
- status: RecordStatus;
348
- isChecked: boolean;
349
- label: string;
350
- data: Record<string, any>;
351
- oldData: Record<string, any>;
352
- images: any[];
353
- aiStatus: {
354
- generatedImages: boolean;
355
- analyzedImages: boolean;
356
- analyzedNoImages: boolean;
357
- };
358
- openGenerationCarousel: Record<string, boolean>;
359
- openImageCompare: Record<string, boolean>;
360
- carouselSaveImages: Record<string, any[]>;
361
- carouselImageIndex: Record<string, number>;
362
- imageGenerationErrorMessage: string;
363
- imageGenerationFailed: boolean;
364
- imageToTextErrorMessages: Record<string, string>;
365
- textToTextErrorMessages: Record<string, string>;
366
- regeneratingFieldsStatus: Record<string, boolean>;
367
- listOfImageNotGenerated: Record<string, any>;
368
- };
94
+ type Prompts = Record<PromptCategory, Record<string, string>>;
369
95
 
370
96
  const recordIds = ref<Array<string | number>>([]);
371
97
  const recordsById = new Map<string, RecordState>();
372
- const uncheckedRecordIds = new Set<string>();
373
98
 
374
99
  defineExpose({
375
100
  click
@@ -377,18 +102,24 @@ defineExpose({
377
102
 
378
103
  const confirmDialog = ref<any>(null);
379
104
  const primaryKey = props.meta.primaryKey;
105
+ const promptsStorageKey = `bulkAiFlowGenerationPrompts_${props.meta.pluginInstanceId}`;
106
+ const defaultPrompts: Prompts = {
107
+ imageFieldsPrompts: props.meta.generationPrompts.imageFieldsPrompts,
108
+ plainFieldsPrompts: props.meta.generationPrompts.plainFieldsPrompts,
109
+ generateImages: props.meta.generationPrompts.imageGenerationPrompts,
110
+ };
380
111
  const isLoading = ref(false);
381
112
  const isFetchingRecords = ref(false);
382
113
  const isError = ref(false);
383
114
  const errorMessage = ref('');
384
115
  const isDialogOpen = ref(false);
385
- const isImageHasPreviewUrl = ref<Record<string, boolean>>({});
386
- const popupMode = ref<'generation' | 'confirmation' | 'settings'>('confirmation');
387
- const generationPrompts = ref<any>({});
388
- const isDataSaved = ref(false);
116
+ const popupMode = ref<'generation' | 'confirmation'>('confirmation');
117
+ // prompts kept in this browser (the plugin config for the ones never saved) and the ones on the screen
118
+ const savedPrompts = ref<Prompts>(promptsWithSaved({}));
119
+ const generationPrompts = ref<Prompts>(promptsWithSaved({}));
389
120
  const overwriteExistingValues = ref<boolean>(false);
121
+ const zoomedImage = ref<string | null>(null);
390
122
 
391
- const checkedCount = computed(() => recordIds.value.length - uncheckedRecordIds.size);
392
123
  const totalRecords = computed(() => recordIds.value.length);
393
124
  const isGenerationPaused = ref(false);
394
125
  const isGenerationCancelled = ref(false);
@@ -399,16 +130,18 @@ const pauseBreakpoints = computed(() => props.meta.askConfirmation || []);
399
130
  const startedRecordCount = ref(0);
400
131
  const isCheckingRateLimits = ref(false);
401
132
  let startGate = Promise.resolve();
402
- const tableRef = ref<any>(null);
403
133
  const generationFailureGroups = new Map<string, {
404
134
  actionType: GenerationAction;
405
135
  error: string;
406
136
  recordIds: Set<string>;
407
137
  }>();
408
- const processedCount = computed(() => {
409
- recordsVersion.value;
410
- return Array.from(recordsById.values()).filter(record => record.status === 'completed' || record.status === 'failed').length;
138
+ const recordsList = computed(() => {
139
+ const ids = isGenerationCancelled.value
140
+ ? recordIds.value.filter(id => completedRecordIds.value.has(String(id)))
141
+ : recordIds.value;
142
+ return ids.map(id => getOrCreateRecord(id));
411
143
  });
144
+ const processedCount = computed(() => recordsList.value.filter(record => record.status === 'completed' || record.status === 'failed').length);
412
145
  const progressStep = computed(() => {
413
146
  if (!totalRecords.value || totalRecords.value < 100) {
414
147
  return 1;
@@ -425,64 +158,52 @@ const displayedProcessedCount = computed(() => {
425
158
  }
426
159
  return Math.floor(processedCount.value / step) * step;
427
160
  });
428
- const displayedProgressPercent = computed(() => {
429
- if (!totalRecords.value) {
430
- return 0;
431
- }
432
- return Math.min(100, Math.round((displayedProcessedCount.value / totalRecords.value) * 100));
433
- });
434
- const isProcessingAny = computed(() => {
435
- recordsVersion.value;
436
- return Array.from(recordsById.values()).some(record => record.status === 'processing');
437
- });
438
-
439
- const tableHeaders = computed(() => generateTableHeaders(props.meta.outputFields));
440
- const customFieldNames = computed(() => tableHeaders.value.slice((props.meta.isAttachFiles) ? 3 : 2).map(h => h.fieldName));
441
- const recordsVersion = ref(0);
442
- const recordsList = computed(() => {
443
- recordsVersion.value;
444
- const ids = isGenerationCancelled.value
445
- ? recordIds.value.filter(id => completedRecordIds.value.has(String(id)))
446
- : recordIds.value;
447
- return ids.map(id => getOrCreateRecord(id));
448
- });
449
-
450
- const generationModeButtons = computed(() => {
451
- const arrayToReturn = [
452
- {
453
- label: checkedCount.value > 1 ? t('Save fields') : t('Save field'),
454
- options: {
455
- disabled: isLoading.value || checkedCount.value < 1 || isFetchingRecords.value || isProcessingAny.value || isGenerationPaused.value,
456
- loader: isLoading.value,
457
- class: 'afcl-button w-1/6'
458
- },
459
- onclick: async (dialog) => { await saveData(); closeDialog(); dialog.hide(false); }
161
+ const isProcessingAny = computed(() => recordsList.value.some(record => record.status === 'processing'));
162
+ const checkedCount = computed(() => recordsList.value.filter(record => record.isChecked).length);
163
+ const checkedProcessedCount = computed(() => recordsList.value.filter(record => record.isChecked && completedRecordIds.value.has(String(record.id))).length);
164
+
165
+ const confirmationModeButtons = computed(() => [
166
+ {
167
+ label: t('Cancel'),
168
+ options: { variant: 'secondary' },
169
+ onclick: (dialog) => dialog.hide(true),
170
+ },
171
+ {
172
+ label: t('Generate for {count} record | Generate for {count} records', totalRecords.value),
173
+ options: {
174
+ disabled: isFetchingRecords.value || isCheckingRateLimits.value || totalRecords.value < 1,
175
+ loader: isCheckingRateLimits.value,
460
176
  },
461
- {
462
- label: t('Cancel'),
463
- options: {
464
- class: 'afcl-button w-1/6',
465
- variant: 'secondary'
466
- },
467
- onclick: async (dialog) => { await dialog.hide(true); }
177
+ onclick: async () => { await runAiActions(); },
178
+ },
179
+ ]);
180
+
181
+ const generationModeButtons = computed(() => [
182
+ {
183
+ label: t('Cancel'),
184
+ options: { variant: 'secondary' },
185
+ onclick: (dialog) => dialog.hide(true),
186
+ },
187
+ ...(isProcessingAny.value ? [{
188
+ label: t('Save processed ({count})', { count: checkedProcessedCount.value }),
189
+ options: {
190
+ variant: 'secondary',
191
+ disabled: isLoading.value || isSavingCurrent.value || checkedProcessedCount.value < 1,
192
+ loader: isSavingCurrent.value,
468
193
  },
469
- ]
470
-
471
- if (isProcessingAny.value) {
472
- arrayToReturn.splice(1, 0, {
473
- label: t('Save processed'),
474
- options: {
475
- disabled: isLoading.value || isSavingCurrent.value || completedRecordIds.value.size < 1,
476
- loader: isSavingCurrent.value,
477
- class: 'afcl-button w-1/6',
478
- },
479
- onclick: async () => { await saveCurrentGenerated(); }
480
- })
481
- }
482
- return arrayToReturn;
483
- });
194
+ onclick: async () => { await saveCurrentGenerated(); },
195
+ }] : []),
196
+ {
197
+ label: t('Save {count} record | Save {count} records', checkedCount.value),
198
+ options: {
199
+ disabled: isLoading.value || checkedCount.value < 1 || isFetchingRecords.value || isProcessingAny.value || isGenerationPaused.value,
200
+ loader: isLoading.value,
201
+ },
202
+ onclick: async (dialog) => { await saveData(); closeDialog(); dialog.hide(false); },
203
+ },
204
+ ]);
484
205
 
485
- async function regenerateRecord({ recordId }: { recordId: string }) {
206
+ async function regenerateRecord({ recordId }: { recordId: string | number }) {
486
207
 
487
208
  const promises = [];
488
209
 
@@ -490,16 +211,7 @@ async function regenerateRecord({ recordId }: { recordId: string }) {
490
211
  promises.push(regenerateImages({ recordId }));
491
212
  }
492
213
 
493
- for (const fieldName of props.meta.outputFieldsForAnalizeFromImages || []) {
494
- promises.push(
495
- regenerateCell({
496
- recordId,
497
- fieldName,
498
- })
499
- );
500
- }
501
-
502
- for (const fieldName of props.meta.outputPlainFields || []) {
214
+ for (const fieldName of [...props.meta.outputFieldsForAnalizeFromImages, ...props.meta.outputPlainFields]) {
503
215
  promises.push(
504
216
  regenerateCell({
505
217
  recordId,
@@ -511,50 +223,19 @@ async function regenerateRecord({ recordId }: { recordId: string }) {
511
223
  await Promise.allSettled(promises);
512
224
  }
513
225
 
514
- const handleBeforeClose = async (dialog?: any) => {
515
- if (popupMode.value === 'generation') {
516
- const confirmed = await adminforth.confirm({
517
- title: t('Close without saving?'),
518
- message: t('Are you sure you want to close without saving?'),
519
- yes: t('Yes'),
520
- no: t('Cancel'),
521
- });
522
-
523
- if (confirmed) {
524
- closeDialog();
525
-
526
- if (confirmDialog.value && typeof confirmDialog.value.hide === 'function') {
527
- confirmDialog.value.hide();
528
- } else if (dialog && typeof dialog.hide === 'function') {
529
- dialog.hide();
530
- }
531
- return true;
532
- }
533
- return false;
534
- }
535
- }
536
-
537
226
  const isSavingCurrent = ref(false);
538
227
  function checkIfDialogOpen() {
539
228
  return isDialogOpen.value === true;
540
229
  }
541
230
 
542
231
  const openDialog = async () => {
543
- window.addEventListener('beforeunload', beforeUnloadHandler);
544
- if (props.meta.askConfirmationBeforeGenerating) {
545
- popupMode.value = 'confirmation';
546
- } else {
547
- popupMode.value = 'generation';
548
- }
232
+ popupMode.value = props.meta.askConfirmationBeforeGenerating ? 'confirmation' : 'generation';
233
+ loadGenerationPrompts();
549
234
  isDialogOpen.value = true;
550
235
  confirmDialog.value.open();
551
236
  isFetchingRecords.value = true;
552
237
  await initializeGlobalState();
553
- await findPreviewURLForImages();
554
238
  isFetchingRecords.value = false;
555
- if (!generationPrompts.value || Object.keys(generationPrompts.value).length === 0) {
556
- await getGenerationPrompts();
557
- }
558
239
  if (!props.meta.askConfirmationBeforeGenerating) {
559
240
  runAiActions();
560
241
  }
@@ -608,14 +289,17 @@ async function getListOfIds() {
608
289
  return props.checkboxes;
609
290
  }
610
291
  }
611
-
292
+
612
293
 
613
294
  async function runAiActions() {
614
295
  const { ok, sessionIds } = await checkRateLimits();
615
296
  if (!ok || !sessionIds) {
297
+ closeDialog();
616
298
  confirmDialog.value.close();
617
299
  return;
618
300
  }
301
+ // from here a page reload loses the generated values and the generation in progress
302
+ window.addEventListener('beforeunload', beforeUnloadHandler);
619
303
  popupMode.value = 'generation';
620
304
  isGenerationCancelled.value = false;
621
305
  isGenerationPaused.value = false;
@@ -623,8 +307,6 @@ async function runAiActions() {
623
307
  completedRecordIds.value = new Set();
624
308
  startedRecordCount.value = 0;
625
309
  generationFailureGroups.clear();
626
- await nextTick();
627
- tableRef.value?.refresh();
628
310
  const limit = pLimit(props.meta.concurrencyLimit || 10);
629
311
  const tasks = recordIds.value
630
312
  .map(id => limit(() => processOneRecord(String(id), sessionIds)));
@@ -640,7 +322,6 @@ const closeDialog = () => {
640
322
  errorMessage.value = '';
641
323
  isDialogOpen.value = false;
642
324
  popupMode.value = 'confirmation';
643
- isDataSaved.value = false;
644
325
  if (isGenerationPaused.value || isGenerationCancelled.value) {
645
326
  resolvePause();
646
327
  }
@@ -648,15 +329,13 @@ const closeDialog = () => {
648
329
 
649
330
  async function initializeGlobalState() {
650
331
  const ids = await getListOfIds();
651
- recordIds.value = ids;
652
332
  recordsById.clear();
653
- uncheckedRecordIds.clear();
333
+ recordIds.value = ids;
654
334
  }
655
335
 
656
336
  function resetGlobalState() {
657
337
  recordIds.value = [];
658
338
  recordsById.clear();
659
- uncheckedRecordIds.clear();
660
339
  generationFailureGroups.clear();
661
340
  }
662
341
 
@@ -666,9 +345,9 @@ function getActionLabel(actionType: GenerationAction) {
666
345
 
667
346
  function getGenerationFailureGroupKey(actionType: GenerationAction, error: string) {
668
347
  const normalizedError = error
669
- .replace(/Please retry in [\d.]+s\.?/g, 'Please retry later.')
670
- .replace(/\b\d+\.\d+s\b/g, '<duration>')
671
- .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<uuid>');
348
+ .replace(RETRY_IN_RE, 'Please retry later.')
349
+ .replace(DURATION_RE, '<duration>')
350
+ .replace(UUID_RE, '<uuid>');
672
351
  return `${actionType}:${normalizedError}`;
673
352
  }
674
353
 
@@ -700,21 +379,17 @@ function showGenerationFailureSummary() {
700
379
  }
701
380
  }
702
381
 
382
+ // records are reactive, so the review rerenders on every result, edit and checkbox
703
383
  function getOrCreateRecord(recordId: string | number): RecordState {
704
384
  const key = String(recordId);
705
385
  let record = recordsById.get(key);
706
386
  if (!record) {
707
- record = createEmptyRecord(recordId);
708
- record.isChecked = !uncheckedRecordIds.has(key);
387
+ record = reactive(createEmptyRecord(recordId)) as RecordState;
709
388
  recordsById.set(key, record);
710
389
  }
711
390
  return record;
712
391
  }
713
392
 
714
- function touchRecords() {
715
- recordsVersion.value += 1;
716
- }
717
-
718
393
  function waitForResumeIfPaused() {
719
394
  if (!isGenerationPaused.value) {
720
395
  return Promise.resolve();
@@ -754,7 +429,7 @@ function resumeGeneration() {
754
429
  resolvePause();
755
430
  }
756
431
 
757
- async function cancelGeneration() {
432
+ function cancelGeneration() {
758
433
  if (isGenerationCancelled.value) {
759
434
  return;
760
435
  }
@@ -768,14 +443,6 @@ async function cancelGeneration() {
768
443
  recordsById.delete(key);
769
444
  }
770
445
  }
771
- for (const key of Array.from(uncheckedRecordIds)) {
772
- if (!generatedIds.has(key)) {
773
- uncheckedRecordIds.delete(key);
774
- }
775
- }
776
- touchRecords();
777
- await nextTick();
778
- tableRef.value?.refresh();
779
446
  }
780
447
 
781
448
  async function withStartGate<T>(fn: () => Promise<T>) {
@@ -792,14 +459,6 @@ async function withStartGate<T>(fn: () => Promise<T>) {
792
459
  }
793
460
  }
794
461
 
795
- function createImageFieldMap<T>(factory: () => T): Record<string, T> {
796
- const result: Record<string, T> = {};
797
- for (const field of props.meta.outputImageFields || []) {
798
- result[field] = factory();
799
- }
800
- return result;
801
- }
802
-
803
462
  function createEmptyRecord(recordId: string | number): RecordState {
804
463
  return {
805
464
  id: recordId,
@@ -808,16 +467,13 @@ function createEmptyRecord(recordId: string | number): RecordState {
808
467
  label: '',
809
468
  data: {},
810
469
  oldData: {},
470
+ oldImagePreviews: {},
811
471
  images: [],
812
472
  aiStatus: {
813
473
  generatedImages: !props.meta.isImageGeneration,
814
474
  analyzedImages: !props.meta.isFieldsForAnalizeFromImages,
815
475
  analyzedNoImages: !props.meta.isFieldsForAnalizePlain,
816
476
  },
817
- openGenerationCarousel: createImageFieldMap(() => false),
818
- openImageCompare: createImageFieldMap(() => false),
819
- carouselSaveImages: createImageFieldMap(() => []),
820
- carouselImageIndex: createImageFieldMap(() => 0),
821
477
  imageGenerationErrorMessage: '',
822
478
  imageGenerationFailed: false,
823
479
  imageToTextErrorMessages: {},
@@ -827,6 +483,13 @@ function createEmptyRecord(recordId: string | number): RecordState {
827
483
  };
828
484
  }
829
485
 
486
+ // a regeneration can fix the last error of a record or add a new one
487
+ function settleRecordStatus(record: RecordState) {
488
+ if (!isRegenerating(record)) {
489
+ record.status = recordError(record) ? 'failed' : 'completed';
490
+ }
491
+ }
492
+
830
493
  async function processOneRecord(recordId: string, sessionIds: string[]) {
831
494
  if (!checkIfDialogOpen()) {
832
495
  return;
@@ -855,11 +518,10 @@ async function processOneRecord(recordId: string, sessionIds: string[]) {
855
518
  return;
856
519
  }
857
520
  const record = getOrCreateRecord(recordId);
858
- if (!record || !record.isChecked) {
521
+ if (!record.isChecked) {
859
522
  return;
860
523
  }
861
524
  record.status = 'processing';
862
- touchRecords();
863
525
  record.imageGenerationFailed = false;
864
526
  record.imageGenerationErrorMessage = '';
865
527
  record.imageToTextErrorMessages = {};
@@ -877,11 +539,11 @@ async function processOneRecord(recordId: string, sessionIds: string[]) {
877
539
  record.aiStatus.generatedImages = true;
878
540
  record.aiStatus.analyzedImages = true;
879
541
  record.aiStatus.analyzedNoImages = true;
880
- touchRecords();
881
542
  return;
882
543
  }
883
- record.label = oldDataResult._label || record.label;
884
- initializeRecordData(record, oldDataResult);
544
+ record.label = oldDataResult.record._label || record.label;
545
+ record.oldImagePreviews = oldDataResult.imagePreviews;
546
+ initializeRecordData(record, oldDataResult.record);
885
547
  if (props.meta.isAttachFiles) {
886
548
  await fetchImages(record, recordId);
887
549
  if (!checkIfDialogOpen()) {
@@ -907,7 +569,6 @@ async function processOneRecord(recordId: string, sessionIds: string[]) {
907
569
  const hasError = results.some(result => result.status === 'rejected');
908
570
  record.status = hasError ? 'failed' : 'completed';
909
571
  completedRecordIds.value.add(String(recordId));
910
- touchRecords();
911
572
  }
912
573
 
913
574
  async function checkRateLimits(actions?: GenerationAction[]): Promise<{ ok: boolean; sessionIds?: string[] }> {
@@ -940,6 +601,7 @@ async function checkRateLimits(actions?: GenerationAction[]): Promise<{ ok: bool
940
601
  variant: 'danger',
941
602
  timeout: 'unlimited',
942
603
  });
604
+ isCheckingRateLimits.value = false;
943
605
  return { ok: false };
944
606
  }
945
607
  sessionIds.push(rateLimitRes.sessionId);
@@ -968,27 +630,15 @@ async function runActionForRecord(record: RecordState, actionType: GenerationAct
968
630
  : 'analyzedNoImages';
969
631
  record.aiStatus[responseFlag] = false;
970
632
 
971
- let customPrompt;
972
- if (actionType === 'generate_images') {
973
- customPrompt = generationPrompts.value.imageGenerationPrompts || generationPrompts.value.generateImages;
974
- } else if (actionType === 'analyze') {
975
- customPrompt = generationPrompts.value.imageFieldsPrompts;
976
- } else if (actionType === 'analyze_no_images') {
977
- customPrompt = generationPrompts.value.plainFieldsPrompts;
978
- }
979
-
980
633
  let createJobResponse;
981
634
  try {
982
- if (!checkIfDialogOpen()) {
983
- return;
984
- }
985
635
  createJobResponse = await callAdminForthApi({
986
636
  path: `/plugin/${props.meta.pluginInstanceId}/create-job`,
987
637
  method: 'POST',
988
638
  body: {
989
639
  actionType,
990
640
  recordId: record.id,
991
- ...(customPrompt !== undefined ? { customPrompt: JSON.stringify(customPrompt) } : {}),
641
+ customPrompt: JSON.stringify(generationPrompts.value[PROMPT_CATEGORY[actionType]]),
992
642
  filterFilledFields: forceFilterFilledFields !== undefined ? forceFilterFilledFields: !overwriteExistingValues.value,
993
643
  sessionIds
994
644
  },
@@ -996,7 +646,7 @@ async function runActionForRecord(record: RecordState, actionType: GenerationAct
996
646
  });
997
647
  } catch (e) {
998
648
  record.aiStatus[responseFlag] = true;
999
- registerGenerationFailure(record, actionType, e instanceof Error ? e.message : String(e));
649
+ applyJobFailure(record, actionType, e instanceof Error ? e.message : String(e));
1000
650
  throw e;
1001
651
  }
1002
652
 
@@ -1006,7 +656,7 @@ async function runActionForRecord(record: RecordState, actionType: GenerationAct
1006
656
 
1007
657
  if (createJobResponse?.error || !createJobResponse?.jobId) {
1008
658
  record.aiStatus[responseFlag] = true;
1009
- registerGenerationFailure(record, actionType, createJobResponse?.error || `Failed to ${getActionLabel(actionType)}. Please, try to re-run the action.`);
659
+ applyJobFailure(record, actionType, createJobResponse?.error || `Failed to ${getActionLabel(actionType)}. Please, try to re-run the action.`);
1010
660
  throw new Error(createJobResponse?.error || 'Failed to create job');
1011
661
  }
1012
662
 
@@ -1031,67 +681,57 @@ async function pollJob(
1031
681
  await waitForRefresh(actionType);
1032
682
  continue;
1033
683
  }
1034
- if (jobResponse?.error) {
684
+ // a failed job comes back as { ok: false, error }
685
+ if (jobResponse.error) {
1035
686
  record.aiStatus[responseFlag] = true;
1036
- registerGenerationFailure(record, actionType, jobResponse.error);
687
+ applyJobFailure(record, actionType, jobResponse.error);
1037
688
  throw new Error(jobResponse.error);
1038
689
  }
1039
- const jobStatus = jobResponse?.job?.status;
1040
- if (jobStatus === 'in_progress') {
690
+ if (jobResponse.job.status === 'in_progress') {
1041
691
  await waitForRefresh(actionType);
1042
692
  continue;
1043
693
  }
1044
- if (jobStatus === 'completed') {
1045
- applyJobResult(record, jobResponse.job, actionType);
1046
- record.aiStatus[responseFlag] = true;
1047
- isInProgress = false;
1048
- continue;
1049
- }
1050
- if (jobStatus === 'failed') {
1051
- applyJobFailure(record, jobResponse.job, actionType);
1052
- record.aiStatus[responseFlag] = true;
1053
- throw new Error(jobResponse.job?.error || 'Job failed');
1054
- }
694
+ applyJobResult(record, jobResponse.job, actionType);
695
+ record.aiStatus[responseFlag] = true;
696
+ isInProgress = false;
1055
697
  }
1056
698
  }
1057
699
 
1058
700
  function applyJobResult(record: RecordState, job: any, actionType: GenerationAction) {
1059
701
  if (actionType === 'generate_images') {
1060
- for (const fieldName of props.meta.outputImageFields || []) {
1061
- const resultValue = job?.result?.[fieldName];
702
+ for (const fieldName of props.meta.outputImageFields) {
703
+ const resultValue = job.result[fieldName];
1062
704
  if (resultValue !== undefined) {
1063
705
  record.data[fieldName] = resultValue;
1064
706
  }
1065
- record.carouselSaveImages[fieldName] = resultValue ? [resultValue] : [];
1066
- if (job?.recordMeta?.[`${fieldName}_meta`]) {
1067
- record.carouselSaveImages[fieldName] = [job.recordMeta[`${fieldName}_meta`].originalImage];
1068
- record.listOfImageNotGenerated[fieldName] = job.recordMeta[`${fieldName}_meta`];
707
+ const imageMeta = job.recordMeta?.[`${fieldName}_meta`];
708
+ if (imageMeta) {
709
+ record.listOfImageNotGenerated[fieldName] = imageMeta;
710
+ } else {
711
+ delete record.listOfImageNotGenerated[fieldName];
1069
712
  }
1070
713
  }
1071
714
  } else {
1072
715
  record.data = {
1073
716
  ...record.data,
1074
- ...(job?.result || {}),
717
+ ...job.result,
1075
718
  };
1076
719
  }
1077
- touchRecords();
1078
720
  }
1079
721
 
1080
- function applyJobFailure(record: RecordState, job: any, actionType: GenerationAction) {
1081
- registerGenerationFailure(record, actionType, job?.error || t('Unknown error'));
722
+ function applyJobFailure(record: RecordState, actionType: GenerationAction, error: string) {
723
+ registerGenerationFailure(record, actionType, error);
1082
724
  if (actionType === 'generate_images') {
1083
725
  record.imageGenerationFailed = true;
1084
- record.imageGenerationErrorMessage = job?.error || t('Unknown error');
1085
- } else if (actionType === 'analyze') {
1086
- for (const field of Object.keys(props.meta.outputFieldsForAnalizeFromImages || {})) {
1087
- record.imageToTextErrorMessages[props.meta.outputFieldsForAnalizeFromImages[field]] = job?.error || t('Unknown error');
1088
- }
1089
- } else if (actionType === 'analyze_no_images') {
1090
- for (const field of Object.keys(props.meta.outputPlainFields || {})) {
1091
- record.textToTextErrorMessages[props.meta.outputPlainFields[field]] = job?.error || t('Unknown error');
1092
- }
726
+ record.imageGenerationErrorMessage = error;
727
+ return;
728
+ }
729
+ const [fields, messages] = actionType === 'analyze'
730
+ ? [props.meta.outputFieldsForAnalizeFromImages, record.imageToTextErrorMessages]
731
+ : [props.meta.outputPlainFields, record.textToTextErrorMessages];
732
+ for (const field of fields) {
733
+ messages[field] = error;
1093
734
  }
1094
- touchRecords();
1095
735
  }
1096
736
 
1097
737
  async function waitForRefresh(actionType: GenerationAction) {
@@ -1104,7 +744,7 @@ async function waitForRefresh(actionType: GenerationAction) {
1104
744
  }
1105
745
  }
1106
746
 
1107
- async function fetchOldData(recordId: string) {
747
+ async function fetchOldData(recordId: string): Promise<{ record: Record<string, any>, imagePreviews: Record<string, string | null> } | null> {
1108
748
  try {
1109
749
  const res = await callAdminForthApi({
1110
750
  path: `/plugin/${props.meta.pluginInstanceId}/get_old_data`,
@@ -1120,7 +760,7 @@ async function fetchOldData(recordId: string) {
1120
760
  });
1121
761
  return null;
1122
762
  }
1123
- return res.record;
763
+ return res;
1124
764
  } catch (error) {
1125
765
  console.error('Failed to get old record:', error);
1126
766
  isError.value = true;
@@ -1132,24 +772,16 @@ async function fetchOldData(recordId: string) {
1132
772
  function initializeRecordData(record: RecordState, oldRecord: Record<string, any>) {
1133
773
  const newData: Record<string, any> = {};
1134
774
  const newOldData: Record<string, any> = {};
1135
- for (const key in props.meta.outputFields || {}) {
775
+ for (const key of props.meta.outputFields) {
1136
776
  const normalizedValue = normalizeEnumValue(key, oldRecord[key] ?? null);
1137
777
  newData[key] = normalizedValue;
1138
778
  newOldData[key] = normalizedValue;
1139
779
  }
1140
- if (props.meta.outputImageFields) {
1141
- for (const key of props.meta.outputImageFields) {
1142
- const normalizedValue = normalizeEnumValue(key, oldRecord[key] ?? null);
1143
- newData[key] = normalizedValue;
1144
- newOldData[key] = normalizedValue;
1145
- }
1146
- }
1147
780
  newData[primaryKey] = oldRecord[primaryKey] ?? record.id;
1148
781
  newOldData[primaryKey] = oldRecord[primaryKey] ?? record.id;
1149
782
  newOldData._label = oldRecord._label;
1150
783
  record.data = newData;
1151
784
  record.oldData = newOldData;
1152
- touchRecords();
1153
785
  }
1154
786
 
1155
787
  function normalizeEnumValue(key: string, value: any) {
@@ -1169,7 +801,6 @@ async function fetchImages(record: RecordState, recordId: string) {
1169
801
  body: { recordId },
1170
802
  });
1171
803
  record.images = res.images || [];
1172
- touchRecords();
1173
804
  } catch (error) {
1174
805
  console.error('Failed to get images:', error);
1175
806
  isError.value = true;
@@ -1177,68 +808,6 @@ async function fetchImages(record: RecordState, recordId: string) {
1177
808
  }
1178
809
  }
1179
810
 
1180
- function formatLabel(str) {
1181
- const labelsMap = props.meta?.columnLabels || {};
1182
- let labelFromMeta = labelsMap[str];
1183
- if (!labelFromMeta) {
1184
- const match = Object.keys(labelsMap).find(k => k.toLowerCase() === String(str).toLowerCase());
1185
- if (match) labelFromMeta = labelsMap[match];
1186
- }
1187
- if (labelFromMeta) return labelFromMeta;
1188
- return str
1189
- .split('_')
1190
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
1191
- .join(' ');
1192
- }
1193
-
1194
- function generateTableHeaders(outputFields) {
1195
- const headers = [];
1196
-
1197
- headers.push({ label: 'Checkboxes', fieldName: 'checkboxes' });
1198
- headers.push({ label: 'Field name', fieldName: 'label' });
1199
- if (props.meta.isAttachFiles) {
1200
- headers.push({ label: 'Source Images', fieldName: 'images' });
1201
- }
1202
- for (const key in outputFields) {
1203
- headers.push({
1204
- label: formatLabel(key),
1205
- fieldName: key,
1206
- });
1207
- }
1208
- return headers;
1209
- }
1210
- function handleTableError(errorData) {
1211
- isError.value = errorData.isError;
1212
- errorMessage.value = errorData.errorMessage;
1213
- }
1214
- async function prepareDataForSave() {
1215
- const checkedRecords = recordIds.value
1216
- .map(id => getOrCreateRecord(id))
1217
- .filter(record => record.isChecked === true);
1218
- const checkedItems = checkedRecords.map(record => ({
1219
- ...record.data,
1220
- [primaryKey]: record.id,
1221
- }));
1222
-
1223
- const promises: Promise<void>[] = [];
1224
- checkedRecords.forEach((record, index) => {
1225
- if (record.imageGenerationFailed) {
1226
- return;
1227
- }
1228
- for (const [key, value] of Object.entries(checkedItems[index])) {
1229
- if (props.meta.outputImageFields?.includes(key)) {
1230
- const p = convertImages(key, value).then(result => {
1231
- checkedItems[index][key] = result;
1232
- });
1233
- promises.push(p);
1234
- }
1235
- }
1236
- });
1237
-
1238
- await Promise.all(promises);
1239
- return [checkedItems, checkedRecords] as const;
1240
- }
1241
-
1242
811
  async function prepareDataForRecords(records: RecordState[]) {
1243
812
  const checkedItems = records.map(record => ({
1244
813
  ...record.data,
@@ -1251,7 +820,7 @@ async function prepareDataForRecords(records: RecordState[]) {
1251
820
  return;
1252
821
  }
1253
822
  for (const [key, value] of Object.entries(checkedItems[index])) {
1254
- if (props.meta.outputImageFields?.includes(key)) {
823
+ if (props.meta.outputImageFields.includes(key)) {
1255
824
  const p = convertImages(key, value).then(result => {
1256
825
  checkedItems[index][key] = result;
1257
826
  });
@@ -1293,7 +862,7 @@ async function uploadImagesForRecords(reqData: Record<string, any>[], checkedRec
1293
862
  const item = reqData[index];
1294
863
  const record = checkedRecords[index];
1295
864
  for (const [key, value] of Object.entries(item)) {
1296
- if (props.meta.outputImageFields?.includes(key)) {
865
+ if (props.meta.outputImageFields.includes(key)) {
1297
866
  if (!value) {
1298
867
  continue;
1299
868
  }
@@ -1315,12 +884,11 @@ async function uploadImagesForRecords(reqData: Record<string, any>[], checkedRec
1315
884
  await Promise.all(imagesToUpload);
1316
885
  }
1317
886
 
1318
-
1319
887
  async function saveData() {
1320
888
  const errorText = 'Failed to save some records. Not all data may be saved'
1321
889
  try {
1322
890
  isLoading.value = true;
1323
- const [reqData, checkedRecords] = await prepareDataForSave();
891
+ const [reqData, checkedRecords] = await prepareDataForRecords(recordsList.value.filter(record => record.isChecked));
1324
892
  if (checkedRecords.length < 1) {
1325
893
  adminforth.alert({ message: t('No items selected'), variant: 'warning' });
1326
894
  return;
@@ -1369,14 +937,13 @@ async function saveData() {
1369
937
  props.updateList();
1370
938
  props.clearCheckboxes?.();
1371
939
  isLoading.value = false;
1372
- isDataSaved.value = true;
1373
940
  window.removeEventListener('beforeunload', beforeUnloadHandler);
1374
941
  }
1375
942
  }
1376
943
  async function uploadImage(imgBlob, id, fieldName) {
1377
944
  const file = new File([imgBlob], `generated_${fieldName}_${id}.${imgBlob.type.split('/').pop()}`, { type: imgBlob.type });
1378
945
  const { name, size, type } = file;
1379
-
946
+
1380
947
  const extension = name.split('.').pop();
1381
948
  const nameNoExtension = name.replace(`.${extension}`, '');
1382
949
 
@@ -1403,14 +970,8 @@ async function uploadImage(imgBlob, id, fieldName) {
1403
970
 
1404
971
  const xhr = new XMLHttpRequest();
1405
972
  const success = await new Promise((resolve) => {
1406
- xhr.upload.onprogress = (e) => {
1407
- if (e.lengthComputable) {
1408
- }
1409
- };
1410
973
  xhr.addEventListener('loadend', () => {
1411
- const success = xhr.readyState === 4 && xhr.status === 200;
1412
- // try to read response
1413
- resolve(success);
974
+ resolve(xhr.readyState === 4 && xhr.status === 200);
1414
975
  });
1415
976
  xhr.open('PUT', uploadUrl, true);
1416
977
  xhr.setRequestHeader('Content-Type', type);
@@ -1444,7 +1005,7 @@ async function uploadImage(imgBlob, id, fieldName) {
1444
1005
  }
1445
1006
  }
1446
1007
 
1447
- async function regenerateImages({ recordId }: { recordId: any }) {
1008
+ async function regenerateImages({ recordId }: { recordId: string | number }) {
1448
1009
  const {ok, sessionIds} = await checkRateLimits(['generate_images']);
1449
1010
  if (!ok || !sessionIds) {
1450
1011
  return;
@@ -1457,13 +1018,10 @@ async function regenerateImages({ recordId }: { recordId: any }) {
1457
1018
  });
1458
1019
  return;
1459
1020
  }
1460
- const record = recordsById.get(String(recordId));
1461
- if (!record) {
1462
- return;
1463
- }
1464
- record.aiStatus.generatedImages = false;
1021
+ const record = recordsById.get(String(recordId))!;
1465
1022
  record.status = 'processing';
1466
- touchRecords();
1023
+ record.imageGenerationFailed = false;
1024
+ record.imageGenerationErrorMessage = '';
1467
1025
 
1468
1026
  try {
1469
1027
  await runActionForRecord(
@@ -1472,41 +1030,10 @@ async function regenerateImages({ recordId }: { recordId: any }) {
1472
1030
  sessionIds,
1473
1031
  false
1474
1032
  );
1475
-
1476
- record.status = 'completed';
1477
- completedRecordIds.value.add(String(recordId));
1478
-
1479
- record.aiStatus.generatedImages = true;
1480
- touchRecords();
1481
-
1482
- } catch (e) {
1483
- record.aiStatus.generatedImages = true;
1484
- record.status = 'failed';
1485
- touchRecords();
1486
- }
1487
- }
1488
-
1489
- async function findPreviewURLForImages() {
1490
- if (props.meta.outputImageFields){
1491
- for (const fieldName of props.meta.outputImageFields) {
1492
- try {
1493
- const res = await callAdminForthApi({
1494
- path: `/plugin/${props.meta.pluginInstanceId}/compile_old_image_link`,
1495
- method: 'POST',
1496
- body: {
1497
- image: "test",
1498
- columnName: fieldName,
1499
- },
1500
- });
1501
- if (res?.ok) {
1502
- isImageHasPreviewUrl.value[fieldName] = true;
1503
- } else {
1504
- isImageHasPreviewUrl.value[fieldName] = false;
1505
- }
1506
- } catch (e) {
1507
- console.error("Error finding preview URL for field", fieldName, e);
1508
- }
1509
- }
1033
+ } catch {
1034
+ // the error is already shown on the image fields of the record
1035
+ } finally {
1036
+ settleRecordStatus(record);
1510
1037
  }
1511
1038
  }
1512
1039
 
@@ -1514,112 +1041,40 @@ function click() {
1514
1041
  openDialog();
1515
1042
  }
1516
1043
 
1517
- function saveSettings() {
1518
- const promptsToSaveGenerateImages = Object.fromEntries(
1519
- Object.entries(generationPrompts.value.generateImages).filter(
1520
- ([key, value]) => value !== props.meta.generationPrompts.imageGenerationPrompts[key].prompt
1521
- )
1522
- );
1523
-
1524
- const promptsToSaveAnalyzeImages = Object.fromEntries(
1525
- Object.entries(generationPrompts.value.imageFieldsPrompts).filter(
1526
- ([key, value]) => value !== props.meta.generationPrompts.imageFieldsPrompts[key]
1527
- )
1528
- );
1529
-
1530
- const promptsToSaveAnalyzeNoImages = Object.fromEntries(
1531
- Object.entries(generationPrompts.value.plainFieldsPrompts).filter(
1532
- ([key, value]) => value !== props.meta.generationPrompts.plainFieldsPrompts[key]
1533
- )
1044
+ // a saved prompt is kept in this browser, only the ones which differ from the plugin config are stored
1045
+ function savePrompt(category: PromptCategory, field: string) {
1046
+ savedPrompts.value[category][field] = generationPrompts.value[category][field];
1047
+ const changedPrompts = Object.fromEntries(
1048
+ Object.entries(savedPrompts.value)
1049
+ .map(([category, prompts]) => [
1050
+ category,
1051
+ Object.fromEntries(Object.entries(prompts).filter(([field, prompt]) => prompt !== defaultPrompts[category][field])),
1052
+ ])
1053
+ .filter(([, prompts]) => Object.keys(prompts).length > 0),
1534
1054
  );
1535
-
1536
- const promptsToSave = {
1537
- ...(Object.keys(promptsToSaveAnalyzeNoImages).length > 0 ? { plainFieldsPrompts: promptsToSaveAnalyzeNoImages } : {}),
1538
- ...(Object.keys(promptsToSaveAnalyzeImages).length > 0 ? { imageFieldsPrompts: promptsToSaveAnalyzeImages } : {}),
1539
- ...(Object.keys(promptsToSaveGenerateImages).length > 0 ? { generateImages: promptsToSaveGenerateImages } : {}),
1540
- };
1541
-
1542
- console.log('Saving prompts:', promptsToSave);
1543
-
1544
- localStorage.setItem(`bulkAiFlowGenerationPrompts_${props.meta.pluginInstanceId}`, JSON.stringify(promptsToSave));
1545
- popupMode.value = 'confirmation';
1546
- }
1547
-
1548
- async function getGenerationPrompts() {
1549
- const calculatedGenerationPrompts: any = {};
1550
- const savedPrompts = localStorage.getItem(`bulkAiFlowGenerationPrompts_${props.meta.pluginInstanceId}`);
1551
- if (props.meta.generationPrompts.plainFieldsPrompts) {
1552
- calculatedGenerationPrompts.plainFieldsPrompts = {
1553
- ...props.meta.generationPrompts.plainFieldsPrompts
1554
- };
1555
- }
1556
- if (props.meta.generationPrompts.imageFieldsPrompts) {
1557
- calculatedGenerationPrompts.imageFieldsPrompts = {
1558
- ...props.meta.generationPrompts.imageFieldsPrompts
1559
- };
1560
- }
1561
- if (props.meta.generationPrompts.imageGenerationPrompts) {
1562
- let imageFields = {};
1563
- for (const [key, value] of Object.entries(props.meta.generationPrompts.imageGenerationPrompts)) {
1564
- // value might be typed as unknown; cast to any to access prompt safely
1565
- imageFields[key] = (value as any).prompt;
1566
- }
1567
- calculatedGenerationPrompts.generateImages = imageFields;
1568
- }
1569
- if (savedPrompts && props.meta.askConfirmationBeforeGenerating) {
1570
-
1571
- generationPrompts.value = checkAndAddNewFieldsToPrompts(JSON.parse(savedPrompts), calculatedGenerationPrompts);
1572
-
1573
- return;
1574
- }
1575
- generationPrompts.value = calculatedGenerationPrompts;
1055
+ localStorage.setItem(promptsStorageKey, JSON.stringify(changedPrompts));
1576
1056
  }
1577
1057
 
1578
- function resetPromptToDefault(categoryKey, promptKey) {
1579
- console.log('Resetting prompt:', categoryKey, promptKey);
1580
- console.log('Default prompt:', props.meta.generationPrompts);
1581
- if (categoryKey === 'generateImages') {
1582
- generationPrompts.value[categoryKey][promptKey] = props.meta.generationPrompts.imageGenerationPrompts[promptKey].prompt;
1583
- return;
1584
- }
1585
- console.log('Resetting prompt to the:', props.meta.generationPrompts[categoryKey][promptKey]);
1586
- generationPrompts.value[categoryKey][promptKey] = props.meta.generationPrompts[categoryKey][promptKey];
1587
- }
1588
-
1589
- function clickSettingsButton() {
1590
- getGenerationPrompts();
1591
- popupMode.value = 'settings';
1058
+ // fields which were removed from the plugin config drop out of the saved prompts, new fields get their defaults
1059
+ function promptsWithSaved(saved: Partial<Prompts>): Prompts {
1060
+ return Object.fromEntries(
1061
+ Object.entries(defaultPrompts).map(([category, prompts]) => [
1062
+ category,
1063
+ Object.fromEntries(Object.entries(prompts).map(([field, prompt]) => [field, saved[category]?.[field] ?? prompt])),
1064
+ ]),
1065
+ ) as Prompts;
1592
1066
  }
1593
1067
 
1594
-
1595
- function checkAndAddNewFieldsToPrompts(savedPrompts, defaultPrompts) {
1596
- for (const categoryKey in defaultPrompts) {
1597
- if (!savedPrompts.hasOwnProperty(categoryKey)) {
1598
- savedPrompts[categoryKey] = defaultPrompts[categoryKey];
1599
- } else {
1600
- for (const promptKey in defaultPrompts[categoryKey]) {
1601
- if (!savedPrompts[categoryKey].hasOwnProperty(promptKey)) {
1602
- savedPrompts[categoryKey][promptKey] = defaultPrompts[categoryKey][promptKey];
1603
- }
1604
- }
1605
- }
1606
- }
1607
- //remove deprecated fields
1608
- for (const categoryKey in savedPrompts) {
1609
- if (!defaultPrompts.hasOwnProperty(categoryKey)) {
1610
- delete savedPrompts[categoryKey];
1611
- } else {
1612
- for (const promptKey in savedPrompts[categoryKey]) {
1613
- if (!defaultPrompts[categoryKey].hasOwnProperty(promptKey)) {
1614
- delete savedPrompts[categoryKey][promptKey];
1615
- }
1616
- }
1617
- }
1618
- }
1619
- return savedPrompts;
1068
+ // only a user who sees the setup screen can edit prompts, so only then the saved ones apply
1069
+ function loadGenerationPrompts() {
1070
+ const saved = props.meta.askConfirmationBeforeGenerating
1071
+ ? JSON.parse(localStorage.getItem(promptsStorageKey) || '{}')
1072
+ : {};
1073
+ savedPrompts.value = promptsWithSaved(saved);
1074
+ generationPrompts.value = promptsWithSaved(saved);
1620
1075
  }
1621
1076
 
1622
- async function regenerateCell(recordInfo: any) {
1077
+ async function regenerateCell({ recordId, fieldName }: { recordId: string | number, fieldName: string }) {
1623
1078
  if (coreStore.isInternetError) {
1624
1079
  adminforth.alert({
1625
1080
  message: t('Cannot regenerate column while internet connection is lost. Please check your connection and try again.'),
@@ -1629,135 +1084,84 @@ async function regenerateCell(recordInfo: any) {
1629
1084
  return;
1630
1085
  }
1631
1086
 
1632
- const {ok, sessionIds} = await checkRateLimits(['analyze_no_images', 'analyze']);
1087
+ const actionType: GenerationAction = props.meta.outputFieldsForAnalizeFromImages.includes(fieldName) ? 'analyze' : 'analyze_no_images';
1088
+ const { ok, sessionIds } = await checkRateLimits([actionType]);
1633
1089
  if (!ok || !sessionIds) {
1634
1090
  return;
1635
1091
  }
1636
1092
 
1637
- const record = recordsById.get(String(recordInfo.recordId));
1638
- if (!record) {
1639
- return;
1640
- }
1641
- if (!record.regeneratingFieldsStatus[recordInfo.fieldName]) {
1642
- record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1643
- }
1644
- record.regeneratingFieldsStatus[recordInfo.fieldName] = true;
1645
- touchRecords();
1646
- const actionType = props.meta.outputFieldsForAnalizeFromImages?.includes(recordInfo.fieldName)
1647
- ? 'analyze'
1648
- : props.meta.outputPlainFields?.includes(recordInfo.fieldName)
1649
- ? 'analyze_no_images'
1650
- : null;
1651
- if (!actionType) {
1652
- console.error(`Field ${recordInfo.fieldName} is not configured for analysis.`);
1653
- record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1654
- touchRecords();
1655
- return;
1656
- }
1657
-
1658
- let generationPromptsForField = {};
1659
- if (actionType === 'analyze') {
1660
- generationPromptsForField = generationPrompts.value.imageFieldsPrompts || {};
1661
- record.aiStatus.analyzedImages = false;
1662
- } else if (actionType === 'analyze_no_images') {
1663
- generationPromptsForField = generationPrompts.value.plainFieldsPrompts || {};
1664
- record.aiStatus.analyzedNoImages = false;
1665
- }
1666
-
1667
- let jobId;
1668
- let res;
1093
+ const record = recordsById.get(String(recordId))!;
1094
+ const errorMessages = actionType === 'analyze' ? record.imageToTextErrorMessages : record.textToTextErrorMessages;
1095
+ const refreshRate = actionType === 'analyze' ? props.meta.refreshRates.fillFieldsFromImages : props.meta.refreshRates.fillPlainFields;
1096
+ record.regeneratingFieldsStatus[fieldName] = true;
1097
+
1669
1098
  try {
1670
- res = await callAdminForthApi({
1099
+ const res = await callAdminForthApi({
1671
1100
  path: `/plugin/${props.meta.pluginInstanceId}/create-job`,
1672
1101
  method: 'POST',
1673
1102
  body: {
1674
- fieldToRegenerate: recordInfo.fieldName,
1675
- recordId: recordInfo.recordId,
1103
+ fieldToRegenerate: fieldName,
1104
+ recordId,
1676
1105
  action: actionType,
1677
1106
  actionType: "regenerate_cell",
1678
- prompt: generationPromptsForField[recordInfo.fieldName] || null,
1107
+ prompt: generationPrompts.value[PROMPT_CATEGORY[actionType]][fieldName] || null,
1679
1108
  sessionIds
1680
1109
  },
1681
1110
  silentError: true,
1682
1111
  });
1683
- } catch (e) {
1684
- record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1685
- console.error(`Error during cell regeneration for record ${recordInfo.recordId}, field ${recordInfo.fieldName}:`, e);
1686
- }
1687
- if ( res.ok === false) {
1688
- adminforth.alert({
1689
- message: res.error,
1690
- variant: 'danger',
1691
- });
1692
- isError.value = true;
1693
- errorMessage.value = t(`Failed to regenerate field`);
1694
- record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1695
- return;
1696
- }
1697
- jobId = res.jobId;
1698
- res = {}
1699
- do {
1700
- res = await callAdminForthApi({
1701
- path: `/plugin/${props.meta.pluginInstanceId}/get-job-status`,
1702
- method: 'POST',
1703
- body: { jobId },
1704
- silentError: true,
1705
- });
1706
- if (actionType === 'analyze') {
1707
- await new Promise(resolve => setTimeout(resolve, props.meta.refreshRates?.fillFieldsFromImages));
1708
- } else if (actionType === 'analyze_no_images') {
1709
- await new Promise(resolve => setTimeout(resolve, props.meta.refreshRates?.fillPlainFields));
1112
+ if (res.ok === false) {
1113
+ adminforth.alert({
1114
+ message: res.error,
1115
+ variant: 'danger',
1116
+ });
1117
+ isError.value = true;
1118
+ errorMessage.value = t(`Failed to regenerate field`);
1119
+ return;
1710
1120
  }
1711
- } while (res.job?.status === 'in_progress');
1712
- if (res.job?.status === 'failed' || !res.ok || !res) {
1713
- adminforth.alert({
1714
- message: t(`Regeneration action failed for record: ${recordInfo.recordId}. Error: ${res.job?.error || 'Unknown error'}`),
1715
- variant: 'danger',
1716
- timeout: 'unlimited',
1717
- });
1718
- if (actionType === 'analyze') {
1719
- record.imageToTextErrorMessages[recordInfo.fieldName] = res.job?.error || 'Unknown error';
1720
- record.aiStatus.analyzedImages = true;
1721
- } else if (actionType === 'analyze_no_images') {
1722
- record.textToTextErrorMessages[recordInfo.fieldName] = res.job?.error || 'Unknown error';
1723
- record.aiStatus.analyzedNoImages = true;
1121
+
1122
+ let jobResponse;
1123
+ do {
1124
+ await new Promise(resolve => setTimeout(resolve, refreshRate));
1125
+ jobResponse = await callAdminForthApi({
1126
+ path: `/plugin/${props.meta.pluginInstanceId}/get-job-status`,
1127
+ method: 'POST',
1128
+ body: { jobId: res.jobId },
1129
+ silentError: true,
1130
+ });
1131
+ } while (jobResponse?.job?.status === 'in_progress');
1132
+
1133
+ // a failed job comes back as { ok: false, error }
1134
+ if (!jobResponse?.ok) {
1135
+ const error = jobResponse?.error || t('Unknown error');
1136
+ adminforth.alert({
1137
+ message: t(`Regeneration action failed for record: ${recordId}. Error: ${error}`),
1138
+ variant: 'danger',
1139
+ timeout: 'unlimited',
1140
+ });
1141
+ errorMessages[fieldName] = error;
1142
+ return;
1724
1143
  }
1725
- record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1726
- touchRecords();
1727
- return;
1728
- } else if (res.job?.status === 'completed') {
1729
1144
  record.data = {
1730
1145
  ...record.data,
1731
- ...res.job.result,
1146
+ ...jobResponse.job.result,
1732
1147
  };
1733
- if (actionType === 'analyze') {
1734
- record.imageToTextErrorMessages[recordInfo.fieldName] = '';
1735
- record.aiStatus.analyzedImages = true;
1736
- } else if (actionType === 'analyze_no_images') {
1737
- record.textToTextErrorMessages[recordInfo.fieldName] = '';
1738
- record.aiStatus.analyzedNoImages = true;
1739
- }
1740
- record.regeneratingFieldsStatus[recordInfo.fieldName] = false;
1741
- touchRecords();
1148
+ delete errorMessages[fieldName];
1149
+ } catch (e) {
1150
+ console.error(`Error during cell regeneration for record ${recordId}, field ${fieldName}:`, e);
1151
+ } finally {
1152
+ record.regeneratingFieldsStatus[fieldName] = false;
1153
+ settleRecordStatus(record);
1742
1154
  }
1743
1155
  }
1744
1156
 
1745
1157
  const beforeUnloadHandler = (event) => {
1746
- if (isDataSaved.value) return;
1747
1158
  event.preventDefault();
1748
1159
  event.returnValue = '';
1749
1160
  };
1750
1161
 
1751
1162
  async function saveCurrentGenerated() {
1752
1163
  const errorText = 'Failed to save some records. Not all data may be saved';
1753
- const generatedIds = new Set(completedRecordIds.value);
1754
- if (generatedIds.size < 1) {
1755
- return;
1756
- }
1757
- const recordsToSave = Array.from(generatedIds)
1758
- .map(id => recordsById.get(String(id)))
1759
- .filter((record): record is RecordState => Boolean(record))
1760
- .filter(record => record.isChecked === true);
1164
+ const recordsToSave = recordsList.value.filter(record => record.isChecked && completedRecordIds.value.has(String(record.id)));
1761
1165
  if (recordsToSave.length < 1) {
1762
1166
  return;
1763
1167
  }
@@ -1802,19 +1206,17 @@ async function saveCurrentGenerated() {
1802
1206
 
1803
1207
  if (savedIds.length > 0) {
1804
1208
  recordIds.value = recordIds.value.filter(id => !savedIds.includes(String(id)));
1805
- for (let index = 0; index < savedIds.length; index++) {
1806
- const id = savedIds[index];
1209
+ for (const id of savedIds) {
1807
1210
  recordsById.delete(id);
1808
- uncheckedRecordIds.delete(id);
1809
1211
  completedRecordIds.value.delete(id);
1810
1212
  }
1811
1213
  adminforth.alert({
1812
1214
  message: t('Successfully saved generated data for {count} records', { count: savedIds.length }),
1813
1215
  variant: 'success',
1814
1216
  });
1815
- touchRecords();
1816
1217
  }
1817
1218
  if (recordIds.value.length === 0) {
1219
+ closeDialog();
1818
1220
  confirmDialog.value.close();
1819
1221
  return;
1820
1222
  }
@@ -1825,21 +1227,7 @@ async function saveCurrentGenerated() {
1825
1227
  } finally {
1826
1228
  isSavingCurrent.value = false;
1827
1229
  }
1828
- await nextTick();
1829
- tableRef.value.refresh();
1830
1230
  props.updateList();
1831
1231
  }
1832
1232
 
1833
- const generationStatusClass = computed(() => {
1834
- if (isGenerationPaused.value) {
1835
- return 'bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-950/30 dark:text-yellow-400 dark:border-yellow-400'
1836
- }
1837
-
1838
- if (displayedProcessedCount.value === totalRecords.value) {
1839
- return 'bg-green-100 text-green-800 border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-400'
1840
- }
1841
-
1842
- return 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-400'
1843
- })
1844
-
1845
1233
  </script>