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
@@ -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![${attachment.file.name}](${attachment.file.url})`;
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 });
}
}
}
+18 -2
View File
@@ -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();
}
}
}
+37 -72
View File
@@ -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(