UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

505 lines (504 loc) • 16 kB
//#region src/client/stream/media.ts const MEDIA_BLOCK_TYPES = new Set([ "audio", "image", "video", "file" ]); /** * Typed error thrown through `media.stream` / rejected from * `media.blob` / `media.objectURL` when a handle fails before its * message completes. Carries the bytes accumulated up to the failure * point on `partialBytes` for callers that want to salvage or diagnose. */ var MediaAssemblyError = class extends Error { kind; messageId; partialBytes; cause; constructor(kind, messageId, partialBytes, message, options) { super(message ?? `media ${kind} for message ${messageId}`); this.name = "MediaAssemblyError"; this.kind = kind; this.messageId = messageId; this.partialBytes = partialBytes; this.cause = options?.cause; } }; function base64ToBytes(b64) { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes; } function concatBytes(parts, totalLength) { const out = new Uint8Array(totalLength); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.byteLength; } return out; } /** * Concrete handle implementation shared by all four media types. * * One instance per `(messageId, blockType)` pair created by the * assembler on first matching `content-block-start`. */ var MediaHandleImpl = class { type; messageId; namespace; node; id; mimeType; url; width; height; filename; monotonic = true; error; #parts = []; #totalBytes = 0; #partialSnapshot = new Uint8Array(0); #stream; #streamController; #blobResolve; #blobReject; #blobPromise; #transcriptParts = []; #transcriptResolve; #transcriptReject; #transcriptPromise; #cachedObjectURL; #urlSourced = false; #urlFetchPromise; #lastIndex = -1; #finished = false; #settled = false; #fetchImpl; constructor(options) { this.type = options.type; this.messageId = options.messageId; this.namespace = options.namespace; this.node = options.node; this.id = options.id; this.mimeType = options.mimeType; this.url = options.url; this.#fetchImpl = options.fetch; this.#blobPromise = new Promise((resolve, reject) => { this.#blobResolve = resolve; this.#blobReject = reject; }); this.#blobPromise.catch(() => void 0); this.#transcriptPromise = new Promise((resolve, reject) => { this.#transcriptResolve = resolve; this.#transcriptReject = reject; }); this.#transcriptPromise.catch(() => void 0); } /** Track a block index for the monotonic-ordering diagnostic. */ observeIndex(index) { if (index !== this.#lastIndex + 1 && index !== this.#lastIndex) this.monotonic = false; if (index > this.#lastIndex) this.#lastIndex = index; } /** Absorb `mime_type` / per-type extras carried on an incoming block. */ absorbBlock(block) { if (this.#urlSourced) return; if (block.type === "audio") this.#absorbAudio(block); else if (block.type === "image") this.#absorbImage(block); else if (block.type === "video") this.#absorbVideo(block); else if (block.type === "file") this.#absorbFile(block); } /** Record that the originating block arrived with `url` not `data`. */ enterUrlMode(url) { this.#urlSourced = true; this.url = url; } /** Push a fresh chunk of bytes into the handle. */ pushBytes(bytes) { if (this.#finished || this.#settled) return; if (bytes.byteLength === 0) return; this.#parts.push(bytes); this.#totalBytes += bytes.byteLength; this.#partialSnapshot = concatBytes(this.#parts, this.#totalBytes); if (this.#streamController != null) try { this.#streamController.enqueue(bytes); } catch {} } /** Append a transcript fragment from an audio block. */ pushTranscript(fragment) { if (this.type !== "audio") return; if (this.#finished || this.#settled) return; if (fragment.length === 0) return; this.#transcriptParts.push(fragment); } /** Called on `message-finish`. Settles blob/transcript/stream. */ finish() { if (this.#finished || this.#settled) return; this.#finished = true; this.#settled = true; const blob = new Blob([this.#partialSnapshot], { type: this.mimeType ?? "" }); this.#blobResolve(blob); this.#transcriptResolve(this.#transcriptParts.length === 0 ? void 0 : this.#transcriptParts.join("")); try { this.#streamController?.close(); } catch {} } /** Propagate an error through blob/transcript/stream. */ fail(kind, reason, cause) { if (this.#settled) return this.error ?? new MediaAssemblyError(kind, this.messageId, this.#partialSnapshot, reason, { cause }); this.#settled = true; const err = new MediaAssemblyError(kind, this.messageId, this.#partialSnapshot, reason, { cause }); this.error = err; this.#blobReject(err); this.#transcriptReject(err); try { this.#streamController?.error(err); } catch {} return err; } get partialBytes() { return this.#partialSnapshot; } get blob() { if (this.#urlSourced) return this.#fetchUrlSourced().then((bytes) => new Blob([bytes], { type: this.mimeType ?? "" })); return this.#blobPromise; } get transcript() { return this.#transcriptPromise; } get objectURL() { if (this.#cachedObjectURL != null) { const cached = this.#cachedObjectURL; return Promise.resolve(cached); } return this.blob.then((blob) => { if (this.#cachedObjectURL != null) return this.#cachedObjectURL; const url = URL.createObjectURL(blob); this.#cachedObjectURL = url; return url; }); } revoke() { const url = this.#cachedObjectURL; if (url == null) return; this.#cachedObjectURL = void 0; try { URL.revokeObjectURL(url); } catch {} } get stream() { if (this.#stream != null) return this.#stream; if (this.#urlSourced) return this.#buildUrlStream(); return this.#buildInlineStream(); } #absorbAudio(block) { const mimeType = block.mime_type ?? block.mimeType; if (this.mimeType == null && mimeType != null) this.mimeType = mimeType; if (block.transcript != null && block.transcript.length > 0) this.pushTranscript(block.transcript); } #absorbImage(block) { const mimeType = block.mime_type ?? block.mimeType; if (this.mimeType == null && mimeType != null) this.mimeType = mimeType; if (this.width == null && block.width != null) this.width = block.width; if (this.height == null && block.height != null) this.height = block.height; } #absorbVideo(block) { const mimeType = block.mime_type ?? block.mimeType; if (this.mimeType == null && mimeType != null) this.mimeType = mimeType; } #absorbFile(block) { const mimeType = block.mime_type ?? block.mimeType; if (this.mimeType == null && mimeType != null) this.mimeType = mimeType; if (this.filename == null && block.filename != null) this.filename = block.filename; } #buildInlineStream() { const seed = this.#partialSnapshot; const alreadyFinished = this.#finished; const alreadyErrored = this.error; this.#stream = new ReadableStream({ start: (controller) => { this.#streamController = controller; if (seed.byteLength > 0) controller.enqueue(seed); if (alreadyErrored != null) { controller.error(alreadyErrored); return; } if (alreadyFinished) controller.close(); }, cancel: () => { this.#streamController = void 0; } }); return this.#stream; } #buildUrlStream() { const urlSourceFetch = this.#startUrlFetch(); this.#stream = new ReadableStream({ start: async (controller) => { try { const response = await urlSourceFetch; if (response.body == null) { const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.byteLength > 0) controller.enqueue(bytes); controller.close(); return; } const reader = response.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; if (value != null) controller.enqueue(value); } controller.close(); } catch (err) { controller.error(this.fail("fetch-failed", err?.message, err)); } }, cancel: () => { this.#streamController = void 0; } }); return this.#stream; } /** Memoised fetch for URL-sourced blocks — returns one `Response`. */ #startUrlFetch() { const url = this.url; return this.#fetchImpl(url).then((response) => { if (!response.ok) throw new Error(`fetch(${url}) failed: ${response.status} ${response.statusText}`); return response; }); } /** Fetch + buffer for URL-sourced `blob` access. Memoised. */ #fetchUrlSourced() { if (this.#urlFetchPromise != null) return this.#urlFetchPromise; this.#urlFetchPromise = (async () => { try { const response = await this.#startUrlFetch(); const bytes = new Uint8Array(await response.arrayBuffer()); this.#parts.length = 0; this.#parts.push(bytes); this.#totalBytes = bytes.byteLength; this.#partialSnapshot = bytes; this.#finished = true; this.#settled = true; return bytes; } catch (err) { throw this.fail("fetch-failed", err?.message, err); } })(); return this.#urlFetchPromise; } }; var MediaAssembler = class { #callbacks; #fetch; #active = /* @__PURE__ */ new Map(); #activeByNamespaceNode = /* @__PURE__ */ new Map(); #syntheticCounter = 0; constructor(options = {}) { this.#callbacks = options; if (options.fetch != null) this.#fetch = options.fetch; else if (typeof fetch === "function") this.#fetch = fetch; else this.#fetch = () => { throw new Error("MediaAssembler: no fetch implementation available. Pass `fetch` in options."); }; } /** * Fold a single `messages` event. Non-media blocks and * informational events (e.g. `content-block-finish`) are no-ops. */ consume(event) { const data = event.params.data; const namespace = event.params.namespace; const node = event.params.node; const nsNodeKey = `${namespace.join("/")}::${node ?? ""}`; if (data.event === "message-start") { this.#flushSlot(nsNodeKey, "finish"); this.#activeByNamespaceNode.set(nsNodeKey, { messageId: data.id ?? "", keys: /* @__PURE__ */ new Set(), indexKeys: /* @__PURE__ */ new Map() }); return; } if (data.event === "message-finish") { this.#flushSlot(nsNodeKey, "finish"); this.#activeByNamespaceNode.delete(nsNodeKey); return; } if (data.event === "error") { this.#flushSlot(nsNodeKey, "error", data.message); this.#activeByNamespaceNode.delete(nsNodeKey); return; } if (data.event !== "content-block-start" && data.event !== "content-block-delta" && data.event !== "content-block-finish") return; const block = data.content; const blockIndex = data.index ?? 0; let active = this.#activeByNamespaceNode.get(nsNodeKey); if (active == null) { active = { messageId: `__synthetic_${++this.#syntheticCounter}`, keys: /* @__PURE__ */ new Set(), indexKeys: /* @__PURE__ */ new Map() }; this.#activeByNamespaceNode.set(nsNodeKey, active); } if (block == null && data.event === "content-block-delta") { const delta = data.delta; const deltaKey = active.indexKeys.get(blockIndex); const deltaHandle = deltaKey != null ? this.#active.get(deltaKey) : void 0; if (delta == null || typeof delta !== "object") return; const record = delta; if (deltaHandle == null) { if (record.type !== "block-delta" || record.fields == null || typeof record.fields !== "object") return; const fields = record.fields; if (!MEDIA_BLOCK_TYPES.has(fields.type)) return; this.#consumeMediaBlock({ active, block: fields, blockIndex, dataEvent: data.event, namespace, node, terminal: false, createIfMissing: true }); return; } deltaHandle.observeIndex(blockIndex); if (record.type === "data-delta" && typeof record.data === "string") { try { deltaHandle.pushBytes(base64ToBytes(record.data)); } catch (err) { deltaHandle.fail("message-error", "invalid base64 on delta", err); } return; } if (record.type === "block-delta" && record.fields != null && typeof record.fields === "object") { const fields = record.fields; deltaHandle.absorbBlock(fields); if (!deltaHandle.error && fields.data != null) try { deltaHandle.pushBytes(base64ToBytes(fields.data)); } catch (err) { deltaHandle.fail("message-error", "invalid base64 on delta", err); } } return; } if (block == null) return; const blockType = block.type; if (!MEDIA_BLOCK_TYPES.has(blockType)) return; this.#consumeMediaBlock({ active, block, blockIndex, dataEvent: data.event, namespace, node, terminal: data.event === "content-block-finish", createIfMissing: data.event === "content-block-start" || data.event === "content-block-finish" }); } #consumeMediaBlock({ active, block, blockIndex, dataEvent, namespace, node, terminal, createIfMissing }) { const blockType = block.type; if (!MEDIA_BLOCK_TYPES.has(blockType)) return; const mediaType = blockType; const key = `${active.messageId}::${mediaType}::${blockIndex}`; let handle = this.#active.get(key); const isStart = dataEvent === "content-block-start"; if (handle == null) { const isTerminalBlock = terminal; if (!isStart && !isTerminalBlock && !createIfMissing) return; const mediaBlock = block; handle = new MediaHandleImpl({ type: mediaType, messageId: active.messageId, namespace: [...namespace], node, id: mediaBlock.id, mimeType: mediaBlock.mime_type ?? mediaBlock.mimeType, url: mediaBlock.url != null && mediaBlock.data == null ? mediaBlock.url : void 0, fetch: this.#fetch }); if (mediaBlock.url != null && mediaBlock.data == null) handle.enterUrlMode(mediaBlock.url); handle.observeIndex(blockIndex); handle.absorbBlock(block); if (mediaBlock.data != null) try { handle.pushBytes(base64ToBytes(mediaBlock.data)); } catch (err) { handle.fail("message-error", "invalid base64 on initial block", err); } this.#active.set(key, handle); active.keys.add(key); active.indexKeys.set(blockIndex, key); this.#emit(handle); if (isTerminalBlock) { handle.finish(); this.#active.delete(key); active.keys.delete(key); active.indexKeys.delete(blockIndex); } return; } if (terminal) return; const mediaBlock = block; handle.observeIndex(blockIndex); handle.absorbBlock(block); if (!handle.error && mediaBlock.data != null) try { handle.pushBytes(base64ToBytes(mediaBlock.data)); } catch (err) { handle.fail("message-error", "invalid base64 on delta", err); } } /** * Finish or fail every media handle currently active under the * given `(namespace, node)` slot and clear its bookkeeping. Called * on `message-finish`, `error`, and when a new `message-start` * rebinds a still-open slot. */ #flushSlot(nsNodeKey, mode, errorMessage) { const active = this.#activeByNamespaceNode.get(nsNodeKey); if (active == null) return; for (const key of active.keys) { const handle = this.#active.get(key); if (handle != null) if (mode === "finish") handle.finish(); else handle.fail("message-error", errorMessage); this.#active.delete(key); } active.keys.clear(); active.indexKeys.clear(); } /** * Abort all outstanding handles with a `stream-closed` error. * Called when the upstream event source terminates before the * messages it was assembling had a chance to finish. */ close() { for (const handle of this.#active.values()) handle.fail("stream-closed", "upstream event stream closed"); this.#active.clear(); this.#activeByNamespaceNode.clear(); } #emit(handle) { switch (handle.type) { case "audio": this.#callbacks.onAudio?.(handle); break; case "image": this.#callbacks.onImage?.(handle); break; case "video": this.#callbacks.onVideo?.(handle); break; case "file": this.#callbacks.onFile?.(handle); break; } this.#callbacks.onMedia?.(handle); } }; //#endregion export { MediaAssembler, MediaAssemblyError }; //# sourceMappingURL=media.js.map