bug fixes, and performance improvements, and drawer
This commit is contained in:
+161
-52
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user