evOo-local/packages/core/src/attachments.ts
2026-10-03 14:27:55 +00:00

249 lines
8.0 KiB
TypeScript

import type { AttachmentKind, AttachmentMimeType, MessageBlock } from "@rakazo/contracts";
import {
ATTACHMENT_MAX_BASE64_LENGTH,
ATTACHMENT_MAX_BYTES,
attachmentKindOf,
isAllowedAttachmentMimeType,
isAttachmentImageMimeType,
} from "@rakazo/contracts";
export class AttachmentValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "AttachmentValidationError";
}
}
export function decodeAttachmentBase64(contentBase64: string): Uint8Array {
const normalized = contentBase64.trim();
if (!normalized) throw new AttachmentValidationError("Attachment content is empty");
if (normalized.length > ATTACHMENT_MAX_BASE64_LENGTH) {
throw new AttachmentValidationError("Attachment exceeds the 10 MiB limit");
}
if (
normalized.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(normalized)
) {
throw new AttachmentValidationError("Attachment content is not valid base64");
}
let bytes: Buffer;
try {
bytes = Buffer.from(normalized, "base64");
} catch {
throw new AttachmentValidationError("Attachment content is not valid base64");
}
if (bytes.byteLength === 0) throw new AttachmentValidationError("Attachment content is empty");
if (bytes.byteLength > ATTACHMENT_MAX_BYTES) {
throw new AttachmentValidationError("Attachment exceeds the 10 MiB limit");
}
return new Uint8Array(bytes);
}
export function validateAttachmentMimeType(mimeType: string): void {
if (!isAllowedAttachmentMimeType(mimeType)) {
throw new AttachmentValidationError(`Unsupported attachment type: ${mimeType}`);
}
}
export function attachmentKindForMimeType(mimeType: string): AttachmentKind {
return attachmentKindOf(mimeType);
}
export function messageBlockForArtifact(artifact: {
id: string;
name: string;
mimeType: string;
size: number;
width?: number | null;
height?: number | null;
durationMs?: number | null;
}): Extract<MessageBlock, { kind: "image" | "video" | "audio" | "file" }> {
const kind = attachmentKindForMimeType(artifact.mimeType);
if (kind === "image") {
return {
kind: "image",
artifactId: artifact.id,
mimeType: artifact.mimeType,
name: artifact.name,
};
}
if (kind === "video") {
return {
kind: "video",
artifactId: artifact.id,
mimeType: artifact.mimeType,
name: artifact.name,
size: artifact.size,
...(artifact.durationMs != null ? { durationMs: artifact.durationMs } : {}),
...(artifact.width != null ? { width: artifact.width } : {}),
...(artifact.height != null ? { height: artifact.height } : {}),
};
}
if (kind === "audio") {
return {
kind: "audio",
artifactId: artifact.id,
mimeType: artifact.mimeType,
name: artifact.name,
size: artifact.size,
...(artifact.durationMs != null ? { durationMs: artifact.durationMs } : {}),
};
}
return {
kind: "file",
artifactId: artifact.id,
mimeType: artifact.mimeType,
name: artifact.name,
size: artifact.size,
};
}
export function promptTextForAttachments(
text: string | undefined,
artifacts: Array<{ name: string; mimeType: string; size: number }>,
): string {
const caption = text?.trim() ?? "";
const notes = artifacts
.filter((artifact) => !isAttachmentImageMimeType(artifact.mimeType))
.map(
(artifact) =>
`User attached file ${JSON.stringify(artifact.name)} (${artifact.mimeType}, ${artifact.size} bytes).`,
);
return [caption, ...notes].filter(Boolean).join("\n\n") || "See attached files.";
}
export function blocksToAgentHistoryText(blocks: MessageBlock[]): string {
return blocks
.map((block) => {
if (block.kind === "text") return block.text;
if (block.kind === "chart") return `[chart: ${block.name}]`;
if (block.kind === "file") {
return `[file: ${block.name} (${block.mimeType}, ${block.size} bytes)]`;
}
// Media keeps the artifact id so a later turn can edit or export what it refers to.
if (block.kind === "image" || block.kind === "video" || block.kind === "audio") {
return `[${block.kind}: ${block.name} (artifact ${block.artifactId})]`;
}
if (block.kind === "media") {
const outputs = block.outputs
.map((output) => `${output.kind} ${output.name} (artifact ${output.artifactId})`)
.join(", ");
return `[media ${block.operation} ${block.status}: ${block.title}${outputs ? ` -> ${outputs}` : ""}]`;
}
// Keep attribution on peer messages: without it a later turn cannot tell
// which lines came from another bot rather than the user.
if (block.kind === "bot_message_received") return `[from ${block.fromBotName}] ${block.text}`;
if (block.kind === "bot_message_sent") return `[to ${block.toBotName}] ${block.text}`;
if (block.kind === "task") {
return `[task for ${block.assigneeBotName}: ${block.title} (${block.status})] ${block.text}`;
}
if (block.kind === "handoff") {
return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`;
}
if ("text" in block && typeof block.text === "string") return block.text;
return "";
})
.filter(Boolean)
.join("\n");
}
const EXTENSION_MIME_TYPES: Record<string, AttachmentMimeType> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".pdf": "application/pdf",
".txt": "text/plain",
".md": "text/markdown",
".markdown": "text/markdown",
".csv": "text/csv",
".json": "application/json",
".mp4": "video/mp4",
".m4v": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".wav": "audio/wav",
".ogg": "audio/ogg",
".oga": "audio/ogg",
".vtt": "text/vtt",
".srt": "application/x-subrip",
};
const MIME_TYPE_EXTENSIONS: Record<AttachmentMimeType, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"application/pdf": ".pdf",
"text/plain": ".txt",
"text/markdown": ".md",
"text/csv": ".csv",
"application/json": ".json",
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/quicktime": ".mov",
"audio/mpeg": ".mp3",
"audio/mp4": ".m4a",
"audio/wav": ".wav",
"audio/webm": ".weba",
"audio/ogg": ".ogg",
"text/vtt": ".vtt",
"application/x-subrip": ".srt",
};
export function inferAttachmentMimeType(
name: string,
reportedType?: string,
): AttachmentMimeType | null {
const dot = name.lastIndexOf(".");
const extensionType = dot < 0 ? undefined : EXTENSION_MIME_TYPES[name.slice(dot).toLowerCase()];
// Some browsers and native document pickers report Markdown as text/plain.
if (extensionType === "text/markdown" && (!reportedType || reportedType === "text/plain")) {
return extensionType;
}
if (reportedType && isAllowedAttachmentMimeType(reportedType)) return reportedType;
return extensionType ?? null;
}
export function attachmentExtensionForMimeType(mimeType: string): string {
return isAllowedAttachmentMimeType(mimeType) ? MIME_TYPE_EXTENSIONS[mimeType] : "";
}
export function attachmentsForThread<T extends { threadKey: string }>(
attachments: readonly T[],
threadKey: string | undefined,
): T[] {
if (!threadKey) return [];
return attachments.filter((attachment) => attachment.threadKey === threadKey);
}
export function attachmentsForBot<T extends { botId: string }>(
attachments: readonly T[],
botId: string | undefined,
): T[] {
if (!botId) return [];
return attachments.filter((attachment) => attachment.botId === botId);
}
export function userTurnBlocksForRun(
trigger: string,
runId: string,
messages: Array<{
id?: string;
role: string;
runId?: string | null;
blocks: MessageBlock[];
}>,
sourceMessageId?: string | null,
): MessageBlock[] | undefined {
if (trigger !== "user") return undefined;
return messages.find(
(message) =>
message.role === "user" &&
(sourceMessageId ? message.id === sourceMessageId : message.runId === runId),
)?.blocks;
}