@adminforth/upload 1.4.1 → 1.4.2
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 +40 -34
- package/index.ts +45 -32
- package/package.json +1 -1
- package/types.ts +22 -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,38 @@ 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
|
-
prompt,
|
|
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,
|
|
465
472
|
});
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
error = json.error;
|
|
473
|
+
if (resp.error) {
|
|
474
|
+
console.error('Error generating image', resp.error);
|
|
475
|
+
error = resp.error;
|
|
470
476
|
return;
|
|
471
477
|
}
|
|
472
|
-
return
|
|
478
|
+
return resp.imageURLs[0];
|
|
473
479
|
})));
|
|
474
480
|
return { error, images };
|
|
475
481
|
})
|
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,51 @@ 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
|
-
|
|
520
|
-
|
|
521
|
-
});
|
|
534
|
+
}
|
|
535
|
+
)
|
|
522
536
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
error = json.error;
|
|
537
|
+
if (resp.error) {
|
|
538
|
+
console.error('Error generating image', resp.error);
|
|
539
|
+
error = resp.error;
|
|
527
540
|
return;
|
|
528
541
|
}
|
|
529
542
|
|
|
530
|
-
return
|
|
543
|
+
return resp.imageURLs[0]
|
|
531
544
|
|
|
532
545
|
})
|
|
533
546
|
);
|
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,14 @@ export type PluginOptions = {
|
|
|
115
116
|
* AI image generation options
|
|
116
117
|
*/
|
|
117
118
|
generation?: {
|
|
119
|
+
adapter: ImageGenerationAdapter,
|
|
120
|
+
|
|
121
|
+
|
|
118
122
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
123
|
+
* Fields for conetext which will be used to generate the image.
|
|
124
|
+
* If specified, the plugin will use fields from the record to provide additional context to the AI model.
|
|
121
125
|
*/
|
|
122
|
-
|
|
126
|
+
fieldsForContext?: string[],
|
|
123
127
|
|
|
124
128
|
/**
|
|
125
129
|
* The number of images to generate
|
|
@@ -128,30 +132,23 @@ export type PluginOptions = {
|
|
|
128
132
|
countToGenerate: number,
|
|
129
133
|
|
|
130
134
|
/**
|
|
131
|
-
*
|
|
135
|
+
* Prompt which will be suggested to user during image generation. You can use record fields with mustache brackets:
|
|
136
|
+
* 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
|
|
137
|
+
* so they should be present in the form.
|
|
138
|
+
*
|
|
139
|
+
* Reserved variables:
|
|
140
|
+
* - {{field}} - label of resource
|
|
141
|
+
* - {{resource}} - label of resource
|
|
132
142
|
*/
|
|
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
|
-
},
|
|
143
|
+
generationPrompt?: string,
|
|
149
144
|
|
|
150
145
|
/**
|
|
151
|
-
*
|
|
152
|
-
* where plugin is used.
|
|
146
|
+
* If you want to use some image as reference for generation, you can use this function to get the path to the image.
|
|
153
147
|
*/
|
|
154
|
-
|
|
148
|
+
attachFiles?: ({ record, adminUser }: {
|
|
149
|
+
record: any,
|
|
150
|
+
adminUser: AdminUser,
|
|
151
|
+
}) => string[],
|
|
155
152
|
|
|
156
153
|
|
|
157
154
|
/**
|
|
@@ -171,6 +168,8 @@ export type PluginOptions = {
|
|
|
171
168
|
*/
|
|
172
169
|
errorMessage: string,
|
|
173
170
|
},
|
|
171
|
+
|
|
172
|
+
|
|
174
173
|
}
|
|
175
174
|
|
|
176
175
|
}
|