@convex-dev/agent
Version:
A agent component for Convex.
250 lines (237 loc) • 7.33 kB
text/typescript
import type {
AssistantContent,
FilePart,
ImagePart,
ModelMessage,
UserContent,
} from "ai";
import type { Id } from "../component/_generated/dataModel.js";
import type {
ActionCtx,
AgentComponent,
MutationCtx,
QueryCtx,
} from "./types.js";
import type { Message } from "../validators.js";
import { assert } from "convex-helpers";
import type { StorageReader } from "convex/server";
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 args.filename The filename to store.
* @param args.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 | MutationCtx,
component: AgentComponent,
blob: Blob,
{ filename, sha256 }: { filename?: string; sha256?: string } = {},
): Promise<{
file: File;
filePart: FilePart;
imagePart: ImagePart | undefined;
}> {
if (!("runAction" in ctx) || !("storage" in ctx)) {
throw new Error(
"You're trying to save a file that's too large in a mutation / workflow. " +
"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.slice().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 ModelMessage 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 & { storage: StorageReader }),
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,
mediaType: string,
filename: string | undefined,
): { filePart: FilePart; imagePart: ImagePart | undefined } {
const filePart: FilePart = {
type: "file",
data: new URL(url),
mediaType,
filename,
};
const imagePart: ImagePart | undefined = mediaType.startsWith("image/")
? { type: "image", image: new URL(url), mediaType }
: undefined;
return { filePart, imagePart };
}
/**
* Check if a URL points to localhost
*/
function isLocalhostUrl(url: URL): boolean {
return (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "::1" ||
url.hostname === "0.0.0.0"
);
}
/**
* Download a file from a URL
*/
async function downloadFile(url: URL): Promise<ArrayBuffer> {
// Fetch the file
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
}
return await response.arrayBuffer();
}
/**
* Process messages to inline file and image URLs that point to localhost
* by converting them to base64. This solves the problem of LLMs not being
* able to access localhost URLs.
*/
export async function inlineMessagesFiles<T extends ModelMessage | Message>(
messages: T[],
): Promise<T[]> {
// Process each message to convert localhost URLs to base64
return Promise.all(
messages.map(async (message): Promise<T> => {
if (
(message.role !== "user" && message.role !== "assistant") ||
typeof message.content === "string" ||
!Array.isArray(message.content)
) {
return message;
}
const processedContent = await Promise.all(
message.content.map(async (part) => {
if (part.type === "image" && part.image instanceof URL) {
assert(
message.role === "user",
"Images can only be in user messages",
);
if (isLocalhostUrl(part.image)) {
const imageData = await downloadFile(part.image);
return { ...part, image: imageData } as ImagePart;
}
}
// Handle file parts
if (part.type === "file" && part.data instanceof URL) {
if (isLocalhostUrl(part.data)) {
const fileData = await downloadFile(part.data);
return { ...part, data: fileData } as FilePart;
}
}
return part;
}),
);
if (message.role === "user") {
return { ...message, content: processedContent as UserContent };
} else {
return { ...message, content: processedContent as AssistantContent };
}
}),
);
}