Files
zoeissleeping 8ccaa824dd feat: file upload retry, secure file tokens, UI polish
- Add HMAC-based file token auth for secure AI model file access
- Add file upload retry with exponential backoff (max 3 retries)
- File endpoint now requires session auth or signed token
- Support assistant role messages in chat input
- Optimistic UI for attachments on message send
- Verify topic ownership before allowing messages
- Switch web scraping to Firecrawl API
- Agent profile page layout fixes (proper flex overflow)
- Add quick switcher (Ctrl+K) to sidenav
- Clean up longcat.ts and stale comments
2026-06-06 00:16:39 -05:00

277 lines
7.4 KiB
TypeScript

import * as z from 'zod';
import { renamePrompt } from '~~/prompts';
import { generateText } from 'ai';
import { GatewayFetchError, getProviderDetails, type ModelGateway } from '~~/server/utils/ai-provider';
import { addPendingRename } 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';
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) {
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,
}
});
}
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 abortController = addPendingRename(topicId);
event.waitUntil(autoRename(topicId, abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, prompt, userId));
return {
ok: true,
};
});
const autoRename = async (
topicId: string,
abortController: AbortController,
model: {
gateway: ModelGateway,
model: Model,
},
textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined,
prompt: string,
userId: string,
) => {
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: {
openrouter: {
user: userId,
}
},
system: renamePrompt,
prompt,
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);
}
}
await db.update(topics).set({
renaming: false,
name: text,
}).where(eq(topics.id, topicId));
userEvents.emit(model.model.userId, 'topics', {
op: 'update',
payload: {
topicId,
name: text,
renaming: false,
},
});
topicEvents.emit(topicId, {
type: 'topic_updated',
payload: {
topicId,
name: text,
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,
},
});
} finally {
completeRename(topicId);
}
}