@adminforth/bulk-ai-flow 2.0.3 → 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.
- package/build.log +7 -6
- package/custom/GenerationSetup.vue +148 -0
- package/custom/ImageLightbox.vue +45 -0
- package/custom/RecordDetail.vue +276 -0
- package/custom/ReviewQueue.vue +253 -0
- package/custom/VisionAction.vue +266 -878
- package/custom/package-lock.json +1 -331
- package/custom/package.json +0 -3
- package/custom/pnpm-lock.yaml +0 -214
- package/custom/reviewModel.ts +124 -0
- package/dist/custom/GenerationSetup.vue +148 -0
- package/dist/custom/ImageLightbox.vue +45 -0
- package/dist/custom/RecordDetail.vue +276 -0
- package/dist/custom/ReviewQueue.vue +253 -0
- package/dist/custom/VisionAction.vue +266 -878
- package/dist/custom/package-lock.json +1 -331
- package/dist/custom/package.json +0 -3
- package/dist/custom/pnpm-lock.yaml +0 -214
- package/dist/custom/reviewModel.ts +124 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +34 -39
- package/dist/types.d.ts +2 -2
- package/index.ts +39 -46
- package/package.json +2 -2
- package/types.ts +2 -2
- package/custom/ImageCompare.vue +0 -185
- package/custom/ImageGenerationCarousel.vue +0 -327
- package/custom/Swiper.vue +0 -76
- package/custom/VisionTable.vue +0 -575
- package/dist/custom/ImageCompare.vue +0 -185
- package/dist/custom/ImageGenerationCarousel.vue +0 -327
- package/dist/custom/Swiper.vue +0 -76
- package/dist/custom/VisionTable.vue +0 -575
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export type RecordStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
|
2
|
+
export type GenerationAction = 'analyze' | 'analyze_no_images' | 'generate_images';
|
|
3
|
+
export type PromptCategory = 'imageFieldsPrompts' | 'plainFieldsPrompts' | 'generateImages';
|
|
4
|
+
export type FieldKind = 'fromImage' | 'text' | 'image';
|
|
5
|
+
export type FieldChange = 'new' | 'changed' | 'same';
|
|
6
|
+
|
|
7
|
+
export type RecordState = {
|
|
8
|
+
id: string | number;
|
|
9
|
+
status: RecordStatus;
|
|
10
|
+
isChecked: boolean;
|
|
11
|
+
label: string;
|
|
12
|
+
data: Record<string, any>;
|
|
13
|
+
oldData: Record<string, any>;
|
|
14
|
+
// links to the stored images of the image fields, null for an empty field
|
|
15
|
+
oldImagePreviews: Record<string, string | null>;
|
|
16
|
+
// source images returned by attachFiles
|
|
17
|
+
images: string[];
|
|
18
|
+
aiStatus: {
|
|
19
|
+
generatedImages: boolean;
|
|
20
|
+
analyzedImages: boolean;
|
|
21
|
+
analyzedNoImages: boolean;
|
|
22
|
+
};
|
|
23
|
+
imageGenerationErrorMessage: string;
|
|
24
|
+
imageGenerationFailed: boolean;
|
|
25
|
+
imageToTextErrorMessages: Record<string, string>;
|
|
26
|
+
textToTextErrorMessages: Record<string, string>;
|
|
27
|
+
regeneratingFieldsStatus: Record<string, boolean>;
|
|
28
|
+
// image fields which kept their stored image, because values are not overwritten
|
|
29
|
+
listOfImageNotGenerated: Record<string, { originalImage: string }>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const PROMPT_CATEGORY: Record<GenerationAction, PromptCategory> = {
|
|
33
|
+
analyze: 'imageFieldsPrompts',
|
|
34
|
+
analyze_no_images: 'plainFieldsPrompts',
|
|
35
|
+
generate_images: 'generateImages',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const CATEGORY_KIND: Record<PromptCategory, FieldKind> = {
|
|
39
|
+
imageFieldsPrompts: 'fromImage',
|
|
40
|
+
plainFieldsPrompts: 'text',
|
|
41
|
+
generateImages: 'image',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function fieldKind(meta: any, field: string): FieldKind {
|
|
45
|
+
if (meta.outputImageFields.includes(field)) {
|
|
46
|
+
return 'image';
|
|
47
|
+
}
|
|
48
|
+
return meta.outputFieldsForAnalizeFromImages.includes(field) ? 'fromImage' : 'text';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// keys of the plugin options may differ in case from the column names
|
|
52
|
+
export function fieldLabel(meta: any, field: string): string {
|
|
53
|
+
const labels: Record<string, string> = meta.columnLabels;
|
|
54
|
+
const key = Object.keys(labels).find((name) => name.toLowerCase() === field.toLowerCase());
|
|
55
|
+
return key ? labels[key] : field;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isFieldGenerating(record: RecordState, meta: any, field: string): boolean {
|
|
59
|
+
const kind = fieldKind(meta, field);
|
|
60
|
+
if (kind === 'image') {
|
|
61
|
+
return !record.aiStatus.generatedImages;
|
|
62
|
+
}
|
|
63
|
+
const isDone = kind === 'fromImage' ? record.aiStatus.analyzedImages : record.aiStatus.analyzedNoImages;
|
|
64
|
+
return !isDone || record.regeneratingFieldsStatus[field] === true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// images and single fields of a record regenerate in parallel, its status settles once all of them finished
|
|
68
|
+
export function isRegenerating(record: RecordState): boolean {
|
|
69
|
+
return !record.aiStatus.generatedImages || Object.values(record.regeneratingFieldsStatus).some(Boolean);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isRecordBusy(record: RecordState): boolean {
|
|
73
|
+
return record.status === 'pending' || record.status === 'processing' || isRegenerating(record);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function fieldError(record: RecordState, meta: any, field: string): string {
|
|
77
|
+
const kind = fieldKind(meta, field);
|
|
78
|
+
if (kind === 'image') {
|
|
79
|
+
return record.imageGenerationFailed ? record.imageGenerationErrorMessage : '';
|
|
80
|
+
}
|
|
81
|
+
return (kind === 'fromImage' ? record.imageToTextErrorMessages : record.textToTextErrorMessages)[field] ?? '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function recordError(record: RecordState): string {
|
|
85
|
+
return [
|
|
86
|
+
record.imageGenerationFailed ? record.imageGenerationErrorMessage : '',
|
|
87
|
+
...Object.values(record.imageToTextErrorMessages),
|
|
88
|
+
...Object.values(record.textToTextErrorMessages),
|
|
89
|
+
].find(Boolean) ?? '';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// the image a field will have after saving: the generated one, or the stored one while nothing new was made
|
|
93
|
+
export function generatedImageUrl(record: RecordState, meta: any, field: string): string | null {
|
|
94
|
+
return fieldChange(record, meta, field) === 'same' ? record.oldImagePreviews[field] ?? null : record.data[field];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isEmpty(value: any): boolean {
|
|
98
|
+
return value === null || value === undefined || value === '';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isSameValue(a: any, b: any): boolean {
|
|
102
|
+
if (isEmpty(a) || isEmpty(b)) {
|
|
103
|
+
return isEmpty(a) && isEmpty(b);
|
|
104
|
+
}
|
|
105
|
+
return typeof a === 'object' || typeof b === 'object'
|
|
106
|
+
? JSON.stringify(a) === JSON.stringify(b)
|
|
107
|
+
: String(a) === String(b);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function fieldChange(record: RecordState, meta: any, field: string): FieldChange {
|
|
111
|
+
const isKeptImage = fieldKind(meta, field) === 'image' && field in record.listOfImageNotGenerated;
|
|
112
|
+
if (isKeptImage || isSameValue(record.data[field], record.oldData[field])) {
|
|
113
|
+
return 'same';
|
|
114
|
+
}
|
|
115
|
+
return isEmpty(record.oldData[field]) ? 'new' : 'changed';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function changedFieldsCount(record: RecordState, meta: any): number {
|
|
119
|
+
return meta.outputFields.filter((field: string) => fieldChange(record, meta, field) !== 'same').length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isRecordChanged(record: RecordState, meta: any): boolean {
|
|
123
|
+
return record.status !== 'pending' && changedFieldsCount(record, meta) > 0;
|
|
124
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="af-confirmation-panel flex flex-col gap-4">
|
|
3
|
+
<section class="af-configure-prompts-card flex flex-col gap-1.5">
|
|
4
|
+
<h4 class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ t('Fields to generate') }}</h4>
|
|
5
|
+
<ul class="af-settings-panel overflow-hidden rounded-lg border border-lightFormBorder divide-y divide-lightFormBorder dark:border-darkFormBorder dark:divide-darkFormBorder">
|
|
6
|
+
<li
|
|
7
|
+
v-for="row in rows"
|
|
8
|
+
:key="row.field"
|
|
9
|
+
:class="openFields.has(row.field) ? 'bg-lightFormHeading dark:bg-darkFormHeading' : 'bg-lightForm dark:bg-darkForm'"
|
|
10
|
+
>
|
|
11
|
+
<div class="flex items-center gap-3 px-3.5 py-2.5">
|
|
12
|
+
<div class="flex-1 min-w-0">
|
|
13
|
+
<p class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ fieldLabel(meta, row.field) }}</p>
|
|
14
|
+
<p v-if="!openFields.has(row.field)" class="truncate text-xs text-lightInputIconColor dark:text-darkInputIconColor">
|
|
15
|
+
{{ prompts[row.category][row.field] }}
|
|
16
|
+
</p>
|
|
17
|
+
</div>
|
|
18
|
+
<span
|
|
19
|
+
v-if="isUnsaved(row)"
|
|
20
|
+
class="inline-flex h-5 shrink-0 items-center rounded-full border px-2 text-xs font-medium border-lightWarningBorder bg-lightWarningSoft text-lightWarning dark:border-darkWarningBorder dark:bg-darkWarningSoft dark:text-darkWarning"
|
|
21
|
+
>
|
|
22
|
+
{{ t('Unsaved') }}
|
|
23
|
+
</span>
|
|
24
|
+
<span class="inline-flex h-5 shrink-0 items-center rounded-full px-2 text-xs font-medium bg-lightSecondaryDarken text-lightInputIconColor dark:bg-darkSecondaryLighten dark:text-darkInputIconColor">
|
|
25
|
+
{{ kindLabels[CATEGORY_KIND[row.category]] }}
|
|
26
|
+
</span>
|
|
27
|
+
<Button
|
|
28
|
+
type="button"
|
|
29
|
+
variant="ghost"
|
|
30
|
+
size="sm"
|
|
31
|
+
iconOnly
|
|
32
|
+
:aria-label="openFields.has(row.field) ? t('Hide prompt') : t('Edit prompt')"
|
|
33
|
+
:aria-expanded="openFields.has(row.field)"
|
|
34
|
+
:aria-controls="`${idPrefix}-${row.field}`"
|
|
35
|
+
@click="toggle(row.field)"
|
|
36
|
+
>
|
|
37
|
+
<IconChevronUp v-if="openFields.has(row.field)" aria-hidden="true" />
|
|
38
|
+
<IconPencil v-else aria-hidden="true" />
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
<div v-if="openFields.has(row.field)" :id="`${idPrefix}-${row.field}`" class="flex flex-col items-start gap-2 px-3.5 pb-3">
|
|
42
|
+
<Textarea
|
|
43
|
+
v-model="prompts[row.category][row.field]"
|
|
44
|
+
rows="4"
|
|
45
|
+
class="resize-y"
|
|
46
|
+
:placeholder="t('Enter prompt instructions...')"
|
|
47
|
+
/>
|
|
48
|
+
<div class="flex w-full flex-wrap items-center justify-between gap-2">
|
|
49
|
+
<Button
|
|
50
|
+
type="button"
|
|
51
|
+
variant="ghost"
|
|
52
|
+
size="sm"
|
|
53
|
+
:disabled="prompts[row.category][row.field] === defaultPrompts[row.category][row.field]"
|
|
54
|
+
@click="prompts[row.category][row.field] = defaultPrompts[row.category][row.field]"
|
|
55
|
+
>
|
|
56
|
+
<IconRotateCcw class="w-3.5 h-3.5" aria-hidden="true" />
|
|
57
|
+
{{ t('Reset to default') }}
|
|
58
|
+
</Button>
|
|
59
|
+
<Button
|
|
60
|
+
type="button"
|
|
61
|
+
variant="secondary"
|
|
62
|
+
size="sm"
|
|
63
|
+
:disabled="!isUnsaved(row)"
|
|
64
|
+
@click="emit('save', row.category, row.field)"
|
|
65
|
+
>
|
|
66
|
+
<IconSave class="w-3.5 h-3.5" aria-hidden="true" />
|
|
67
|
+
{{ t('Save prompt') }}
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</li>
|
|
72
|
+
</ul>
|
|
73
|
+
<p class="text-xs text-lightInputIconColor dark:text-darkInputIconColor">
|
|
74
|
+
{{ t('Saved prompts are kept in this browser, unsaved changes apply to this run only.') }}
|
|
75
|
+
</p>
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<div class="af-overwrite-settings-card rounded-lg border border-lightFormBorder bg-lightForm px-3.5 py-3 dark:border-darkFormBorder dark:bg-darkForm">
|
|
79
|
+
<Toggle v-model="overwrite" class="w-full flex-row-reverse justify-between gap-3 [&>label]:ms-0">
|
|
80
|
+
<span class="flex flex-col">
|
|
81
|
+
<span class="font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ t('Overwrite existing values') }}</span>
|
|
82
|
+
<span class="text-xs text-lightInputIconColor dark:text-darkInputIconColor">
|
|
83
|
+
{{ overwrite ? t('AI-generated data will replace existing values.') : t('AI fills only empty fields.') }}
|
|
84
|
+
</span>
|
|
85
|
+
</span>
|
|
86
|
+
</Toggle>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div
|
|
90
|
+
v-if="overwrite"
|
|
91
|
+
class="flex items-start gap-2.5 rounded-default border px-3 py-2.5 text-sm border-lightWarningBorder bg-lightWarningSoft text-lightWarning dark:border-darkWarningBorder dark:bg-darkWarningSoft dark:text-darkWarning"
|
|
92
|
+
>
|
|
93
|
+
<IconTriangleAlert class="mt-0.5 w-4 h-4 shrink-0" aria-hidden="true" />
|
|
94
|
+
<p>{{ t('Existing values will be replaced when you save.') }}</p>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</template>
|
|
98
|
+
|
|
99
|
+
<script setup lang="ts">
|
|
100
|
+
import { computed, reactive, useId } from 'vue';
|
|
101
|
+
import { useI18n } from 'vue-i18n';
|
|
102
|
+
import { Button, Textarea, Toggle } from '@/afcl';
|
|
103
|
+
import { IconChevronUp, IconPencil, IconRotateCcw, IconSave, IconTriangleAlert } from '@iconify-prerendered/vue-lucide';
|
|
104
|
+
import { CATEGORY_KIND, fieldLabel, type FieldKind, type PromptCategory } from './reviewModel';
|
|
105
|
+
|
|
106
|
+
type Prompts = Record<PromptCategory, Record<string, string>>;
|
|
107
|
+
type PromptRow = { category: PromptCategory, field: string };
|
|
108
|
+
|
|
109
|
+
const props = defineProps<{
|
|
110
|
+
meta: any,
|
|
111
|
+
defaultPrompts: Prompts,
|
|
112
|
+
savedPrompts: Prompts,
|
|
113
|
+
}>();
|
|
114
|
+
|
|
115
|
+
const emit = defineEmits<{
|
|
116
|
+
save: [category: PromptCategory, field: string],
|
|
117
|
+
}>();
|
|
118
|
+
|
|
119
|
+
const prompts = defineModel<Prompts>('prompts', { required: true });
|
|
120
|
+
const overwrite = defineModel<boolean>('overwrite', { required: true });
|
|
121
|
+
|
|
122
|
+
const { t } = useI18n();
|
|
123
|
+
const idPrefix = useId();
|
|
124
|
+
const openFields = reactive(new Set<string>());
|
|
125
|
+
|
|
126
|
+
const kindLabels = computed<Record<FieldKind, string>>(() => ({
|
|
127
|
+
fromImage: t('From image'),
|
|
128
|
+
text: t('Text'),
|
|
129
|
+
image: t('Image'),
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
// in the order of the columns in the review
|
|
133
|
+
const rows = computed<PromptRow[]>(() => (Object.keys(CATEGORY_KIND) as PromptCategory[]).flatMap((category) =>
|
|
134
|
+
Object.keys(prompts.value[category]).map((field) => ({ category, field })),
|
|
135
|
+
));
|
|
136
|
+
|
|
137
|
+
function isUnsaved(row: PromptRow): boolean {
|
|
138
|
+
return prompts.value[row.category][row.field] !== props.savedPrompts[row.category][row.field];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toggle(field: string) {
|
|
142
|
+
if (openFields.has(field)) {
|
|
143
|
+
openFields.delete(field);
|
|
144
|
+
} else {
|
|
145
|
+
openFields.add(field);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
</script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<div
|
|
4
|
+
v-if="src"
|
|
5
|
+
class="af-image-lightbox fixed inset-0 z-[100] flex items-center justify-center bg-black/90 cursor-zoom-out"
|
|
6
|
+
@click="src = null"
|
|
7
|
+
>
|
|
8
|
+
<img :src="src" alt="" class="max-w-[90vw] max-h-[90vh] rounded-lg object-contain select-none" />
|
|
9
|
+
<button
|
|
10
|
+
type="button"
|
|
11
|
+
class="absolute top-4 right-4 flex items-center justify-center w-9 h-9 rounded-full bg-white/10 text-white/70 hover:bg-white/20 hover:text-white focus:outline-none focus-visible:ring-[3px] focus-visible:ring-white/40 transition-colors"
|
|
12
|
+
:aria-label="t('Close')"
|
|
13
|
+
>
|
|
14
|
+
<IconX class="w-4 h-4" aria-hidden="true" />
|
|
15
|
+
</button>
|
|
16
|
+
</div>
|
|
17
|
+
</Teleport>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
import { onBeforeUnmount, watch } from 'vue';
|
|
22
|
+
import { useI18n } from 'vue-i18n';
|
|
23
|
+
import { IconX } from '@iconify-prerendered/vue-lucide';
|
|
24
|
+
|
|
25
|
+
// a photo is dark in both themes, so the lightbox keeps black and white tints instead of tokens
|
|
26
|
+
const src = defineModel<string | null>({ required: true });
|
|
27
|
+
|
|
28
|
+
const { t } = useI18n();
|
|
29
|
+
|
|
30
|
+
function onKeydown(event: KeyboardEvent) {
|
|
31
|
+
if (event.key === 'Escape') {
|
|
32
|
+
src.value = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
watch(src, (value) => {
|
|
37
|
+
if (value) {
|
|
38
|
+
document.addEventListener('keydown', onKeydown);
|
|
39
|
+
} else {
|
|
40
|
+
document.removeEventListener('keydown', onKeydown);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown));
|
|
45
|
+
</script>
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="af-review-record @container/detail flex min-h-0 flex-col">
|
|
3
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-lightFormBorder px-4 py-3 dark:border-darkFormBorder">
|
|
4
|
+
<div class="min-w-0 flex-1 basis-full @md/detail:basis-0">
|
|
5
|
+
<h4 class="truncate text-sm font-semibold text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ label }}</h4>
|
|
6
|
+
<p class="text-xs text-lightInputIconColor dark:text-darkInputIconColor">
|
|
7
|
+
{{ t('Record {position} of {total}', { position, total }) }}
|
|
8
|
+
<i18n-t keypath="· {up} {down} to move, {space} to include" tag="span" class="hidden @3xl/review:inline">
|
|
9
|
+
<template #up><kbd :class="KBD_CLASSES">↑</kbd></template>
|
|
10
|
+
<template #down><kbd :class="KBD_CLASSES">↓</kbd></template>
|
|
11
|
+
<template #space><kbd :class="KBD_CLASSES">Space</kbd></template>
|
|
12
|
+
</i18n-t>
|
|
13
|
+
</p>
|
|
14
|
+
</div>
|
|
15
|
+
<Checkbox v-model="record.isChecked">{{ t('Save this record') }}</Checkbox>
|
|
16
|
+
<Button type="button" variant="secondary" size="sm" :disabled="!isSettled" @click="emit('regenerateRecord')">
|
|
17
|
+
<IconRefreshCw class="w-3.5 h-3.5" aria-hidden="true" />
|
|
18
|
+
{{ t('Regenerate') }}
|
|
19
|
+
</Button>
|
|
20
|
+
<div class="flex gap-1">
|
|
21
|
+
<Button type="button" variant="secondary" size="sm" iconOnly :disabled="position === 1" :aria-label="t('Previous record')" @click="emit('prev')">
|
|
22
|
+
<IconChevronUp aria-hidden="true" />
|
|
23
|
+
</Button>
|
|
24
|
+
<Button type="button" variant="secondary" size="sm" iconOnly :disabled="position === total" :aria-label="t('Next record')" @click="emit('next')">
|
|
25
|
+
<IconChevronDown aria-hidden="true" />
|
|
26
|
+
</Button>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="min-h-0 flex-1 overflow-y-auto">
|
|
31
|
+
<div v-if="record.status === 'pending'" class="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
|
|
32
|
+
<IconClock class="w-6 h-6 text-lightInputPlaceholderText dark:text-darkInputPlaceholderText" aria-hidden="true" />
|
|
33
|
+
<p class="text-sm text-lightInputIconColor dark:text-darkInputIconColor">{{ t('This record is waiting for its turn.') }}</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<template v-else>
|
|
37
|
+
<div class="hidden gap-3 border-b border-lightFormBorder bg-lightFormHeading px-4 py-2 text-xs font-medium text-lightInputIconColor dark:border-darkFormBorder dark:bg-darkFormHeading dark:text-darkInputIconColor @lg/detail:grid @lg/detail:grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)]">
|
|
38
|
+
<span>{{ t('Current') }}</span>
|
|
39
|
+
<span></span>
|
|
40
|
+
<span>{{ t('Generated') }}</span>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div v-if="meta.isAttachFiles" class="flex flex-col gap-2 border-b border-lightFormBorder px-4 py-3 dark:border-darkFormBorder">
|
|
44
|
+
<span class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ t('Source images') }}</span>
|
|
45
|
+
<div v-if="record.images.length" class="flex flex-wrap gap-2">
|
|
46
|
+
<button
|
|
47
|
+
v-for="(image, index) in record.images"
|
|
48
|
+
:key="image"
|
|
49
|
+
type="button"
|
|
50
|
+
:class="IMAGE_BUTTON_CLASSES"
|
|
51
|
+
:aria-label="t('Open source image {number}', { number: index + 1 })"
|
|
52
|
+
@click="emit('zoom', image)"
|
|
53
|
+
>
|
|
54
|
+
<img :src="image" alt="" class="h-16 w-24 object-cover" />
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
<Skeleton v-else-if="record.status === 'processing'" type="image" class="h-16 w-24 rounded-default" />
|
|
58
|
+
<p v-else class="text-sm text-lightInputPlaceholderText dark:text-darkInputPlaceholderText">{{ t('No source images') }}</p>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div
|
|
62
|
+
v-for="field in meta.outputFields"
|
|
63
|
+
:key="field"
|
|
64
|
+
class="flex flex-col gap-2 border-b border-lightFormBorder px-4 py-3 last:border-b-0 dark:border-darkFormBorder"
|
|
65
|
+
>
|
|
66
|
+
<div class="flex items-center gap-2">
|
|
67
|
+
<span class="text-sm font-medium text-lightDialogHeaderText dark:text-darkDialogHeaderText">{{ fieldLabel(meta, field) }}</span>
|
|
68
|
+
<span v-if="!isFieldGenerating(record, meta, field)" :class="[BADGE_CLASSES, badgeClasses(field)]">{{ badgeLabel(field) }}</span>
|
|
69
|
+
<span class="flex-1"></span>
|
|
70
|
+
<Tooltip>
|
|
71
|
+
<Button
|
|
72
|
+
type="button"
|
|
73
|
+
variant="ghost"
|
|
74
|
+
size="sm"
|
|
75
|
+
iconOnly
|
|
76
|
+
:loader="record.regeneratingFieldsStatus[field] === true"
|
|
77
|
+
:disabled="!isSettled || isFieldGenerating(record, meta, field)"
|
|
78
|
+
:aria-label="t('Regenerate {field}', { field: fieldLabel(meta, field) })"
|
|
79
|
+
@click="regenerate(field)"
|
|
80
|
+
>
|
|
81
|
+
<IconRefreshCw aria-hidden="true" />
|
|
82
|
+
</Button>
|
|
83
|
+
<template #tooltip>{{ t('Regenerate') }}</template>
|
|
84
|
+
</Tooltip>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="grid grid-cols-1 items-start gap-2 @lg/detail:grid-cols-[minmax(0,1fr)_1rem_minmax(0,1fr)] @lg/detail:gap-3">
|
|
88
|
+
<div class="flex min-w-0 flex-col gap-1">
|
|
89
|
+
<span class="text-xs text-lightInputIconColor dark:text-darkInputIconColor @lg/detail:hidden">{{ t('Current') }}</span>
|
|
90
|
+
<template v-if="fieldKind(meta, field) === 'image'">
|
|
91
|
+
<button
|
|
92
|
+
v-if="record.oldImagePreviews[field]"
|
|
93
|
+
type="button"
|
|
94
|
+
:class="IMAGE_BUTTON_CLASSES"
|
|
95
|
+
:aria-label="t('Open current image')"
|
|
96
|
+
@click="emit('zoom', record.oldImagePreviews[field])"
|
|
97
|
+
>
|
|
98
|
+
<img :src="record.oldImagePreviews[field]" alt="" class="aspect-[16/10] w-full object-cover" />
|
|
99
|
+
</button>
|
|
100
|
+
<div v-else :class="IMAGE_PLACEHOLDER_CLASSES">
|
|
101
|
+
<IconImageOff class="w-5 h-5" aria-hidden="true" />
|
|
102
|
+
{{ t('No image') }}
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
105
|
+
<div
|
|
106
|
+
v-else
|
|
107
|
+
class="min-h-[2.125rem] max-h-40 overflow-y-auto whitespace-pre-wrap break-words rounded-default bg-lightSecondaryDarken px-2.5 py-1.5 text-sm leading-5 text-lightInputIconColor dark:bg-darkSecondaryLighten dark:text-darkInputIconColor"
|
|
108
|
+
>
|
|
109
|
+
<span v-if="isEmpty(record.oldData[field])" class="text-lightInputPlaceholderText dark:text-darkInputPlaceholderText">-</span>
|
|
110
|
+
<template v-else>{{ displayValue(field, record.oldData[field]) }}</template>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<IconArrowRight class="mt-2.5 hidden w-4 h-4 text-lightInputPlaceholderText dark:text-darkInputPlaceholderText @lg/detail:block" aria-hidden="true" />
|
|
115
|
+
|
|
116
|
+
<div class="flex min-w-0 flex-col gap-1">
|
|
117
|
+
<span class="text-xs text-lightInputIconColor dark:text-darkInputIconColor @lg/detail:hidden">{{ t('Generated') }}</span>
|
|
118
|
+
<template v-if="fieldKind(meta, field) === 'image'">
|
|
119
|
+
<Skeleton v-if="isFieldGenerating(record, meta, field)" type="image" class="aspect-[16/10] w-full rounded-lg" />
|
|
120
|
+
<button
|
|
121
|
+
v-else-if="generatedImageUrl(record, meta, field)"
|
|
122
|
+
type="button"
|
|
123
|
+
:class="IMAGE_BUTTON_CLASSES"
|
|
124
|
+
:aria-label="t('Open generated image')"
|
|
125
|
+
@click="emit('zoom', generatedImageUrl(record, meta, field))"
|
|
126
|
+
>
|
|
127
|
+
<img :src="generatedImageUrl(record, meta, field)" alt="" class="aspect-[16/10] w-full object-cover" />
|
|
128
|
+
</button>
|
|
129
|
+
<div v-else :class="IMAGE_PLACEHOLDER_CLASSES">
|
|
130
|
+
<IconImageOff class="w-5 h-5" aria-hidden="true" />
|
|
131
|
+
{{ t('Not generated') }}
|
|
132
|
+
</div>
|
|
133
|
+
</template>
|
|
134
|
+
<Skeleton
|
|
135
|
+
v-else-if="isFieldGenerating(record, meta, field)"
|
|
136
|
+
type="input"
|
|
137
|
+
class="w-full"
|
|
138
|
+
:class="editorKind(field) === 'multiline' ? 'h-24' : 'h-[2.125rem]'"
|
|
139
|
+
/>
|
|
140
|
+
<Select
|
|
141
|
+
v-else-if="editorKind(field) === 'enum'"
|
|
142
|
+
v-model="record.data[field]"
|
|
143
|
+
class="w-full"
|
|
144
|
+
:options="enumOptions(field)"
|
|
145
|
+
teleportToTop
|
|
146
|
+
/>
|
|
147
|
+
<div v-else-if="editorKind(field) === 'boolean'" class="flex h-[2.125rem] items-center">
|
|
148
|
+
<Toggle v-model="record.data[field]" />
|
|
149
|
+
</div>
|
|
150
|
+
<Input v-else-if="editorKind(field) === 'number'" v-model="record.data[field]" type="number" fullWidth />
|
|
151
|
+
<Input v-else-if="editorKind(field) === 'line'" v-model="record.data[field]" type="text" fullWidth />
|
|
152
|
+
<Textarea
|
|
153
|
+
v-else
|
|
154
|
+
:modelValue="record.data[field] ?? ''"
|
|
155
|
+
rows="4"
|
|
156
|
+
class="resize-y"
|
|
157
|
+
@update:modelValue="(value) => record.data[field] = value"
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<p v-if="fieldError(record, meta, field)" class="flex items-start gap-1.5 text-xs text-lightDanger dark:text-darkDanger">
|
|
163
|
+
<IconCircleAlert class="mt-px w-3.5 h-3.5 shrink-0" aria-hidden="true" />
|
|
164
|
+
{{ fieldError(record, meta, field) }}
|
|
165
|
+
</p>
|
|
166
|
+
</div>
|
|
167
|
+
</template>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</template>
|
|
171
|
+
|
|
172
|
+
<script setup lang="ts">
|
|
173
|
+
import { computed } from 'vue';
|
|
174
|
+
import { useI18n } from 'vue-i18n';
|
|
175
|
+
import { Button, Checkbox, Input, Select, Skeleton, Textarea, Toggle, Tooltip } from '@/afcl';
|
|
176
|
+
import {
|
|
177
|
+
IconArrowRight, IconChevronDown, IconChevronUp, IconCircleAlert, IconClock, IconImageOff, IconRefreshCw,
|
|
178
|
+
} from '@iconify-prerendered/vue-lucide';
|
|
179
|
+
import {
|
|
180
|
+
fieldChange, fieldError, fieldKind, fieldLabel, generatedImageUrl, isFieldGenerating, isRecordBusy, type RecordState,
|
|
181
|
+
} from './reviewModel';
|
|
182
|
+
|
|
183
|
+
type EditorKind = 'enum' | 'boolean' | 'number' | 'line' | 'multiline';
|
|
184
|
+
|
|
185
|
+
const NUMBER_TYPES = ['integer', 'decimal', 'float'];
|
|
186
|
+
const KBD_CLASSES = 'rounded border border-lightFormBorder px-1 font-mono text-[11px] dark:border-darkFormBorder';
|
|
187
|
+
const BADGE_CLASSES = 'inline-flex h-5 items-center rounded-full border px-2 text-xs font-medium';
|
|
188
|
+
const IMAGE_BUTTON_CLASSES = 'block overflow-hidden rounded-lg border border-lightFormBorder cursor-zoom-in hover:border-lightPrimary focus:outline-none focus-visible:ring-[3px] focus-visible:ring-lightFocusRing dark:border-darkFormBorder dark:hover:border-darkPrimary dark:focus-visible:ring-darkFocusRing transition-colors';
|
|
189
|
+
const IMAGE_PLACEHOLDER_CLASSES = 'flex aspect-[16/10] w-full flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-lightFormBorder bg-lightFormHeading text-xs text-lightInputPlaceholderText dark:border-darkFormBorder dark:bg-darkFormHeading dark:text-darkInputPlaceholderText';
|
|
190
|
+
|
|
191
|
+
const props = defineProps<{
|
|
192
|
+
record: RecordState,
|
|
193
|
+
meta: any,
|
|
194
|
+
label: string,
|
|
195
|
+
position: number,
|
|
196
|
+
total: number,
|
|
197
|
+
}>();
|
|
198
|
+
|
|
199
|
+
const emit = defineEmits<{
|
|
200
|
+
prev: [],
|
|
201
|
+
next: [],
|
|
202
|
+
regenerateRecord: [],
|
|
203
|
+
regenerateImages: [],
|
|
204
|
+
regenerateField: [field: string],
|
|
205
|
+
zoom: [url: string],
|
|
206
|
+
}>();
|
|
207
|
+
|
|
208
|
+
const { t } = useI18n();
|
|
209
|
+
|
|
210
|
+
const isSettled = computed(() => !isRecordBusy(props.record));
|
|
211
|
+
|
|
212
|
+
function columnEnum(field: string) {
|
|
213
|
+
return props.meta.columnEnums.find((column: any) => column.name === field);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function editorKind(field: string): EditorKind {
|
|
217
|
+
if (columnEnum(field)) {
|
|
218
|
+
return 'enum';
|
|
219
|
+
}
|
|
220
|
+
const type = props.meta.columnTypes[field];
|
|
221
|
+
if (type === 'boolean') {
|
|
222
|
+
return 'boolean';
|
|
223
|
+
}
|
|
224
|
+
if (NUMBER_TYPES.includes(type)) {
|
|
225
|
+
return 'number';
|
|
226
|
+
}
|
|
227
|
+
return type === 'string' ? 'line' : 'multiline';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function enumOptions(field: string) {
|
|
231
|
+
return columnEnum(field).enum.map((item: any) => ({ label: item.label, value: item.value }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isEmpty(value: any) {
|
|
235
|
+
return value === null || value === undefined || value === '';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function displayValue(field: string, value: any): string {
|
|
239
|
+
const option = columnEnum(field)?.enum.find((item: any) => item.value === value);
|
|
240
|
+
if (option) {
|
|
241
|
+
return option.label;
|
|
242
|
+
}
|
|
243
|
+
if (typeof value === 'boolean') {
|
|
244
|
+
return value ? t('Yes') : t('No');
|
|
245
|
+
}
|
|
246
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function badgeLabel(field: string): string {
|
|
250
|
+
if (fieldError(props.record, props.meta, field)) {
|
|
251
|
+
return t('Failed');
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
new: t('New'),
|
|
255
|
+
changed: t('Changed'),
|
|
256
|
+
same: t('Unchanged'),
|
|
257
|
+
}[fieldChange(props.record, props.meta, field)];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function badgeClasses(field: string): string {
|
|
261
|
+
if (fieldError(props.record, props.meta, field)) {
|
|
262
|
+
return 'border-lightDangerBorder bg-lightDangerSoft text-lightDanger dark:border-darkDangerBorder dark:bg-darkDangerSoft dark:text-darkDanger';
|
|
263
|
+
}
|
|
264
|
+
return fieldChange(props.record, props.meta, field) === 'same'
|
|
265
|
+
? 'border-transparent bg-lightSecondaryDarken text-lightInputIconColor dark:bg-darkSecondaryLighten dark:text-darkInputIconColor'
|
|
266
|
+
: 'border-lightPrimary/30 bg-lightPrimaryOpacity text-lightPrimary dark:border-darkPrimary/40 dark:bg-darkPrimaryOpacity dark:text-darkPrimary';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function regenerate(field: string) {
|
|
270
|
+
if (fieldKind(props.meta, field) === 'image') {
|
|
271
|
+
emit('regenerateImages');
|
|
272
|
+
} else {
|
|
273
|
+
emit('regenerateField', field);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
</script>
|