Skip to content

Commit

Permalink
feat(ContributionAssistant): new experiment, a contribution assistant…
Browse files Browse the repository at this point in the history
… using gemini (#1025)

Co-authored-by: Raphaël Odini <[email protected]>
  • Loading branch information
TTalex and raphodn authored Nov 13, 2024
1 parent a059c04 commit cdeec76
Show file tree
Hide file tree
Showing 9 changed files with 573 additions and 1 deletion.
38 changes: 38 additions & 0 deletions src/components/ContributionAssistantCropImageList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<template>
<v-row v-if="croppedImages.length">
<v-col
v-for="(imageSrc, index) in croppedImages"
:key="index"
cols="6"
md="4"
>
<v-card style="height: 200px;" class="pa-2">
<v-btn
density="compact"
icon="mdi-delete"
style="left:calc(100% - 30px); z-index: 3;"
color="error"
@click="removeCrop(index)"
/>
<v-img :src="imageSrc" class="cropped-image" />
</v-card>
</v-col>
</v-row>
</template>

<script>
export default {
props: {
croppedImages: {
type: Array,
default: () => []
}
},
emits: ['removeCrop'],
methods: {
removeCrop(index) {
this.$emit('removeCrop', index)
}
}
}
</script>
148 changes: 148 additions & 0 deletions src/components/ContributionAssistantDrawCanvas.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<template>
<canvas
id="canvas"
ref="canvas"
style="width: 100%; touch-action: none;"
@mousedown="startDrawing"
@mousemove="drawContent"
@mouseup="finishDrawing"
@touchstart="startDrawing"
@touchmove="drawContent"
@touchend="finishDrawing"
/>
</template>

<script>
export default {
props: {
image: {
type: Image,
default: null
}
},
emits: ['croppedImages'],
data() {
return {
isDrawing: false,
startX: 0,
startY: 0,
scale: 1,
rectangles: []
}
},
watch: {
image(newImage) {
newImage.onload = this.init
}
},
methods: {
init() {
const canvas = this.$refs.canvas
const ctx = canvas.getContext("2d")
canvas.style.width = "100%"
this.scale = canvas.offsetWidth / this.image.width
const preferedHeight = 400
if (preferedHeight < this.image.height * this.scale) {
// Image will be too tall
// Ajust to fit preferedHeight
canvas.style.width = "auto"
this.scale = preferedHeight / this.image.height
}
const newWidth = this.image.width * this.scale
const newHeight = this.image.height * this.scale
canvas.width = newWidth
canvas.height = newHeight
ctx.drawImage(this.image, 0, 0, newWidth, newHeight)
this.rectangles = [] // reset rectangles
this.drawRectangles(); // Draw previous rectangles after resizing
},
startDrawing(event) {
if (event.type == "touchstart") {
const rect = event.target.getBoundingClientRect()
event.offsetX = event.targetTouches[0].clientX - rect.left
event.offsetY = event.targetTouches[0].clientY - rect.top
}
this.startX = event.offsetX / this.scale
this.startY = event.offsetY / this.scale
this.isDrawing = true;
},
drawContent(event) {
if (event.type == "touchmove") {
const rect = event.target.getBoundingClientRect()
event.offsetX = event.targetTouches[0].clientX - rect.left
event.offsetY = event.targetTouches[0].clientY - rect.top
}
if (this.isDrawing) {
const canvas = this.$refs.canvas
const ctx = canvas.getContext("2d")
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height); // Redraw image
this.drawRectangles(); // Redraw previous rectangles
const currentX = event.offsetX / this.scale
const currentY = event.offsetY / this.scale
const width = currentX - this.startX
const height = currentY - this.startY
ctx.strokeStyle = "red"
ctx.strokeRect(this.startX * this.scale, this.startY * this.scale, width * this.scale, height * this.scale)
}
},
finishDrawing(event) {
this.isDrawing = false
if (event.type == "touchend") {
const rect = event.target.getBoundingClientRect()
event.offsetX = event.changedTouches[0].clientX - rect.left
event.offsetY = event.changedTouches[0].clientY - rect.top
}
const endX = event.offsetX / this.scale
const endY = event.offsetY / this.scale
this.rectangles.push({ startX: this.startX, startY: this.startY, endX, endY })
this.cropImages()
},
drawRectangles() {
const ctx = this.$refs.canvas.getContext("2d")
ctx.strokeStyle = "red"
this.rectangles.forEach(rect => {
const { startX, startY, endX, endY } = rect
const width = endX - startX
const height = endY - startY
ctx.strokeRect(startX * this.scale, startY * this.scale, width * this.scale, height * this.scale)
});
},
async cropImages() {
let croppedImages = []
let croppedBlobs = []
const originalCanvas = document.createElement("canvas")
const ctx = originalCanvas.getContext("2d")
for (let i = 0; i < this.rectangles.length; i++) {
const rect = this.rectangles[i]
const { startX, startY, endX, endY } = rect
const width = Math.abs(endX - startX)
const height = Math.abs(endY - startY)
originalCanvas.width = width
originalCanvas.height = height
ctx.drawImage(this.image, Math.min(startX, endX), Math.min(startY, endY), width, height, 0, 0, width, height)
croppedImages[i] = originalCanvas.toDataURL()
croppedBlobs[i] = await new Promise(resolve => originalCanvas.toBlob(resolve, 'image/webp'))
}
this.$emit('croppedImages', [croppedImages, croppedBlobs])
},
removeRectangle(index) {
this.rectangles.splice(index, 1)
const canvas = this.$refs.canvas
const ctx = canvas.getContext("2d")
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height)
this.drawRectangles()
this.cropImages()
}
}
}
</script>
84 changes: 84 additions & 0 deletions src/components/ContributionAssistantPriceFormCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<!-- eslint-disable vue/no-mutating-props -->
<template>
<v-card
class="mb-4"
title="Label"
prepend-icon="mdi-tag-outline"
height="100%"
style="border: 1px solid transparent"
:color="productPriceForm.processed ? 'success' : ''"
>
<v-divider />
<v-img
height="200px"
:src="productPriceForm.proofImage"
contain
/>
<v-card-text>
<ProductInputRow :productForm="productPriceForm" :disableInitWhenSwitchingModes="true" @filled="productFormFilled = $event" />
<v-row>
<v-col>
<h3 class="required mb-1">
Price
</h3>
<h3 class="mb-1">
<v-item-group v-model="productPriceForm.price_per" class="d-inline" mandatory>
<v-item v-for="cpp in categoryPricePerList" :key="cpp.key" v-slot="{ isSelected, toggle }" :value="cpp.key">
<v-chip class="mr-1" :style="isSelected ? 'border: 1px solid #9E9E9E' : 'border: 1px solid transparent'" @click="toggle">
<v-icon start :icon="isSelected ? 'mdi-checkbox-marked-circle' : 'mdi-circle-outline'" />
{{ cpp.value }}
</v-chip>
</v-item>
</v-item-group>
</h3>
<PriceInputRow :priceForm="productPriceForm" @filled="pricePriceFormFilled = $event" />
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>


<script>
import { defineAsyncComponent } from 'vue'
export default {
components: {
ProductInputRow: defineAsyncComponent(() => import('../components/ProductInputRow.vue')),
PriceInputRow: defineAsyncComponent(() => import('../components/PriceInputRow.vue')),
},
props: {
productPriceForm: {
type: Object,
default: () => ({
category_tag: null,
origins_tags: '',
labels_tags: [],
price: null,
price_per: null,
price_is_discounted: false,
price_without_discount: null,
currency: null,
proofImage: null,
processed: null
})
},
},
data() {
return {
productFormFilled: false,
pricePriceFormFilled: false,
categoryPricePerList: [
{key: 'KILOGRAM', value: "per kg", icon: 'mdi-weight-kilogram'},
{key: 'UNIT', value: "per unit", icon: 'mdi-numeric-1-circle'}
],
}
},
computed: {
},
mounted() {
},
methods: {
}
}
</script>
8 changes: 7 additions & 1 deletion src/components/ProductInputRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ export default {
productForm: {
type: Object,
default: () => ({ mode: '', product: null, product_code: '', category_tag: null, origins_tags: '', labels_tags: [] })
},
disableInitWhenSwitchingModes : {
type: Boolean,
default: () => false
}
},
emits: ['filled'],
Expand Down Expand Up @@ -172,7 +176,9 @@ export default {
},
setMode(mode) {
this.productForm.mode = mode
this.initProductForm()
if (!this.disableInitWhenSwitchingModes) {
this.initProductForm()
}
},
setProductCode(code) {
this.productForm.product_code = code
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,9 @@
},
"TopProducts": {
"Title": "Top products"
},
"ContributionAssistant": {
"Title": "Contribution Assistant"
}
},
"Search": {
Expand Down
1 change: 1 addition & 0 deletions src/router.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,5 +329,23 @@ export default {
// default to nominatim
return this.openstreetmapNominatimSearch(q)
}
},
processWithGemini(croppedBlobs) {
const store = useAppStore()
const url = `${import.meta.env.VITE_OPEN_PRICES_API_URL}/proofs/process_with_gemini`
const formData = new FormData()

croppedBlobs.forEach((blob) => {
formData.append('files', blob)
});

return fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${store.user.token}`,
},
body: formData,
})
.then((response) => response.json())
}
}
Loading

0 comments on commit cdeec76

Please sign in to comment.