255 lines
8.0 KiB
TypeScript
255 lines
8.0 KiB
TypeScript
type StreamHooks = {
|
|
readonly onFirstOutput?: () => Promise<void>;
|
|
readonly onComplete?: (output: string) => Promise<void>;
|
|
readonly onError?: (error: unknown, emitted: boolean, output: string) => Promise<void>;
|
|
readonly onCancel?: (emitted: boolean) => Promise<void>;
|
|
};
|
|
|
|
type StreamTextResponseOptions = StreamHooks & {
|
|
readonly mode: "engine" | "mastra";
|
|
readonly requestId: string;
|
|
readonly headers?: Record<string, string>;
|
|
readonly transformText?: (text: string) => string;
|
|
readonly continueAfterDisconnect?: boolean;
|
|
};
|
|
|
|
const hiddenBlockOpeners = [
|
|
"<!--AYANAM_SUGGESTIONS:",
|
|
"<!--AYANAM_TITLE:",
|
|
] as const;
|
|
|
|
function longestOpenerPrefixSuffix(value: string) {
|
|
const maximum = Math.min(
|
|
value.length,
|
|
Math.max(...hiddenBlockOpeners.map((opener) => opener.length - 1)),
|
|
);
|
|
for (let length = maximum; length > 0; length -= 1) {
|
|
const suffix = value.slice(-length);
|
|
if (hiddenBlockOpeners.some((opener) => opener.startsWith(suffix))) return length;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/** Sends only visible prose through the output guard and preserves metadata bytes. */
|
|
export function createVisibleTextTransformer(transform: (text: string) => string) {
|
|
let rawBuffer = "";
|
|
let visibleBuffer = "";
|
|
let hiddenBuffer = "";
|
|
let hiddenBlocks: Array<{ readonly offset: number; readonly text: string }> = [];
|
|
let hidden = false;
|
|
|
|
function parse(value: string, final: boolean) {
|
|
rawBuffer += value;
|
|
while (rawBuffer) {
|
|
if (hidden) {
|
|
const closeIndex = rawBuffer.indexOf("-->");
|
|
if (closeIndex < 0) {
|
|
if (final) {
|
|
hiddenBlocks.push({
|
|
offset: visibleBuffer.length,
|
|
text: hiddenBuffer + rawBuffer,
|
|
});
|
|
hiddenBuffer = "";
|
|
rawBuffer = "";
|
|
hidden = false;
|
|
} else {
|
|
const retainedLength = Math.min(2, rawBuffer.length);
|
|
hiddenBuffer += rawBuffer.slice(0, rawBuffer.length - retainedLength);
|
|
rawBuffer = rawBuffer.slice(rawBuffer.length - retainedLength);
|
|
}
|
|
break;
|
|
}
|
|
hiddenBuffer += rawBuffer.slice(0, closeIndex + 3);
|
|
rawBuffer = rawBuffer.slice(closeIndex + 3);
|
|
hiddenBlocks.push({ offset: visibleBuffer.length, text: hiddenBuffer });
|
|
hiddenBuffer = "";
|
|
hidden = false;
|
|
continue;
|
|
}
|
|
|
|
const openerIndex = hiddenBlockOpeners.reduce<number>((earliest, opener) => {
|
|
const index = rawBuffer.indexOf(opener);
|
|
return index >= 0 && (earliest < 0 || index < earliest) ? index : earliest;
|
|
}, -1);
|
|
if (openerIndex >= 0) {
|
|
visibleBuffer += rawBuffer.slice(0, openerIndex);
|
|
rawBuffer = rawBuffer.slice(openerIndex);
|
|
hiddenBuffer = "";
|
|
hidden = true;
|
|
continue;
|
|
}
|
|
|
|
if (final) {
|
|
visibleBuffer += rawBuffer;
|
|
rawBuffer = "";
|
|
break;
|
|
}
|
|
const retainedLength = longestOpenerPrefixSuffix(rawBuffer);
|
|
const visibleLength = rawBuffer.length - retainedLength;
|
|
if (visibleLength > 0) visibleBuffer += rawBuffer.slice(0, visibleLength);
|
|
rawBuffer = rawBuffer.slice(visibleLength);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function renderVisiblePrefix(length: number) {
|
|
if (length === 0) return "";
|
|
const visible = visibleBuffer.slice(0, length);
|
|
const included = hiddenBlocks.filter((block) => block.offset <= length);
|
|
const remaining = hiddenBlocks
|
|
.filter((block) => block.offset > length)
|
|
.map((block) => ({ ...block, offset: block.offset - length }));
|
|
const transformed = transform(visible);
|
|
let output = "";
|
|
if (transformed === visible) {
|
|
let start = 0;
|
|
for (const block of included) {
|
|
output += visible.slice(start, block.offset) + block.text;
|
|
start = block.offset;
|
|
}
|
|
output += visible.slice(start);
|
|
} else {
|
|
// A refusal may replace the whole sentence, so an in-sentence byte offset
|
|
// no longer has meaning. Keep metadata exact and in order after the safe
|
|
// visible replacement; the frontend parser accepts metadata at any point.
|
|
output = transformed + included.map((block) => block.text).join("");
|
|
}
|
|
visibleBuffer = visibleBuffer.slice(length);
|
|
hiddenBlocks = remaining;
|
|
return output;
|
|
}
|
|
|
|
function lastCompleteClauseBoundary() {
|
|
let boundary = 0;
|
|
for (const match of visibleBuffer.matchAll(/[。!?.!?\n]+/gu)) {
|
|
boundary = (match.index ?? 0) + match[0].length;
|
|
}
|
|
return boundary;
|
|
}
|
|
|
|
function consume(value: string, final: boolean) {
|
|
parse(value, final);
|
|
if (final) {
|
|
const output = renderVisiblePrefix(visibleBuffer.length);
|
|
if (hiddenBlocks.length === 0) return output;
|
|
const metadata = hiddenBlocks.map((block) => block.text).join("");
|
|
hiddenBlocks = [];
|
|
return output + metadata;
|
|
}
|
|
return renderVisiblePrefix(lastCompleteClauseBoundary());
|
|
}
|
|
|
|
return Object.freeze({
|
|
push: (value: string) => consume(value, false),
|
|
finish: (value: string) => consume(value, true),
|
|
});
|
|
}
|
|
|
|
export function streamTextResponse(
|
|
stream: AsyncIterable<string>,
|
|
options: StreamTextResponseOptions,
|
|
) {
|
|
const iterator = stream[Symbol.asyncIterator]();
|
|
const encoder = new TextEncoder();
|
|
const visibleTransformer = options.transformText
|
|
? createVisibleTextTransformer(options.transformText)
|
|
: null;
|
|
let settled = false;
|
|
let cancellationStarted = false;
|
|
let disconnected = false;
|
|
let emitted = false;
|
|
let fullOutput = "";
|
|
let firstOutputSettlementStarted = false;
|
|
|
|
function startFirstOutputSettlement(value: string) {
|
|
if (!/\S/.test(value) || firstOutputSettlementStarted) return undefined;
|
|
firstOutputSettlementStarted = true;
|
|
return options.onFirstOutput?.();
|
|
}
|
|
|
|
async function output(
|
|
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
|
|
value: string,
|
|
) {
|
|
if (!value) return;
|
|
if (disconnected || !controller) {
|
|
fullOutput += value;
|
|
return;
|
|
}
|
|
const firstOutputSettlement = startFirstOutputSettlement(value);
|
|
controller.enqueue(encoder.encode(value));
|
|
fullOutput += value;
|
|
if (/\S/.test(value)) emitted = true;
|
|
await firstOutputSettlement;
|
|
}
|
|
|
|
async function consume(
|
|
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
|
|
) {
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await iterator.next();
|
|
if (settled) return;
|
|
if (done) {
|
|
await output(controller, visibleTransformer ? visibleTransformer.finish("") : "");
|
|
if (settled) return;
|
|
settled = true;
|
|
if (!/\S/.test(fullOutput)) {
|
|
const error = new Error("empty_stream");
|
|
await options.onError?.(error, false, fullOutput);
|
|
if (!disconnected) controller?.error(error);
|
|
return;
|
|
}
|
|
await options.onComplete?.(fullOutput);
|
|
if (!disconnected) controller?.close();
|
|
return;
|
|
}
|
|
|
|
const transformed = visibleTransformer
|
|
? visibleTransformer.push(value)
|
|
: value;
|
|
await output(controller, transformed);
|
|
if (settled) return;
|
|
}
|
|
} catch (error) {
|
|
if (cancellationStarted) return;
|
|
if (!settled) {
|
|
settled = true;
|
|
await options.onError?.(error, emitted, fullOutput);
|
|
}
|
|
if (!disconnected) controller?.error(error);
|
|
}
|
|
}
|
|
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
void consume(controller).catch(() => {});
|
|
},
|
|
async cancel() {
|
|
if (settled) return;
|
|
if (options.continueAfterDisconnect) {
|
|
disconnected = true;
|
|
return;
|
|
}
|
|
settled = true;
|
|
cancellationStarted = true;
|
|
try {
|
|
await iterator.return?.();
|
|
} finally {
|
|
await options.onCancel?.(emitted);
|
|
}
|
|
},
|
|
});
|
|
|
|
return new Response(body, {
|
|
headers: {
|
|
"cache-control": "no-cache, no-transform",
|
|
"content-type": "text/plain; charset=utf-8",
|
|
"x-accel-buffering": "no",
|
|
"x-ayanam-mode": options.mode,
|
|
"x-ayanam-request-id": options.requestId,
|
|
...options.headers,
|
|
},
|
|
});
|
|
}
|