feat: ditch triplit, move to postgresql + drizzle orm
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user