feat: ditch triplit, move to postgresql + drizzle orm

This commit is contained in:
Zoe
2026-04-08 17:03:07 -05:00
parent c341c96798
commit e2e3ac6e86
121 changed files with 6680 additions and 4373 deletions
+52 -228
View File
@@ -1,131 +1,22 @@
<script setup lang="ts">
import type schema from '#triplit/schema';
import type { Entity } from '@triplit/client';
import type { BaseMessage } from '~/composables/useChat';
import { type BaseMessage, type Message, ChatErrorType } from '~/composables/useChat';
import type { ModelWithProvider } from '~/composables/useModels';
import { buildFocusedMessageTree } from '~~/utils/message';
const rootStart = Date.now();
const triplit = useTriplitClient();
const chatPaneWrapper = ref<HTMLElement | null>(null);
const inputValue = ref<BaseMessage>({ content: '', fileIds: [] });
const route = useRoute();
const { sendMessage, regenerateMessage } = useChat(route.params.id as string);
const { getAgent } = useAgents();
const { getAgent } = await useAgents();
const { open: sidebarOpen, openSidebar } = useSidebar();
const { providers, allModels } = useModels();
const { providers, allModels } = await useModels();
const { addShortcut } = useKeyboardShortcuts();
const agent = getAgent(route.params.id as string);
const topicQuery = computed(() =>
triplit
.query('topics')
.Where(['id', '=', route.params.topicId])
.Limit(1)
);
const messagesQuery = computed(() =>
triplit
.query('messages')
.Where(['topicId', '=', route.params.topicId])
.Order('createdAt', 'ASC')
);
const attachmentsQuery = computed(() =>
triplit
.query('attachments')
.Where(['topicId', '=', route.params.topicId])
.Order('createdAt', 'ASC')
);
const partsQuery = computed(() =>
triplit
.query('message_parts')
.Where(['topicId', '=', route.params.topicId])
.Order('createdAt', 'ASC')
.Include('toolCall')
);
const generationsQuery = computed(() =>
triplit
.query('generations')
.Where(['topicId', '=', route.params.topicId])
);
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),
]);
const topic = computed(() => {
if (!rawMessages.value || !rawTopic.value?.[0]) return null;
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) {
for (const part of rawParts.value) {
if (!partsByMessage.has(part.messageId)) {
partsByMessage.set(part.messageId, []);
}
if (part.content !== '' || part.toolCall !== null) {
partsByMessage.get(part.messageId)!.push(part);
}
}
}
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]) ?? []
);
// Single pass to build messages
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
});
}
// Build tree
const rootMessages: Message[] = [];
for (const msg of messagesMap.values()) {
if (msg.parentMessageId && messagesMap.has(msg.parentMessageId)) {
messagesMap.get(msg.parentMessageId)!.children.push(msg);
} else {
rootMessages.push(msg);
}
}
return {
...rawTopic.value[0],
messages: rootMessages as Message[],
generations: rawGenerations.value || []
};
});
const topicId = computed(() => route.params.topicId as string);
const { topic, sendMessage, startGeneration, regenerateMessage, patchMessageLocally } = await useChat(topicId);
watch(() => topic.value?.name, (newTopicName) => {
if (newTopicName !== undefined) {
@@ -139,7 +30,14 @@ const submitMessage = async (message: BaseMessage, model: ModelWithProvider | nu
return;
}
const res = await sendMessage(message, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model);
inputValue.value = { content: '', fileIds: [] };
const res = await sendMessage(message, async () => {
await nextTick();
setTimeout(() => {
scrollToBottom('instant')
});
});
if (!res.ok) {
console.error('Failed to send message:', res.error);
const chatInput = document.getElementById('chat') as HTMLInputElement | null;
@@ -153,33 +51,16 @@ const submitMessage = async (message: BaseMessage, model: ModelWithProvider | nu
return;
}
scrollToBottom('instant');
startGeneration(model);
};
const focusedMessageTree = computed(() => {
const tree: Readonly<MessageEntity>[] = [];
for (const message of topic.value?.messages || []) {
if (message.focusedIndex !== undefined && message.focusedIndex !== null) {
if (message.focusedIndex === 0) {
tree.push(message);
continue;
}
tree.push(message.children[message.focusedIndex - 1]!);
} else {
tree.push(message);
}
}
return tree;
})
const handleRegenerate = async (message: Message) => {
if (!agent.value!.defaultModelId) {
console.error('No model selected');
return;
}
let messageId;
let messageId: string;
if (
(message.focusedIndex !== undefined && message.focusedIndex !== null)
&& message.focusedIndex > 0
@@ -191,90 +72,41 @@ const handleRegenerate = async (message: Message) => {
}
const model = allModels.value.find(m => m.id === agent.value!.defaultModelId);
if (!model) {
console.error('Model not found');
if (!model || !model.provider) {
console.error('Model not found or provider not found');
return;
}
const res = await regenerateMessage(messageId, topic.value!, focusedMessageTree.value, agent.value!, model.provider, model);
const res = await regenerateMessage(messageId, buildFocusedMessageTree(topic.value!.messages), model);
if (!res.ok) {
console.error('Failed to regenerate message:', ChatErrorType[res.error]);
return;
}
}
const deeplyDeleteMessage = async (message: MessageEntity) => {
triplit.delete('messages', message.id);
if (message.generationId !== null && message.generationId !== undefined) {
triplit.delete('generations', message.generationId);
}
for (const part of message.parts || []) {
triplit.delete('message_parts', part.id);
}
if (topic.value?.messages.filter(m => m.id !== message.id).length === 0) {
triplit.delete('topics', topic.value!.id);
return navigateTo(`/agent/${route.params.id}/`);
}
}
const handleDelete = async (rootMessage: Message) => {
if (rootMessage.role === 'user') {
deeplyDeleteMessage(rootMessage);
return;
}
if (rootMessage.deleted === true) {
const message = rootMessage.children[rootMessage.focusedIndex!];
if (!message) {
console.error('Message not found');
return;
}
deeplyDeleteMessage(message);
if (rootMessage.children.filter(child => child!.id !== message.id).length === 0) {
deeplyDeleteMessage(rootMessage);
}
return;
}
// - If the message has children, check if they are all soft deleted
// - If they are all soft deleted, delete the message
// - If they are not all soft deleted, mark only this message as deleted
if (
(rootMessage.focusedIndex !== undefined && rootMessage.focusedIndex !== null)
&& rootMessage.focusedIndex > 0
&& rootMessage.children.length > 0
) {
// we are a child message
const message = rootMessage.children[rootMessage.focusedIndex - 1]!;
if (!message) {
console.error('Message not found');
return;
}
deeplyDeleteMessage(message);
return;
}
// we have no children
if (rootMessage.children.length === 0) {
deeplyDeleteMessage(rootMessage);
return;
}
// we are a root message and we have at least one living child, soft delete
await triplit.update('messages', rootMessage.id, {
deleted: true
await $fetch(`/api/messages/${rootMessage.id}`, {
method: 'DELETE',
});
}
const flatMessages = computed(() => {
const messages: Message[] = [];
for (const message of topic.value?.messages ?? []) {
messages.push(message);
if (message.children.length > 0) {
messages.push(...message.children as Message[]);
}
}
return messages;
});
const activeGeneration = computed(() => {
if (topic.value === null) return null;
return topic.value?.generations?.find((generation) => generation.status === 'pending') ?? null;
const generations = flatMessages.value.flatMap(message => message.generation);
return generations?.find((generation) => generation?.status === 'pending') ?? null;
});
const { scrollToBottom } = useAutoScroll(chatPaneWrapper);
@@ -318,9 +150,8 @@ addShortcut(['alt', '['], async (event) => {
const lastMessage = topic.value?.messages?.at(-1);
if (lastMessage && lastMessage.children.length > 0) {
await triplit.update('messages', lastMessage.id, {
focusedIndex: Math.max(0, lastMessage.focusedIndex! - 1)
});
const newIndex = Math.max(0, (lastMessage.focusedIndex || 0) - 1);
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
}
})
@@ -329,10 +160,10 @@ addShortcut(['alt', ']'], async (event) => {
event.stopPropagation();
const lastMessage = topic.value?.messages?.at(-1);
const messageCount = lastMessage ? (lastMessage.deleted ? lastMessage.children.length : lastMessage.children.length + 1) : 0;
if (lastMessage && lastMessage.children.length > 0) {
await triplit.update('messages', lastMessage.id, {
focusedIndex: Math.min(lastMessage.children.length, lastMessage.focusedIndex! + 1)
});
const newIndex = Math.min(messageCount - 1, (lastMessage.focusedIndex || 0) + 1);
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
}
})
@@ -342,21 +173,20 @@ addShortcut(['ctrl', 'alt', 'arrowleft'], async (event) => {
event.preventDefault();
event.stopPropagation();
await triplit.update('messages', lastMessage.id, {
focusedIndex: Math.max(0, lastMessage.focusedIndex! - 1)
});
const newIndex = Math.max(0, (lastMessage.focusedIndex || 0) - 1);
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
}
})
addShortcut(['ctrl', 'alt', 'arrowright'], async (event) => {
const lastMessage = topic.value?.messages?.at(-1);
const messageCount = lastMessage ? (lastMessage.deleted ? lastMessage.children.length : lastMessage.children.length + 1) : 0;
if (lastMessage && lastMessage.children.length > 0) {
event.preventDefault();
event.stopPropagation();
await triplit.update('messages', lastMessage.id, {
focusedIndex: Math.min(lastMessage.children.length, lastMessage.focusedIndex! + 1)
});
const newIndex = Math.min(messageCount - 1, (lastMessage.focusedIndex || 0) + 1);
patchMessageLocally(lastMessage.id, { focusedIndex: newIndex });
}
})
@@ -365,20 +195,13 @@ onMounted(() => {
});
const handleCancel = async () => {
await $fetch(`/api/chat/cancel/${activeGeneration.value?.id}`, {
// TODO: cancel generation
await $fetch(`/api/topic/${topicId.value}/chat/cancel/${activeGeneration.value?.id}`, {
method: 'POST',
});
};
console.log("full page render took", Date.now() - rootStart);
onUnmounted(() => {
unsubscribeTopic?.();
unsubscribeMessages?.();
unsubscribeAttachments?.();
unsubscribeParts?.();
unsubscribeGenerations?.();
});
</script>
<template>
@@ -386,10 +209,10 @@ onUnmounted(() => {
<div class="h-14 flex items-center justify-between px-4 border-b border-[var(--color-border)]">
<div class="flex items-center gap-2 max-w-full">
<button v-if="!sidebarOpen" @click="openSidebar"
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent hover:bg-[var(--color-hover)] transition-colors">
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
<span class="i-mynaui-panel-left-open text-5"></span>
</button>
<h4 class="text-lg text-ellipsis overflow-hidden whitespace-nowrap">
<h4 class="text-lg truncate">
{{ topic?.name }}
</h4>
</div>
@@ -403,7 +226,8 @@ onUnmounted(() => {
<Suspense>
<template v-if="Array.isArray(topic?.messages) && topic.messages.length > 0">
<Message v-for="message in topic.messages" :key="message.id" :message="message"
v-memo="[message.id, message.parts?.length, message.children, message.focusedIndex, message.content]"
@edit="(value) => patchMessageLocally(message.id, { content: value })"
@patch="(updates) => patchMessageLocally(message.id, updates)"
@delete="handleDelete(message)" @regenerate="handleRegenerate(message)" />
</template>
</Suspense>