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
This commit is contained in:
Zoe
2026-06-06 00:16:39 -05:00
parent 47009b1f0a
commit 8ccaa824dd
20 changed files with 827 additions and 406 deletions
@@ -44,6 +44,11 @@ export default defineEventHandler(async (event) => {
},
with: {
parts: true,
attachments: {
with: {
file: true,
}
},
},
});
@@ -160,8 +165,18 @@ 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 abortController = addPendingRename(topicId);
event.waitUntil(autoRename(topicId, abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, firstMessage.content!, userId));
event.waitUntil(autoRename(topicId, abortController, { gateway: gateway.gateway, model }, gateway.textTransformer, prompt, userId));
return {
ok: true,
+53 -17
View File
@@ -3,7 +3,7 @@ import * as z from 'zod';
import { type MessageEntity } from '~/composables/useChat';
import { promises as fs } from 'fs';
import { glob } from 'glob';
import { isLoopFinished, type ModelMessage, rerank, type RerankingModel, streamText, type StreamTextTransform, Tool, tool } from "ai";
import { isLoopFinished, type ModelMessage, rerank, type RerankingModel, streamText, type StreamTextTransform, type Tool, tool } from "ai";
import { generations, messageParts, messages, toolCalls, ToolCallType } from "~~/drizzle/schema";
import { topicEvents } from "~~/server/utils/events";
import { nanoid } from "nanoid";
@@ -13,6 +13,7 @@ import { eq } from "drizzle-orm";
import { buildFocusedMessageTree, buildMessageTree, marshallMessages } from "~~/utils/message";
import path from "path";
import { isRerankingProvider } from "~~/server/utils/ai-provider";
import { generateFileToken } from "~~/server/utils/file-token";
export default defineEventHandler(async (event) => {
await protectRoute(event);
@@ -237,7 +238,7 @@ export default defineEventHandler(async (event) => {
}
agentmessage.generationId = generation.id;
// @ts-ignore
// @ts-ignore - doesnt exist on the type but yeah it does now
agentmessage.generation = generation;
return agentmessage;
@@ -311,7 +312,13 @@ export default defineEventHandler(async (event) => {
}
}
const topicMessages = marshallMessages(topic.agent, buildFocusedMessageTree(topicMessageTree));
const fileTokenSecret = process.env.BETTER_AUTH_SECRET!;
const topicMessages = marshallMessages(topic.agent, buildFocusedMessageTree(topicMessageTree), {
signFileUrl: (fileKey) => {
const { exp, sig } = generateFileToken(fileKey, fileTokenSecret);
return `exp=${exp}&sig=${sig}`;
},
});
if (topicMessages.ok === false) {
throw createError({
statusCode: 500,
@@ -353,7 +360,7 @@ const formatPartId = (partType: string, existingId: string) => {
};
const formatToolCallId = (nativeId: string) => {
return `veridian__tool-${nativeId}-${nanoid()}`;
return `veridian__tool-${nativeId.slice(0, 16)}-${nanoid()}`;
};
const evalPython = async (code: string) => {
@@ -528,10 +535,20 @@ const { listDirectoryTool, globTool, readFileTool, readFilesTool, fetchUrlTool,
content: z.string(),
}),
execute: async ({ url }) => {
const response = await fetch(url);
const content = await response.text();
const response = await fetch('https://api.firecrawl.dev/v2/scrape', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + process.env.FIRECRAWL_API_KEY,
},
body: JSON.stringify({
url,
}),
});
const content = await response.json();
console.log(content);
return {
content,
content: content.data.markdown,
};
},
}),
@@ -629,7 +646,7 @@ async function generateResponse(
glob: globTool,
readFile: readFileTool,
readFiles: readFilesTool,
// fetchUrl: fetchUrlTool,
fetchUrl: fetchUrlTool,
python: pythonTool,
bash: bashTool,
};
@@ -657,13 +674,14 @@ async function generateResponse(
const response = streamText({
model: model.gateway(model.model.externalId),
messages,
allowSystemInMessages: true,
providerOptions: {
openrouter: {
debug: {
echo_upstream_body: true,
},
user: userId,
}
},
},
experimental_transform: streamTransoforms,
stopWhen: isLoopFinished(),
@@ -959,7 +977,7 @@ async function generateResponse(
throw new Error('Failed to insert message part');
}
// @ts-ignore
// @ts-ignore - doesnt exist on the type but yeah it does now
part.toolCall = toolCall;
await topicEvents.emit(topicId, {
@@ -1005,6 +1023,11 @@ async function generateResponse(
},
})
.where(eq(toolCalls.id, dbToolCallId));
if (token.providerMetadata) await db.update(messageParts)
.set({
providerOptions: token.providerMetadata,
})
.where(eq(messageParts.toolCallId, dbToolCallId));
await topicEvents.emit(topicId, {
type: 'tool-call-delta',
@@ -1049,6 +1072,7 @@ async function generateResponse(
topicId,
messageId: message.id,
toolCallId: dbToolCallId,
providerOptions: token.providerMetadata,
type: 'tool-call',
content: null,
finished: false,
@@ -1060,7 +1084,7 @@ async function generateResponse(
throw new Error('Failed to insert message part');
}
// @ts-ignore
// @ts-ignore - doesnt exist on the type but yeah it does now
part.toolCall = toolCall;
await topicEvents.emit(topicId, {
@@ -1097,6 +1121,11 @@ async function generateResponse(
status: 'failed',
error: { type: ToolCallType.Text, value: 'Tool returned invalid output' }
}).where(eq(toolCalls.id, dbToolCallId));
if (token.providerMetadata) await db.update(messageParts)
.set({
providerOptions: token.providerMetadata,
})
.where(eq(messageParts.toolCallId, dbToolCallId));
await topicEvents.emit(topicId, {
type: 'tool-call-delta',
@@ -1125,6 +1154,11 @@ async function generateResponse(
value: outputValue,
},
}).where(eq(toolCalls.id, dbToolCallId));
if (token.providerMetadata) await db.update(messageParts)
.set({
providerOptions: token.providerMetadata,
})
.where(eq(messageParts.toolCallId, dbToolCallId));
await topicEvents.emit(topicId, {
type: 'tool-call-delta',
@@ -1176,6 +1210,11 @@ async function generateResponse(
value: outputValue as string,
}
}).where(eq(toolCalls.id, existingDbToolCallId));
if (token.providerMetadata) await db.update(messageParts)
.set({
providerOptions: token.providerMetadata,
})
.where(eq(messageParts.toolCallId, existingDbToolCallId));
await topicEvents.emit(topicId, {
type: 'tool-call-delta',
@@ -1220,6 +1259,7 @@ async function generateResponse(
topicId,
messageId: message.id,
toolCallId: dbToolCallId,
providerOptions: token.providerMetadata,
type: 'tool-call',
content: null,
finished: false,
@@ -1250,12 +1290,8 @@ async function generateResponse(
case 'finish': {
let tps;
if (ttft !== undefined && token.totalUsage.outputTokens !== undefined) {
const tokenStreamStart = requestStart! + ttft;
// this is the *real* request duration, excluding the
// TTFT
const requestDuration = performance.now() - tokenStreamStart;
if (requestStart !== undefined && token.totalUsage.outputTokens !== undefined) {
const requestDuration = performance.now() - requestStart;
tps = token.totalUsage.outputTokens / (requestDuration / 1000);
}
+119 -42
View File
@@ -1,6 +1,6 @@
import { modelMessageSchema } from 'ai';
import * as z from 'zod';
import { attachments, messages } from '~~/drizzle/schema';
import { attachments, messageParts, messages } from '~~/drizzle/schema';
import { db } from '~~/server/lib/db';
export default defineEventHandler(async (event) => {
@@ -9,6 +9,21 @@ export default defineEventHandler(async (event) => {
const topicId = getRouterParam(event, 'topicId')!;
const userId = event.context.user!.id;
const topic = await db.query.topics.findFirst({
where: {
id: topicId,
userId,
},
});
if (!topic) {
throw createError({
statusCode: 404,
statusMessage: 'Not Found',
message: 'Topic not found',
});
}
const result = await readValidatedBody(event, z.object({
message: z.intersection(
z.object({
@@ -19,6 +34,7 @@ export default defineEventHandler(async (event) => {
),
}).safeParse);
if (!result.success) {
console.log(result.error);
throw createError({
statusCode: 400,
statusMessage: 'Bad Request',
@@ -28,49 +44,110 @@ export default defineEventHandler(async (event) => {
const { message } = result.data;
await db.insert(messages).values({
// @ts-ignore - drizzle bug
id: message.id,
userId,
topicId,
parentMessageId: null,
generationId: null,
role: message.role,
content: message.content,
});
for (const fileId of message.fileIds || []) {
await db.insert(attachments).values({
userId,
topicId,
messageId: message.id,
fileId,
});
}
const usermessage = await db.query.messages.findFirst({
where: {
id: message.id,
},
with: {
attachments: {
with: {
file: true,
}
}
},
});
if (!usermessage) {
if (['user', 'assistant'].includes(message.role) === false) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to insert message',
message: 'Failed to insert message',
statusCode: 400,
statusMessage: 'Bad Request',
message: 'Invalid role',
});
}
topicEvents.emit(topicId, { type: 'MESSAGE_CREATED', payload: usermessage });
return {
ok: true
};
switch (message.role) {
case 'user': {
await db.insert(messages).values({
// @ts-ignore - drizzle bug
id: message.id,
userId,
topicId,
parentMessageId: null,
generationId: null,
role: message.role,
content: message.content,
});
for (const fileId of message.fileIds || []) {
await db.insert(attachments).values({
userId,
topicId,
messageId: message.id,
fileId,
});
}
const usermessage = await db.query.messages.findFirst({
where: {
id: message.id,
},
with: {
attachments: {
with: {
file: true,
}
}
},
});
if (!usermessage) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to insert message',
message: 'Failed to insert message',
});
}
topicEvents.emit(topicId, { type: 'MESSAGE_CREATED', payload: usermessage });
return {
ok: true
};
}
case 'assistant': {
const [dbmessage] = await db.insert(messages).values({
// @ts-ignore - drizzle bug
id: message.id,
userId,
topicId,
parentMessageId: null,
generationId: null,
role: message.role,
content: message.content,
}).returning();
if (!dbmessage) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to insert message',
message: 'Failed to insert message',
});
}
const [part] = await db.insert(messageParts).values({
userId,
topicId,
messageId: dbmessage.id,
type: 'text',
content: message.content,
providerOptions: null,
finished: true,
createdAt: new Date(),
lastUpdatedAt: new Date(),
}).returning();
// TODO: transaction
if (!part) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to insert message part',
message: 'Failed to insert message part',
});
}
topicEvents.emit(topicId, { type: 'MESSAGE_CREATED', payload: dbmessage });
topicEvents.emit(topicId, { type: 'text-start', payload: { messageId: dbmessage.id, part } });
topicEvents.emit(topicId, { type: 'text-end', payload: { messageId: dbmessage.id, partId: part.id, lastUpdatedAt: new Date(), content: message.content } });
return {
ok: true
};
}
}
})