bug fixes, and performance improvements, and drawer

This commit is contained in:
Zoe
2026-09-27 18:44:51 -05:00
parent d48d8163a7
commit 2b9f8d64d7
52 changed files with 4913 additions and 778 deletions
+161 -52
View File
@@ -1,7 +1,9 @@
import { nanoid } from 'nanoid';
import { toRaw } from 'vue';
import * as schema from '~~/drizzle/schema';
import { Err, Ok, type Result } from '~~/types/result';
import { buildMessageTree } from '~~/utils/message';
import { appendTextDelta, serializeTopicSnapshot } from '~~/utils/topic-sync';
export type BaseMessage = {
content: string;
@@ -38,7 +40,7 @@ export enum ChatErrorType {
Unimplemented,
}
type Event = { type: string; payload: any; timestamp: number };
type Event = { type: string; payload: any; timestamp: number; replay?: boolean };
const TARGET_UPDATES_PER_SECOND = 24;
@@ -46,6 +48,10 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
let data = useState<Topic & { messages: Message[] } | undefined>('useChat:data', () => undefined);
let sse: EventSource | undefined;
let disposed = false;
let connectionAttempt = 0;
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined;
const { promise: connectionReady, resolve: markConnected } = Promise.withResolvers<void>();
const textDeltaBuffer: Map<string, Map<string, {
content: string;
@@ -64,7 +70,7 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
for (const [partId, content] of parts) {
const part = msg.parts?.find(p => p.id === partId);
if (part) {
part.content += content.content;
part.content = content.content;
part.lastUpdatedAt = new Date(content.lastUpdatedAt);
}
}
@@ -93,6 +99,23 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
flushTimeout = setTimeout(flushTextDeltas, 1000 / TARGET_UPDATES_PER_SECOND);
};
const resetTextDeltas = () => {
if (flushTimeout !== undefined) clearTimeout(flushTimeout);
flushTimeout = undefined;
textDeltaBuffer.clear();
flushCbs.clear();
};
const scheduleReconnect = (forceSnapshot = false) => {
sse?.close();
sse = undefined;
if (disposed || reconnectTimeout !== undefined) return;
reconnectTimeout = setTimeout(() => {
reconnectTimeout = undefined;
void connectSSE(forceSnapshot);
}, 1500);
};
const processEvent = (event: Event) => {
if (!data.value) return;
@@ -102,12 +125,17 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
case 'MESSAGE_CREATED': {
// Push the new message if it doesn't exist (prevents duplicates from HTTP vs SSE)
if (!data.value.messages.find(m => m.id === payload.id)) {
data.value.messages.push({ ...payload, parts: [] });
data.value.messages.push({ ...payload, parts: payload.parts ?? [], attachments: payload.attachments ?? [] });
} else {
// replace the existing data with the new data
const index = data.value.messages.findIndex(m => m.id === payload.id);
if (index !== -1) {
data.value.messages[index] = { ...payload, parts: [] };
if (index !== -1 && !event.replay) {
const existing = data.value.messages[index]!;
data.value.messages[index] = {
...payload,
parts: payload.parts ?? [],
attachments: payload.attachments ?? existing.attachments ?? [],
};
}
}
break;
@@ -130,7 +158,9 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg) {
if (!msg.parts) msg.parts = [];
msg.parts.push(payload.part);
if (!msg.parts.some(part => part.id === payload.part.id)) {
msg.parts.push(payload.part);
}
}
break;
}
@@ -138,18 +168,33 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
case 'text-delta': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (!msg) break;
const part = msg.parts?.find(part => part.id === payload.partId);
if (!part || part.finished) break;
if (!textDeltaBuffer.has(payload.messageId)) {
textDeltaBuffer.set(payload.messageId, new Map());
}
const messageBuffer = textDeltaBuffer.get(payload.messageId)!;
const existing = messageBuffer.get(payload.partId)?.content || '';
messageBuffer.set(payload.partId, { content: existing + payload.content, lastUpdatedAt: payload.lastUpdatedAt });
const existing = messageBuffer.get(payload.partId)?.content ?? part.content ?? '';
if (typeof payload.offset === 'number' && payload.offset > existing.length) {
scheduleReconnect(true);
break;
}
const content = appendTextDelta(existing, payload.content, payload.offset);
if (content === existing) break;
messageBuffer.set(payload.partId, { content, lastUpdatedAt: payload.lastUpdatedAt });
scheduleFlush();
break;
}
case 'text-delete': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
if (msg?.parts) msg.parts = msg.parts.filter(part => part.id !== payload.partId);
textDeltaBuffer.get(payload.messageId)?.delete(payload.partId);
break;
}
case 'text-end': {
const msg = data.value.messages.find(m => m.id === payload.messageId);
const part = msg?.parts?.find(p => p.id === payload.partId);
@@ -175,7 +220,9 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
if (!part.createdAt) {
part.createdAt = new Date().toISOString();
}
msg.parts.push(part);
if (!msg.parts.some(existing => existing.id === part.id)) {
msg.parts.push(part);
}
}
break;
}
@@ -229,55 +276,79 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
}
}
const connectSSE = async () => {
const { promise, resolve } = Promise.withResolvers<void>();
const connectSSE = async (forceSnapshot = false) => {
if (disposed) return;
const attempt = ++connectionAttempt;
const id = unref(topicId);
const lastUpdate = data.value?.messages.at(-1)?.updatedAt || '0';
const msgCount = data.value?.messages.length || 0;
if (sse && sse.readyState !== EventSource.CLOSED) {
if (!(new URL(sse.url).pathname.startsWith(`/api/topic/${id}`))) {
sse.close();
} else {
return;
sse?.close();
if (flushTimeout !== undefined) {
clearTimeout(flushTimeout);
flushTextDeltas();
}
let offeredSnapshot: string | undefined;
const query = new URLSearchParams();
if (!forceSnapshot && data.value?.id === id && globalThis.crypto?.subtle) {
try {
offeredSnapshot = serializeTopicSnapshot(toRaw(data.value));
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(offeredSnapshot));
query.set('snapshot', Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''));
} catch {
// Fingerprinting is an optimization; a full snapshot remains safe.
offeredSnapshot = undefined;
}
}
if (disposed || attempt !== connectionAttempt) return;
sse = new EventSource(`/api/topic/${id}?lastUpdate=${lastUpdate}&count=${msgCount}`);
const source = new EventSource(`/api/topic/${id}?${query}`);
sse = source;
source.addEventListener('error', () => {
if (sse === source) scheduleReconnect();
});
sse.addEventListener("error", (e) => {
console.error('sse error', e);
setTimeout(connectSSE, 1500);
})
sse.addEventListener("message", (e) => {
source.addEventListener('message', (e) => {
if (disposed || sse !== source) return;
const raw = JSON.parse(e.data);
const events: Event[] = Array.isArray(raw) ? raw : [raw];
for (const { type, payload, timestamp } of events) {
for (const event of events) {
const { type, payload } = event;
if (!type || !payload) continue;
if (type === 'initial_state') {
data.value = payload;
resolve()
if (type === 'synced') {
// Optimistic edits can occur while the handshake is in flight.
// Only retain the exact state that we offered to the server.
if (offeredSnapshot !== serializeTopicSnapshot(toRaw(data.value))) {
scheduleReconnect(true);
return;
}
offeredSnapshot = undefined;
markConnected();
continue;
}
processEvent({ type, payload, timestamp });
if (type === 'initial_state') {
resetTextDeltas();
data.value = payload;
offeredSnapshot = undefined;
markConnected();
continue;
}
processEvent(event);
}
});
await promise;
}
await connectionReady;
};
if (connect) {
onBeforeUnmount(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
flushTextDeltas();
}
disposed = true;
connectionAttempt++;
if (reconnectTimeout !== undefined) clearTimeout(reconnectTimeout);
resetTextDeltas();
markConnected();
if (sse) {
console.log('[SSE] Closing connection');
sse.close();
@@ -525,38 +596,76 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
}
const deleteMessagesFocusedDescendant = async (messageId: string): Promise<Result<void, Error>> => {
if (!data.value) return Err(new Error('No data'));
if (!data.value) {
return Err(new Error("No data"));
}
const msg = data.value.messages.find(m => m.id === messageId);
if (!msg) return Err(new Error('Message not found'));
const msg = data.value.messages.find((m) => {
return m.id === messageId;
});
if (!msg) {
return Err(new Error("Message not found"));
}
const previousMessages = data.value!.messages;
const previousMessages = data.value.messages;
const res = await $fetch(`/api/messages/${messageId}`, {
method: 'DELETE',
method: "DELETE",
onRequest() {
if (!msg.activeChildId && (msg.parentMessageId === null || msg.parentMessageId === undefined)) {
msg.deleted = true;
if (!data.value) {
return;
}
const parent = data.value!.messages.find(m => m.id === msg.parentMessageId);
data.value!.messages = data.value!.messages.filter(m => m.id !== msg.id);
if (parent?.deleted) {
data.value!.messages = data.value!.messages.filter(m => m.id !== parent.id);
if (msg.parentMessageId) {
const parent = data.value.messages.find((m) => {
return m.id === msg.parentMessageId;
});
data.value.messages = data.value.messages.filter((m) => {
return m.id !== msg.id;
});
if (parent) {
const remainingChildren = data.value.messages.filter((m) => {
return m.parentMessageId === parent.id;
});
if (parent.deleted && remainingChildren.length === 0) {
data.value.messages = data.value.messages.filter((m) => {
return m.id !== parent.id;
});
} else if (parent.activeChildId === msg.id) {
parent.activeChildId = remainingChildren.length > 0
? (parent.deleted ? remainingChildren[0]!.id : remainingChildren[remainingChildren.length - 1]!.id)
: null;
}
}
return;
}
const children = data.value.messages.filter((m) => {
return m.parentMessageId === msg.id;
});
if (children.length === 0) {
data.value.messages = data.value.messages.filter((m) => {
return m.id !== msg.id;
});
return;
}
msg.deleted = true;
msg.activeChildId = children[0]?.id ?? null;
},
onResponseError() {
data.value!.messages = previousMessages;
if (data.value) {
data.value.messages = previousMessages;
}
},
});
if (!res.ok) {
return Err(new Error('Failed to delete message'));
return Err(new Error("Failed to delete message"));
}
return Ok(undefined);
}
};
return {
topic,