import * as z from 'zod'; import { renamePrompt } from '~~/prompts'; 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, 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 { and, eq, gt, isNull, lte, or } from "drizzle-orm"; export default defineEventHandler(async (event) => { await protectRoute(event); const userId = event.context.user!.id; const topicId = getRouterParam(event, 'topicId')!; const result = await readValidatedBody(event, (body) => z .object({ modelId: z.string(), providerApiKey: z.string().optional(), }) .safeParse(body), ); if (!result.success) { throw createError({ statusCode: 400, message: result.error.issues[0]!.message, data: { code: 'INVALID_BODY', ok: false, } }); } const firstMessage = await db.query.messages.findFirst({ where: { topicId, userId, }, orderBy: { createdAt: 'asc', }, with: { parts: true, attachments: { with: { file: true, } }, }, }); if (firstMessage === undefined) { throw createError({ statusCode: 400, statusMessage: 'Invalid topic', data: { code: 'INVALID_TOPIC', ok: false, } }); } if (firstMessage.role !== 'user') { throw createError({ statusCode: 400, statusMessage: 'Unimplemented', data: { code: 'INVALID_TOPIC', ok: false, } }); } const { modelId, providerApiKey } = result.data; const topic = await db.query.topics.findFirst({ where: { id: topicId, userId, }, }); if (topic === undefined || topic.userId !== userId) { throw createError({ statusCode: 400, statusMessage: 'Invalid topic', data: { code: 'INVALID_TOPIC', ok: false, } }); } if (topic.renaming && topic.renameExpiresAt && topic.renameExpiresAt > new Date()) { throw createError({ statusCode: 400, statusMessage: 'Already renaming', data: { code: 'ALREADY_RENAMING', ok: false, } }); } const model = await db.query.models.findFirst({ where: { id: modelId, userId, }, with: { provider: true, }, }); if (model === undefined || model.userId !== userId) { throw createError({ statusCode: 400, statusMessage: 'Invalid model', data: { code: 'INVALID_MODEL', ok: false, } }); } const providerDetails = await getProviderDetails(model.provider, providerApiKey, model); if (!providerDetails.ok) { switch (providerDetails.error) { case GatewayFetchError.NoProviderApiKey: { setResponseStatus(event, 400, "No provider API key"); throw createError({ statusCode: 400, statusMessage: `${model.provider.type} provider requires an API key`, data: { code: 'NO_PROVIDER_API_KEY', ok: false, } }); } case GatewayFetchError.NoProviderBaseUrl: { throw createError({ statusCode: 400, statusMessage: 'Invalid provider URL', data: { code: 'BAD_PROVIDER_URL', ok: false, } }); } } } const { gateway } = providerDetails.data; if (gateway === null) { throw createError({ statusCode: 500, statusMessage: 'Invalid gateway', data: { code: 'INVALID_GATEWAY', ok: false, } }); } 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 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 as const, }; }); const autoRename = async ( topicId: string, attemptId: string, abortController: AbortController, model: { gateway: ModelGateway, model: Model, }, textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined, content: UserContent, userId: string, ) => { const deadline = setTimeout(() => abortController.abort(), 90000); try { const response = await generateText({ model: model.gateway(model.model.externalId), providerOptions: { openrouter: { user: userId, } }, system: renamePrompt, messages: [{ role: "user", content }], maxRetries: 0, timeout: 90 * 1000, abortSignal: abortController.signal, }); let text = response.text; if (textTransformer !== undefined) { if (Array.isArray(textTransformer)) { for (const transformer of textTransformer) { text = transformer(text); } } else { text = textTransformer(text); } } 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(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, }, }); topicEvents.emit(topicId, { type: 'topic_updated', payload: { topicId, name: text, titleSource: "generated", renaming: false, }, }); } catch (error) { if (!abortController.signal.aborted) { console.error("Failed to auto-rename:", error); } } finally { 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 }); } } }