assistant-stream
Version:
Streaming utilities for AI assistants
265 lines (264 loc) • 9.31 kB
JavaScript
import { generateId } from "../../utils/generateId.js";
import { PipeableTransformStream } from "../../utils/stream/PipeableTransformStream.js";
import { createSSEJsonDecoder } from "../../utils/stream/SSEJson.js";
import { AssistantTransformStream } from "../../utils/stream/AssistantTransformStream.js";
import { createToolCallPartRegistry } from "../tool-call-part-registry.js";
import { createChunkNormalizer } from "./chunk-normalizer.js";
import sjson from "secure-json-parse";
// src/core/serialization/ui-message-stream/UIMessageStream.ts
const isDataChunk = (chunk) => chunk.type.startsWith("data-");
/**
* Decodes AI SDK v6 UI Message Stream format into AssistantStreamChunks.
*/
var UIMessageStreamDecoder = class extends PipeableTransformStream {
constructor(options = {}) {
super((readable) => {
const toolCallPartRegistry = createToolCallPartRegistry();
const normalizer = createChunkNormalizer();
const textParts = {
text: /* @__PURE__ */ new Map(),
reasoning: /* @__PURE__ */ new Map()
};
const activeTextIds = {};
const closeTextParts = () => {
for (const kind of ["text", "reasoning"]) {
for (const part of textParts[kind].values()) part.close();
textParts[kind].clear();
delete activeTextIds[kind];
}
};
let activeToolCallId;
let currentMessageId;
let stepOpenedByStart = false;
const approvalsByToolCallId = /* @__PURE__ */ new Map();
const transformer = {
transform(chunk, controller) {
const type = chunk.type;
if (isDataChunk(chunk)) {
const name = chunk.type.slice(5);
if (options.onData) options.onData({
type: chunk.type,
name,
data: chunk.data,
...chunk.transient !== void 0 && { transient: chunk.transient }
});
if (!chunk.transient) controller.enqueue({
type: "data",
path: [],
data: [{
name,
data: chunk.data
}]
});
return;
}
switch (type) {
case "start":
currentMessageId = chunk.messageId;
stepOpenedByStart = true;
controller.enqueue({
type: "step-start",
path: [],
messageId: chunk.messageId
});
break;
case "text-start":
case "reasoning-start": {
const kind = type === "text-start" ? "text" : "reasoning";
textParts[kind].get(chunk.id)?.close();
textParts[kind].set(chunk.id, kind === "text" ? controller.addTextPart(chunk.providerMetadata != null ? { providerMetadata: chunk.providerMetadata } : void 0) : controller.addReasoningPart(chunk.providerMetadata != null ? { providerMetadata: chunk.providerMetadata } : void 0));
activeTextIds[kind] = chunk.id;
break;
}
case "text-delta":
case "reasoning-delta": {
const kind = type === "text-delta" ? "text" : "reasoning";
const id = chunk.id ?? activeTextIds[kind];
const part = id === void 0 ? void 0 : textParts[kind].get(id);
const delta = type === "text-delta" ? chunk.textDelta : chunk.delta;
if (part) part.append(delta, chunk.providerMetadata);
else if (kind === "text") controller.appendText(delta);
else controller.appendReasoning(delta);
break;
}
case "text-end":
case "reasoning-end": {
const kind = type === "text-end" ? "text" : "reasoning";
const id = chunk.id ?? activeTextIds[kind];
if (id === void 0) break;
textParts[kind].get(id)?.close(chunk.providerMetadata);
textParts[kind].delete(id);
if (activeTextIds[kind] === id) delete activeTextIds[kind];
break;
}
case "source":
controller.appendSource({
type: "source",
sourceType: chunk.source.sourceType,
id: chunk.source.id,
url: chunk.source.url,
...chunk.source.title && { title: chunk.source.title }
});
break;
case "source-document":
controller.appendSource({
type: "source",
sourceType: "document",
id: chunk.sourceId,
title: chunk.title,
mediaType: chunk.mediaType,
...chunk.filename && { filename: chunk.filename }
});
break;
case "file":
controller.appendFile({
type: "file",
mimeType: chunk.file.mimeType,
data: chunk.file.data
});
break;
case "tool-call-start":
if (activeToolCallId !== void 0) {
toolCallPartRegistry.closeArgsText(toolCallPartRegistry.get(activeToolCallId));
activeToolCallId = void 0;
}
toolCallPartRegistry.start(chunk.toolCallId, () => controller.addToolCallPart({
toolCallId: chunk.toolCallId,
toolName: chunk.toolName
}));
activeToolCallId = chunk.toolCallId;
break;
case "tool-call-delta":
if (activeToolCallId !== void 0) toolCallPartRegistry.appendArgsText(toolCallPartRegistry.get(activeToolCallId), chunk.argsText);
break;
case "tool-call-end":
if (activeToolCallId !== void 0) {
toolCallPartRegistry.closeArgsText(toolCallPartRegistry.get(activeToolCallId));
activeToolCallId = void 0;
}
break;
case "tool-result": {
if (chunk.toolCallId === activeToolCallId) activeToolCallId = void 0;
const toolCallController = toolCallPartRegistry.tryGet(chunk.toolCallId);
if (!toolCallController) throw new Error(`Encountered tool result with unknown id: ${chunk.toolCallId}`);
toolCallPartRegistry.setResponse(toolCallController, {
result: chunk.result,
isError: chunk.isError ?? false,
...chunk.isPreliminary ? { isPreliminary: true } : {},
...chunk.messages !== void 0 ? { messages: chunk.messages } : {}
});
break;
}
case "tool-approval-request": {
const toolCallController = toolCallPartRegistry.tryGet(chunk.toolCallId);
if (!toolCallController) break;
approvalsByToolCallId.set(chunk.toolCallId, chunk.approval);
toolCallController.setApproval(chunk.approval);
break;
}
case "tool-approval-response": {
let entry;
for (const candidate of approvalsByToolCallId) {
if (candidate[1].id !== chunk.approvalId) continue;
entry = candidate;
break;
}
if (!entry) break;
const [toolCallId, approval] = entry;
const decided = {
...approval,
approved: chunk.approved,
...chunk.reason !== void 0 && { reason: chunk.reason }
};
approvalsByToolCallId.set(toolCallId, decided);
toolCallPartRegistry.tryGet(toolCallId)?.setApproval(decided);
break;
}
case "tool-output-denied": {
const toolCallController = toolCallPartRegistry.tryGet(chunk.toolCallId);
if (!toolCallController) break;
const reason = approvalsByToolCallId.get(chunk.toolCallId)?.reason;
toolCallPartRegistry.setResponse(toolCallController, {
result: { error: reason || "Tool approval denied" },
isError: true
});
break;
}
case "start-step": {
const messageId = chunk.messageId ?? currentMessageId ?? generateId();
const isOpenStep = stepOpenedByStart && messageId === currentMessageId;
stepOpenedByStart = false;
if (isOpenStep) break;
controller.enqueue({
type: "step-start",
path: [],
messageId
});
break;
}
case "finish-step":
stepOpenedByStart = false;
closeTextParts();
controller.enqueue({
type: "step-finish",
path: [],
finishReason: chunk.finishReason,
usage: chunk.usage,
isContinued: chunk.isContinued
});
break;
case "finish":
closeTextParts();
controller.enqueue({
type: "message-finish",
path: [],
finishReason: chunk.finishReason,
usage: chunk.usage
});
break;
case "error": controller.enqueue({
type: "error",
path: [],
error: chunk.errorText
});
}
},
flush(controller) {
normalizer.flush({ enqueue(chunk) {
transformer.transform(chunk, controller);
} });
closeTextParts();
if (activeToolCallId !== void 0) toolCallPartRegistry.closeArgsText(toolCallPartRegistry.get(activeToolCallId));
toolCallPartRegistry.closeAll();
}
};
const transform = new AssistantTransformStream(transformer);
return createSSEJsonDecoder({
strict: true,
maxLineLength: options.maxLineLength,
maxEventLength: options.maxEventLength,
parse(data, controller) {
let chunk;
try {
chunk = sjson.parse(data);
} catch {
chunk = void 0;
}
if (typeof chunk !== "object" || chunk === null || Array.isArray(chunk) || typeof chunk.type !== "string") {
console.warn(`Dropped invalid UIMessageStream chunk: ${data.slice(0, 200)}`);
return;
}
normalizer.normalize(chunk, controller);
},
done: {
marker: "[DONE]",
onMissing() {
throw new Error("Stream ended abruptly without receiving [DONE] marker");
}
}
})(readable).pipeThrough(transform);
});
}
};
export { UIMessageStreamDecoder };
//# sourceMappingURL=UIMessageStream.js.map