Skip to content

Commit 8956d6a

Browse files
committed
feat: add askConfirmation
1 parent 8802582 commit 8956d6a

4 files changed

Lines changed: 174 additions & 10 deletions

File tree

custom/VisionAction.vue

Lines changed: 146 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
{
2121
label: checkedCount > 1 ? t('Save fields') : t('Save field'),
2222
options: {
23-
disabled: isLoading || checkedCount < 1 || isFetchingRecords || isProcessingAny,
23+
disabled: isLoading || checkedCount < 1 || isFetchingRecords || isProcessingAny || isGenerationPaused,
2424
loader: isLoading, class: 'w-fit'
2525
},
2626
onclick: async (dialog) => { await saveData(); dialog.hide(); }
@@ -71,20 +71,41 @@
7171

7272

7373
<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>
7491
<div
75-
class="w-full h-[30px] rounded-md bg-gray-200 dark:bg-gray-700 overflow-hidden relative"
92+
class="w-full h-[30px] rounded-2xl bg-gray-200 dark:bg-gray-700 overflow-hidden relative"
93+
:class="isGenerationPaused ? 'opacity-80' : ''"
7694
role="progressbar"
7795
:aria-valuenow="displayedProcessedCount"
7896
:aria-valuemin="0"
7997
:aria-valuemax="totalRecords"
8098
>
8199
<div
82-
class="h-full bg-gradient-to-r from-purple-500 via-purple-600 to-purple-700 transition-all duration-200"
100+
class="h-full bg-gradient-to-r from-purple-500 via-purple-600 to-purple-700 transition-all duration-200 "
83101
:style="{ width: `${displayedProgressPercent}%` }"
84102
></div>
85103
<div class="absolute inset-0 flex items-center justify-center text-sm font-medium text-white drop-shadow">
86-
<template v-if="isProcessingAny">
87-
{{ displayedProcessedCount }} / {{ totalRecords }}
104+
<template v-if="isProcessingAny || isGenerationPaused">
105+
{{ (displayedProcessedCount / totalRecords) * 100 }}%
106+
</template>
107+
<template v-else-if="isGenerationCancelled">
108+
{{ t('Generation cancelled') }}
88109
</template>
89110
<template v-else>
90111
{{ t('Processed') }}
@@ -96,6 +117,7 @@
96117

97118
<VisionTable
98119
class="md:max-h-[75vh] max-w-[1560px] w-full h-full"
120+
ref="tableRef"
99121
:records="recordsList"
100122
:meta="props.meta"
101123
:tableHeaders="tableHeaders"
@@ -244,6 +266,15 @@ const overwriteExistingValues = ref<boolean>(false);
244266
245267
const checkedCount = computed(() => recordIds.value.length - uncheckedRecordIds.size);
246268
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);
247278
const processedCount = computed(() => {
248279
recordsVersion.value;
249280
return Array.from(recordsById.values()).filter(record => record.status === 'completed' || record.status === 'failed').length;
@@ -280,7 +311,10 @@ const customFieldNames = computed(() => tableHeaders.value.slice((props.meta.isA
280311
const recordsVersion = ref(0);
281312
const recordsList = computed(() => {
282313
recordsVersion.value;
283-
return recordIds.value.map(id => getOrCreateRecord(id));
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));
284318
});
285319
286320
function checkIfDialogOpen() {
@@ -343,10 +377,16 @@ async function runAiActions() {
343377
if (!await checkRateLimits()) {
344378
return;
345379
}
380+
isGenerationCancelled.value = false;
381+
isGenerationPaused.value = false;
382+
isActiveGeneration.value = true;
383+
completedRecordIds.value = new Set();
384+
startedRecordCount.value = 0;
346385
const limit = pLimit(props.meta.concurrencyLimit || 10);
347386
const tasks = recordIds.value
348387
.map(id => limit(() => processOneRecord(String(id))));
349388
await Promise.all(tasks);
389+
isActiveGeneration.value = false;
350390
}
351391
352392
const closeDialog = () => {
@@ -387,6 +427,82 @@ function touchRecords() {
387427
recordsVersion.value += 1;
388428
}
389429
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;
453+
}
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;
464+
}
465+
isGenerationPaused.value = false;
466+
resolvePause();
467+
}
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+
}
491+
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!();
503+
}
504+
}
505+
390506
function createImageFieldMap<T>(factory: () => T): Record<string, T> {
391507
const result: Record<string, T> = {};
392508
for (const field of props.meta.outputImageFields || []) {
@@ -426,6 +542,29 @@ async function processOneRecord(recordId: string) {
426542
if (!checkIfDialogOpen()) {
427543
return;
428544
}
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+
}
429568
const record = getOrCreateRecord(recordId);
430569
if (!record || !record.isChecked) {
431570
return;
@@ -478,6 +617,7 @@ async function processOneRecord(recordId: string) {
478617
}
479618
const hasError = results.some(result => result.status === 'rejected');
480619
record.status = hasError ? 'failed' : 'completed';
620+
completedRecordIds.value.add(String(recordId));
481621
touchRecords();
482622
}
483623

custom/VisionTable.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<template>
22
<Table
3+
ref="tableRef"
34
:columns="tableHeaders"
45
:data="tableDataProvider"
56
:pageSize="pageSize"
@@ -245,6 +246,15 @@ const pageSize = 6;
245246
const pagination = reactive({ offset: 0, limit: pageSize });
246247
const zoomedImage = ref(null);
247248
const hovers = ref<Record<string, Record<string, boolean>>>({});
249+
const tableRef = ref(null);
250+
251+
defineExpose({
252+
refresh() {
253+
if (tableRef.value) {
254+
tableRef.value.refreshTable();
255+
}
256+
}
257+
});
248258
249259
const paginatedRecords = computed(() => props.records.slice(pagination.offset, pagination.offset + pagination.limit));
250260

index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,15 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
192192
const selectedId = recordId;
193193
let isError = false;
194194
if (STUB_MODE) {
195-
await new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 20000) + 1000));
196-
jobs.set(jobId, { status: 'completed', result: {} });
197-
jobs.set(jobId, { status: 'failed', error: `ERROR: test error` });
198-
return { ok: false, error: 'test error' };
195+
const fakeError = Math.random() < 0.005; // 0.05% chance of error
196+
// await new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 20000) + 1000));
197+
if (fakeError) {
198+
jobs.set(jobId, { status: 'failed', error: `ERROR: test error` });
199+
return { ok: false, error: 'test error' };
200+
} else {
201+
jobs.set(jobId, { status: 'completed', result: {description: 'test description', price: 99999999, engine_power: 999} });
202+
return { ok: true };
203+
}
199204
} else {
200205
const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
201206
const record = await this.adminforth.resource(this.resourceConfig.resourceId).get( [Filters.EQ(primaryKeyColumn.name, selectedId)] );
@@ -611,6 +616,7 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
611616
askConfirmationBeforeGenerating: this.options.askConfirmationBeforeGenerating || false,
612617
concurrencyLimit: this.options.concurrencyLimit || 10,
613618
recordSelector: this.options.recordSelector || 'checkbox',
619+
askConfirmation: this.options.askConfirmation || [],
614620
generationPrompts: {
615621
plainFieldsPrompts: this.options.fillPlainFields || {},
616622
imageFieldsPrompts: this.options.fillFieldsFromImages || {},

types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,12 @@ export interface PluginOptions {
136136
* Default is 'checkbox'.
137137
*/
138138
recordSelector?: 'checkbox' | 'filtered';
139+
140+
/**
141+
* additional confirmation: generation of very many records is risky in terms of budget. On 1m budget it might be thousands USD,
142+
* this settings allows to suspend rgeneration and allow user to review everything what was already generated so far
143+
* and then suggest Resume / Stop Generation buttons. You can set it in mode where it is shown only afterFirst N records (e.g. at start) or every N records
144+
*/
145+
askConfirmation?: ({ afterRecords: number } | { everyRecords: number })[]
146+
139147
}

0 commit comments

Comments
 (0)