Support durable assistant image attachments in Agent clients
This commit is contained in:
@@ -18,7 +18,7 @@ export type AgentMessagesPage = {
|
||||
};
|
||||
export type AgentDescriptor = {
|
||||
id: string; title: string; model: string; activity: AgentActivity;
|
||||
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
|
||||
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability; assistantImages?: { version: 1 } };
|
||||
};
|
||||
export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null };
|
||||
export type AgentRoomState = {
|
||||
@@ -175,6 +175,8 @@ export class AgentRoomClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetcher: typeof globalThis.fetch;
|
||||
private readonly token: () => string | null;
|
||||
private activeDownloads = 0;
|
||||
private readonly downloadWaiters = new Set<() => void>();
|
||||
constructor(options: AgentRoomClientOptions = {}) {
|
||||
this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, "");
|
||||
this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis);
|
||||
@@ -223,12 +225,46 @@ export class AgentRoomClient {
|
||||
}
|
||||
async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> {
|
||||
const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" });
|
||||
if (!response.ok) { const error = await response.json().catch(() => null); throw new Error(error?.message || "Attachment download failed."); }
|
||||
const blob = await response.blob();
|
||||
if (blob.size !== file.sizeBytes || blob.size > 8 * 1024 * 1024) throw new Error("Attachment download was incomplete.");
|
||||
return blob;
|
||||
signal?.throwIfAborted();
|
||||
while (this.activeDownloads >= 2) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => { this.downloadWaiters.delete(wake); signal?.removeEventListener("abort", abort); };
|
||||
const wake = () => { cleanup(); resolve(); };
|
||||
const abort = () => { cleanup(); reject(signal?.reason || new Error("Download canceled.")); };
|
||||
this.downloadWaiters.add(wake); signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
this.activeDownloads++;
|
||||
try {
|
||||
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" });
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error("Attachment download failed. Retry the download."); }
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Attachment download was incomplete.");
|
||||
const bytes = new Uint8Array(file.sizeBytes); let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
signal?.throwIfAborted();
|
||||
if (size + value.length > bytes.length) throw new Error("Attachment download exceeds its size limit.");
|
||||
bytes.set(value, size); size += value.length;
|
||||
}
|
||||
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
||||
if (size !== file.sizeBytes) throw new Error("Attachment download was incomplete.");
|
||||
const sha256 = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(n => n.toString(16).padStart(2, "0")).join("");
|
||||
if (sha256 !== file.sha256) throw new Error("Attachment checksum did not match.");
|
||||
if (file.kind === "image" && response.headers.get("content-type")?.split(";")[0].trim() !== file.mimeType)
|
||||
throw new Error("Attachment image type did not match.");
|
||||
signal?.throwIfAborted();
|
||||
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
|
||||
return new Blob([bytes], { type: response.headers.get("content-type") || "application/octet-stream" });
|
||||
} finally {
|
||||
this.activeDownloads--;
|
||||
this.downloadWaiters.values().next().value?.();
|
||||
}
|
||||
}
|
||||
websocketUrl(pageUrl: string): string {
|
||||
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);
|
||||
|
||||
Reference in New Issue
Block a user