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
+95 -54
View File
@@ -1,4 +1,4 @@
import type { FilePart, ImagePart, ModelMessage } from "ai";
import type { AssistantContent, FilePart, ImagePart, JSONValue, ModelMessage, ToolContent } from "ai";
import { ToolCallType } from "~~/drizzle/schema";
import { Err, Ok, type Result } from "~~/types/result";
import type { Message, MessageEntity } from "~/composables/useChat";
@@ -37,7 +37,11 @@ export const buildFocusedMessageTree = (messages: Readonly<Message[]>): MessageE
return focusedMessageTree;
}
export const marshallMessages = (agent: Agent, messages: Readonly<MessageEntity[]>): Result<ModelMessage[], string> => {
export interface MarshallOptions {
signFileUrl?: (fileKey: string) => string;
}
export const marshallMessages = (agent: Agent, messages: Readonly<MessageEntity[]>, opts?: MarshallOptions): Result<ModelMessage[], string> => {
const marshalledMessages: ModelMessage[] = [];
if (agent && agent.systemPrompt) {
@@ -51,47 +55,72 @@ export const marshallMessages = (agent: Agent, messages: Readonly<MessageEntity[
switch (message.role) {
case 'user': {
const attachments = message.attachments.map(attachment => {
const url = resolveFileUrl(attachment.file.url);
let url = resolveFileUrl(attachment.file.url);
if (opts?.signFileUrl && attachment.file.url.startsWith('/api/files/')) {
const fileKey = attachment.file.url.slice('/api/files/'.length);
const token = opts.signFileUrl(fileKey);
url = `${url}?${token}`;
}
if (attachment.file.mimeType.startsWith('image/')) {
return {
type: 'image',
image: url,
image: new URL(url),
};
}
return {
type: 'file',
data: url,
data: new URL(url),
filename: attachment.file.name,
mediaType: attachment.file.mimeType,
};
}) as (FilePart | ImagePart)[];
let messageDate = new Date(message.createdAt);
const prompt = `[${messageDate.toDateString()} ${messageDate.toLocaleTimeString()}]: ${message.content!}`;
marshalledMessages.push({
role: 'user',
content: [
content: attachments ? [
{
type: 'text',
text: `[${messageDate.toDateString()} ${messageDate.toLocaleTimeString()}]: ${message.content!}`
},
...attachments,
],
] : prompt,
});
break;
}
case 'assistant':
let assistantPart: AssistantContent = [];
let toolParts: ToolContent = [];
for (const part of (message.parts || [])) {
if (!part) return Err('Part is undefined');
switch (part.type) {
case 'text':
case 'reasoning': {
marshalledMessages.push({
role: 'assistant',
content: part.content!,
});
if (toolParts.length > 0) {
marshalledMessages.push({
role: 'assistant',
content: assistantPart,
});
marshalledMessages.push({
role: 'tool',
content: toolParts,
});
toolParts = [];
assistantPart = [];
}
if (part.providerOptions || part.content) assistantPart.push({
type: part.type,
text: part.content || '',
providerOptions: part.providerOptions ? part.providerOptions as Record<string, any> : undefined,
})
break;
}
case 'tool-call': {
@@ -101,33 +130,32 @@ export const marshallMessages = (agent: Agent, messages: Readonly<MessageEntity[
return Err('Marshalling tool call that is still pending. This is likely a UI bug if this happens.');
}
let inputValue: string = '';
let inputValue: string | object = '';
switch (part.toolCall.input!.type) {
case ToolCallType.Text:
inputValue = part.toolCall.input!.value;
break;
case ToolCallType.Json:
inputValue = JSON.stringify(part.toolCall.input!.value);
if (typeof part.toolCall.input!.value === 'string') {
inputValue = JSON.parse(part.toolCall.input!.value);
} else {
inputValue = part.toolCall.input!.value;
}
break;
}
marshalledMessages.push({
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
input: inputValue,
},
],
assistantPart.push({
type: 'tool-call',
toolCallId: part.toolCall.id!,
toolName: part.toolCall.toolName!,
input: inputValue,
providerOptions: part.providerOptions ? part.providerOptions as Record<string, any> : undefined,
});
})
if (part.toolCall.status === 'failed') {
let failureType: 'error-text' | 'error-json';
let failureValue: string;
let failureValue: string | JSONValue;
if (part.toolCall.error === null || part.toolCall.error === undefined) {
failureType = 'error-text';
@@ -140,27 +168,32 @@ export const marshallMessages = (agent: Agent, messages: Readonly<MessageEntity[
break;
case ToolCallType.Json:
failureType = 'error-json';
failureValue = JSON.stringify(part.toolCall.error!.value);
if (typeof part.toolCall.error!.value === 'string') {
failureValue = JSON.parse(part.toolCall.error!.value);
} else {
failureValue = part.toolCall.error!.value;
}
break;
}
failureType = 'error-json';
failureValue = JSON.stringify(part.toolCall.error!.value);
// failureValue = JSON.stringify(part.toolCall.error!.value);
if (typeof part.toolCall.error!.value === 'string') {
failureValue = JSON.parse(part.toolCall.error!.value);
} else {
failureValue = part.toolCall.error!.value;
}
}
marshalledMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
output: {
type: failureType,
value: failureValue,
},
},
],
toolParts.push({
type: 'tool-result',
toolCallId: part.toolCall.id!,
toolName: part.toolCall.toolName!,
// @ts-expect-error - This is a type error, because typescript cant provie that the value must be a string when the type is error-text
output: {
type: failureType,
value: failureValue,
},
providerOptions: part.providerOptions ? part.providerOptions as Record<string, any> : undefined,
});
break;
@@ -177,23 +210,22 @@ export const marshallMessages = (agent: Agent, messages: Readonly<MessageEntity[
break;
case ToolCallType.Json:
outputType = 'json';
outputValue = JSON.stringify(part.toolCall.output!.value);
if (typeof part.toolCall.output!.value === 'string') {
outputValue = JSON.parse(part.toolCall.output!.value);
} else {
outputValue = part.toolCall.output!.value;
}
break;
}
marshalledMessages.push({
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: part.toolCall.id,
toolName: part.toolCall.toolName,
output: {
type: outputType,
value: outputValue,
},
},
],
toolParts.push({
type: 'tool-result',
toolCallId: part.toolCall.id!,
toolName: part.toolCall.toolName!,
output: {
type: outputType,
value: outputValue,
},
providerOptions: part.providerOptions ? part.providerOptions as Record<string, any> : undefined,
});
break;
@@ -203,6 +235,15 @@ export const marshallMessages = (agent: Agent, messages: Readonly<MessageEntity[
return Err(`Unknown part type: ${part.type}`);
}
}
if (assistantPart.length > 0) marshalledMessages.push({
role: 'assistant',
content: assistantPart,
});
if (toolParts.length > 0) marshalledMessages.push({
role: 'tool',
content: toolParts,
});
break;
default:
return Err(`Unknown message role: ${message.role}`);