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
+20
View File
@@ -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>
+32
View File
@@ -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>
+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)]',
+119
View File
@@ -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>
+13 -3
View File
@@ -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>
+53 -5
View File
@@ -1,21 +1,27 @@
import type schema from "#triplit/schema";
import type { Entity } from "@triplit/client";
import type { ModelMessage } from "ai";
import type { FilePart, ImagePart, ModelMessage } from "ai";
import { nanoid } from "nanoid";
import { decrypt, base64ToUint8Array } from "~/utils/crypto";
import { type Result, Ok, Err, attempt } from "~~/types/result";
import { assert } from "~~/utils/assert";
export type BaseMessage = {
content: string;
fileIds: string[];
}
export type MessageEntity = Entity<typeof schema, 'messages'> & {
parts: (Entity<typeof schema, 'message_parts'> & {
toolCall: Entity<typeof schema, 'tool_calls'> | null
})[] | undefined
} & { generation: Entity<typeof schema, 'generations'> | null }
& { attachments: Entity<typeof schema, 'attachments'>[] }
export type Message =
MessageEntity & {
children: (MessageEntity | undefined)[];
};
}
export enum ChatErrorType {
NoModel = 0,
@@ -67,10 +73,32 @@ export const useChat = (agentId: string) => {
messages.forEach((message) => {
switch (message.role) {
case 'user':
const attachments = message.attachments.map(attachment => {
if (attachment.mimeType.startsWith('image/')) {
return {
type: 'image',
image: attachment.url,
};
}
return {
type: 'file',
data: attachment.url,
filename: attachment.name,
mediaType: attachment.mimeType,
};
}) as (FilePart | ImagePart)[];
marshalledMessages.push({
role: 'user',
// TODO: when we have images or files, this is where we need to handle them
content: message.content,
content: [
{
type: 'text',
text: message.content
},
...attachments,
],
});
break;
case 'assistant':
@@ -243,7 +271,7 @@ export const useChat = (agentId: string) => {
}
const sendMessage = async (
message: string,
message: BaseMessage,
topic: Entity<typeof schema, 'topics'>,
topicMessages: MessageEntity[],
agent: Entity<typeof schema, 'agents'>,
@@ -257,12 +285,28 @@ export const useChat = (agentId: string) => {
}
const messageId = nanoid();
const attachmentsPromise = message.fileIds.map(async fileId => {
const file = await triplit.fetchOne(triplit.query('files').Where('id', '=', fileId));
assert(file !== null);
return await triplit.insert('attachments', {
userId: user.value!.id,
topicId: topic.id,
messageId: messageId,
fileId: fileId,
name: file.name,
mimeType: file.mimeType,
url: file.url,
createdAt: file.createdAt,
})!;
});
const newMessage = await triplit.insert('messages', {
id: messageId,
userId: user.value.id,
topicId: topic.id,
createdAt: new Date().toISOString(),
content: message,
content: message.content,
role: 'user',
}).catch(async error => {
console.error('Failed to insert message:', error);
@@ -270,6 +314,10 @@ export const useChat = (agentId: string) => {
return Err(ChatErrorType.DatabaseOperationFailed);
}) as Message;
const attachments = await Promise.all(attachmentsPromise);
newMessage.attachments = attachments;
const messages = marshallMessages(
agent,
topicMessages.concat(newMessage)
+17 -12
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import type { BaseMessage } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels';
const triplit = useTriplitClient();
const route = useRoute();
const inputValue = ref('');
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const pendingMessage = ref<Message | null>(null);
const { open: sidebarOpen, openSidebar } = useSidebar();
@@ -18,7 +19,7 @@ if (!agent.value) navigateTo('/');
useHead({ title: `${agent.value!.name} | Veridian` });
const handleSubmit = async (message: string, model: ModelWithProvider | null) => {
const handleSubmit = async (message: BaseMessage, model: ModelWithProvider | null) => {
if (!model) {
console.error('No model selected');
return;
@@ -34,7 +35,7 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
id: '',
userId: user.value!.id,
topicId: null,
content: message,
content: message.content,
role: 'user',
parts: [],
generation: null,
@@ -42,6 +43,7 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
children: [],
generationId: null,
focusedIndex: null,
attachments: [],
deleted: false,
createdAt: new Date(),
}
@@ -49,7 +51,7 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
const topic = await createTopic();
if (!topic) throw new Error('Failed to create topic');
autoRename(topic.id, message);
autoRename(topic.id, message.content);
await navigateTo(`/agent/${route.params.id}/topic/${topic.id}`);
@@ -62,14 +64,17 @@ const handleSubmit = async (message: string, model: ModelWithProvider | null) =>
await navigateTo(`/agent/${route.params.id}`);
await triplit.delete('topics', topic.id);
const chatInput = document.getElementById('chat') as HTMLInputElement;
if (chatInput) {
chatInput.value = message;
chatInput.dispatchEvent(new Event('input'));
nextTick(() => {
chatInput.focus();
});
}
// const chatInput = document.getElementById('chat') as HTMLInputElement;
// if (chatInput) {
// chatInput.value = message;
// chatInput.dispatchEvent(new Event('input'));
// nextTick(() => {
// chatInput.focus();
// });
// }
nextTick(() => {
inputValue.value = message;
});
}
});
};
+24 -2
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
import type schema from '#triplit/schema';
import type { Entity } from '@triplit/client';
import type { BaseMessage } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels';
const rootStart = Date.now();
const triplit = useTriplitClient();
const chatPaneWrapper = ref<HTMLElement | null>(null);
const inputValue = ref('');
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const route = useRoute();
const { sendMessage, regenerateMessage } = useChat(route.params.id as string);
const { getAgent } = useAgents();
@@ -31,6 +32,13 @@ const messagesQuery = computed(() =>
.Order('createdAt', 'ASC')
);
const attachmentsQuery = computed(() =>
triplit
.query('attachments')
.Where(['topicId', '=', route.params.topicId])
.Order('createdAt', 'ASC')
);
const partsQuery = computed(() =>
triplit
.query('message_parts')
@@ -48,11 +56,13 @@ const generationsQuery = computed(() =>
const [
{ results: rawTopic, unsubscribe: unsubscribeTopic },
{ results: rawMessages, unsubscribe: unsubscribeMessages },
{ results: rawAttachments, unsubscribe: unsubscribeAttachments },
{ results: rawParts, unsubscribe: unsubscribeParts },
{ results: rawGenerations, unsubscribe: unsubscribeGenerations }
] = await Promise.all([
useQuery('topic', triplit, topicQuery),
useQuery('messages', triplit, messagesQuery),
useQuery('attachments', triplit, attachmentsQuery),
useQuery('parts', triplit, partsQuery),
useQuery('generations', triplit, generationsQuery),
]);
@@ -62,6 +72,7 @@ const topic = computed(() => {
const messagesMap = new Map();
const partsByMessage = new Map<string, Entity<typeof schema, 'message_parts'>[]>();
const attachmentsByMessage = new Map<string, Entity<typeof schema, 'attachments'>[]>();
// Group parts by message ID once
if (rawParts.value) {
@@ -75,6 +86,15 @@ const topic = computed(() => {
}
}
if (rawAttachments.value) {
for (const attachment of rawAttachments.value) {
if (!attachmentsByMessage.has(attachment.messageId)) {
attachmentsByMessage.set(attachment.messageId, []);
}
attachmentsByMessage.get(attachment.messageId)!.push(attachment);
}
}
const generationsMap = new Map(
rawGenerations.value?.map(g => [g.id, g]) ?? []
);
@@ -83,6 +103,7 @@ const topic = computed(() => {
for (const msg of rawMessages.value) {
messagesMap.set(msg.id, {
...msg,
attachments: attachmentsByMessage.get(msg.id) ?? [],
parts: partsByMessage.get(msg.id) ?? [],
children: [],
generation: generationsMap.get(msg.generationId!) ?? null
@@ -112,7 +133,7 @@ watch(() => topic.value?.name, (newTopicName) => {
}
}, { immediate: true });
const submitMessage = async (message: string, model: ModelWithProvider | null) => {
const submitMessage = async (message: BaseMessage, model: ModelWithProvider | null) => {
if (!model) {
console.error('No model selected');
return;
@@ -354,6 +375,7 @@ console.log("full page render took", Date.now() - rootStart);
onUnmounted(() => {
unsubscribeTopic?.();
unsubscribeMessages?.();
unsubscribeAttachments?.();
unsubscribeParts?.();
unsubscribeGenerations?.();
});
+18 -13
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { assert } from '~~/utils/assert';
import type { BaseMessage } from '~/composables/useChat';
import type { Agent } from '~/composables/useAgents';
const triplit = useTriplitClient();
@@ -8,6 +9,8 @@ const { open: sidebarOpen, openSidebar } = useSidebar();
const { agents, createAgent } = useAgents();
const { providers, getFirstAvailableModel, allModels } = useModels();
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const taglines = {
morning: [
'crush your goals before breakfast',
@@ -77,7 +80,7 @@ const agent = computed(() => {
return agents.value?.[0] ?? null;
});
const handleChatSubmit = async (message: string, model: ModelWithProvider | null) => {
const handleChatSubmit = async (message: BaseMessage, model: ModelWithProvider | null) => {
console.log('Message submitted:', message, agents);
let agent: Agent | null = agents.value?.[0] ?? null;
if (!agent) {
@@ -110,7 +113,7 @@ const handleChatSubmit = async (message: string, model: ModelWithProvider | null
await navigateTo(`/agent/${agent.id}/topic/${topic.id}`);
autoRename(topic.id, message);
autoRename(topic.id, message.content);
return sendMessage(message, topic, [], agent, model.provider, model).then(async res => {
if (res.ok === false) {
@@ -118,15 +121,17 @@ const handleChatSubmit = async (message: string, model: ModelWithProvider | null
await navigateTo(`/agent/${agent.id}`);
await triplit.delete('topics', topic.id);
const chatInput = document.getElementById('chat') as HTMLInputElement;
if (chatInput) {
chatInput.value = message;
chatInput.dispatchEvent(new Event('input'));
nextTick(() => {
chatInput.focus();
});
}
// const chatInput = document.getElementById('chat') as HTMLInputElement;
// if (chatInput) {
// chatInput.value = message;
// chatInput.dispatchEvent(new Event('input'));
// nextTick(() => {
// chatInput.focus();
// });
// }
nextTick(() => {
inputValue.value = message;
});
}
});
};
@@ -173,8 +178,8 @@ onUnmounted(() => {
</h1>
<div class="max-w-4xl h-full w-full">
<!-- TODO: view transitions have caused me issues with the page flashing with no content (so just a black or white screen depending on the theme) so I have disabled them for now. -->
<ChatInput class="[view-transition-name:chat-prompt] duration-150 ease-in-out" :agent="agent"
:providers="providers" @submit="handleChatSubmit" />
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
:agent="agent" :providers="providers" @submit="handleChatSubmit" />
</div>
</div>
</div>
+30 -1
View File
@@ -1,3 +1,32 @@
<template>
<script setup lang="ts">
const uploadFile = async () => {
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
if (!fileInput?.files?.[0]) return;
const file = fileInput.files[0];
const mimeType = file.type || 'application/octet-stream';
const { url } = await $fetch('/api/upload/presigned', {
method: 'POST',
body: {
file: {
name: file.name,
mimeType: mimeType,
}
},
});
const res = await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': mimeType,
}
});
}
</script>
<template>
<input type="file" />
<button @click="uploadFile">Upload</button>
</template>