Skip to content

Commit 276ba4c

Browse files
committed
BREAKING CHANGE: use generation adapter instead of generation section
2 parents c490038 + f2bb289 commit 276ba4c

File tree

3 files changed

+198
-88
lines changed

3 files changed

+198
-88
lines changed

custom/imageGenerator.vue

+135-31
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
:title="$t('Prompt which will be passed to AI network')"
2727
></textarea>
2828

29-
<div class="flex items-center justify-center w-full relative">
29+
<div class="flex flex-col items-center justify-center w-full relative">
3030
<div
3131
v-if="loading"
3232
class=" absolute flex items-center justify-center w-full h-full z-50 bg-white/80 dark:bg-gray-900/80 rounded-lg"
@@ -36,13 +36,36 @@
3636
<span class="sr-only">{{ $t('Loading...') }}</span>
3737
</div>
3838
</div>
39+
40+
<div v-if="loadingTimer" class="absolute pt-12 flex items-center justify-center w-full h-full z-50 bg-white/80 dark:bg-gray-900/80 rounded-lg">
41+
<div class="text-gray-800 dark:text-gray-100 text-lg font-semibold"
42+
v-if="!historicalAverage"
43+
>
44+
{{ formatTime(loadingTimer) }} {{ $t('passed...') }}
45+
</div>
46+
<div class="w-64" v-else>
47+
<ProgressBar
48+
class="absolute max-w-full"
49+
:currentValue="loadingTimer < historicalAverage ? loadingTimer : historicalAverage"
50+
:minValue="0"
51+
:maxValue="historicalAverage"
52+
:showValues="false"
53+
:progressFormatter="(value: number, percentage: number) => `${ formatTime(loadingTimer) } ( ${ Math.floor( (
54+
loadingTimer < historicalAverage ? loadingTimer : historicalAverage
55+
) / historicalAverage * 100) }% )`"
56+
/>
57+
</div>
58+
</div>
59+
3960

4061
<div id="gallery" class="relative w-full" data-carousel="static">
4162
<!-- Carousel wrapper -->
4263
<div class="relative h-56 overflow-hidden rounded-lg md:h-72">
4364
<!-- Item 1 -->
4465
<div v-for="(img, index) in images" :key="index" class="hidden duration-700 ease-in-out" data-carousel-item>
45-
<img :src="img" class="absolute block max-w-full h-auto -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2" alt="">
66+
<img :src="img" class="absolute block max-w-full max-h-full -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2 object-cover"
67+
:alt="`Generated image ${index + 1}`"
68+
/>
4669
</div>
4770

4871
<div v-if="images.length === 0" class="flex items-center justify-center w-full h-full">
@@ -57,19 +80,31 @@
5780
<!-- Slider controls -->
5881
<button type="button" class="absolute top-0 start-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none"
5982
@click="slide(-1)"
83+
:disabled="images.length === 0"
6084
>
61-
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
62-
<svg class="w-4 h-4 text-white dark:text-gray-800 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
85+
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none ">
86+
<svg class="w-4 h-4 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10"
87+
:class="{
88+
'text-gray-800 dark:text-gray-200': images.length > 0,
89+
'text-gray-200 dark:text-gray-800': images.length === 0
90+
}"
91+
>
6392
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
6493
</svg>
6594
<span class="sr-only">{{ $t('Previous') }}</span>
6695
</span>
6796
</button>
68-
<button type="button" class="absolute top-0 end-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none"
97+
<button type="button" class="absolute top-0 end-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none "
98+
:disabled="images.length === 0"
6999
@click="slide(1)"
70100
>
71-
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none">
72-
<svg class="w-4 h-4 text-white dark:text-gray-800 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
101+
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none ">
102+
<svg class="w-4 h-4 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10"
103+
:class="{
104+
'text-gray-800 dark:text-gray-200': images.length > 0,
105+
'text-gray-200 dark:text-gray-800': images.length === 0
106+
}"
107+
>
73108
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
74109
</svg>
75110
<span class="sr-only">{{ $t('Next') }}</span>
@@ -103,11 +138,12 @@
103138

104139
<script setup lang="ts">
105140
106-
import { ref, onMounted, nextTick } from 'vue'
141+
import { ref, onMounted, nextTick, Ref, h, computed } from 'vue'
107142
import { Carousel } from 'flowbite';
108143
import { callAdminForthApi } from '@/utils';
109144
import { useI18n } from 'vue-i18n';
110145
import adminforth from '@/adminforth';
146+
import { ProgressBar } from '@/afcl';
111147
112148
const { t: $t } = useI18n();
113149
@@ -127,22 +163,42 @@ function minifyField(field: string): string {
127163
const caurosel = ref(null);
128164
onMounted(() => {
129165
// Initialize carousel
130-
let additionalContext = null;
131-
if (props.meta.fieldsForContext) {
132-
additionalContext = props.meta.fieldsForContext.filter((field: string) => props.record[field]).map((field: string) => {
133-
return `${field}: ${minifyField(props.record[field])}`;
134-
}).join('\n');
135-
}
136-
137-
prompt.value = $t('Generate image for field "{field}" in {resource}. No text should be on image.', {
166+
const context = {
138167
field: props.meta.pathColumnLabel,
139168
resource: props.meta.resourceLabel,
140-
});
141-
if (additionalContext) {
142-
prompt.value += ` ${additionalContext}`;
169+
};
170+
let template = '';
171+
if (props.meta.generationPrompt) {
172+
template = props.meta.generationPrompt;
173+
} else {
174+
template = 'Generate image for field {{field}} in {{resource}}. No text should be on image.';
175+
}
176+
// iterate over all variables in template and replace them with their values from props.record[field].
177+
// if field is not present in props.record[field] then replace it with empty string and drop warning
178+
const regex = /{{(.*?)}}/g;
179+
const matches = template.match(regex);
180+
if (matches) {
181+
matches.forEach((match) => {
182+
const field = match.replace(/{{|}}/g, '').trim();
183+
if (field in context) {
184+
return;
185+
} else if (field in props.record) {
186+
context[field] = minifyField(props.record[field]);
187+
} else {
188+
adminforth.alert({
189+
message: $t('Field {{field}} defined in template but not found in record', { field }),
190+
variant: 'warning',
191+
timeout: 15,
192+
});
193+
}
194+
});
143195
}
144196
145-
})
197+
prompt.value = template.replace(regex, (_, field) => {
198+
return context[field.trim()] || '';
199+
});
200+
201+
});
146202
147203
async function slide(direction: number) {
148204
if (!caurosel.value) return;
@@ -164,27 +220,75 @@ async function confirmImage() {
164220
const currentIndex = caurosel.value?.getActiveItem()?.position || 0;
165221
const img = images.value[currentIndex];
166222
// read url to base64 and send it to the parent component
167-
const imgBlob = await fetch(
168-
`${import.meta.env.VITE_ADMINFORTH_PUBLIC_PATH || ''}/adminapi/v1/plugin/${props.meta.pluginInstanceId}/cors-proxy?url=${encodeURIComponent(img)}`
169-
).then(res => { return res.blob() });
223+
224+
let imgBlob;
225+
if (img.startsWith('data:')) {
226+
const base64 = img.split(',')[1];
227+
const mimeType = img.split(';')[0].split(':')[1];
228+
const byteCharacters = atob(base64);
229+
const byteNumbers = new Array(byteCharacters.length);
230+
for (let i = 0; i < byteCharacters.length; i++) {
231+
byteNumbers[i] = byteCharacters.charCodeAt(i);
232+
}
233+
const byteArray = new Uint8Array(byteNumbers);
234+
imgBlob = new Blob([byteArray], { type: mimeType });
235+
} else {
236+
imgBlob = await fetch(
237+
`${import.meta.env.VITE_ADMINFORTH_PUBLIC_PATH || ''}/adminapi/v1/plugin/${props.meta.pluginInstanceId}/cors-proxy?url=${encodeURIComponent(img)}`
238+
).then(res => { return res.blob() });
239+
}
170240
171241
emit('uploadImage', imgBlob);
172242
emit('close');
173243
174244
loading.value = false;
175245
}
176246
247+
const loadingTimer: Ref<number | null> = ref(null);
248+
249+
const historicalRuns: Ref<number[]> = ref([]);
250+
251+
const historicalAverage: Ref<number | null> = computed(() => {
252+
if (historicalRuns.value.length === 0) return null;
253+
const sum = historicalRuns.value.reduce((a, b) => a + b, 0);
254+
return Math.floor(sum / historicalRuns.value.length);
255+
});
256+
257+
258+
function formatTime(seconds: number): string {
259+
const minutes = Math.floor(seconds / 60);
260+
return `${minutes % 60}m ${Math.floor(seconds % 60)}s`;
261+
}
262+
263+
177264
async function generateImages() {
178265
loading.value = true;
266+
loadingTimer.value = 0;
267+
const start = Date.now();
268+
const ticker = setInterval(() => {
269+
const elapsed = (Date.now() - start) / 1000;
270+
loadingTimer.value = elapsed;
271+
}, 100);
179272
const currentIndex = caurosel.value?.getActiveItem()?.position || 0;
180-
const resp = await callAdminForthApi({
181-
path: `/plugin/${props.meta.pluginInstanceId}/generate_images`,
182-
method: 'POST',
183-
body: {
184-
prompt: prompt.value,
185-
},
186-
});
273+
274+
let resp;
275+
try {
276+
resp = await callAdminForthApi({
277+
path: `/plugin/${props.meta.pluginInstanceId}/generate_images`,
278+
method: 'POST',
279+
body: {
280+
prompt: prompt.value,
281+
recordId: props.record[props.meta.recorPkFieldName]
282+
},
283+
});
284+
} catch (e) {
285+
console.error(e);
286+
} finally {
287+
historicalRuns.value.push(loadingTimer.value);
288+
clearInterval(ticker);
289+
loadingTimer.value = null;
187290
291+
}
188292
if (resp.error) {
189293
adminforth.alert({
190294
message: $t('Error: {error}', { error: JSON.stringify(resp.error) }),
@@ -197,7 +301,7 @@ async function generateImages() {
197301
198302
images.value = [
199303
...images.value,
200-
...resp.images.map(im => im.data[0].url),
304+
...resp.images,
201305
];
202306
203307
// images.value = [

index.ts

+45-32
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ export default class UploadPlugin extends AdminForthPlugin {
2727
const CLEANUP_RULE_ID = 'adminforth-unused-cleaner';
2828

2929
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+
});
3636

3737
// check bucket exists
3838
const bucketExists = s3.headBucket({ Bucket: this.options.s3Bucket })
@@ -125,13 +125,14 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
125125
resourceLabel: resourceConfig.label,
126126
generateImages: this.options.generation ? true : false,
127127
pathColumnLabel: resourceConfig.columns[pathColumnIndex].label,
128-
fieldsForContext: this.options.generation?.fieldsForContext,
129128
maxWidth: this.options.preview?.maxWidth,
130129
maxListWidth: this.options.preview?.maxListWidth,
131130
maxShowWidth: this.options.preview?.maxShowWidth,
132131
minWidth: this.options.preview?.minWidth,
133132
minListWidth: this.options.preview?.minListWidth,
134133
minShowWidth: this.options.preview?.minShowWidth,
134+
generationPrompt: this.options.generation?.generationPrompt,
135+
recorPkFieldName: this.resourceConfig.columns.find((column: any) => column.primaryKey)?.name,
135136
};
136137
// define components which will be imported from other components
137138
this.componentPath('imageGenerator.vue');
@@ -481,12 +482,8 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
481482
server.endpoint({
482483
method: 'POST',
483484
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;
490487

491488
if (this.options.generation.rateLimit?.limit) {
492489
// rate limit
@@ -499,35 +496,51 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
499496
return { error: this.options.generation.rateLimit.errorMessage };
500497
}
501498
}
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+
);
502506

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;
505522

506-
let error = null;
507523
const images = await Promise.all(
508524
(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+
{
517531
prompt,
532+
inputFiles: attachmentFiles,
518533
n: 1,
519-
size,
520-
})
521-
});
534+
}
535+
)
522536

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;
527540
return;
528541
}
529542

530-
return json;
543+
return resp.imageURLs[0]
531544

532545
})
533546
);

0 commit comments

Comments
 (0)