2626 :title =" $t('Prompt which will be passed to AI network')"
2727 ></textarea >
2828
29- <div class =" flex items-center justify-center w-full relative" >
29+ <div class =" flex flex-col items-center justify-center w-full relative" >
3030 <div
3131 v-if =" loading"
3232 class =" absolute flex items-center justify-center w-full h-full z-50 bg-white/80 dark:bg-gray-900/80 rounded-lg"
3636 <span class =" sr-only" >{{ $t('Loading...') }}</span >
3737 </div >
3838 </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+
3960
4061 <div id =" gallery" class =" relative w-full" data-carousel =" static" >
4162 <!-- Carousel wrapper -->
4263 <div class =" relative h-56 overflow-hidden rounded-lg md:h-72" >
4364 <!-- Item 1 -->
4465 <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+ />
4669 </div >
4770
4871 <div v-if =" images.length === 0" class =" flex items-center justify-center w-full h-full" >
5780 <!-- Slider controls -->
5881 <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"
5982 @click =" slide(-1)"
83+ :disabled =" images.length === 0"
6084 >
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+ >
6392 <path stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" stroke-width =" 2" d =" M5 1 1 5l4 4" />
6493 </svg >
6594 <span class =" sr-only" >{{ $t('Previous') }}</span >
6695 </span >
6796 </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"
6999 @click =" slide(1)"
70100 >
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+ >
73108 <path stroke =" currentColor" stroke-linecap =" round" stroke-linejoin =" round" stroke-width =" 2" d =" m1 9 4-4-4-4" />
74109 </svg >
75110 <span class =" sr-only" >{{ $t('Next') }}</span >
103138
104139<script setup lang="ts">
105140
106- import { ref , onMounted , nextTick } from ' vue'
141+ import { ref , onMounted , nextTick , Ref , h , computed } from ' vue'
107142import { Carousel } from ' flowbite' ;
108143import { callAdminForthApi } from ' @/utils' ;
109144import { useI18n } from ' vue-i18n' ;
110145import adminforth from ' @/adminforth' ;
146+ import { ProgressBar } from ' @/afcl' ;
111147
112148const { t : $t } = useI18n ();
113149
@@ -127,22 +163,42 @@ function minifyField(field: string): string {
127163const caurosel = ref (null );
128164onMounted (() => {
129165 // 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 = {
138167 field: props .meta .pathColumnLabel ,
139168 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+ });
143195 }
144196
145- })
197+ prompt .value = template .replace (regex , (_ , field ) => {
198+ return context [field .trim ()] || ' ' ;
199+ });
200+
201+ });
146202
147203async function slide(direction : number ) {
148204 if (! caurosel .value ) return ;
@@ -164,27 +220,75 @@ async function confirmImage() {
164220 const currentIndex = caurosel .value ?.getActiveItem ()?.position || 0 ;
165221 const img = images .value [currentIndex ];
166222 // 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+ }
170240
171241 emit (' uploadImage' , imgBlob );
172242 emit (' close' );
173243
174244 loading .value = false ;
175245}
176246
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+
177264async function generateImages() {
178265 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 );
179272 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 ;
187290
291+ }
188292 if (resp .error ) {
189293 adminforth .alert ({
190294 message: $t (' Error: {error}' , { error: JSON .stringify (resp .error ) }),
@@ -197,7 +301,7 @@ async function generateImages() {
197301
198302 images .value = [
199303 ... images .value ,
200- ... resp .images . map ( im => im . data [ 0 ]. url ) ,
304+ ... resp .images ,
201305 ];
202306
203307 // images.value = [
0 commit comments