adds attachment support

This commit is contained in:
2026-05-02 19:21:06 -07:00
parent 11e6875de9
commit 38da3cea72
15 changed files with 949 additions and 67 deletions

View File

@@ -4,23 +4,33 @@ import type { FastifyInstance } from "fastify";
import { prisma } from "./db.js";
import { requireAdmin } from "./auth.js";
import { env } from "./env.js";
import { buildComparableAttachments } from "./llm/message-content.js";
import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream } from "./llm/streaming.js";
import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
import { openaiClient } from "./llm/providers.js";
import { exaClient } from "./search/exa.js";
import type { ChatAttachment } from "./llm/types.js";
type IncomingChatMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
attachments?: ChatAttachment[];
};
function sameMessage(
a: { role: string; content: string; name?: string | null },
b: { role: string; content: string; name?: string | null }
a: { role: string; content: string; name?: string | null; metadata?: unknown },
b: { role: string; content: string; name?: string | null; attachments?: ChatAttachment[] }
) {
return a.role === b.role && a.content === b.content && (a.name ?? null) === (b.name ?? null);
const existingAttachments = JSON.stringify(buildComparableAttachments((a.metadata as Record<string, unknown> | null)?.attachments ?? null));
const incomingAttachments = JSON.stringify(b.attachments ?? []);
return (
a.role === b.role &&
a.content === b.content &&
(a.name ?? null) === (b.name ?? null) &&
existingAttachments === incomingAttachments
);
}
function isToolCallLogMetadata(value: unknown) {
@@ -60,10 +70,67 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
role: m.role as any,
content: m.content,
name: m.name,
metadata: m.attachments?.length ? ({ attachments: m.attachments } as any) : undefined,
})),
});
}
const MAX_CHAT_ATTACHMENTS = 8;
const MAX_IMAGE_ATTACHMENT_BYTES = 6 * 1024 * 1024;
const MAX_TEXT_ATTACHMENT_CHARS = 200_000;
const MAX_IMAGE_DATA_URL_CHARS = 8_500_000;
const ChatAttachmentSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("image"),
id: z.string().trim().min(1).max(128),
filename: z.string().trim().min(1).max(255),
mimeType: z.enum(["image/png", "image/jpeg"]),
sizeBytes: z.number().int().positive().max(MAX_IMAGE_ATTACHMENT_BYTES),
dataUrl: z
.string()
.max(MAX_IMAGE_DATA_URL_CHARS)
.regex(/^data:image\/(?:png|jpeg);base64,[a-z0-9+/=\s]+$/i, "Invalid image data URL"),
}),
z.object({
kind: z.literal("text"),
id: z.string().trim().min(1).max(128),
filename: z.string().trim().min(1).max(255),
mimeType: z.string().trim().min(1).max(127),
sizeBytes: z.number().int().positive().max(8 * 1024 * 1024),
text: z.string().max(MAX_TEXT_ATTACHMENT_CHARS),
truncated: z.boolean().optional(),
}),
]);
const CompletionMessageSchema = z
.object({
role: z.enum(["system", "user", "assistant", "tool"]),
content: z.string(),
name: z.string().optional(),
attachments: z.array(ChatAttachmentSchema).max(MAX_CHAT_ATTACHMENTS).optional(),
})
.superRefine((value, ctx) => {
if (value.attachments?.length && value.role === "tool") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Tool messages cannot include attachments.",
path: ["attachments"],
});
}
});
function mergeAttachmentsIntoMetadata(metadata: unknown, attachments?: ChatAttachment[]) {
if (!attachments?.length) return metadata as any;
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return { attachments };
}
return {
...(metadata as Record<string, unknown>),
attachments,
};
}
const SearchRunBody = z.object({
query: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).optional(),
@@ -768,6 +835,7 @@ export async function registerRoutes(app: FastifyInstance) {
content: z.string(),
name: z.string().optional(),
metadata: z.unknown().optional(),
attachments: z.array(ChatAttachmentSchema).max(MAX_CHAT_ATTACHMENTS).optional(),
});
const { chatId } = Params.parse(req.params);
@@ -779,7 +847,7 @@ export async function registerRoutes(app: FastifyInstance) {
role: body.role as any,
content: body.content,
name: body.name,
metadata: body.metadata as any,
metadata: mergeAttachmentsIntoMetadata(body.metadata, body.attachments) as any,
},
});
@@ -794,13 +862,7 @@ export async function registerRoutes(app: FastifyInstance) {
chatId: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]),
model: z.string().min(1),
messages: z.array(
z.object({
role: z.enum(["system", "user", "assistant", "tool"]),
content: z.string(),
name: z.string().optional(),
})
),
messages: z.array(CompletionMessageSchema),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
});
@@ -834,13 +896,7 @@ export async function registerRoutes(app: FastifyInstance) {
chatId: z.string().optional(),
provider: z.enum(["openai", "anthropic", "xai"]),
model: z.string().min(1),
messages: z.array(
z.object({
role: z.enum(["system", "user", "assistant", "tool"]),
content: z.string(),
name: z.string().optional(),
})
),
messages: z.array(CompletionMessageSchema),
temperature: z.number().min(0).max(2).optional(),
maxTokens: z.number().int().positive().optional(),
});