bug fixes, and performance improvements, and drawer
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import type { BaseMessage } from "~/composables/useChat";
|
||||
import { nanoid } from "nanoid";
|
||||
import { provisionalTopicTitle } from "~~/utils/topic-title";
|
||||
import { attempt } from "~~/types/result";
|
||||
import * as schema from '~~/drizzle/schema';
|
||||
|
||||
@@ -153,11 +155,11 @@ export const useAgents = async () => {
|
||||
|
||||
// TODO: since topics are contained within each agent struct, all topic
|
||||
// actions must be done through the agents composable
|
||||
const createTopic = async (agentId: string) => {
|
||||
const createTopic = async (agentId: string, message?: BaseMessage) => {
|
||||
const topicId = nanoid();
|
||||
const topic = {
|
||||
id: topicId,
|
||||
name: 'New Topic',
|
||||
name: provisionalTopicTitle(message),
|
||||
agentId,
|
||||
};
|
||||
|
||||
@@ -214,6 +216,9 @@ export const useAgents = async () => {
|
||||
agentId: agent.id,
|
||||
userId: agent.userId,
|
||||
renaming: false,
|
||||
renameAttemptId: null,
|
||||
renameExpiresAt: null,
|
||||
titleSource: "manual",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
agents.value = agents.value.map(a =>
|
||||
|
||||
@@ -15,6 +15,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
let lastScrollTop = 0;
|
||||
let mutationRaf: number | null = null;
|
||||
let anchorRaf: number | null = null;
|
||||
let anchorDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let observer: MutationObserver | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let lastWidth = 0;
|
||||
@@ -26,15 +27,25 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
const setUnhooked = (value: boolean) => {
|
||||
unhooked.value = value;
|
||||
shouldAutoScroll.value = !value;
|
||||
if (!value) anchor = null;
|
||||
};
|
||||
|
||||
const scrollToBottom = (behavior: ScrollBehavior = 'instant') => {
|
||||
const el = elementRef.value;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bottom = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
if (Math.abs(el.scrollTop - bottom) < 0.5) {
|
||||
setUnhooked(false);
|
||||
lastScrollTop = el.scrollTop;
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammatic = true;
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
top: bottom,
|
||||
behavior,
|
||||
});
|
||||
|
||||
@@ -48,13 +59,19 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
};
|
||||
|
||||
const captureAnchor = (el: HTMLElement) => {
|
||||
if (!unhooked.value) {
|
||||
anchor = null;
|
||||
return;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const offsets = [4, 24, 48, 80, 120];
|
||||
|
||||
for (const y of offsets) {
|
||||
const target = document.elementFromPoint(x, rect.top + y);
|
||||
if (!target || target === el || !el.contains(target)) continue;
|
||||
if (!target || target === el || !el.contains(target)) {
|
||||
continue;
|
||||
}
|
||||
anchor = {
|
||||
element: target,
|
||||
offsetFromTop: target.getBoundingClientRect().top - rect.top,
|
||||
@@ -65,8 +82,8 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
anchor = null;
|
||||
};
|
||||
|
||||
const scheduleAnchorCapture = () => {
|
||||
if (anchorRaf !== null) return;
|
||||
const requestAnchorCapture = () => {
|
||||
if (!unhooked.value || anchorRaf !== null) return;
|
||||
anchorRaf = requestAnimationFrame(() => {
|
||||
anchorRaf = null;
|
||||
const el = elementRef.value;
|
||||
@@ -74,13 +91,38 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleAnchorCapture = () => {
|
||||
const el = elementRef.value;
|
||||
// Native scrollend is the primary path; debounce is only a fallback.
|
||||
if (!el || 'onscrollend' in el || !unhooked.value) return;
|
||||
if (anchorDebounceTimeout !== null) {
|
||||
clearTimeout(anchorDebounceTimeout);
|
||||
}
|
||||
anchorDebounceTimeout = setTimeout(() => {
|
||||
anchorDebounceTimeout = null;
|
||||
requestAnchorCapture();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleScrollEnd = () => {
|
||||
if (anchorDebounceTimeout !== null) {
|
||||
clearTimeout(anchorDebounceTimeout);
|
||||
anchorDebounceTimeout = null;
|
||||
}
|
||||
requestAnchorCapture();
|
||||
};
|
||||
|
||||
const restoreAnchor = (el: HTMLElement) => {
|
||||
if (!anchor || !el.contains(anchor.element)) return;
|
||||
if (!anchor || !el.contains(anchor.element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const top = el.getBoundingClientRect().top;
|
||||
const delta =
|
||||
anchor.element.getBoundingClientRect().top - top - anchor.offsetFromTop;
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
if (Math.abs(delta) < 0.5) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammatic = true;
|
||||
el.scrollTop += delta;
|
||||
@@ -99,37 +141,54 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = elementRef.value;
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollingUp = el.scrollTop < lastScrollTop;
|
||||
lastScrollTop = el.scrollTop;
|
||||
|
||||
scheduleAnchorCapture();
|
||||
|
||||
if (isProgrammatic) {
|
||||
if (isAtBottom(el)) setUnhooked(false);
|
||||
if (isAtBottom(el)) {
|
||||
setUnhooked(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only re-hook when actually inside the bottom threshold.
|
||||
// Mid-page scroll (up or down) must never re-enable stick.
|
||||
// If the user is scrolling up, they are deliberately navigating away from the bottom.
|
||||
// Unhook immediately so we never fight the scroll or snap back down.
|
||||
if (scrollingUp) {
|
||||
setUnhooked(true);
|
||||
scheduleAnchorCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only re-hook when scrolling down into the bottom threshold.
|
||||
if (isAtBottom(el)) {
|
||||
setUnhooked(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollingUp) {
|
||||
setUnhooked(true);
|
||||
}
|
||||
scheduleAnchorCapture();
|
||||
};
|
||||
|
||||
const followIfHooked = () => {
|
||||
if (unhooked.value) return;
|
||||
if (unhooked.value) {
|
||||
return;
|
||||
}
|
||||
scrollToBottom('instant');
|
||||
};
|
||||
|
||||
const handleMutation = () => {
|
||||
if (mutationRaf !== null) return;
|
||||
const handleMutation = (records: MutationRecord[]) => {
|
||||
if (unhooked.value || mutationRaf !== null) {
|
||||
return;
|
||||
}
|
||||
// The isolated sizing mirror changes on every keystroke, but cannot
|
||||
// change scroll height. Keep observing real composer content (files,
|
||||
// tool controls, etc.) as well as message content.
|
||||
if (records.every(record => {
|
||||
const target = record.target instanceof Element ? record.target : record.target.parentElement;
|
||||
return target?.closest('[data-chat-measurement]');
|
||||
})) return;
|
||||
mutationRaf = requestAnimationFrame(() => {
|
||||
mutationRaf = null;
|
||||
followIfHooked();
|
||||
@@ -144,6 +203,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
|
||||
el.addEventListener('wheel', handleWheel, { passive: true });
|
||||
el.addEventListener('scroll', handleScroll, { passive: true });
|
||||
el.addEventListener('scrollend', handleScrollEnd, { passive: true });
|
||||
|
||||
observer = new MutationObserver(handleMutation);
|
||||
observer.observe(el, {
|
||||
@@ -154,7 +214,9 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const current = elementRef.value;
|
||||
if (!current) return;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = current.clientWidth;
|
||||
if (width === lastWidth) {
|
||||
@@ -172,13 +234,18 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!anchor) {
|
||||
captureAnchor(current);
|
||||
}
|
||||
restoreAnchor(current);
|
||||
});
|
||||
resizeObserver.observe(el);
|
||||
|
||||
// Content already taller than the viewport on mount — catch up once
|
||||
requestAnimationFrame(() => {
|
||||
if (!unhooked.value) scrollToBottom('instant');
|
||||
if (!unhooked.value) {
|
||||
scrollToBottom('instant');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -186,6 +253,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
if (el) {
|
||||
el.removeEventListener('wheel', handleWheel);
|
||||
el.removeEventListener('scroll', handleScroll);
|
||||
el.removeEventListener('scrollend', handleScrollEnd);
|
||||
}
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
@@ -199,12 +267,18 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
|
||||
cancelAnimationFrame(anchorRaf);
|
||||
anchorRaf = null;
|
||||
}
|
||||
if (anchorDebounceTimeout !== null) {
|
||||
clearTimeout(anchorDebounceTimeout);
|
||||
anchorDebounceTimeout = null;
|
||||
}
|
||||
anchor = null;
|
||||
};
|
||||
|
||||
watch(elementRef, (newEl, oldEl) => {
|
||||
detach(oldEl ?? null);
|
||||
if (newEl) attach(newEl);
|
||||
if (newEl) {
|
||||
attach(newEl);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
+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,
|
||||
|
||||
@@ -8,8 +8,6 @@ export const useTokenDropdown = () => {
|
||||
const triggerElement = useState<HTMLElement | null>('token-dropdown:trigger', () => null);
|
||||
|
||||
const open = (generation: Generation, trigger: HTMLElement) => {
|
||||
console.log("open", generation, trigger);
|
||||
|
||||
activeGeneration.value = generation;
|
||||
triggerElement.value = trigger;
|
||||
isOpen.value = true;
|
||||
|
||||
@@ -10,7 +10,7 @@ export const useTopic = () => {
|
||||
FailedToDecryptProviderApiKey,
|
||||
FailedToGenerate,
|
||||
}
|
||||
const autoRename = async (topicId: string): Promise<Result<string, AutoRenameError>> => {
|
||||
const autoRename = async (topicId: string): Promise<Result<void, AutoRenameError>> => {
|
||||
const { settings } = await useUserSettings();
|
||||
|
||||
if (!settings.value.systemAssistants.rename.enabled) {
|
||||
@@ -54,7 +54,7 @@ export const useTopic = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const { agents } = await useAgents();
|
||||
const { patchTopicLocally, refresh } = await useAgents();
|
||||
|
||||
try {
|
||||
// @ts-ignore - excessive stack depth
|
||||
@@ -65,31 +65,20 @@ export const useTopic = () => {
|
||||
providerApiKey,
|
||||
}),
|
||||
onRequest() {
|
||||
const topics = agents.value.flatMap(agent => agent.topics);
|
||||
const topic = topics.find(topic => topic.id === topicId);
|
||||
if (topic) {
|
||||
agents.value = agents.value.map(agent => {
|
||||
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: true } : t) } : agent;
|
||||
});
|
||||
}
|
||||
patchTopicLocally(topicId, { renaming: true });
|
||||
},
|
||||
onRequestError() {
|
||||
const topics = agents.value.flatMap(agent => agent.topics);
|
||||
const topic = topics.find(topic => topic.id === topicId);
|
||||
if (topic) {
|
||||
agents.value = agents.value.map(agent => {
|
||||
return agent.id === topic.agentId ? { ...agent, topics: agent.topics.map(t => t.id === topic.id ? { ...topic, renaming: false } : t) } : agent;
|
||||
});
|
||||
}
|
||||
},
|
||||
}) as { ok: true, renameId: string } | { ok: false, code: string };
|
||||
});
|
||||
if (!res.ok) {
|
||||
patchTopicLocally(topicId, { renaming: false });
|
||||
await refresh();
|
||||
console.error('Failed to auto-rename:', res.code);
|
||||
return Err(AutoRenameError.FailedToGenerate);
|
||||
}
|
||||
|
||||
return Ok(res.renameId);
|
||||
return Ok(undefined);
|
||||
} catch (error) {
|
||||
patchTopicLocally(topicId, { renaming: false });
|
||||
await refresh();
|
||||
console.error('Failed to auto-rename:', error);
|
||||
return Err(AutoRenameError.FailedToGenerate);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
export const useUserEvents = () => {
|
||||
const eventSource = ref<EventSource | null>(null);
|
||||
const agents = useState<AgentWithTopics[]>("agents_state", () => []);
|
||||
const isConnected = useState('userEvents:connected', () => false);
|
||||
const lastEventTimestamp = useState<number>('userEvents:lastTimestamp', () => 0);
|
||||
|
||||
const reconnectAttempts = ref(0);
|
||||
const connectionStartTime = ref<number>(0);
|
||||
const pendingReconnect = ref<NodeJS.Timeout | null>(null);
|
||||
let reconciliationTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let reconciling = false;
|
||||
|
||||
const BASE_DELAY = 1000;
|
||||
const MAX_DELAY = 30000;
|
||||
@@ -50,7 +53,6 @@ export const useUserEvents = () => {
|
||||
cleanup();
|
||||
|
||||
connectionStartTime.value = Date.now();
|
||||
reconnectAttempts.value = 0;
|
||||
|
||||
const source = new EventSource('/api/events');
|
||||
eventSource.value = source;
|
||||
@@ -59,6 +61,7 @@ export const useUserEvents = () => {
|
||||
console.log('User events connected');
|
||||
isConnected.value = true;
|
||||
reconnectAttempts.value = 0;
|
||||
void reconcile();
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
@@ -164,20 +167,48 @@ export const useUserEvents = () => {
|
||||
};
|
||||
|
||||
const reconcile = async () => {
|
||||
console.log('Reconciling user state...');
|
||||
const { data } = await useFetch<AgentWithTopics[]>('/api/agents');
|
||||
if (data.value) {
|
||||
const { agents } = await useAgents();
|
||||
agents.value = data.value;
|
||||
if (reconciling) {
|
||||
return;
|
||||
}
|
||||
reconciling = true;
|
||||
try {
|
||||
agents.value = await $fetch<AgentWithTopics[]>("/api/agents");
|
||||
} catch (error) {
|
||||
console.error("Failed to reconcile topics:", error);
|
||||
} finally {
|
||||
reconciling = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
if (!isConnected.value) {
|
||||
reconnectAttempts.value = 0;
|
||||
void connect();
|
||||
}
|
||||
void reconcile();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
connect();
|
||||
void connect();
|
||||
document.addEventListener("visibilitychange", resume);
|
||||
window.addEventListener("online", resume);
|
||||
window.addEventListener("pageshow", resume);
|
||||
reconciliationTimer = setInterval(() => {
|
||||
if (document.visibilityState === "visible" && agents.value.some(agent => agent.topics.some(topic => topic.renaming))) {
|
||||
void reconcile();
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup();
|
||||
clearInterval(reconciliationTimer);
|
||||
document.removeEventListener("visibilitychange", resume);
|
||||
window.removeEventListener("online", resume);
|
||||
window.removeEventListener("pageshow", resume);
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user