Skip to content

Commit a016af6

Browse files
committed
Add support for image attachments
1 parent 88e810a commit a016af6

File tree

4 files changed

+215
-63
lines changed

4 files changed

+215
-63
lines changed

components/Assistant.vue

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { Prec } from '@codemirror/state'
33
import { keymap } from '@codemirror/view'
4+
import { PhotoIcon as AddImagesIcon } from '@heroicons/vue/24/outline'
45
import { StopIcon as TokenIcon } from '@heroicons/vue/24/solid'
56
import { type Options } from 'ink-mde'
67
import { nanoid } from 'nanoid'
@@ -11,6 +12,7 @@ import { type SystemInstruction, useSystemInstructions } from '#root/composables
1112
1213
export default defineComponent({
1314
components: {
15+
AddImagesIcon,
1416
TokenIcon,
1517
},
1618
props: {
@@ -22,6 +24,7 @@ export default defineComponent({
2224
const { id } = useId()
2325
const { isDesktop, modKey } = useDevice()
2426
const chatId = computed(() => props.chatId || id())
27+
// Todo: Update types for `ChatMessage` in vellma...
2528
const { chatMessages } = useChatMessages({ chatId })
2629
const { addSystemInstruction, systemInstructions } = useSystemInstructions()
2730
const systemInstruction = ref<SystemInstruction>({
@@ -194,11 +197,17 @@ export default defineComponent({
194197
messages.push(chatFactory.value.system({ text: systemInstruction.value.text }))
195198
}
196199
197-
if (input.value) {
198-
messages.push(chatFactory.value.human({ text: input.value }))
200+
if (input.value || files.value.length > 0) {
201+
const attachments = files.value.map((file) => ({ type: 'image', url: file.dataUrl }))
202+
203+
messages.push(chatFactory.value.human({
204+
attachments,
205+
text: input.value,
206+
}))
199207
}
200208
201209
input.value = ''
210+
files.value = []
202211
203212
if (!messages.length) return
204213
@@ -229,18 +238,57 @@ export default defineComponent({
229238
})
230239
}
231240
241+
const files = ref<{ name: string, blob: File, dataUrl: string }[]>([])
242+
243+
const addFiles = async (event: Event) => {
244+
if (event.target && 'files' in event.target && event.target.files) {
245+
files.value = []
246+
247+
for (const blob of Array.from(event.target.files as FileList)) {
248+
files.value.push({
249+
blob,
250+
dataUrl: await toDataUrl(blob),
251+
name: blob.name,
252+
})
253+
}
254+
}
255+
}
256+
257+
const isAllowedToSend = computed(() => !!input.value || !!files.value.length || !!systemInstruction.value.text)
258+
259+
const toFileUrl = (file: File) => {
260+
return URL.createObjectURL(file)
261+
}
262+
263+
const toDataUrl = async (file: File) => {
264+
const fileReader = new FileReader()
265+
266+
return new Promise<string>((resolve, reject) => {
267+
fileReader.onload = () => {
268+
resolve(fileReader.result as string)
269+
}
270+
271+
fileReader.onerror = reject
272+
273+
fileReader.readAsDataURL(file)
274+
})
275+
}
276+
232277
return {
233278
CoreDivider,
234279
CoreLink,
280+
addFiles,
235281
apiKey,
236282
chatMessages,
237283
choosePrompt,
238284
clearSystemInstruction,
239285
examplePrompts,
286+
files,
240287
historyElement,
241288
input,
242289
inputElement,
243290
inputOptions,
291+
isAllowedToSend,
244292
isDesktop,
245293
isToBeSaved,
246294
isWaiting,
@@ -255,6 +303,7 @@ export default defineComponent({
255303
systemInstruction,
256304
systemInstructions,
257305
systemMessage,
306+
toFileUrl,
258307
tryAgain,
259308
}
260309
},
@@ -387,7 +436,14 @@ export default defineComponent({
387436
</div>
388437
</div>
389438
<template v-if="chatMessages.length">
390-
<AssistantChatMessage v-for="message in nonSystemMessages" :key="message.id" :created-at="message.createdAt" :role="message.role" :text="message.text" />
439+
<AssistantChatMessage
440+
v-for="message in nonSystemMessages"
441+
:key="message.id"
442+
:created-at="message.createdAt"
443+
:role="message.role"
444+
:text="message.text"
445+
:attachments="message.attachments"
446+
/>
391447
<div class="h-4" />
392448
</template>
393449
<div v-else class="flex flex-col flex-grow gap-4 justify-end">
@@ -429,24 +485,35 @@ export default defineComponent({
429485
<CoreScrollable class="bg-layer rounded max-h-[40vh]">
430486
<div class="flex gap-2">
431487
<CoreEditor ref="inputElement" v-model="input" :layer="0" :options="inputOptions" />
432-
<CoreLayer v-if="apiKey">
433-
<CoreButton v-if="showTryAgainMessage" class="m-1 self-start sticky top-1" @click="tryAgain">
488+
<CoreLayer v-if="apiKey" class="m-1 self-start sticky top-1">
489+
<CoreButton v-if="showTryAgainMessage" @click="tryAgain">
434490
Try again
435491
</CoreButton>
436-
<CoreButton v-else :disabled="isWaiting || (!input && !systemInstruction.text)" class="m-1 self-start sticky top-1" @click="onSend">
437-
<span v-if="isWaiting">
438-
Waiting for a reply...
439-
</span>
440-
<span v-else class="flex items-center gap-2">
441-
<span>Send</span>
442-
<span v-if="isDesktop" class="hidden md:flex text-layer-muted text-opacity-[inherit]">
443-
<Key>{{ modKey }}</Key>
444-
<Key>⏎</Key>
492+
<div v-else class="flex gap-1">
493+
<CoreButton :disabled="isWaiting || !isAllowedToSend" @click="onSend">
494+
<span v-if="isWaiting">
495+
Waiting for a reply...
445496
</span>
446-
</span>
447-
</CoreButton>
497+
<span v-else class="flex items-center gap-2">
498+
<span>Send</span>
499+
<span v-if="isDesktop" class="hidden md:flex text-layer-muted text-opacity-[inherit]">
500+
<Key>{{ modKey }}</Key>
501+
<Key>⏎</Key>
502+
</span>
503+
</span>
504+
</CoreButton>
505+
<CoreButton as="label" title="Attach images">
506+
<AddImagesIcon class="sq-5" />
507+
<input class="hidden" type="file" accept="image/*" multiple @change="addFiles">
508+
</CoreButton>
509+
</div>
448510
</CoreLayer>
449511
</div>
512+
<div v-if="files.length" class="flex flex-wrap gap-1 p-1">
513+
<div v-for="file in files" :key="file.name" class="relative border border-layer flex items-center justify-center sq-12 rounded overflow-hidden">
514+
<img class="h-min w-min object-cover" :src="file.dataUrl" :alt="file.name" :title="file.name">
515+
</div>
516+
</div>
450517
</CoreScrollable>
451518
</CoreLayer>
452519
</div>

components/AssistantChatMessage.vue

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ClipboardIcon } from '@heroicons/vue/24/outline'
33
import { readonly } from '#root/src/vendor/plugins/readonly'
44
import { useVue } from '#shared/composables'
55
6-
const props = defineProps<{ createdAt: Date, role: string, text: string }>()
6+
const props = defineProps<{ createdAt: Date, role: string, text: string, attachments?: { url: string }[] }>()
77
88
const { copy } = useClipboard()
99
const { addToast } = useToasts()
@@ -48,8 +48,11 @@ const copyMessage = async () => {
4848
<small>{{ name }}</small>
4949
</label>
5050
<div class="flex items-start" :class="{ 'justify-end': isHuman, 'justify-start': isAssistant }">
51-
<CoreLayer>
52-
<CoreEditor v-if="isMounted" :model-value="text" :options="options" class="bg-layer" />
51+
<CoreLayer class="bg-layer rounded">
52+
<CoreEditor v-if="isMounted && text" :model-value="text" :options="options" />
53+
<div v-if="attachments?.length" class="flex p-2" :class="{ 'justify-end': isHuman, 'justify-start': isAssistant }">
54+
<img v-for="attachment in attachments" :key="attachment.url" class="max-h-24 rounded" :src="attachment.url">
55+
</div>
5356
</CoreLayer>
5457
</div>
5558
</div>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"overlayscrollbars": "^2.4.6",
5252
"pinia": "^2.1.7",
5353
"remarkable": "^2.0.1",
54-
"vellma": "^0.7.0",
54+
"vellma": "^0.8.1",
5555
"vue3-mq": "^3.1.3",
5656
"vuex": "^4.1.0"
5757
},

0 commit comments

Comments
 (0)