feat: add message attachments (wip)
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
file: {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
status: 'pending' | 'uploaded';
|
||||
url: string;
|
||||
}
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="props.file.mimeType.startsWith('image/')" :src="props.file.url"
|
||||
class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" />
|
||||
<video v-else-if="props.file.mimeType.startsWith('video/')" controls :src="props.file.url"
|
||||
class="rounded-lg h-full w-full max-h-36 max-w-64 aspect-ratio-video object-cover" />
|
||||
<audio v-else-if="props.file.mimeType.startsWith('audio/')" :src="props.file.url"
|
||||
class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" />
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import Display from './Display.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
file: {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
status: 'pending' | 'uploaded';
|
||||
url: string;
|
||||
}
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
const handleDelete = async () => {
|
||||
emit('delete');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full w-fit">
|
||||
<Display :file="props.file" />
|
||||
|
||||
<button @click="handleDelete"
|
||||
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 flex items-center justify-center w-4 h-4 rounded-full bg-[var(--bg-base)] border border-[var(--color-border)] text-xs text-[#ff3b3b]">
|
||||
<span class="i-mynaui-x text-3"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { BaseMessage } from '~/composables/useChat';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import type { ModelWithProvider, ProviderWithModels } from '~/composables/useModels';
|
||||
import type { Agent } from '~/composables/useAgents';
|
||||
@@ -8,11 +9,25 @@ const { allModels } = useModels();
|
||||
const inputHeight: Ref<string> = ref('auto');
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null);
|
||||
let tempInput = '';
|
||||
const inputValue = defineModel<string>({ required: false, default: '' });
|
||||
const files = ref<{
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
status: 'pending' | 'uploaded';
|
||||
url: string;
|
||||
}[]>([]);
|
||||
const inputValue = defineModel<BaseMessage>({ required: false, default: { content: '', fileIds: [] } });
|
||||
const textAreaValue = ref('');
|
||||
watch(textAreaValue, (newValue) => {
|
||||
inputValue.value.content = newValue;
|
||||
});
|
||||
watch(files, (newFiles) => {
|
||||
inputValue.value.fileIds = newFiles.map(f => f.id);
|
||||
});
|
||||
const triplit = useTriplitClient();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [value: string, model: ModelWithProvider | null];
|
||||
submit: [value: BaseMessage, model: ModelWithProvider | null];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
@@ -78,9 +93,12 @@ const handleSubmit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue.value.trim()) {
|
||||
console.log(inputValue.value);
|
||||
|
||||
if (inputValue.value.content.trim() || inputValue.value.fileIds.length > 0) {
|
||||
emit('submit', inputValue.value, selectedModel.value);
|
||||
inputValue.value = '';
|
||||
inputValue.value = { content: '', fileIds: [] };
|
||||
textAreaValue.value = '';
|
||||
}
|
||||
// Reset height after sending
|
||||
if (inputRef.value) inputRef.value.style.height = 'auto';
|
||||
@@ -97,8 +115,8 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
if (cursorPosition === undefined) return;
|
||||
if (cursorPosition !== inputRef.value.selectionEnd) return;
|
||||
|
||||
inputValue.value =
|
||||
inputValue.value.slice(0, cursorPosition) + '\n' + inputValue.value.slice(cursorPosition);
|
||||
textAreaValue.value =
|
||||
textAreaValue.value.slice(0, cursorPosition) + '\n' + textAreaValue.value.slice(cursorPosition);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
@@ -118,7 +136,7 @@ const handleWindowKeyDown = async (event: KeyboardEvent) => {
|
||||
|
||||
if (isPrintable) {
|
||||
inputRef.value?.focus();
|
||||
} else if (event.key === 'Enter') {
|
||||
} else if (event.key === 'Enter' && !props.loading) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
inputRef.value?.focus();
|
||||
@@ -127,7 +145,7 @@ const handleWindowKeyDown = async (event: KeyboardEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(inputValue, async () => {
|
||||
watch(textAreaValue, async () => {
|
||||
const textarea = inputRef.value;
|
||||
if (!textarea) return;
|
||||
|
||||
@@ -160,7 +178,7 @@ onBeforeMount(() => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
inputValue.value = tempInput;
|
||||
textAreaValue.value = tempInput;
|
||||
document.addEventListener('keydown', handleWindowKeyDown);
|
||||
});
|
||||
|
||||
@@ -173,10 +191,15 @@ onUnmounted(() => {
|
||||
<div :class="['w-full flex max-h-full', $attrs.class]">
|
||||
<div class="relative w-full flex flex-shrink-1 flex-col gap-3 p-2 rounded-2xl border transition-border ease-in-out duration-300 bg-[var(--bg-container)]
|
||||
border-[var(--color-border)] focus-within:border-[var(--color-border-active)]">
|
||||
<div v-if="files.length > 0" class="flex-1 flex gap-2 max-w-full max-h-24 pt-2 px-2">
|
||||
<!-- TODO: show attachment previews -->
|
||||
<AttachmentPreview v-for="file in files" @delete="files = files.filter((f) => f.id !== file.id)"
|
||||
:key="file.id" :file="file" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 max-h-full">
|
||||
<!-- Grammarly literally breaks everything, go fuck yourself -->
|
||||
<!-- It is absolutely paramount that the closing tag for the textare has ZERO whitespace between the end of the textarea opening tag, otherwise there will be hydration errors -->
|
||||
<textarea data-gramm="false" id="chat" v-model="inputValue" ref="inputRef"
|
||||
<textarea data-gramm="false" id="chat" v-model="textAreaValue" ref="inputRef"
|
||||
:placeholder="`Start something great. Press ${hasCommandKey ? '⌘ + Enter' : 'ctrl + Enter'} to insert a new line.`"
|
||||
@keydown="handleKeyDown" :style="{ height: inputHeight }"
|
||||
class="[scrollbar-width:none] w-full bg-transparent resize-none text-[0.95em] placeholder:text-[var(--text-tertiary)]"></textarea>
|
||||
@@ -184,13 +207,16 @@ onUnmounted(() => {
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<!-- TODO: since we dont want to model selector dropdown to potentially overflow, it has max-width: 100%, so, we need to maake the trigger large enough to fit the entire width of the dropdown -->
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-1 gap-1">
|
||||
<ModelSelector v-if="providers !== undefined" v-model="selectedModel" :providers="providers" />
|
||||
<FileSelector
|
||||
v-if="selectedModel && [...selectedModel.attributes.inputModalities].filter(p => p !== 'text').length > 0"
|
||||
v-model="files" />
|
||||
</div>
|
||||
<button aria-label="Send message" @click="handleSubmit" :disabled="!inputValue.trim() && !loading"
|
||||
:class="[
|
||||
<button aria-label="Send message" @click="handleSubmit"
|
||||
:disabled="(!inputValue.content.trim() && files.length === 0) && !loading" :class="[
|
||||
'h-8 w-8 rounded-xl transition-all duration-200 flex items-center justify-center disabled:cursor-not-allowed disabled:bg-transparent',
|
||||
inputValue.trim() && !loading
|
||||
(inputValue.content.trim() || files.length > 0) && !loading
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-text)] hover:bg-[var(--color-accent-hover)]'
|
||||
: 'text-[var(--text-dim)]',
|
||||
loading && 'bg-[var(--color-hover)] hover:bg-[var(--color-active)]',
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { nanoid } from 'nanoid';
|
||||
import { assert } from '~~/utils/assert';
|
||||
|
||||
const { openDropdown, closeDropdown, dropdownState } = useDropdown();
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const files = defineModel<{
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
status: 'pending' | 'uploaded';
|
||||
url: string;
|
||||
}[]>({ required: false, default: [] });
|
||||
const rawFiles = ref<{
|
||||
name: string;
|
||||
type: string;
|
||||
file: File;
|
||||
}[]>([]);
|
||||
|
||||
const triplit = useTriplitClient();
|
||||
watch(rawFiles, async (newFiles, oldFiles) => {
|
||||
const { user } = useAuth();
|
||||
assert(user.value !== null);
|
||||
|
||||
const diff = newFiles.filter(f => !oldFiles.find(o => o.name === f.name));
|
||||
if (diff.length === 0) return;
|
||||
|
||||
for (const file of diff) {
|
||||
files.value.push({
|
||||
id: nanoid(),
|
||||
name: file.name,
|
||||
mimeType: file.type,
|
||||
status: 'pending',
|
||||
url: URL.createObjectURL(file.file),
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(diff.map(async file => {
|
||||
const { url: uploadUrl, assetUrl } = await $fetch('/api/upload/presigned', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
file: {
|
||||
name: file.name,
|
||||
mimeType: file.type,
|
||||
}
|
||||
}),
|
||||
}) as { url: string; assetUrl: string };
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: file.file,
|
||||
});
|
||||
|
||||
assert(uploadResponse.ok, 'Failed to upload file');
|
||||
|
||||
await triplit.insert('files', {
|
||||
id: files.value.find(f => f.name === file.name)!.id,
|
||||
userId: user.value!.id,
|
||||
name: file.name,
|
||||
mimeType: file.type,
|
||||
url: assetUrl,
|
||||
})
|
||||
files.value = files.value.map(f => {
|
||||
if (f.name === file.name) {
|
||||
return {
|
||||
...f,
|
||||
status: 'uploaded',
|
||||
};
|
||||
}
|
||||
return f;
|
||||
});
|
||||
}));
|
||||
})
|
||||
|
||||
const toggleSelectorDropdown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (inputRef.value === null) return;
|
||||
|
||||
if (dropdownState.open) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
openDropdown(e, () => [
|
||||
{ label: "Upload file", icon: "i-mynaui-file-plus", onClick: () => inputRef.value?.click() },
|
||||
])
|
||||
};
|
||||
|
||||
const handleFileChange = (e: Event) => {
|
||||
const inputFiles = (e.target! as HTMLInputElement).files;
|
||||
if (inputFiles === null) return;
|
||||
|
||||
console.log(inputFiles);
|
||||
|
||||
rawFiles.value = [...inputFiles].map(file => ({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
file: file,
|
||||
}));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
inputRef.value?.addEventListener('change', handleFileChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
inputRef.value?.removeEventListener('change', handleFileChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input ref="inputRef" type="file" multiple class="hidden" />
|
||||
<button @click="toggleSelectorDropdown"
|
||||
class="flex items-center justify-center h-8.5 w-8.5 hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="pointer-events-none i-mynaui-paperclip text-5 text-[var(--text-secondary)]"></span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -3,11 +3,21 @@ import type { Entity } from '@triplit/client';
|
||||
import type schema from '#triplit/schema';
|
||||
|
||||
defineProps<{
|
||||
message: Readonly<Entity<typeof schema, 'messages'>>;
|
||||
message: Readonly<Entity<typeof schema, 'messages'> & { attachments: Entity<typeof schema, 'attachments'>[] }>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MarkdownRenderer :finished="true" class="max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl"
|
||||
:content="message.content!" :id="message.id" />
|
||||
<div class="flex flex-col gap-2 max-w-full bg-[var(--bg-container)] py-2 px-3 rounded-xl">
|
||||
<MarkdownRenderer :finished="true" :content="message.content!" :id="message.id" />
|
||||
<div v-if="message.attachments.length > 0" class="flex flex-col gap-2">
|
||||
<div v-for="attachment in message.attachments" :key="attachment.id"
|
||||
class="flex flex-wrap items-center gap-2">
|
||||
<div
|
||||
class="flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center">
|
||||
<AttachmentDisplay :file="attachment" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user