@adminforth/upload 1.4.1 → 1.4.3
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 +2 -2
- package/custom/imageGenerator.vue +135 -31
- package/dist/custom/imageGenerator.vue +135 -31
- package/dist/index.js +41 -34
- package/index.ts +46 -32
- package/package.json +1 -1
- package/types.ts +26 -23
package/build.log
CHANGED
|
@@ -11,5 +11,5 @@ custom/preview.vue
|
|
|
11
11
|
custom/tsconfig.json
|
|
12
12
|
custom/uploader.vue
|
|
13
13
|
|
|
14
|
-
sent
|
|
15
|
-
total size is
|
|
14
|
+
sent 42,322 bytes received 134 bytes 84,912.00 bytes/sec
|
|
15
|
+
total size is 41,840 speedup is 0.99
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
:title="$t('Prompt which will be passed to AI network')"
|
|
27
27
|
></textarea>
|
|
28
28
|
|
|
29
|
-
<div class="flex items-center justify-center w-full relative">
|
|
29
|
+
<div class="flex flex-col items-center justify-center w-full relative">
|
|
30
30
|
<div
|
|
31
31
|
v-if="loading"
|
|
32
32
|
class=" absolute flex items-center justify-center w-full h-full z-50 bg-white/80 dark:bg-gray-900/80 rounded-lg"
|
|
@@ -36,13 +36,36 @@
|
|
|
36
36
|
<span class="sr-only">{{ $t('Loading...') }}</span>
|
|
37
37
|
</div>
|
|
38
38
|
</div>
|
|
39
|
+
|
|
40
|
+
<div v-if="loadingTimer" class="absolute pt-12 flex items-center justify-center w-full h-full z-50 bg-white/80 dark:bg-gray-900/80 rounded-lg">
|
|
41
|
+
<div class="text-gray-800 dark:text-gray-100 text-lg font-semibold"
|
|
42
|
+
v-if="!historicalAverage"
|
|
43
|
+
>
|
|
44
|
+
{{ formatTime(loadingTimer) }} {{ $t('passed...') }}
|
|
45
|
+
</div>
|
|
46
|
+
<div class="w-64" v-else>
|
|
47
|
+
<ProgressBar
|
|
48
|
+
class="absolute max-w-full"
|
|
49
|
+
:currentValue="loadingTimer < historicalAverage ? loadingTimer : historicalAverage"
|
|
50
|
+
:minValue="0"
|
|
51
|
+
:maxValue="historicalAverage"
|
|
52
|
+
:showValues="false"
|
|
53
|
+
:progressFormatter="(value: number, percentage: number) => `${ formatTime(loadingTimer) } ( ${ Math.floor( (
|
|
54
|
+
loadingTimer < historicalAverage ? loadingTimer : historicalAverage
|
|
55
|
+
) / historicalAverage * 100) }% )`"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
39
60
|
|
|
40
61
|
<div id="gallery" class="relative w-full" data-carousel="static">
|
|
41
62
|
<!-- Carousel wrapper -->
|
|
42
63
|
<div class="relative h-56 overflow-hidden rounded-lg md:h-72">
|
|
43
64
|
<!-- Item 1 -->
|
|
44
65
|
<div v-for="(img, index) in images" :key="index" class="hidden duration-700 ease-in-out" data-carousel-item>
|
|
45
|
-
<img :src="img" class="absolute block max-w-full h-
|
|
66
|
+
<img :src="img" class="absolute block max-w-full max-h-full -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2 object-cover"
|
|
67
|
+
:alt="`Generated image ${index + 1}`"
|
|
68
|
+
/>
|
|
46
69
|
</div>
|
|
47
70
|
|
|
48
71
|
<div v-if="images.length === 0" class="flex items-center justify-center w-full h-full">
|
|
@@ -57,19 +80,31 @@
|
|
|
57
80
|
<!-- Slider controls -->
|
|
58
81
|
<button type="button" class="absolute top-0 start-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none"
|
|
59
82
|
@click="slide(-1)"
|
|
83
|
+
:disabled="images.length === 0"
|
|
60
84
|
>
|
|
61
|
-
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
|
|
62
|
-
<svg class="w-4 h-4
|
|
85
|
+
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none ">
|
|
86
|
+
<svg class="w-4 h-4 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10"
|
|
87
|
+
:class="{
|
|
88
|
+
'text-gray-800 dark:text-gray-200': images.length > 0,
|
|
89
|
+
'text-gray-200 dark:text-gray-800': images.length === 0
|
|
90
|
+
}"
|
|
91
|
+
>
|
|
63
92
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
|
|
64
93
|
</svg>
|
|
65
94
|
<span class="sr-only">{{ $t('Previous') }}</span>
|
|
66
95
|
</span>
|
|
67
96
|
</button>
|
|
68
|
-
<button type="button" class="absolute top-0 end-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none"
|
|
97
|
+
<button type="button" class="absolute top-0 end-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none "
|
|
98
|
+
:disabled="images.length === 0"
|
|
69
99
|
@click="slide(1)"
|
|
70
100
|
>
|
|
71
|
-
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
|
|
72
|
-
<svg class="w-4 h-4
|
|
101
|
+
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none ">
|
|
102
|
+
<svg class="w-4 h-4 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10"
|
|
103
|
+
:class="{
|
|
104
|
+
'text-gray-800 dark:text-gray-200': images.length > 0,
|
|
105
|
+
'text-gray-200 dark:text-gray-800': images.length === 0
|
|
106
|
+
}"
|
|
107
|
+
>
|
|
73
108
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
|
|
74
109
|
</svg>
|
|
75
110
|
<span class="sr-only">{{ $t('Next') }}</span>
|
|
@@ -103,11 +138,12 @@
|
|
|
103
138
|
|
|
104
139
|
<script setup lang="ts">
|
|
105
140
|
|
|
106
|
-
import { ref, onMounted, nextTick } from 'vue'
|
|
141
|
+
import { ref, onMounted, nextTick, Ref, h, computed } from 'vue'
|
|
107
142
|
import { Carousel } from 'flowbite';
|
|
108
143
|
import { callAdminForthApi } from '@/utils';
|
|
109
144
|
import { useI18n } from 'vue-i18n';
|
|
110
145
|
import adminforth from '@/adminforth';
|
|
146
|
+
import { ProgressBar } from '@/afcl';
|
|
111
147
|
|
|
112
148
|
const { t: $t } = useI18n();
|
|
113
149
|
|
|
@@ -127,22 +163,42 @@ function minifyField(field: string): string {
|
|
|
127
163
|
const caurosel = ref(null);
|
|
128
164
|
onMounted(() => {
|
|
129
165
|
// Initialize carousel
|
|
130
|
-
|
|
131
|
-
if (props.meta.fieldsForContext) {
|
|
132
|
-
additionalContext = props.meta.fieldsForContext.filter((field: string) => props.record[field]).map((field: string) => {
|
|
133
|
-
return `${field}: ${minifyField(props.record[field])}`;
|
|
134
|
-
}).join('\n');
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
prompt.value = $t('Generate image for field "{field}" in {resource}. No text should be on image.', {
|
|
166
|
+
const context = {
|
|
138
167
|
field: props.meta.pathColumnLabel,
|
|
139
168
|
resource: props.meta.resourceLabel,
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
169
|
+
};
|
|
170
|
+
let template = '';
|
|
171
|
+
if (props.meta.generationPrompt) {
|
|
172
|
+
template = props.meta.generationPrompt;
|
|
173
|
+
} else {
|
|
174
|
+
template = 'Generate image for field {{field}} in {{resource}}. No text should be on image.';
|
|
175
|
+
}
|
|
176
|
+
// iterate over all variables in template and replace them with their values from props.record[field].
|
|
177
|
+
// if field is not present in props.record[field] then replace it with empty string and drop warning
|
|
178
|
+
const regex = /{{(.*?)}}/g;
|
|
179
|
+
const matches = template.match(regex);
|
|
180
|
+
if (matches) {
|
|
181
|
+
matches.forEach((match) => {
|
|
182
|
+
const field = match.replace(/{{|}}/g, '').trim();
|
|
183
|
+
if (field in context) {
|
|
184
|
+
return;
|
|
185
|
+
} else if (field in props.record) {
|
|
186
|
+
context[field] = minifyField(props.record[field]);
|
|
187
|
+
} else {
|
|
188
|
+
adminforth.alert({
|
|
189
|
+
message: $t('Field {{field}} defined in template but not found in record', { field }),
|
|
190
|
+
variant: 'warning',
|
|
191
|
+
timeout: 15,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
});
|
|
143
195
|
}
|
|
144
196
|
|
|
145
|
-
|
|
197
|
+
prompt.value = template.replace(regex, (_, field) => {
|
|
198
|
+
return context[field.trim()] || '';
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
});
|
|
146
202
|
|
|
147
203
|
async function slide(direction: number) {
|
|
148
204
|
if (!caurosel.value) return;
|
|
@@ -164,9 +220,23 @@ async function confirmImage() {
|
|
|
164
220
|
const currentIndex = caurosel.value?.getActiveItem()?.position || 0;
|
|
165
221
|
const img = images.value[currentIndex];
|
|
166
222
|
// read url to base64 and send it to the parent component
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
223
|
+
|
|
224
|
+
let imgBlob;
|
|
225
|
+
if (img.startsWith('data:')) {
|
|
226
|
+
const base64 = img.split(',')[1];
|
|
227
|
+
const mimeType = img.split(';')[0].split(':')[1];
|
|
228
|
+
const byteCharacters = atob(base64);
|
|
229
|
+
const byteNumbers = new Array(byteCharacters.length);
|
|
230
|
+
for (let i = 0; i < byteCharacters.length; i++) {
|
|
231
|
+
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
232
|
+
}
|
|
233
|
+
const byteArray = new Uint8Array(byteNumbers);
|
|
234
|
+
imgBlob = new Blob([byteArray], { type: mimeType });
|
|
235
|
+
} else {
|
|
236
|
+
imgBlob = await fetch(
|
|
237
|
+
`${import.meta.env.VITE_ADMINFORTH_PUBLIC_PATH || ''}/adminapi/v1/plugin/${props.meta.pluginInstanceId}/cors-proxy?url=${encodeURIComponent(img)}`
|
|
238
|
+
).then(res => { return res.blob() });
|
|
239
|
+
}
|
|
170
240
|
|
|
171
241
|
emit('uploadImage', imgBlob);
|
|
172
242
|
emit('close');
|
|
@@ -174,17 +244,51 @@ async function confirmImage() {
|
|
|
174
244
|
loading.value = false;
|
|
175
245
|
}
|
|
176
246
|
|
|
247
|
+
const loadingTimer: Ref<number | null> = ref(null);
|
|
248
|
+
|
|
249
|
+
const historicalRuns: Ref<number[]> = ref([]);
|
|
250
|
+
|
|
251
|
+
const historicalAverage: Ref<number | null> = computed(() => {
|
|
252
|
+
if (historicalRuns.value.length === 0) return null;
|
|
253
|
+
const sum = historicalRuns.value.reduce((a, b) => a + b, 0);
|
|
254
|
+
return Math.floor(sum / historicalRuns.value.length);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
function formatTime(seconds: number): string {
|
|
259
|
+
const minutes = Math.floor(seconds / 60);
|
|
260
|
+
return `${minutes % 60}m ${Math.floor(seconds % 60)}s`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
177
264
|
async function generateImages() {
|
|
178
265
|
loading.value = true;
|
|
266
|
+
loadingTimer.value = 0;
|
|
267
|
+
const start = Date.now();
|
|
268
|
+
const ticker = setInterval(() => {
|
|
269
|
+
const elapsed = (Date.now() - start) / 1000;
|
|
270
|
+
loadingTimer.value = elapsed;
|
|
271
|
+
}, 100);
|
|
179
272
|
const currentIndex = caurosel.value?.getActiveItem()?.position || 0;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
273
|
+
|
|
274
|
+
let resp;
|
|
275
|
+
try {
|
|
276
|
+
resp = await callAdminForthApi({
|
|
277
|
+
path: `/plugin/${props.meta.pluginInstanceId}/generate_images`,
|
|
278
|
+
method: 'POST',
|
|
279
|
+
body: {
|
|
280
|
+
prompt: prompt.value,
|
|
281
|
+
recordId: props.record[props.meta.recorPkFieldName]
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.error(e);
|
|
286
|
+
} finally {
|
|
287
|
+
historicalRuns.value.push(loadingTimer.value);
|
|
288
|
+
clearInterval(ticker);
|
|
289
|
+
loadingTimer.value = null;
|
|
187
290
|
|
|
291
|
+
}
|
|
188
292
|
if (resp.error) {
|
|
189
293
|
adminforth.alert({
|
|
190
294
|
message: $t('Error: {error}', { error: JSON.stringify(resp.error) }),
|
|
@@ -197,7 +301,7 @@ async function generateImages() {
|
|
|
197
301
|
|
|
198
302
|
images.value = [
|
|
199
303
|
...images.value,
|
|
200
|
-
...resp.images
|
|
304
|
+
...resp.images,
|
|
201
305
|
];
|
|
202
306
|
|
|
203
307
|
// images.value = [
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
:title="$t('Prompt which will be passed to AI network')"
|
|
27
27
|
></textarea>
|
|
28
28
|
|
|
29
|
-
<div class="flex items-center justify-center w-full relative">
|
|
29
|
+
<div class="flex flex-col items-center justify-center w-full relative">
|
|
30
30
|
<div
|
|
31
31
|
v-if="loading"
|
|
32
32
|
class=" absolute flex items-center justify-center w-full h-full z-50 bg-white/80 dark:bg-gray-900/80 rounded-lg"
|
|
@@ -36,13 +36,36 @@
|
|
|
36
36
|
<span class="sr-only">{{ $t('Loading...') }}</span>
|
|
37
37
|
</div>
|
|
38
38
|
</div>
|
|
39
|
+
|
|
40
|
+
<div v-if="loadingTimer" class="absolute pt-12 flex items-center justify-center w-full h-full z-50 bg-white/80 dark:bg-gray-900/80 rounded-lg">
|
|
41
|
+
<div class="text-gray-800 dark:text-gray-100 text-lg font-semibold"
|
|
42
|
+
v-if="!historicalAverage"
|
|
43
|
+
>
|
|
44
|
+
{{ formatTime(loadingTimer) }} {{ $t('passed...') }}
|
|
45
|
+
</div>
|
|
46
|
+
<div class="w-64" v-else>
|
|
47
|
+
<ProgressBar
|
|
48
|
+
class="absolute max-w-full"
|
|
49
|
+
:currentValue="loadingTimer < historicalAverage ? loadingTimer : historicalAverage"
|
|
50
|
+
:minValue="0"
|
|
51
|
+
:maxValue="historicalAverage"
|
|
52
|
+
:showValues="false"
|
|
53
|
+
:progressFormatter="(value: number, percentage: number) => `${ formatTime(loadingTimer) } ( ${ Math.floor( (
|
|
54
|
+
loadingTimer < historicalAverage ? loadingTimer : historicalAverage
|
|
55
|
+
) / historicalAverage * 100) }% )`"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
39
60
|
|
|
40
61
|
<div id="gallery" class="relative w-full" data-carousel="static">
|
|
41
62
|
<!-- Carousel wrapper -->
|
|
42
63
|
<div class="relative h-56 overflow-hidden rounded-lg md:h-72">
|
|
43
64
|
<!-- Item 1 -->
|
|
44
65
|
<div v-for="(img, index) in images" :key="index" class="hidden duration-700 ease-in-out" data-carousel-item>
|
|
45
|
-
<img :src="img" class="absolute block max-w-full h-
|
|
66
|
+
<img :src="img" class="absolute block max-w-full max-h-full -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2 object-cover"
|
|
67
|
+
:alt="`Generated image ${index + 1}`"
|
|
68
|
+
/>
|
|
46
69
|
</div>
|
|
47
70
|
|
|
48
71
|
<div v-if="images.length === 0" class="flex items-center justify-center w-full h-full">
|
|
@@ -57,19 +80,31 @@
|
|
|
57
80
|
<!-- Slider controls -->
|
|
58
81
|
<button type="button" class="absolute top-0 start-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none"
|
|
59
82
|
@click="slide(-1)"
|
|
83
|
+
:disabled="images.length === 0"
|
|
60
84
|
>
|
|
61
|
-
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
|
|
62
|
-
<svg class="w-4 h-4
|
|
85
|
+
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none ">
|
|
86
|
+
<svg class="w-4 h-4 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10"
|
|
87
|
+
:class="{
|
|
88
|
+
'text-gray-800 dark:text-gray-200': images.length > 0,
|
|
89
|
+
'text-gray-200 dark:text-gray-800': images.length === 0
|
|
90
|
+
}"
|
|
91
|
+
>
|
|
63
92
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
|
|
64
93
|
</svg>
|
|
65
94
|
<span class="sr-only">{{ $t('Previous') }}</span>
|
|
66
95
|
</span>
|
|
67
96
|
</button>
|
|
68
|
-
<button type="button" class="absolute top-0 end-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none"
|
|
97
|
+
<button type="button" class="absolute top-0 end-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none "
|
|
98
|
+
:disabled="images.length === 0"
|
|
69
99
|
@click="slide(1)"
|
|
70
100
|
>
|
|
71
|
-
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
|
|
72
|
-
<svg class="w-4 h-4
|
|
101
|
+
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none ">
|
|
102
|
+
<svg class="w-4 h-4 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10"
|
|
103
|
+
:class="{
|
|
104
|
+
'text-gray-800 dark:text-gray-200': images.length > 0,
|
|
105
|
+
'text-gray-200 dark:text-gray-800': images.length === 0
|
|
106
|
+
}"
|
|
107
|
+
>
|
|
73
108
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
|
|
74
109
|
</svg>
|
|
75
110
|
<span class="sr-only">{{ $t('Next') }}</span>
|
|
@@ -103,11 +138,12 @@
|
|
|
103
138
|
|
|
104
139
|
<script setup lang="ts">
|
|
105
140
|
|
|
106
|
-
import { ref, onMounted, nextTick } from 'vue'
|
|
141
|
+
import { ref, onMounted, nextTick, Ref, h, computed } from 'vue'
|
|
107
142
|
import { Carousel } from 'flowbite';
|
|
108
143
|
import { callAdminForthApi } from '@/utils';
|
|
109
144
|
import { useI18n } from 'vue-i18n';
|
|
110
145
|
import adminforth from '@/adminforth';
|
|
146
|
+
import { ProgressBar } from '@/afcl';
|
|
111
147
|
|
|
112
148
|
const { t: $t } = useI18n();
|
|
113
149
|
|
|
@@ -127,22 +163,42 @@ function minifyField(field: string): string {
|
|
|
127
163
|
const caurosel = ref(null);
|
|
128
164
|
onMounted(() => {
|
|
129
165
|
// Initialize carousel
|
|
130
|
-
|
|
131
|
-
if (props.meta.fieldsForContext) {
|
|
132
|
-
additionalContext = props.meta.fieldsForContext.filter((field: string) => props.record[field]).map((field: string) => {
|
|
133
|
-
return `${field}: ${minifyField(props.record[field])}`;
|
|
134
|
-
}).join('\n');
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
prompt.value = $t('Generate image for field "{field}" in {resource}. No text should be on image.', {
|
|
166
|
+
const context = {
|
|
138
167
|
field: props.meta.pathColumnLabel,
|
|
139
168
|
resource: props.meta.resourceLabel,
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
169
|
+
};
|
|
170
|
+
let template = '';
|
|
171
|
+
if (props.meta.generationPrompt) {
|
|
172
|
+
template = props.meta.generationPrompt;
|
|
173
|
+
} else {
|
|
174
|
+
template = 'Generate image for field {{field}} in {{resource}}. No text should be on image.';
|
|
175
|
+
}
|
|
176
|
+
// iterate over all variables in template and replace them with their values from props.record[field].
|
|
177
|
+
// if field is not present in props.record[field] then replace it with empty string and drop warning
|
|
178
|
+
const regex = /{{(.*?)}}/g;
|
|
179
|
+
const matches = template.match(regex);
|
|
180
|
+
if (matches) {
|
|
181
|
+
matches.forEach((match) => {
|
|
182
|
+
const field = match.replace(/{{|}}/g, '').trim();
|
|
183
|
+
if (field in context) {
|
|
184
|
+
return;
|
|
185
|
+
} else if (field in props.record) {
|
|
186
|
+
context[field] = minifyField(props.record[field]);
|
|
187
|
+
} else {
|
|
188
|
+
adminforth.alert({
|
|
189
|
+
message: $t('Field {{field}} defined in template but not found in record', { field }),
|
|
190
|
+
variant: 'warning',
|
|
191
|
+
timeout: 15,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
});
|
|
143
195
|
}
|
|
144
196
|
|
|
145
|
-
|
|
197
|
+
prompt.value = template.replace(regex, (_, field) => {
|
|
198
|
+
return context[field.trim()] || '';
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
});
|
|
146
202
|
|
|
147
203
|
async function slide(direction: number) {
|
|
148
204
|
if (!caurosel.value) return;
|
|
@@ -164,9 +220,23 @@ async function confirmImage() {
|
|
|
164
220
|
const currentIndex = caurosel.value?.getActiveItem()?.position || 0;
|
|
165
221
|
const img = images.value[currentIndex];
|
|
166
222
|
// read url to base64 and send it to the parent component
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
223
|
+
|
|
224
|
+
let imgBlob;
|
|
225
|
+
if (img.startsWith('data:')) {
|
|
226
|
+
const base64 = img.split(',')[1];
|
|
227
|
+
const mimeType = img.split(';')[0].split(':')[1];
|
|
228
|
+
const byteCharacters = atob(base64);
|
|
229
|
+
const byteNumbers = new Array(byteCharacters.length);
|
|
230
|
+
for (let i = 0; i < byteCharacters.length; i++) {
|
|
231
|
+
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
232
|
+
}
|
|
233
|
+
const byteArray = new Uint8Array(byteNumbers);
|
|
234
|
+
imgBlob = new Blob([byteArray], { type: mimeType });
|
|
235
|
+
} else {
|
|
236
|
+
imgBlob = await fetch(
|
|
237
|
+
`${import.meta.env.VITE_ADMINFORTH_PUBLIC_PATH || ''}/adminapi/v1/plugin/${props.meta.pluginInstanceId}/cors-proxy?url=${encodeURIComponent(img)}`
|
|
238
|
+
).then(res => { return res.blob() });
|
|
239
|
+
}
|
|
170
240
|
|
|
171
241
|
emit('uploadImage', imgBlob);
|
|
172
242
|
emit('close');
|
|
@@ -174,17 +244,51 @@ async function confirmImage() {
|
|
|
174
244
|
loading.value = false;
|
|
175
245
|
}
|
|
176
246
|
|
|
247
|
+
const loadingTimer: Ref<number | null> = ref(null);
|
|
248
|
+
|
|
249
|
+
const historicalRuns: Ref<number[]> = ref([]);
|
|
250
|
+
|
|
251
|
+
const historicalAverage: Ref<number | null> = computed(() => {
|
|
252
|
+
if (historicalRuns.value.length === 0) return null;
|
|
253
|
+
const sum = historicalRuns.value.reduce((a, b) => a + b, 0);
|
|
254
|
+
return Math.floor(sum / historicalRuns.value.length);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
function formatTime(seconds: number): string {
|
|
259
|
+
const minutes = Math.floor(seconds / 60);
|
|
260
|
+
return `${minutes % 60}m ${Math.floor(seconds % 60)}s`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
177
264
|
async function generateImages() {
|
|
178
265
|
loading.value = true;
|
|
266
|
+
loadingTimer.value = 0;
|
|
267
|
+
const start = Date.now();
|
|
268
|
+
const ticker = setInterval(() => {
|
|
269
|
+
const elapsed = (Date.now() - start) / 1000;
|
|
270
|
+
loadingTimer.value = elapsed;
|
|
271
|
+
}, 100);
|
|
179
272
|
const currentIndex = caurosel.value?.getActiveItem()?.position || 0;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
273
|
+
|
|
274
|
+
let resp;
|
|
275
|
+
try {
|
|
276
|
+
resp = await callAdminForthApi({
|
|
277
|
+
path: `/plugin/${props.meta.pluginInstanceId}/generate_images`,
|
|
278
|
+
method: 'POST',
|
|
279
|
+
body: {
|
|
280
|
+
prompt: prompt.value,
|
|
281
|
+
recordId: props.record[props.meta.recorPkFieldName]
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.error(e);
|
|
286
|
+
} finally {
|
|
287
|
+
historicalRuns.value.push(loadingTimer.value);
|
|
288
|
+
clearInterval(ticker);
|
|
289
|
+
loadingTimer.value = null;
|
|
187
290
|
|
|
291
|
+
}
|
|
188
292
|
if (resp.error) {
|
|
189
293
|
adminforth.alert({
|
|
190
294
|
message: $t('Error: {error}', { error: JSON.stringify(resp.error) }),
|
|
@@ -197,7 +301,7 @@ async function generateImages() {
|
|
|
197
301
|
|
|
198
302
|
images.value = [
|
|
199
303
|
...images.value,
|
|
200
|
-
...resp.images
|
|
304
|
+
...resp.images,
|
|
201
305
|
];
|
|
202
306
|
|
|
203
307
|
// images.value = [
|
package/dist/index.js
CHANGED
|
@@ -99,7 +99,7 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
|
|
|
99
99
|
modifyResourceConfig: { get: () => super.modifyResourceConfig }
|
|
100
100
|
});
|
|
101
101
|
return __awaiter(this, void 0, void 0, function* () {
|
|
102
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
102
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
|
103
103
|
_super.modifyResourceConfig.call(this, adminforth, resourceConfig);
|
|
104
104
|
// after column to store the path of the uploaded file, add new VirtualColumn,
|
|
105
105
|
// show only in edit and create views
|
|
@@ -125,13 +125,14 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
|
|
|
125
125
|
resourceLabel: resourceConfig.label,
|
|
126
126
|
generateImages: this.options.generation ? true : false,
|
|
127
127
|
pathColumnLabel: resourceConfig.columns[pathColumnIndex].label,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
128
|
+
maxWidth: (_c = this.options.preview) === null || _c === void 0 ? void 0 : _c.maxWidth,
|
|
129
|
+
maxListWidth: (_d = this.options.preview) === null || _d === void 0 ? void 0 : _d.maxListWidth,
|
|
130
|
+
maxShowWidth: (_e = this.options.preview) === null || _e === void 0 ? void 0 : _e.maxShowWidth,
|
|
131
|
+
minWidth: (_f = this.options.preview) === null || _f === void 0 ? void 0 : _f.minWidth,
|
|
132
|
+
minListWidth: (_g = this.options.preview) === null || _g === void 0 ? void 0 : _g.minListWidth,
|
|
133
|
+
minShowWidth: (_h = this.options.preview) === null || _h === void 0 ? void 0 : _h.minShowWidth,
|
|
134
|
+
generationPrompt: (_j = this.options.generation) === null || _j === void 0 ? void 0 : _j.generationPrompt,
|
|
135
|
+
recorPkFieldName: (_k = this.resourceConfig.columns.find((column) => column.primaryKey)) === null || _k === void 0 ? void 0 : _k.name,
|
|
135
136
|
};
|
|
136
137
|
// define components which will be imported from other components
|
|
137
138
|
this.componentPath('imageGenerator.vue');
|
|
@@ -433,12 +434,9 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
|
|
|
433
434
|
server.endpoint({
|
|
434
435
|
method: 'POST',
|
|
435
436
|
path: `/plugin/${this.pluginInstanceId}/generate_images`,
|
|
436
|
-
handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, headers }) {
|
|
437
|
-
var _b, _c;
|
|
438
|
-
const { prompt } = body;
|
|
439
|
-
if (this.options.generation.provider !== 'openai-dall-e') {
|
|
440
|
-
throw new Error(`Provider ${this.options.generation.provider} is not supported`);
|
|
441
|
-
}
|
|
437
|
+
handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers }) {
|
|
438
|
+
var _b, _c, _d;
|
|
439
|
+
const { prompt, recordId } = body;
|
|
442
440
|
if ((_b = this.options.generation.rateLimit) === null || _b === void 0 ? void 0 : _b.limit) {
|
|
443
441
|
// rate limit
|
|
444
442
|
const { error } = RateLimiter.checkRateLimit(this.pluginInstanceId, (_c = this.options.generation.rateLimit) === null || _c === void 0 ? void 0 : _c.limit, this.adminforth.auth.getClientIp(headers));
|
|
@@ -446,30 +444,39 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
|
|
|
446
444
|
return { error: this.options.generation.rateLimit.errorMessage };
|
|
447
445
|
}
|
|
448
446
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
447
|
+
let attachmentFiles = [];
|
|
448
|
+
if (this.options.generation.attachFiles) {
|
|
449
|
+
// TODO - does it require additional allowed action to check this record id has access to get the image?
|
|
450
|
+
// or should we mention in docs that user should do validation in method itself
|
|
451
|
+
const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ((_d = this.resourceConfig.columns.find((column) => column.primaryKey)) === null || _d === void 0 ? void 0 : _d.name, recordId)]);
|
|
452
|
+
if (!record) {
|
|
453
|
+
return { error: `Record with id ${recordId} not found` };
|
|
454
|
+
}
|
|
455
|
+
attachmentFiles = this.options.generation.attachFiles({ record, adminUser });
|
|
456
|
+
// if files is not array, make it array
|
|
457
|
+
if (!Array.isArray(attachmentFiles)) {
|
|
458
|
+
attachmentFiles = [attachmentFiles];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
let error = undefined;
|
|
462
|
+
const STUB_MODE = false;
|
|
452
463
|
const images = yield Promise.all((new Array(this.options.generation.countToGenerate)).fill(0).map(() => __awaiter(this, void 0, void 0, function* () {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
n: 1,
|
|
463
|
-
size,
|
|
464
|
-
})
|
|
464
|
+
if (STUB_MODE) {
|
|
465
|
+
yield new Promise((resolve) => setTimeout(resolve, 2000));
|
|
466
|
+
return `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`;
|
|
467
|
+
}
|
|
468
|
+
const resp = yield this.options.generation.adapter.generate({
|
|
469
|
+
prompt,
|
|
470
|
+
inputFiles: attachmentFiles,
|
|
471
|
+
n: 1,
|
|
472
|
+
size: this.options.generation.outputSize,
|
|
465
473
|
});
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
error = json.error;
|
|
474
|
+
if (resp.error) {
|
|
475
|
+
console.error('Error generating image', resp.error);
|
|
476
|
+
error = resp.error;
|
|
470
477
|
return;
|
|
471
478
|
}
|
|
472
|
-
return
|
|
479
|
+
return resp.imageURLs[0];
|
|
473
480
|
})));
|
|
474
481
|
return { error, images };
|
|
475
482
|
})
|
package/index.ts
CHANGED
|
@@ -27,12 +27,12 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
27
27
|
const CLEANUP_RULE_ID = 'adminforth-unused-cleaner';
|
|
28
28
|
|
|
29
29
|
const s3 = new S3({
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
credentials: {
|
|
31
|
+
accessKeyId: this.options.s3AccessKeyId,
|
|
32
|
+
secretAccessKey: this.options.s3SecretAccessKey,
|
|
33
|
+
},
|
|
34
|
+
region: this.options.s3Region,
|
|
35
|
+
});
|
|
36
36
|
|
|
37
37
|
// check bucket exists
|
|
38
38
|
const bucketExists = s3.headBucket({ Bucket: this.options.s3Bucket })
|
|
@@ -125,13 +125,14 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
|
|
|
125
125
|
resourceLabel: resourceConfig.label,
|
|
126
126
|
generateImages: this.options.generation ? true : false,
|
|
127
127
|
pathColumnLabel: resourceConfig.columns[pathColumnIndex].label,
|
|
128
|
-
fieldsForContext: this.options.generation?.fieldsForContext,
|
|
129
128
|
maxWidth: this.options.preview?.maxWidth,
|
|
130
129
|
maxListWidth: this.options.preview?.maxListWidth,
|
|
131
130
|
maxShowWidth: this.options.preview?.maxShowWidth,
|
|
132
131
|
minWidth: this.options.preview?.minWidth,
|
|
133
132
|
minListWidth: this.options.preview?.minListWidth,
|
|
134
133
|
minShowWidth: this.options.preview?.minShowWidth,
|
|
134
|
+
generationPrompt: this.options.generation?.generationPrompt,
|
|
135
|
+
recorPkFieldName: this.resourceConfig.columns.find((column: any) => column.primaryKey)?.name,
|
|
135
136
|
};
|
|
136
137
|
// define components which will be imported from other components
|
|
137
138
|
this.componentPath('imageGenerator.vue');
|
|
@@ -481,12 +482,8 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
|
|
|
481
482
|
server.endpoint({
|
|
482
483
|
method: 'POST',
|
|
483
484
|
path: `/plugin/${this.pluginInstanceId}/generate_images`,
|
|
484
|
-
handler: async ({ body, headers }) => {
|
|
485
|
-
const { prompt } = body;
|
|
486
|
-
|
|
487
|
-
if (this.options.generation.provider !== 'openai-dall-e') {
|
|
488
|
-
throw new Error(`Provider ${this.options.generation.provider} is not supported`);
|
|
489
|
-
}
|
|
485
|
+
handler: async ({ body, adminUser, headers }) => {
|
|
486
|
+
const { prompt, recordId } = body;
|
|
490
487
|
|
|
491
488
|
if (this.options.generation.rateLimit?.limit) {
|
|
492
489
|
// rate limit
|
|
@@ -499,35 +496,52 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
|
|
|
499
496
|
return { error: this.options.generation.rateLimit.errorMessage };
|
|
500
497
|
}
|
|
501
498
|
}
|
|
499
|
+
let attachmentFiles = [];
|
|
500
|
+
if (this.options.generation.attachFiles) {
|
|
501
|
+
// TODO - does it require additional allowed action to check this record id has access to get the image?
|
|
502
|
+
// or should we mention in docs that user should do validation in method itself
|
|
503
|
+
const record = await this.adminforth.resource(this.resourceConfig.resourceId).get(
|
|
504
|
+
[Filters.EQ(this.resourceConfig.columns.find((column: any) => column.primaryKey)?.name, recordId)]
|
|
505
|
+
);
|
|
502
506
|
|
|
503
|
-
|
|
504
|
-
|
|
507
|
+
if (!record) {
|
|
508
|
+
return { error: `Record with id ${recordId} not found` };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
attachmentFiles = this.options.generation.attachFiles({ record, adminUser });
|
|
512
|
+
// if files is not array, make it array
|
|
513
|
+
if (!Array.isArray(attachmentFiles)) {
|
|
514
|
+
attachmentFiles = [attachmentFiles];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
let error: string | undefined = undefined;
|
|
520
|
+
|
|
521
|
+
const STUB_MODE = false;
|
|
505
522
|
|
|
506
|
-
let error = null;
|
|
507
523
|
const images = await Promise.all(
|
|
508
524
|
(new Array(this.options.generation.countToGenerate)).fill(0).map(async () => {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
body: JSON.stringify({
|
|
516
|
-
model,
|
|
525
|
+
if (STUB_MODE) {
|
|
526
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
527
|
+
return `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`;
|
|
528
|
+
}
|
|
529
|
+
const resp = await this.options.generation.adapter.generate(
|
|
530
|
+
{
|
|
517
531
|
prompt,
|
|
532
|
+
inputFiles: attachmentFiles,
|
|
518
533
|
n: 1,
|
|
519
|
-
size,
|
|
520
|
-
}
|
|
521
|
-
|
|
534
|
+
size: this.options.generation.outputSize,
|
|
535
|
+
}
|
|
536
|
+
)
|
|
522
537
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
error = json.error;
|
|
538
|
+
if (resp.error) {
|
|
539
|
+
console.error('Error generating image', resp.error);
|
|
540
|
+
error = resp.error;
|
|
527
541
|
return;
|
|
528
542
|
}
|
|
529
543
|
|
|
530
|
-
return
|
|
544
|
+
return resp.imageURLs[0]
|
|
531
545
|
|
|
532
546
|
})
|
|
533
547
|
);
|
package/package.json
CHANGED
package/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AdminUser, ImageGenerationAdapter } from "adminforth";
|
|
1
2
|
|
|
2
3
|
export type PluginOptions = {
|
|
3
4
|
|
|
@@ -115,11 +116,18 @@ export type PluginOptions = {
|
|
|
115
116
|
* AI image generation options
|
|
116
117
|
*/
|
|
117
118
|
generation?: {
|
|
119
|
+
adapter: ImageGenerationAdapter,
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The size of the generated image.
|
|
123
|
+
*/
|
|
124
|
+
outputSize?: string,
|
|
125
|
+
|
|
118
126
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
127
|
+
* Fields for conetext which will be used to generate the image.
|
|
128
|
+
* If specified, the plugin will use fields from the record to provide additional context to the AI model.
|
|
121
129
|
*/
|
|
122
|
-
|
|
130
|
+
fieldsForContext?: string[],
|
|
123
131
|
|
|
124
132
|
/**
|
|
125
133
|
* The number of images to generate
|
|
@@ -128,30 +136,23 @@ export type PluginOptions = {
|
|
|
128
136
|
countToGenerate: number,
|
|
129
137
|
|
|
130
138
|
/**
|
|
131
|
-
*
|
|
139
|
+
* Prompt which will be suggested to user during image generation. You can use record fields with mustache brackets:
|
|
140
|
+
* E.g. 'Generate a photo of car {{ model }} from {{ brand }} in {{ color }} color of {{ year }} year'. For now plugin get's these fields from open create/edit form
|
|
141
|
+
* so they should be present in the form.
|
|
142
|
+
*
|
|
143
|
+
* Reserved variables:
|
|
144
|
+
* - {{field}} - label of resource
|
|
145
|
+
* - {{resource}} - label of resource
|
|
132
146
|
*/
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The model to use, e.g. 'dall-e-3'
|
|
136
|
-
*/
|
|
137
|
-
model: string,
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* The size of the image to generate, e.g. '1792x1024'
|
|
141
|
-
*/
|
|
142
|
-
size: string,
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* The OpenAI API key
|
|
146
|
-
*/
|
|
147
|
-
apiKey: string,
|
|
148
|
-
},
|
|
147
|
+
generationPrompt?: string,
|
|
149
148
|
|
|
150
149
|
/**
|
|
151
|
-
*
|
|
152
|
-
* where plugin is used.
|
|
150
|
+
* If you want to use some image as reference for generation, you can use this function to get the path to the image.
|
|
153
151
|
*/
|
|
154
|
-
|
|
152
|
+
attachFiles?: ({ record, adminUser }: {
|
|
153
|
+
record: any,
|
|
154
|
+
adminUser: AdminUser,
|
|
155
|
+
}) => string[],
|
|
155
156
|
|
|
156
157
|
|
|
157
158
|
/**
|
|
@@ -171,6 +172,8 @@ export type PluginOptions = {
|
|
|
171
172
|
*/
|
|
172
173
|
errorMessage: string,
|
|
173
174
|
},
|
|
175
|
+
|
|
176
|
+
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
}
|