bug fixes, and performance improvements, and drawer
This commit is contained in:
+14
-6
@@ -1,5 +1,9 @@
|
||||
import { createLiveTopicText } from './live-topic-text';
|
||||
|
||||
type StreamSink = Pick<ReadableStreamDefaultController, 'enqueue'>;
|
||||
|
||||
interface StreamConnection {
|
||||
controller: ReadableStreamDefaultController;
|
||||
controller: StreamSink;
|
||||
lastPing: number;
|
||||
subscribedAt: number;
|
||||
}
|
||||
@@ -91,9 +95,13 @@ export const userEvents = {
|
||||
};
|
||||
|
||||
const topicSubscribers = new Map<string, Set<StreamConnection>>();
|
||||
const liveTopicText = createLiveTopicText();
|
||||
|
||||
export const topicEvents = {
|
||||
subscribe(topicId: string, controller: ReadableStreamDefaultController) {
|
||||
applyLiveText: liveTopicText.apply,
|
||||
clearLiveText: liveTopicText.removeMessage,
|
||||
|
||||
subscribe(topicId: string, controller: StreamSink) {
|
||||
if (!topicSubscribers.has(topicId)) {
|
||||
topicSubscribers.set(topicId, new Set());
|
||||
}
|
||||
@@ -120,7 +128,7 @@ export const topicEvents = {
|
||||
(conn as any).pingInterval = pingInterval;
|
||||
},
|
||||
|
||||
unsubscribe(topicId: string, controller: ReadableStreamDefaultController) {
|
||||
unsubscribe(topicId: string, controller: StreamSink) {
|
||||
const connections = topicSubscribers.get(topicId);
|
||||
if (!connections) return;
|
||||
|
||||
@@ -137,14 +145,14 @@ export const topicEvents = {
|
||||
},
|
||||
|
||||
async emit(topicId: string, events: Array<{ type: string; payload: any; timestamp?: number }> | { type: string; payload: any; timestamp?: number }) {
|
||||
const connections = topicSubscribers.get(topicId);
|
||||
if (!connections || connections.size === 0) return;
|
||||
|
||||
if (!Array.isArray(events)) {
|
||||
events = [events];
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
liveTopicText.update(topicId, event);
|
||||
const connections = topicSubscribers.get(topicId);
|
||||
if (!connections || connections.size === 0) continue;
|
||||
const timestamp = event.timestamp ?? Date.now();
|
||||
const data = `data: ${JSON.stringify({ ...event, timestamp })}\n\n`;
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { appendTextDelta } from '../../utils/topic-sync';
|
||||
|
||||
interface LivePart {
|
||||
messageId: string;
|
||||
part: Record<string, any> & { id: string; content: string };
|
||||
}
|
||||
|
||||
/** Covers the interval between an emitted delta and its throttled DB write. */
|
||||
export const createLiveTopicText = () => {
|
||||
const topics = new Map<string, Map<string, LivePart>>();
|
||||
|
||||
const removeMessage = (topicId: string, messageId: string) => {
|
||||
const parts = topics.get(topicId);
|
||||
if (!parts) return;
|
||||
for (const [id, entry] of parts) {
|
||||
if (entry.messageId === messageId) parts.delete(id);
|
||||
}
|
||||
if (parts.size === 0) topics.delete(topicId);
|
||||
};
|
||||
|
||||
return {
|
||||
update(topicId: string, event: { type: string; payload: any }) {
|
||||
const { type, payload } = event;
|
||||
if (type === 'text-start') {
|
||||
let parts = topics.get(topicId);
|
||||
if (!parts) topics.set(topicId, parts = new Map());
|
||||
parts.set(payload.part.id, {
|
||||
messageId: payload.messageId,
|
||||
part: { ...payload.part, toolCall: null, content: payload.part.content ?? '' },
|
||||
});
|
||||
} else if (type === 'text-delta') {
|
||||
const entry = topics.get(topicId)?.get(payload.partId);
|
||||
if (entry) {
|
||||
entry.part.content = appendTextDelta(entry.part.content, payload.content, payload.offset);
|
||||
entry.part.lastUpdatedAt = payload.lastUpdatedAt;
|
||||
}
|
||||
} else if (type === 'text-end' || type === 'text-delete') {
|
||||
const parts = topics.get(topicId);
|
||||
parts?.delete(payload.partId);
|
||||
if (parts?.size === 0) topics.delete(topicId);
|
||||
} else if (type === 'MESSAGE_DELETED') {
|
||||
removeMessage(topicId, payload.id);
|
||||
}
|
||||
},
|
||||
apply(topicId: string, messages: { id: string; parts: any[] }[]) {
|
||||
const parts = topics.get(topicId);
|
||||
if (!parts) return;
|
||||
const byId = new Map(messages.map(message => [message.id, message]));
|
||||
for (const entry of parts.values()) {
|
||||
const message = byId.get(entry.messageId);
|
||||
if (!message) continue;
|
||||
const existing = message.parts.find(part => part.id === entry.part.id);
|
||||
if (existing) {
|
||||
if (!existing.finished && (existing.content?.length ?? 0) <= entry.part.content.length) {
|
||||
existing.content = entry.part.content;
|
||||
existing.lastUpdatedAt = entry.part.lastUpdatedAt;
|
||||
}
|
||||
} else {
|
||||
message.parts.push({ ...entry.part });
|
||||
}
|
||||
}
|
||||
},
|
||||
removeMessage,
|
||||
};
|
||||
};
|
||||
+15
-25
@@ -1,31 +1,21 @@
|
||||
interface PendingRename {
|
||||
topicId: string;
|
||||
abortController: AbortController;
|
||||
}
|
||||
const pendingRenames = new Map<string, { attemptId: string; controller: AbortController }>();
|
||||
|
||||
const pendingRenames: Map<string, AbortController> = new Map();
|
||||
|
||||
export const cancelPendingRename = (topicId: string) => {
|
||||
const pendingRename = pendingRenames.get(topicId);
|
||||
if (pendingRename) {
|
||||
pendingRename.abort();
|
||||
pendingRenames.delete(topicId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const completeRename = (topicId: string) => {
|
||||
const controller = pendingRenames.get(topicId);
|
||||
if (controller) {
|
||||
export const cancelPendingRename = (topicId: string, attemptId?: string | null) => {
|
||||
const pending = pendingRenames.get(topicId);
|
||||
if (pending && pending.attemptId === attemptId) {
|
||||
pending.controller.abort();
|
||||
pendingRenames.delete(topicId);
|
||||
}
|
||||
};
|
||||
|
||||
export const addPendingRename = (topicId: string) => {
|
||||
const abortController = new AbortController();
|
||||
export const completeRename = (topicId: string, attemptId: string) => {
|
||||
if (pendingRenames.get(topicId)?.attemptId === attemptId) {
|
||||
pendingRenames.delete(topicId);
|
||||
}
|
||||
};
|
||||
|
||||
pendingRenames.set(topicId, abortController);
|
||||
return abortController;
|
||||
};
|
||||
export const addPendingRename = (topicId: string, attemptId: string) => {
|
||||
const controller = new AbortController();
|
||||
pendingRenames.set(topicId, { attemptId, controller });
|
||||
return controller;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Result } from "../../types/result";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { Err, Ok } from "../../types/result";
|
||||
|
||||
@@ -20,6 +21,7 @@ export const searchOutputSchema = z.object({
|
||||
results: z.array(z.object({
|
||||
title: z.string(),
|
||||
link: z.string(),
|
||||
sourceId: z.string().describe('Copy this exact ID into [[cite:ID]]. The literal "cite:" prefix is required; [[ID]] is invalid.').optional(),
|
||||
snippet: z.string(),
|
||||
})),
|
||||
warnings: z.array(z.object({ engine: z.string(), reason: z.string() })),
|
||||
@@ -43,6 +45,7 @@ export const readSearchResponse = (body: unknown): Result<SearchOutput, string>
|
||||
}
|
||||
return Ok({
|
||||
results: parsed.data.results.map(item => ({
|
||||
sourceId: `s${randomUUID().replaceAll("-", "")}`,
|
||||
title: item.title,
|
||||
link: item.url,
|
||||
snippet: item.content,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Err, Ok } from '../../types/result';
|
||||
import { serializeTopicSnapshot } from '../../utils/topic-sync';
|
||||
import { topicEvents } from './events';
|
||||
|
||||
interface TopicSnapshot {
|
||||
messages: { id: string; parts: any[] }[];
|
||||
}
|
||||
|
||||
export const createTopicSnapshotStream = (topicId: string, offeredFingerprint: unknown) => {
|
||||
let controller: ReadableStreamDefaultController | undefined;
|
||||
let closed = false;
|
||||
const buffered: string[] = [];
|
||||
const sink = {
|
||||
enqueue(frame: string) {
|
||||
if (closed) return;
|
||||
if (controller) controller.enqueue(frame);
|
||||
else buffered.push(frame);
|
||||
},
|
||||
};
|
||||
const close = () => {
|
||||
closed = true;
|
||||
topicEvents.unsubscribe(topicId, sink);
|
||||
buffered.length = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
close,
|
||||
async open(readSnapshot: () => Promise<TopicSnapshot | undefined>) {
|
||||
if (closed) return Err('closed' as const);
|
||||
// Subscribe before reading: changes during the query must not be lost.
|
||||
topicEvents.subscribe(topicId, sink);
|
||||
let topic;
|
||||
try {
|
||||
topic = await readSnapshot();
|
||||
} catch (error) {
|
||||
close();
|
||||
console.error('Failed to read topic snapshot', error);
|
||||
return Err('read-failed' as const);
|
||||
}
|
||||
if (closed || !topic) {
|
||||
close();
|
||||
return Err('not-found' as const);
|
||||
}
|
||||
// Include deltas already emitted but not yet flushed to PostgreSQL.
|
||||
topicEvents.applyLiveText(topicId, topic.messages);
|
||||
const fingerprint = createHash('sha256').update(serializeTopicSnapshot(topic)).digest('hex');
|
||||
const matches = offeredFingerprint === fingerprint;
|
||||
|
||||
return Ok(new ReadableStream<string>({
|
||||
start(streamController) {
|
||||
controller = streamController;
|
||||
controller.enqueue(`data: ${JSON.stringify(matches
|
||||
? { type: 'synced', payload: { id: topicId } }
|
||||
: { type: 'initial_state', payload: topic })}\n\n`);
|
||||
for (const frame of buffered) {
|
||||
if (frame.startsWith('data: ')) {
|
||||
const replay = { ...JSON.parse(frame.slice(6)), replay: true };
|
||||
controller.enqueue(`data: ${JSON.stringify(replay)}\n\n`);
|
||||
} else {
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
}
|
||||
buffered.length = 0;
|
||||
},
|
||||
cancel: close,
|
||||
}));
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user