@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 CHANGED
@@ -11,5 +11,5 @@ custom/preview.vue
11
11
  custom/tsconfig.json
12
12
  custom/uploader.vue
13
13
 
14
- sent 38,412 bytes received 134 bytes 77,092.00 bytes/sec
15
- total size is 37,930 speedup is 0.98
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-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,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 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,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
- 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,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
- 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');
@@ -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
- 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 = [
@@ -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-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,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 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,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
- 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,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
- 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');
@@ -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
- 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 = [
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
- fieldsForContext: (_c = this.options.generation) === null || _c === void 0 ? void 0 : _c.fieldsForContext,
129
- maxWidth: (_d = this.options.preview) === null || _d === void 0 ? void 0 : _d.maxWidth,
130
- maxListWidth: (_e = this.options.preview) === null || _e === void 0 ? void 0 : _e.maxListWidth,
131
- maxShowWidth: (_f = this.options.preview) === null || _f === void 0 ? void 0 : _f.maxShowWidth,
132
- minWidth: (_g = this.options.preview) === null || _g === void 0 ? void 0 : _g.minWidth,
133
- minListWidth: (_h = this.options.preview) === null || _h === void 0 ? void 0 : _h.minListWidth,
134
- minShowWidth: (_j = this.options.preview) === null || _j === void 0 ? void 0 : _j.minShowWidth,
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
- const { model, size, apiKey } = this.options.generation.openAiOptions;
450
- const url = 'https://api.openai.com/v1/images/generations';
451
- let error = null;
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
- const response = yield fetch(url, {
454
- method: 'POST',
455
- headers: {
456
- 'Content-Type': 'application/json',
457
- 'Authorization': `Bearer ${apiKey}`,
458
- },
459
- body: JSON.stringify({
460
- model,
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,
472
+ size: this.options.generation.outputSize,
465
473
  });
466
- const json = yield response.json();
467
- if (json.error) {
468
- console.error('Error generating image', json.error);
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 json;
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
- credentials: {
31
- accessKeyId: this.options.s3AccessKeyId,
32
- secretAccessKey: this.options.s3SecretAccessKey,
33
- },
34
- region: this.options.s3Region,
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
- const { model, size, apiKey } = this.options.generation.openAiOptions;
504
- const url = 'https://api.openai.com/v1/images/generations';
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
- const response = await fetch(url, {
510
- method: 'POST',
511
- headers: {
512
- 'Content-Type': 'application/json',
513
- 'Authorization': `Bearer ${apiKey}`,
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
- const json = await response.json();
524
- if (json.error) {
525
- console.error('Error generating image', json.error);
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 json;
544
+ return resp.imageURLs[0]
531
545
 
532
546
  })
533
547
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/upload",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Plugin for uploading files for adminforth",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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
- * The provider to use for image generation
120
- * for now only 'openai-dall-e' is supported
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
- provider: string,
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
- * Options for OpenAI
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
- openAiOptions: {
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
- * Fields of record to use for context. if supplied must be array of valid column names for resource
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
- fieldsForContext? : string[],
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
  }