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
+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>