|
20 | 20 | { |
21 | 21 | label: checkedCount > 1 ? t('Save fields') : t('Save field'), |
22 | 22 | options: { |
23 | | - disabled: isLoading || checkedCount < 1 || isFetchingRecords || isProcessingAny, |
| 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(); } |
|
71 | 71 |
|
72 | 72 |
|
73 | 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> |
74 | 91 | <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' : ''" |
76 | 94 | role="progressbar" |
77 | 95 | :aria-valuenow="displayedProcessedCount" |
78 | 96 | :aria-valuemin="0" |
79 | 97 | :aria-valuemax="totalRecords" |
80 | 98 | > |
81 | 99 | <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 " |
83 | 101 | :style="{ width: `${displayedProgressPercent}%` }" |
84 | 102 | ></div> |
85 | 103 | <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') }} |
88 | 109 | </template> |
89 | 110 | <template v-else> |
90 | 111 | {{ t('Processed') }} |
|
96 | 117 |
|
97 | 118 | <VisionTable |
98 | 119 | class="md:max-h-[75vh] max-w-[1560px] w-full h-full" |
| 120 | + ref="tableRef" |
99 | 121 | :records="recordsList" |
100 | 122 | :meta="props.meta" |
101 | 123 | :tableHeaders="tableHeaders" |
@@ -244,6 +266,15 @@ const overwriteExistingValues = ref<boolean>(false); |
244 | 266 |
|
245 | 267 | const checkedCount = computed(() => recordIds.value.length - uncheckedRecordIds.size); |
246 | 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); |
247 | 278 | const processedCount = computed(() => { |
248 | 279 | recordsVersion.value; |
249 | 280 | 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 |
280 | 311 | const recordsVersion = ref(0); |
281 | 312 | const recordsList = computed(() => { |
282 | 313 | 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)); |
284 | 318 | }); |
285 | 319 |
|
286 | 320 | function checkIfDialogOpen() { |
@@ -343,10 +377,16 @@ async function runAiActions() { |
343 | 377 | if (!await checkRateLimits()) { |
344 | 378 | return; |
345 | 379 | } |
| 380 | + isGenerationCancelled.value = false; |
| 381 | + isGenerationPaused.value = false; |
| 382 | + isActiveGeneration.value = true; |
| 383 | + completedRecordIds.value = new Set(); |
| 384 | + startedRecordCount.value = 0; |
346 | 385 | const limit = pLimit(props.meta.concurrencyLimit || 10); |
347 | 386 | const tasks = recordIds.value |
348 | 387 | .map(id => limit(() => processOneRecord(String(id)))); |
349 | 388 | await Promise.all(tasks); |
| 389 | + isActiveGeneration.value = false; |
350 | 390 | } |
351 | 391 |
|
352 | 392 | const closeDialog = () => { |
@@ -387,6 +427,82 @@ function touchRecords() { |
387 | 427 | recordsVersion.value += 1; |
388 | 428 | } |
389 | 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; |
| 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 | +
|
390 | 506 | function createImageFieldMap<T>(factory: () => T): Record<string, T> { |
391 | 507 | const result: Record<string, T> = {}; |
392 | 508 | for (const field of props.meta.outputImageFields || []) { |
@@ -426,6 +542,29 @@ async function processOneRecord(recordId: string) { |
426 | 542 | if (!checkIfDialogOpen()) { |
427 | 543 | return; |
428 | 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 | + } |
429 | 568 | const record = getOrCreateRecord(recordId); |
430 | 569 | if (!record || !record.isChecked) { |
431 | 570 | return; |
@@ -478,6 +617,7 @@ async function processOneRecord(recordId: string) { |
478 | 617 | } |
479 | 618 | const hasError = results.some(result => result.status === 'rejected'); |
480 | 619 | record.status = hasError ? 'failed' : 'completed'; |
| 620 | + completedRecordIds.value.add(String(recordId)); |
481 | 621 | touchRecords(); |
482 | 622 | } |
483 | 623 |
|
|
0 commit comments