UNPKG

agents

Version:

A home for your AI agents

272 lines (271 loc) 9.57 kB
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