adds attachment support
This commit is contained in:
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user