evOo-local/packages/adapters/src/thread-artifacts.ts

210 lines
7.2 KiB
TypeScript

import path from "node:path";
import type {
AdapterContext,
ArtifactStore,
ComputerRef,
SandboxProvider,
} from "@rakazo/adapter-kit";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import type { ComputerMode, MessageBlock } from "@rakazo/contracts";
import { ATTACHMENT_MAX_BYTES, artifactMaxBytes } from "@rakazo/contracts";
import {
attachmentExtensionForMimeType,
inferAttachmentMimeType,
messageBlockForArtifact,
} from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { storeArtifactStream } from "./artifact-files.js";
import { collectBytes, discardByteStream } from "./byte-streams.js";
import {
assertTeamWorkspacePathAllowed,
computerFileWriteMaxBytes,
resolveBotWorkspacePath,
writeComputerFileStream,
} from "./computer-support.js";
export type MaterializedThreadFile = {
artifactId: string;
name: string;
mimeType: string;
size: number;
/** Workspace path, or null when the file is too large to copy to this computer. */
path: string | null;
};
type AttachmentBlock = Extract<MessageBlock, { kind: "image" | "video" | "audio" | "file" }>;
const ATTACHMENT_BLOCK_KINDS: ReadonlySet<string> = new Set(["image", "video", "audio", "file"]);
/** Media up to its kind cap when the store streams; otherwise the inline attachment cap. */
export function attachableFileMaxBytes(artifacts: ArtifactStore, mimeType: string): number {
return artifacts.describe().capabilities.stream
? artifactMaxBytes(mimeType)
: ATTACHMENT_MAX_BYTES;
}
export function formatMiB(bytes: number): string {
return `${Math.floor(bytes / (1024 * 1024))} MiB`;
}
export async function attachWorkspaceFileToThread(
deps: {
prisma: PrismaClient;
artifacts: ArtifactStore;
},
input: {
spaceId: string;
userId: string;
botId: string;
groupId?: string;
runId: string;
filePath: string;
/** A stream (e.g. from readComputerFileStream) is stored without buffering the file. */
content: Uint8Array | ReadableStream<Uint8Array>;
operationId: string;
signal?: AbortSignal;
present?: boolean;
},
): Promise<{ artifactId: string; block: AttachmentBlock }> {
const content = input.content;
const rawName = path.basename(input.filePath) || input.filePath;
const isSvg = /\.svg$/i.test(rawName);
let stream =
content instanceof Uint8Array
? (async function* () {
yield content;
})()
: content;
let name = rawName;
let row: Awaited<ReturnType<typeof storeArtifactStream>>;
try {
let mimeType = inferAttachmentMimeType(name);
if (isSvg) {
const svgBytes = content instanceof Uint8Array ? content : await collectBytes(stream);
const { default: sharp } = await import("sharp");
const pngBuffer = await sharp(Buffer.from(svgBytes), { density: 144 }).png().toBuffer();
const pngBytes = new Uint8Array(pngBuffer.buffer, pngBuffer.byteOffset, pngBuffer.byteLength);
name = rawName.replace(/\.svg$/i, ".png");
mimeType = "image/png";
stream = (async function* () {
yield pngBytes;
})();
}
if (!mimeType) {
throw new Error(`Unsupported attachment type for ${name}`);
}
row = await storeArtifactStream(deps, {
spaceId: input.spaceId,
userId: input.userId,
botId: input.botId,
groupId: input.groupId ?? null,
runId: input.runId,
name,
mimeType,
stream,
maxBytes: attachableFileMaxBytes(deps.artifacts, mimeType),
operationId: input.operationId,
signal: input.signal,
});
} catch (error) {
discardByteStream(stream);
if (error instanceof ArtifactTooLargeError) {
throw new Error(`file exceeds the ${formatMiB(error.maxBytes)} attachment limit`);
}
throw error;
}
const block = messageBlockForArtifact(row);
const presentable = block.kind === "image" || block.kind === "file";
return {
artifactId: row.id,
block: input.present && presentable ? { ...block, present: true } : block,
};
}
/**
* Copies this turn's attachments (files, images, video and audio) into the bot's workspace so its
* tools can open them. Files above the computer's transfer cap stay in storage and are listed
* with their artifact id instead.
*/
export async function materializeCurrentTurnFiles(
deps: {
prisma: PrismaClient;
artifacts: ArtifactStore;
sandbox: SandboxProvider;
},
blocks: MessageBlock[] | undefined,
input: {
context: AdapterContext & { botId: string };
computer: ComputerRef;
computerMode: ComputerMode;
teamBotIds?: ReadonlySet<string>;
markWorkspaceDirty?: () => void;
},
): Promise<MaterializedThreadFile[]> {
const attachmentBlocks = blocks?.filter((block): block is AttachmentBlock =>
ATTACHMENT_BLOCK_KINDS.has(block.kind),
);
if (!attachmentBlocks?.length) return [];
const rows = await deps.prisma.artifact.findMany({
where: {
id: { in: attachmentBlocks.map((block) => block.artifactId) },
spaceId: input.context.spaceId,
userId: input.context.userId,
},
});
const byId = new Map(rows.map((row) => [row.id, row]));
const transferMaxBytes = computerFileWriteMaxBytes(input.computer);
const materialized: MaterializedThreadFile[] = [];
for (const block of attachmentBlocks) {
const row = byId.get(block.artifactId);
if (!row) throw new Error(`Attached file is unavailable: ${block.name}`);
const entry = { artifactId: row.id, name: row.name, mimeType: row.mimeType, size: row.size };
if (row.size > Math.min(transferMaxBytes, artifactMaxBytes(row.mimeType))) {
materialized.push({ ...entry, path: null });
continue;
}
const relativePath = `attachments/${row.id}${attachmentExtensionForMimeType(row.mimeType)}`;
const storedPath = resolveBotWorkspacePath(
input.computerMode,
input.context.botId,
relativePath,
);
if (input.teamBotIds) assertTeamWorkspacePathAllowed(storedPath, input.teamBotIds);
// Streamed, so a large video copied to a local computer never sits in memory whole.
const stream = await deps.artifacts.createReadStream(row.storageKey, input.context);
input.markWorkspaceDirty?.();
await writeComputerFileStream(
deps.sandbox,
input.computer,
{ path: storedPath, stream },
input.context,
);
materialized.push({ ...entry, path: relativePath });
}
return materialized;
}
export function currentTurnFilesInstruction(files: readonly MaterializedThreadFile[]): string {
const copied = files.filter((file) => file.path !== null);
const kept = files.filter((file) => file.path === null);
const describe = (file: MaterializedThreadFile) =>
`- ${JSON.stringify(file.name)} (${file.mimeType}, ${file.size} bytes)`;
return [
...(copied.length
? [
"Current-turn attached files are available on your computer. Inspect them before answering:",
...copied.map((file) => `${describe(file)}: ${JSON.stringify(file.path)}`),
]
: []),
...(kept.length
? [
"These current-turn attachments are too large to copy to your computer. Refer to them by artifact id; do not guess their contents:",
...kept.map((file) => `${describe(file)}: artifact ${file.artifactId}`),
]
: []),
].join("\n");
}