@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 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,38 @@ 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,
465
472
  });
466
- const json = yield response.json();
467
- if (json.error) {
468
- console.error('Error generating image', json.error);
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 json;
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
- 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,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
- 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
+ }
535
+ )
522
536
 
523
- const json = await response.json();
524
- if (json.error) {
525
- console.error('Error generating image', json.error);
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 json;
543
+ return resp.imageURLs[0]
531
544
 
532
545
  })
533
546
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/upload",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
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,14 @@ export type PluginOptions = {
115
116
  * AI image generation options
116
117
  */
117
118
  generation?: {
119
+ adapter: ImageGenerationAdapter,
120
+
121
+
118
122
  /**
119
- * The provider to use for image generation
120
- * for now only 'openai-dall-e' is supported
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
- provider: string,
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
- * Options for OpenAI
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
- 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
- },
143
+ generationPrompt?: string,
149
144
 
150
145
  /**
151
- * Fields of record to use for context. if supplied must be array of valid column names for resource
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
- fieldsForContext? : string[],
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
  }