Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
250 lines
8.0 KiB
TypeScript
250 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 === "image") return `[image: ${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 === "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;
|
|
}
|