@convex-dev/agent
Version:
A agent component for Convex.
172 lines (166 loc) • 4.81 kB
text/typescript
import type { FilePart, ImagePart } from "ai";
import type { Id } from "../component/_generated/dataModel.js";
import type {
ActionCtx,
AgentComponent,
QueryCtx,
RunMutationCtx,
} from "./types.js";
export const MAX_FILE_SIZE = 1024 * 64;
type File = {
url: string;
fileId: string;
storageId: Id<"_storage">;
hash: string;
filename: string | undefined;
};
/**
* Store a file in the file storage and return the URL and fileId.
* @param ctx A ctx object from an action.
* @param component The agent component.
* @param blob The blob to store.
* @param filename The filename to store.
* @param sha256 The sha256 hash of the file. If not provided, it will be
* computed. However, to ensure no corruption during transfer, you can
* calculate this on the client to enforce integrity.
* @returns The URL, fileId, and storageId of the stored file.
*/
export async function storeFile(
ctx: ActionCtx | RunMutationCtx,
component: AgentComponent,
blob: Blob,
filename?: string,
sha256?: string
): Promise<{
file: File;
filePart: FilePart;
imagePart: ImagePart | undefined;
}> {
if (!("runAction" in ctx)) {
throw new Error(
"You're trying to save a file that's too large in a mutation. " +
"You can store the file in file storage from an action first, then pass a URL instead. " +
"To have the agent component track the file, you can use `saveFile` from an action then use the fileId with getFile in the mutation. " +
"Read more in the docs."
);
}
const hash =
sha256 ||
Array.from(
new Uint8Array(
await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())
)
)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const reused = await ctx.runMutation(component.files.useExistingFile, {
hash,
filename,
});
if (reused) {
const url = (await ctx.storage.getUrl(reused.storageId))!;
return {
...getParts(url, blob.type, filename),
file: {
url,
fileId: reused.fileId,
storageId: reused.storageId as Id<"_storage">,
hash,
filename,
},
};
}
const newStorageId = await ctx.storage.store(blob);
if (sha256) {
const metadata = await ctx.storage.getMetadata(newStorageId);
if (metadata?.sha256 !== sha256) {
throw new Error("Hash mismatch: " + metadata?.sha256 + " != " + sha256);
}
}
const { fileId, storageId } = await ctx.runMutation(component.files.addFile, {
storageId: newStorageId,
hash,
filename,
mimeType: blob.type,
});
const url = (await ctx.storage.getUrl(storageId as Id<"_storage">))!;
if (storageId !== newStorageId) {
// We're re-using another file's storageId
// Because we try to reuse the file above, this should be very very rare
// and only in the case of racing to check then store the file.
await ctx.storage.delete(newStorageId);
}
return {
...getParts(url, blob.type, filename),
file: {
url,
fileId,
storageId: storageId as Id<"_storage">,
hash,
filename,
},
};
}
/**
* Get file metadata from the component.
* This also returns filePart (and imagePart if the file is an image),
* which are useful to construct a CoreMessage like
* ```ts
* const { filePart, imagePart } = await getFile(ctx, components.agent, fileId);
* const message: UserMessage = {
* role: "user",
* content: [imagePart ?? filePart],
* };
* ```
* @param ctx A ctx object from an action or query.
* @param component The agent component, usually `components.agent`.
* @param fileId The fileId of the file to get.
* @returns The file metadata and content parts.
*/
export async function getFile(
ctx: ActionCtx | QueryCtx,
component: AgentComponent,
fileId: string
) {
const file = await ctx.runQuery(component.files.get, { fileId });
if (!file) {
throw new Error(`File not found in component: ${fileId}`);
}
const url = await ctx.storage.getUrl(file.storageId as Id<"_storage">);
if (!url) {
throw new Error(`File not found in storage: ${file.storageId}`);
}
return {
...getParts(url, file.mimeType, file.filename),
file: {
fileId,
url,
storageId: file.storageId as Id<"_storage">,
hash: file.hash,
filename: file.filename,
},
};
}
function getParts(
url: string,
mimeType: string,
filename: string | undefined
): {
filePart: FilePart;
imagePart: ImagePart | undefined;
} {
const filePart: FilePart = {
type: "file",
data: new URL(url),
mimeType,
filename,
};
const imagePart: ImagePart | undefined = mimeType.startsWith("image/")
? {
type: "image",
image: new URL(url),
mimeType,
}
: undefined;
return { filePart, imagePart };
}