feat: add message attachments (wip)

This commit is contained in:
Zoe
2026-02-28 17:19:48 -06:00
parent 5decf9939b
commit 874f0f7397
16 changed files with 780 additions and 50 deletions
+40 -14
View File
@@ -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)]',