bug fixes, and performance improvements, and drawer
This commit is contained in:
@@ -1,9 +1,22 @@
|
||||
import { and, eq, isNull, lte, or } from "drizzle-orm";
|
||||
import { topics } from "~~/drizzle/schema";
|
||||
import { db } from "~~/server/lib/db";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
const userId = event.context.user!.id as string;
|
||||
|
||||
// Expired leases also recover jobs interrupted by a server restart.
|
||||
await db.update(topics).set({
|
||||
renaming: false,
|
||||
renameAttemptId: null,
|
||||
renameExpiresAt: null,
|
||||
}).where(and(
|
||||
eq(topics.userId, userId),
|
||||
eq(topics.renaming, true),
|
||||
or(isNull(topics.renameExpiresAt), lte(topics.renameExpiresAt, new Date())),
|
||||
));
|
||||
|
||||
const agents = await db.query.agents.findMany({
|
||||
where: {
|
||||
userId,
|
||||
|
||||
@@ -63,62 +63,106 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const rootMessage = messagesMap.get(messageId);
|
||||
if (!rootMessage) {
|
||||
const targetMessage = messagesMap.get(messageId);
|
||||
if (!targetMessage) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid message',
|
||||
});
|
||||
}
|
||||
|
||||
if (rootMessage.role === 'user') {
|
||||
await deeplyDeleteMessage(rootMessage.id, topicId, userId);
|
||||
if (targetMessage.role === 'user') {
|
||||
await deeplyDeleteMessage(targetMessage.id, topicId, userId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (rootMessage.deleted === true) {
|
||||
const childMessage = rootMessage.activeChildId
|
||||
? rootMessage.children.find(c => c.id === rootMessage.activeChildId)
|
||||
: rootMessage.children[0];
|
||||
if (!childMessage) {
|
||||
console.error('Message not found');
|
||||
return { ok: false };
|
||||
if (targetMessage.parentMessageId && messagesMap.has(targetMessage.parentMessageId)) {
|
||||
const parent = messagesMap.get(targetMessage.parentMessageId)!;
|
||||
await deeplyDeleteMessage(targetMessage.id, topicId, userId);
|
||||
|
||||
const remainingChildren = parent.children.filter((child) => {
|
||||
return child.id !== targetMessage.id;
|
||||
});
|
||||
|
||||
if (parent.deleted === true && remainingChildren.length === 0) {
|
||||
await deeplyDeleteMessage(parent.id, topicId, userId);
|
||||
} else if (parent.activeChildId === targetMessage.id) {
|
||||
const nextActiveChildId = remainingChildren.length > 0
|
||||
? (parent.deleted ? remainingChildren[0]!.id : remainingChildren[remainingChildren.length - 1]!.id)
|
||||
: null;
|
||||
await db.update(messages).set({ activeChildId: nextActiveChildId }).where(sql`${messages.id} = ${parent.id} AND ${messages.userId} = ${userId}`);
|
||||
topicEvents.emit(topicId, {
|
||||
type: 'MESSAGE_UPDATED',
|
||||
payload: { ...parent, activeChildId: nextActiveChildId },
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await deeplyDeleteMessage(childMessage.id, topicId, userId);
|
||||
if (targetMessage.deleted === true) {
|
||||
const childMessage = targetMessage.activeChildId
|
||||
? targetMessage.children.find((c) => { return c.id === targetMessage.activeChildId; })
|
||||
: targetMessage.children[0];
|
||||
|
||||
const remainingChildren = rootMessage.children.filter(child => child!.id !== childMessage.id);
|
||||
if (remainingChildren.length === 0) {
|
||||
await deeplyDeleteMessage(rootMessage.id, topicId, userId);
|
||||
if (childMessage) {
|
||||
await deeplyDeleteMessage(childMessage.id, topicId, userId);
|
||||
const remainingChildren = targetMessage.children.filter((child) => {
|
||||
return child.id !== childMessage.id;
|
||||
});
|
||||
if (remainingChildren.length === 0) {
|
||||
await deeplyDeleteMessage(targetMessage.id, topicId, userId);
|
||||
} else if (targetMessage.activeChildId === childMessage.id) {
|
||||
const nextActiveChildId = remainingChildren[0]!.id;
|
||||
await db.update(messages).set({ activeChildId: nextActiveChildId }).where(sql`${messages.id} = ${targetMessage.id} AND ${messages.userId} = ${userId}`);
|
||||
topicEvents.emit(topicId, {
|
||||
type: 'MESSAGE_UPDATED',
|
||||
payload: { ...targetMessage, activeChildId: nextActiveChildId },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await deeplyDeleteMessage(targetMessage.id, topicId, userId);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (
|
||||
rootMessage.activeChildId
|
||||
&& rootMessage.children.length > 0
|
||||
targetMessage.activeChildId
|
||||
&& targetMessage.children.length > 0
|
||||
) {
|
||||
const childMessage = rootMessage.children.find(c => c.id === rootMessage.activeChildId);
|
||||
if (!childMessage) {
|
||||
console.error('Message not found');
|
||||
return { ok: false };
|
||||
const childMessage = targetMessage.children.find((c) => {
|
||||
return c.id === targetMessage.activeChildId;
|
||||
});
|
||||
if (childMessage) {
|
||||
await deeplyDeleteMessage(childMessage.id, topicId, userId);
|
||||
const remainingChildren = targetMessage.children.filter((child) => {
|
||||
return child.id !== childMessage.id;
|
||||
});
|
||||
const nextActiveChildId = remainingChildren.length > 0
|
||||
? remainingChildren[remainingChildren.length - 1]!.id
|
||||
: null;
|
||||
await db.update(messages).set({ activeChildId: nextActiveChildId }).where(sql`${messages.id} = ${targetMessage.id} AND ${messages.userId} = ${userId}`);
|
||||
topicEvents.emit(topicId, {
|
||||
type: 'MESSAGE_UPDATED',
|
||||
payload: { ...targetMessage, activeChildId: nextActiveChildId },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
await deeplyDeleteMessage(childMessage.id, topicId, userId);
|
||||
|
||||
if (targetMessage.children.length === 0) {
|
||||
await deeplyDeleteMessage(targetMessage.id, topicId, userId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (rootMessage.children.length === 0) {
|
||||
await deeplyDeleteMessage(rootMessage.id, topicId, userId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await db.update(messages).set({ deleted: true }).where(sql`${messages.id} = ${rootMessage.id} AND ${messages.userId} = ${userId}`);
|
||||
const nextActiveChildId = targetMessage.children[0]?.id ?? null;
|
||||
await db.update(messages).set({
|
||||
deleted: true,
|
||||
activeChildId: nextActiveChildId,
|
||||
}).where(sql`${messages.id} = ${targetMessage.id} AND ${messages.userId} = ${userId}`);
|
||||
|
||||
topicEvents.emit(topicId, {
|
||||
type: 'MESSAGE_UPDATED',
|
||||
payload: { ...rootMessage, deleted: true },
|
||||
payload: { ...targetMessage, deleted: true, activeChildId: nextActiveChildId },
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
|
||||
@@ -1,44 +1,39 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { topics } from '~~/drizzle/schema';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { cancelPendingRename } from '~~/server/utils/renames';
|
||||
import { userEvents } from '~~/server/utils/events';
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { topics } from "~~/drizzle/schema";
|
||||
import { db } from "~~/server/lib/db";
|
||||
import { cancelPendingRename } from "~~/server/utils/renames";
|
||||
import { topicEvents, userEvents } from "~~/server/utils/events";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const topicId = getRouterParam(event, 'topicId')!;
|
||||
|
||||
const success = cancelPendingRename(topicId);
|
||||
if (success) {
|
||||
const res = await db.update(topics).set({
|
||||
renaming: false,
|
||||
}).where(and(eq(topics.id, topicId), eq(topics.userId, event.context.user!.id)));
|
||||
if (res.rowCount === 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid topic',
|
||||
data: {
|
||||
code: 'INVALID_TOPIC',
|
||||
ok: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
userEvents.emit(topicId, 'topics', {
|
||||
op: 'update',
|
||||
payload: {
|
||||
topicId,
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
const topicId = getRouterParam(event, "topicId");
|
||||
const userId = event.context.user?.id;
|
||||
if (!topicId || !userId) {
|
||||
setResponseStatus(event, 400);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
};
|
||||
});
|
||||
const topic = await db.query.topics.findFirst({ where: { id: topicId, userId } });
|
||||
if (!topic) {
|
||||
setResponseStatus(event, 404);
|
||||
return { ok: false };
|
||||
}
|
||||
const cleared = await db.update(topics).set({
|
||||
renaming: false,
|
||||
renameAttemptId: null,
|
||||
renameExpiresAt: null,
|
||||
}).where(and(
|
||||
eq(topics.id, topicId),
|
||||
eq(topics.userId, userId),
|
||||
topic.renameAttemptId ? eq(topics.renameAttemptId, topic.renameAttemptId) : isNull(topics.renameAttemptId),
|
||||
)).returning({ id: topics.id });
|
||||
if (cleared.length === 0) {
|
||||
return { ok: false };
|
||||
}
|
||||
cancelPendingRename(topicId, topic.renameAttemptId);
|
||||
|
||||
const payload = { topicId, renaming: false };
|
||||
userEvents.emit(userId, "topics", { op: "update", payload });
|
||||
topicEvents.emit(topicId, { type: "topic_updated", payload });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import * as z from 'zod';
|
||||
import { renamePrompt } from '~~/prompts';
|
||||
import { generateText } from 'ai';
|
||||
import { generateText, type UserContent } from "ai";
|
||||
import { nanoid } from "nanoid";
|
||||
import { resolveFileUrl } from "~~/utils/url";
|
||||
import { generateFileToken } from "~~/server/utils/file-token";
|
||||
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
|
||||
import { addPendingRename } from '~~/server/utils/renames';
|
||||
import { addPendingRename, completeRename } from "~~/server/utils/renames";
|
||||
import { userEvents } from '~~/server/utils/events';
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { type Model } from '~/composables/useModels';
|
||||
import { topics } from '~~/drizzle/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq, gt, isNull, lte, or } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
@@ -94,7 +97,7 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (topic.renaming) {
|
||||
if (topic.renaming && topic.renameExpiresAt && topic.renameExpiresAt > new Date()) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Already renaming',
|
||||
@@ -165,47 +168,70 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
let prompt = firstMessage.content!;
|
||||
|
||||
if (firstMessage.attachments.length > 0) {
|
||||
for (const attachment of firstMessage.attachments) {
|
||||
if (attachment.file.mimeType.startsWith('image/')) {
|
||||
prompt += `\n`;
|
||||
const content: UserContent = [{
|
||||
type: "text",
|
||||
text: [firstMessage.content || "Describe the attached content.",
|
||||
...firstMessage.attachments.map(({ file }) => `Attachment: ${file.name}`),
|
||||
].join("\n").slice(0, 12000),
|
||||
}];
|
||||
if (model.inputModalities.includes("image")) {
|
||||
const images = firstMessage.attachments.filter(({ file }) =>
|
||||
file.mimeType.startsWith("image/") && file.size <= 10 * 1024 * 1024,
|
||||
).slice(0, 2);
|
||||
for (const { file } of images) {
|
||||
const url = new URL(resolveFileUrl(file.url));
|
||||
if (file.url.startsWith("/api/files/")) {
|
||||
const secret = process.env.BETTER_AUTH_SECRET;
|
||||
if (!secret) {
|
||||
continue;
|
||||
}
|
||||
const { exp, sig } = generateFileToken(file.url.slice("/api/files/".length), secret);
|
||||
url.searchParams.set("exp", String(exp));
|
||||
url.searchParams.set("sig", sig);
|
||||
}
|
||||
content.push({ type: "image", image: url, mediaType: file.mimeType });
|
||||
}
|
||||
}
|
||||
|
||||
const abortController = addPendingRename(topicId);
|
||||
event.waitUntil(autoRename(topicId, abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, prompt, userId));
|
||||
const attemptId = nanoid();
|
||||
const claimed = await db.update(topics).set({
|
||||
renaming: true,
|
||||
renameAttemptId: attemptId,
|
||||
renameExpiresAt: new Date(Date.now() + 90000),
|
||||
}).where(and(
|
||||
eq(topics.id, topicId),
|
||||
eq(topics.userId, userId),
|
||||
eq(topics.name, topic.name),
|
||||
or(eq(topics.renaming, false), isNull(topics.renameExpiresAt), lte(topics.renameExpiresAt, new Date())),
|
||||
)).returning({ id: topics.id });
|
||||
if (claimed.length === 0) {
|
||||
return { ok: false as const, code: "ALREADY_RENAMING" };
|
||||
}
|
||||
userEvents.emit(userId, "topics", {
|
||||
op: "update", payload: { topicId, renaming: true },
|
||||
});
|
||||
const abortController = addPendingRename(topicId, attemptId);
|
||||
event.waitUntil(autoRename(topicId, attemptId, abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, content, userId));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
ok: true as const,
|
||||
};
|
||||
});
|
||||
|
||||
const autoRename = async (
|
||||
topicId: string,
|
||||
attemptId: string,
|
||||
abortController: AbortController,
|
||||
model: {
|
||||
gateway: ModelGateway,
|
||||
model: Model,
|
||||
},
|
||||
textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined,
|
||||
prompt: string,
|
||||
content: UserContent,
|
||||
userId: string,
|
||||
) => {
|
||||
const deadline = setTimeout(() => abortController.abort(), 90000);
|
||||
try {
|
||||
await db.update(topics).set({
|
||||
renaming: true,
|
||||
}).where(eq(topics.id, topicId));
|
||||
userEvents.emit(model.model.userId, 'topics', {
|
||||
op: 'update',
|
||||
payload: {
|
||||
topicId,
|
||||
renaming: true,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await generateText({
|
||||
model: model.gateway(model.model.externalId),
|
||||
providerOptions: {
|
||||
@@ -214,10 +240,11 @@ const autoRename = async (
|
||||
}
|
||||
},
|
||||
system: renamePrompt,
|
||||
prompt,
|
||||
messages: [{ role: "user", content }],
|
||||
maxRetries: 0,
|
||||
timeout: 90 * 1000,
|
||||
abortSignal: abortController.signal,
|
||||
})
|
||||
});
|
||||
|
||||
let text = response.text;
|
||||
|
||||
@@ -231,15 +258,30 @@ const autoRename = async (
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(topics).set({
|
||||
text = text.trim().replace(/^['"]|['"]$/g, "").split("\n")[0]?.trim().slice(0, 120) || "";
|
||||
if (!text || abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const updated = await db.update(topics).set({
|
||||
renaming: false,
|
||||
renameAttemptId: null,
|
||||
renameExpiresAt: null,
|
||||
titleSource: "generated",
|
||||
name: text,
|
||||
}).where(eq(topics.id, topicId));
|
||||
}).where(and(
|
||||
eq(topics.id, topicId),
|
||||
eq(topics.renameAttemptId, attemptId),
|
||||
gt(topics.renameExpiresAt, new Date()),
|
||||
)).returning({ id: topics.id });
|
||||
if (updated.length === 0) {
|
||||
return;
|
||||
}
|
||||
userEvents.emit(model.model.userId, 'topics', {
|
||||
op: 'update',
|
||||
payload: {
|
||||
topicId,
|
||||
name: text,
|
||||
titleSource: "generated",
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
@@ -248,29 +290,26 @@ const autoRename = async (
|
||||
payload: {
|
||||
topicId,
|
||||
name: text,
|
||||
titleSource: "generated",
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-rename:', error);
|
||||
await db.update(topics).set({
|
||||
renaming: false,
|
||||
}).where(eq(topics.id, topicId));
|
||||
userEvents.emit(model.model.userId, 'topics', {
|
||||
op: 'update',
|
||||
payload: {
|
||||
topicId,
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
topicEvents.emit(topicId, {
|
||||
type: 'topic_updated',
|
||||
payload: {
|
||||
topicId,
|
||||
renaming: false,
|
||||
},
|
||||
});
|
||||
if (!abortController.signal.aborted) {
|
||||
console.error("Failed to auto-rename:", error);
|
||||
}
|
||||
} finally {
|
||||
completeRename(topicId);
|
||||
clearTimeout(deadline);
|
||||
completeRename(topicId, attemptId);
|
||||
const cleared = await db.update(topics).set({
|
||||
renaming: false,
|
||||
renameAttemptId: null,
|
||||
renameExpiresAt: null,
|
||||
}).where(and(eq(topics.id, topicId), eq(topics.renameAttemptId, attemptId))).returning({ id: topics.id });
|
||||
if (cleared.length > 0) {
|
||||
const payload = { topicId, renaming: false };
|
||||
userEvents.emit(userId, "topics", { op: "update", payload });
|
||||
topicEvents.emit(topicId, { type: "topic_updated", payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,7 +433,21 @@ async function generateResponse(
|
||||
|
||||
if (search) {
|
||||
tools.search = tool({
|
||||
description: 'Searches the web. Choose how many results to return on every call using the required results argument.',
|
||||
description: `Searches the web. Choose how many results to return on every call using the required results argument.
|
||||
|
||||
CITATION FORMAT:
|
||||
Every result includes a sourceId. Cite supporting results immediately after the claim using this exact construction:
|
||||
"[[cite:" + the result's sourceId + "]]"
|
||||
The prefix "cite:" is mandatory literal text. Copy the sourceId unchanged after that prefix. Do not output the word "sourceId" as a placeholder.
|
||||
|
||||
Example: if a result has sourceId "sd625ebbd4d3544e4914f5a51840d7632":
|
||||
CORRECT: The report describes rising prices. [[cite:sd625ebbd4d3544e4914f5a51840d7632]]
|
||||
INCORRECT: The report describes rising prices. [[sd625ebbd4d3544e4914f5a51840d7632]]
|
||||
The incorrect form is missing "cite:" and will not render as a citation.
|
||||
|
||||
For multiple sources, put their exact IDs after a single "cite:" prefix, separated by commas without spaces: [[cite:ID_FROM_FIRST_RESULT,ID_FROM_SECOND_RESULT]]. Replace both placeholders with returned sourceId values. Separate complete citations are also valid: [[cite:ID_FROM_FIRST_RESULT]] [[cite:ID_FROM_SECOND_RESULT]].
|
||||
Use only IDs returned by searches during this response; never invent IDs or copy the example ID unless it actually appears in a result. Write citation markers directly in answer text, not inside backticks or code blocks. Do not repeat URLs or add footnote definitions for these citations.
|
||||
Search snippets are excerpts, not full-page reads.`,
|
||||
inputSchema: searchInputSchema,
|
||||
outputSchema: searchOutputSchema,
|
||||
execute: async ({ query, results }) => {
|
||||
@@ -652,6 +666,7 @@ async function generateResponse(
|
||||
partId: part.id,
|
||||
lastUpdatedAt: new Date(),
|
||||
content: token.text,
|
||||
offset: part.accumulatedContent.length - token.text.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1244,7 +1259,8 @@ async function generateResponse(
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
topicEvents.clearLiveText(topicId, message.id);
|
||||
completeGeneration(generationId);
|
||||
if (logFile !== undefined) logFile.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +1,60 @@
|
||||
import { db } from "~~/server/lib/db";
|
||||
import { topicEvents } from "~~/server/utils/events";
|
||||
import { db } from '~~/server/lib/db';
|
||||
import { topicEvents } from '~~/server/utils/events';
|
||||
import { createTopicSnapshotStream } from '~~/server/utils/topic-snapshot-stream';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await protectRoute(event);
|
||||
|
||||
const topicId = getRouterParam(event, 'topicId')!;
|
||||
const isSSE = getHeader(event, 'accept')?.includes('text/event-stream') ?? false;
|
||||
|
||||
const userId = event.context.user!.id as string;
|
||||
|
||||
const topic = await db.query.topics.findFirst({
|
||||
where: {
|
||||
id: topicId,
|
||||
userId,
|
||||
},
|
||||
const isSSE = getHeader(event, 'accept')?.includes('text/event-stream') ?? false;
|
||||
const readTopic = () => db.query.topics.findFirst({
|
||||
where: { id: topicId, userId },
|
||||
with: {
|
||||
messages: {
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
with: {
|
||||
parts: {
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
with: {
|
||||
toolCall: true,
|
||||
}
|
||||
},
|
||||
attachments: {
|
||||
with: {
|
||||
file: true,
|
||||
}
|
||||
orderBy: { createdAt: 'asc' },
|
||||
with: { toolCall: true },
|
||||
},
|
||||
attachments: { with: { file: true } },
|
||||
generation: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!topic) {
|
||||
console.log("topic not found");
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Not Found',
|
||||
});
|
||||
}
|
||||
|
||||
if (isSSE === false) {
|
||||
if (!isSSE) {
|
||||
const topic = await readTopic();
|
||||
if (!topic) {
|
||||
setResponseStatus(event, 404);
|
||||
return { ok: false, error: 'Topic not found' };
|
||||
}
|
||||
topicEvents.applyLiveText(topicId, topic.messages);
|
||||
return topic;
|
||||
}
|
||||
|
||||
// Authorize before subscribing; no topic data is exposed before this check.
|
||||
const ownedTopic = await db.query.topics.findFirst({
|
||||
where: { id: topicId, userId },
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!ownedTopic) {
|
||||
setResponseStatus(event, 404);
|
||||
return { ok: false, error: 'Topic not found' };
|
||||
}
|
||||
|
||||
const subscription = createTopicSnapshotStream(topicId, getQuery(event).snapshot);
|
||||
event.node.res.once('close', subscription.close);
|
||||
const stream = await subscription.open(readTopic);
|
||||
if (!stream.ok) {
|
||||
setResponseStatus(event, stream.error === 'read-failed' ? 500 : 404);
|
||||
return { ok: false, error: stream.error };
|
||||
}
|
||||
setHeader(event, 'Content-Type', 'text/event-stream');
|
||||
setHeader(event, 'Cache-Control', 'no-cache');
|
||||
setHeader(event, 'Connection', 'keep-alive');
|
||||
setHeader(event, 'X-Accel-Buffering', 'no');
|
||||
|
||||
const { lastUpdate, count } = getQuery(event);
|
||||
const serverLastUpdate = topic.messages.at(-1)?.updatedAt;
|
||||
const serverCount = topic.messages.length;
|
||||
|
||||
// TODO: there is potentially a race condition here where the client could
|
||||
// connect at milisecond 87, and the last database write was at 0 (there
|
||||
// is a write every 100ms for some operations), if there were tokens sent
|
||||
// at 20 40 and 60, those tokens are lost to the client.
|
||||
let streamController: ReadableStreamDefaultController;
|
||||
let interval: NodeJS.Timeout;
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
streamController = controller;
|
||||
controller.enqueue(`:connected\n\n`);
|
||||
|
||||
if ((lastUpdate && count) && (lastUpdate === serverLastUpdate?.toISOString() && count === serverCount)) {
|
||||
controller.enqueue(':already_synced\n\n')
|
||||
} else {
|
||||
controller.enqueue(`data: ${JSON.stringify({
|
||||
type: 'initial_state',
|
||||
payload: topic
|
||||
})}\n\n`);
|
||||
}
|
||||
|
||||
interval = setInterval(() => {
|
||||
controller.enqueue(`:heartbeat\n\n`);
|
||||
}, 15000);
|
||||
|
||||
topicEvents.subscribe(topicId, streamController);
|
||||
},
|
||||
cancel() {
|
||||
topicEvents.unsubscribe(topicId, streamController);
|
||||
clearInterval(interval);
|
||||
}
|
||||
});
|
||||
|
||||
return sendStream(event, stream);
|
||||
return sendStream(event, stream.data);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,12 @@ export default defineEventHandler(async (event) => {
|
||||
const res = await db.update(topics)
|
||||
.set({
|
||||
name,
|
||||
...(name === undefined ? {} : {
|
||||
titleSource: "manual",
|
||||
renaming: false,
|
||||
renameAttemptId: null,
|
||||
renameExpiresAt: null,
|
||||
}),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
|
||||
@@ -30,6 +30,7 @@ export default defineEventHandler(async (event) => {
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
titleSource: "provisional",
|
||||
agentId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
+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