agents
Version:
A home for your AI agents
272 lines (271 loc) • 9.57 kB
JavaScript
import { i as _classPrivateFieldInitSpec, n as _classPrivateFieldSet2, r as _assertClassBrand, t as _classPrivateFieldGet2 } from "./classPrivateFieldGet2-DZBYAB34.js";
import { t as _classPrivateMethodInitSpec } from "./classPrivateMethodInitSpec-qMjJ6sHQ.js";
import { t as TextSegmentJoiner } from "./text-segment-joiner-BtAFQSA_.js";
//#region src/voice/sentence-chunker.ts
/**
* Sentence chunker — accumulates streaming text and yields complete sentences.
*
* Isolated and testable: no dependencies on the voice pipeline, Agent, or AI APIs.
* Feed it tokens via `add()`, get back sentences via the return value.
* Call `flush()` at end-of-stream to get any remaining text.
*
* Current implementation: splits on sentence-ending punctuation (. ! ?) followed
* by a space or end-of-input. This is intentionally simple — optimize later with
* better heuristics (abbreviations, decimal numbers, quoted speech, etc.).
*/
/**
* Punctuation characters that can end a sentence.
*/
const SENTENCE_TERMINATORS = /* @__PURE__ */ new Set([
".",
"!",
"?"
]);
/**
* Minimum character count before we'll emit a sentence.
* Prevents emitting fragments like "Dr." or "U.S." as standalone sentences,
* while still allowing short responses like "Sure thing!" to stream quickly.
*/
const MIN_SENTENCE_LENGTH = 10;
var _buffer = /* @__PURE__ */ new WeakMap();
var _SentenceChunker_brand = /* @__PURE__ */ new WeakSet();
var SentenceChunker = class {
constructor() {
_classPrivateMethodInitSpec(this, _SentenceChunker_brand);
_classPrivateFieldInitSpec(this, _buffer, "");
}
/**
* Add a chunk of text (e.g. a streamed LLM token).
* Returns an array of complete sentences extracted from the buffer.
* May return 0, 1, or multiple sentences depending on the input.
*/
add(text) {
_classPrivateFieldSet2(_buffer, this, _classPrivateFieldGet2(_buffer, this) + text);
return _assertClassBrand(_SentenceChunker_brand, this, _extractSentences).call(this);
}
/**
* Flush any remaining text in the buffer as a final sentence.
* Call this when the LLM stream ends.
* Returns the remaining text (trimmed), or an empty array if nothing is left.
*/
flush() {
const remaining = _classPrivateFieldGet2(_buffer, this).trim();
_classPrivateFieldSet2(_buffer, this, "");
if (remaining.length > 0) return [remaining];
return [];
}
/**
* Reset the chunker, discarding any buffered text.
*/
reset() {
_classPrivateFieldSet2(_buffer, this, "");
}
};
/**
* Extract complete sentences from the buffer.
* A sentence boundary is a terminator (. ! ?) followed by:
* - a space and an uppercase letter (start of next sentence)
* - a space and end of current buffer (likely a boundary)
* - end of buffer after the terminator
*
* We leave ambiguous cases in the buffer until more text arrives.
*/
function _extractSentences() {
const sentences = [];
while (true) {
const boundary = _assertClassBrand(_SentenceChunker_brand, this, _findSentenceBoundary).call(this);
if (boundary === -1) break;
const sentence = _classPrivateFieldGet2(_buffer, this).slice(0, boundary + 1).trim();
_classPrivateFieldSet2(_buffer, this, _classPrivateFieldGet2(_buffer, this).slice(boundary + 1).trimStart());
if (sentence.length > 0) sentences.push(sentence);
}
return sentences;
}
/**
* Find the index of the end of the first complete sentence in the buffer.
* Returns -1 if no complete sentence boundary is found.
*/
function _findSentenceBoundary() {
for (let i = 0; i < _classPrivateFieldGet2(_buffer, this).length; i++) {
const char = _classPrivateFieldGet2(_buffer, this)[i];
if (!SENTENCE_TERMINATORS.has(char)) continue;
const nextChar = _classPrivateFieldGet2(_buffer, this)[i + 1];
if (nextChar === void 0) continue;
if (nextChar === " " || nextChar === "\n") {
if (_classPrivateFieldGet2(_buffer, this).slice(0, i + 1).trim().length >= MIN_SENTENCE_LENGTH) return i;
}
}
return -1;
}
//#endregion
//#region src/voice/text-stream.ts
const warnedTextStreamSources = /* @__PURE__ */ new WeakSet();
/**
* Turn any {@link TextSource} into a lazy async generator of string chunks.
*
* - `string` → yields the string once (if non-empty).
* - `ReadableStream<string>` → yields each chunk directly.
* - `ReadableStream<Uint8Array>` → decodes and parses as newline-delimited
* JSON (NDJSON) / SSE (`data: …` lines), extracting text from common AI
* response formats.
* - `AsyncIterable<string>` → re-yields each chunk.
*/
async function* iterateText(source) {
for await (const event of iterateTextEvents(source)) if (event.type === "text") yield event.text;
else if (event.type === "error") throw toError(event.error);
}
async function* iterateTextEvents(source) {
if (typeof source === "string") {
if (source) yield textEvent(source);
return;
}
if (hasCustomAsyncIterator(source)) {
for await (const event of iterateAsyncTextEvents(source)) yield event;
return;
}
if (source instanceof ReadableStream) {
const reader = source.getReader();
const first = await reader.read();
if (first.done || first.value === void 0) return;
if (first.value instanceof Uint8Array) {
const peeked = first.value;
const combined = new ReadableStream({ async start(controller) {
controller.enqueue(peeked);
while (true) {
const { done, value } = await reader.read();
if (done) break;
controller.enqueue(value);
}
controller.close();
} });
for await (const chunk of parseNDJSON(combined.getReader())) {
const ai = chunk;
if (ai.response) yield textEvent(ai.response);
else if (ai.choices && ai.choices.length > 0) {
const choice = ai.choices[0];
if (choice.delta?.content && choice.delta?.role === "assistant") yield textEvent(choice.delta.content);
}
}
} else for await (const event of iterateAsyncTextEvents(readWithFirst(first.value, reader))) yield event;
return;
}
if (Symbol.asyncIterator in source) for await (const event of iterateAsyncTextEvents(source)) yield event;
}
async function* readWithFirst(first, reader) {
yield first;
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield value;
}
}
function hasCustomAsyncIterator(source) {
const iterator = source[Symbol.asyncIterator];
if (typeof iterator !== "function") return false;
if (!(source instanceof ReadableStream)) return true;
return Object.prototype.hasOwnProperty.call(source, Symbol.asyncIterator) || iterator !== ReadableStream.prototype[Symbol.asyncIterator];
}
async function* iterateAsyncTextEvents(source) {
const textSegmentJoiner = new TextSegmentJoiner();
for await (const value of source) {
if (typeof value === "string") {
warnDeprecatedTextStream(source);
if (value) yield textEvent(value);
continue;
}
if (!isRecord(value)) continue;
const chunk = value;
for (const event of textSegmentJoiner.pushChunk(chunk)) yield event;
switch (chunk.type) {
case "reasoning-start":
case "reasoning-end":
yield chunk;
break;
case "reasoning-delta": break;
case "finish":
yield chunk;
break;
case "error":
yield {
type: "error",
error: toError(chunk.error)
};
return;
}
}
}
function textEvent(text) {
return {
type: "text",
text
};
}
function warnDeprecatedTextStream(source) {
if (!source || !(source instanceof ReadableStream)) return;
if (warnedTextStreamSources.has(source)) return;
warnedTextStreamSources.add(source);
console.warn("[voice] AI SDK textStream is not recommended because non-adjacent text parts may be joined incorrectly. Return result.stream from onTurn() instead.");
}
function toError(error) {
if (error instanceof Error) return error;
if (typeof error === "string") return new Error(error);
return new Error("AI SDK stream error", { cause: error });
}
function isRecord(value) {
return typeof value === "object" && value !== null;
}
/**
* Parse a `ReadableStream<Uint8Array>` that contains newline-delimited JSON
* or Server-Sent Events (`data: {…}` lines). Yields each parsed JSON object.
*
* Handles the `data: [DONE]` sentinel used by OpenAI-compatible APIs.
*/
async function* parseNDJSON(reader, leftOverBuffer = "") {
const decoder = new TextDecoder();
let buffer = leftOverBuffer;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const parsed = parseLine(line);
if (parsed === "DONE") return;
if (parsed) yield parsed;
}
}
if (buffer.trim()) {
const remaining = buffer.split("\n").filter((l) => l.trim());
for (const line of remaining) {
const parsed = parseLine(line);
if (parsed === "DONE") return;
if (parsed) yield parsed;
}
}
}
function parseLine(line) {
const trimmed = line.trim();
if (!trimmed) return null;
if (trimmed.startsWith("data:")) {
const json = trimmed.slice(5).trim();
if (json === "[DONE]") return "DONE";
try {
return JSON.parse(json);
} catch {
console.warn("[voice] Skipping malformed SSE data:", json);
return null;
}
}
if (trimmed === "[DONE]") return "DONE";
if (trimmed.startsWith(":") || trimmed.startsWith("event:") || trimmed.startsWith("id:") || trimmed.startsWith("retry:")) return null;
try {
return JSON.parse(trimmed);
} catch {
console.warn("[voice] Skipping malformed NDJSON line:", trimmed);
return null;
}
}
//#endregion
export { iterateTextEvents as n, SentenceChunker as r, iterateText as t };
//# sourceMappingURL=text-stream-CpdiKrJB.js.map