8ccaa824dd
- 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
277 lines
7.4 KiB
TypeScript
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`;
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|