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
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-auto -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2" alt =" " >
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
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 text-white dark:text-gray-800 rtl:rotate-180" aria-hidden =" true" xmlns =" http://www.w3.org/2000/svg" fill =" none" viewBox =" 0 0 6 10" >
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 text-white dark:text-gray-800 rtl:rotate-180" aria-hidden =" true" xmlns =" http://www.w3.org/2000/svg" fill =" none" viewBox =" 0 0 6 10" >
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
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
- let additionalContext = null ;
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
- if (additionalContext ) {
142
- prompt .value += ` ${additionalContext } ` ;
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,27 +220,75 @@ 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
- const imgBlob = await fetch (
168
- ` ${import .meta .env .VITE_ADMINFORTH_PUBLIC_PATH || ' ' }/adminapi/v1/plugin/${props .meta .pluginInstanceId }/cors-proxy?url=${encodeURIComponent (img )} `
169
- ).then (res => { return res .blob () });
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' );
173
243
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
- const resp = await callAdminForthApi ({
181
- path: ` /plugin/${props .meta .pluginInstanceId }/generate_images ` ,
182
- method: ' POST' ,
183
- body: {
184
- prompt: prompt .value ,
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 . map ( im => im . data [ 0 ]. url ) ,
304
+ ... resp .images ,
201
305
];
202
306
203
307
// images.value = [
0 commit comments