UNPKG

assistant-stream

Version:

Streaming utilities for AI assistants

693 lines (630 loc) • 18.1 kB
import { createJsonParserBackend, type JsonParserBackend, } from "./json-parser-backend"; import { getPartialJsonObjectMeta, parsePartialJsonObject, } from "./parse-partial-json-object"; import type { ReadonlyJSONObject, ReadonlyJSONValue } from "./json-value"; type JSONPath = (string | number)[]; type MutableJSONObject = Record<string, ReadonlyJSONValue>; type MutableJSONContainer = MutableJSONObject | ReadonlyJSONValue[]; type ObjectFrame = { kind: "object"; path: JSONPath; state: "first-key-or-end" | "key" | "colon" | "value" | "comma-or-end"; key?: string | undefined; }; type ArrayFrame = { kind: "array"; path: JSONPath; state: "first-value-or-end" | "value" | "comma-or-end"; index: number; }; type Frame = ObjectFrame | ArrayFrame; type TextChunk = { length: number; previous: TextChunk | undefined; value: string; }; type StringToken = | { kind: "string"; role: "key"; value: string; escape: "none" | "single" | "unicode"; unicode: string; } | { kind: "string"; role: "value"; path: JSONPath; value: string; escape: "none" | "single" | "unicode"; unicode: string; }; type NumberToken = { kind: "number"; path: JSONPath; value: string; }; type LiteralToken = { kind: "literal"; value: "true" | "false" | "null"; offset: number; }; type Token = StringToken | NumberToken | LiteralToken; const COMPLETE_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/; const PARTIAL_NUMBER = /^-?$|^-?(?:0|[1-9]\d*)(?:\.\d*)?$|^-?(?:0|[1-9]\d*)(?:\.\d+)?[eE][+-]?\d*$/; const JSON_WHITESPACE = /^[\t\n\r ]$/; const HEX_DIGIT = /^[0-9a-fA-F]$/; const SINGLE_ESCAPES: Record<string, string> = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", }; const isContainer = (value: unknown): value is MutableJSONContainer => typeof value === "object" && value !== null; const setValue = ( target: MutableJSONContainer, key: string | number, value: ReadonlyJSONValue, ) => { if ( key === "__proto__" || (key === "constructor" && isContainer(value) && Object.hasOwn(value, "prototype")) ) throw new Error("Unsafe argument key"); (target as Record<string | number, ReadonlyJSONValue>)[key] = value; }; const cloneContainer = (value: MutableJSONContainer): MutableJSONContainer => { if (Array.isArray(value)) return [...value]; const copy: MutableJSONObject = {}; for (const key of Object.keys(value)) { setValue(copy, key, value[key]!); } return copy; }; const writeAtPath = ( root: MutableJSONObject, path: JSONPath, value: ReadonlyJSONValue, ownedContainers: Set<MutableJSONContainer>, ): MutableJSONObject => { const update = ( container: MutableJSONContainer, depth: number, ): MutableJSONContainer => { const target = ownedContainers.has(container) ? container : cloneContainer(container); ownedContainers.add(target); const key = path[depth]!; if (depth === path.length - 1) { setValue(target, key, value); if (isContainer(value)) ownedContainers.add(value); return target; } const child = target[key as keyof typeof target]; if (!isContainer(child)) throw new Error("Invalid incremental JSON path"); setValue(target, key, update(child, depth + 1)); return target; }; return update(root, 0) as MutableJSONObject; }; const createArgsSnapshot = ( root: MutableJSONObject, state: "complete" | "partial", partialPath: JSONPath, ): ReadonlyJSONObject => { const result = parsePartialJsonObject("")! as MutableJSONObject; for (const key of Object.keys(root)) { setValue(result, key, root[key]!); } const meta = getPartialJsonObjectMeta(result)!; meta.state = state; meta.partialPath = partialPath.map(String); return result; }; const materializeArgsSnapshot = ( args: ReadonlyJSONObject, ): ReadonlyJSONObject => { const pending: MutableJSONContainer[] = [args]; while (pending.length > 0) { const container = pending.pop()!; for (const key of Object.keys(container)) { const value = container[key as keyof typeof container]; if (!isContainer(value)) continue; const copy = cloneContainer(value); setValue(container, key, copy); pending.push(copy); } } return args; }; export class IncrementalJsonObjectParser { private backend: JsonParserBackend | undefined; private text: TextChunk | undefined; private mode: "start" | "parsing" | "complete" | "fallback" = "start"; private root: MutableJSONObject = {}; private frames: Frame[] = []; private token: Token | undefined; private args: ReadonlyJSONObject; private publishedArgs: ReadonlyJSONObject | undefined; private ownedContainers: Set<MutableJSONContainer> | undefined; private constructor(fallback: ReadonlyJSONObject) { this.args = fallback; this.publishedArgs = fallback; } static from( text = "", fallback: ReadonlyJSONObject = parsePartialJsonObject("")!, ) { return IncrementalJsonObjectParser.create(text, fallback, true); } private static create( text: string, fallback: ReadonlyJSONObject, allowBackend: boolean, ) { const parser = new IncrementalJsonObjectParser(fallback); if (text.length !== 0) { parser.text = { length: text.length, previous: undefined, value: text }; } const complete = text.trimEnd().endsWith("}") ? parsePartialJsonObject(text) : undefined; if (complete && getPartialJsonObjectMeta(complete)?.state === "complete") { parser.args = complete; parser.publishedArgs = complete; parser.mode = "complete"; return parser; } if (allowBackend) { try { parser.backend = createJsonParserBackend({ text, fallback, createSnapshot: (value, state, partialPath) => materializeArgsSnapshot( createArgsSnapshot(value, state, [...partialPath]), ), }); if (parser.backend) { parser.publishedArgs = parser.backend.currentArgs; return parser; } } catch { parser.backend = undefined; } } parser.consumeDelta(text, true); parser.args = parser.snapshot(fallback); if (parser.args !== fallback) parser.publishedArgs = parser.mode === "fallback" ? parser.args : undefined; return parser; } get currentTextLength() { return this.text?.length ?? 0; } get currentArgs() { return (this.publishedArgs ??= materializeArgsSnapshot(this.args)); } append(delta: string) { if (delta.length === 0) return this; if (this.backend) { let backend: JsonParserBackend | undefined; let args: ReadonlyJSONObject | undefined; try { backend = this.backend.append(delta); args = backend?.currentArgs; } catch { backend = undefined; } if (!backend) return IncrementalJsonObjectParser.create( this.accumulatedText() + delta, this.currentArgs, false, ); const parser = new IncrementalJsonObjectParser(this.args); parser.backend = backend; parser.publishedArgs = args; parser.text = { length: this.currentTextLength + delta.length, previous: this.text, value: delta, }; return parser; } if (this.currentTextLength === 0) return IncrementalJsonObjectParser.create(delta, this.args, false); if (this.mode === "complete") { const parser = IncrementalJsonObjectParser.create( this.accumulatedText() + delta, this.args, false, ); if (parser.args === this.args) parser.publishedArgs = this.currentArgs; return parser; } const parser = this.clone(); const fallback = this.args; parser.consumeDelta(delta); parser.text = { length: (this.text?.length ?? 0) + delta.length, previous: this.text, value: delta, }; parser.args = parser.snapshot(fallback); parser.publishedArgs = parser.args === fallback ? this.currentArgs : parser.mode === "fallback" ? parser.args : undefined; return parser; } private clone() { const parser = new IncrementalJsonObjectParser(this.args); parser.text = this.text; parser.mode = this.mode; parser.root = this.root; parser.frames = this.frames.map((frame) => ({ ...frame, path: [...frame.path], })); parser.token = this.token ? { ...this.token, ...("path" in this.token && this.token.path ? { path: [...this.token.path] } : undefined), } : undefined; return parser; } private snapshot(fallback: ReadonlyJSONObject) { if (this.mode === "fallback") { return parsePartialJsonObject(this.accumulatedText()) ?? fallback; } if (this.mode === "start") return fallback; return createArgsSnapshot( this.root, this.mode === "complete" ? "complete" : "partial", this.partialPath(), ); } private accumulatedText() { const chunks: string[] = []; for (let chunk = this.text; chunk; chunk = chunk.previous) { chunks.push(chunk.value); } return chunks.reverse().join(""); } private partialPath(): JSONPath { if (this.mode === "complete") return []; if ( this.token && (this.token.kind === "number" || (this.token.kind === "string" && this.token.role === "value")) ) { return this.token.path; } return this.frames.at(-1)?.path ?? []; } private isFallback() { return this.mode === "fallback"; } private consumeDelta(delta: string, ownsRoot = false) { if (this.isFallback()) return; this.ownedContainers = ownsRoot ? new Set<MutableJSONContainer>([this.root]) : new Set(); for (const char of delta) { this.consumeCharacter(char); if (this.isFallback()) break; } if ( !this.isFallback() && this.token?.kind === "string" && this.token.role === "value" ) { this.writeValue(this.token.path, this.token.value); } else if ( !this.isFallback() && this.token?.kind === "number" && COMPLETE_NUMBER.test(this.token.value) ) { this.writeValue(this.token.path, Number(this.token.value)); } this.ownedContainers = undefined; } private consumeCharacter(char: string) { let reconsume = true; while (reconsume && this.mode !== "fallback") { reconsume = false; if (this.token) { reconsume = this.consumeToken(char); continue; } if (this.mode === "complete") { if (!JSON_WHITESPACE.test(char)) this.mode = "fallback"; continue; } if (this.mode === "start") { if (JSON_WHITESPACE.test(char)) continue; if (char !== "{") { this.mode = "fallback"; continue; } this.mode = "parsing"; this.frames.push({ kind: "object", path: [], state: "first-key-or-end", }); continue; } const frame = this.frames.at(-1); if (!frame) { this.mode = "fallback"; continue; } if (frame.kind === "object") { this.consumeObjectCharacter(frame, char); } else { this.consumeArrayCharacter(frame, char); } } } private consumeObjectCharacter(frame: ObjectFrame, char: string) { if (JSON_WHITESPACE.test(char)) return; switch (frame.state) { case "first-key-or-end": if (char === "}") this.closeContainer(); else if (char === '"') { frame.state = "key"; this.startKeyString(); } else this.mode = "fallback"; break; case "key": if (char === '"') this.startKeyString(); else this.mode = "fallback"; break; case "colon": if (char === ":") frame.state = "value"; else this.mode = "fallback"; break; case "value": this.startValue(char, [...frame.path, frame.key!]); break; case "comma-or-end": if (char === ",") { frame.state = "key"; frame.key = undefined; } else if (char === "}") this.closeContainer(); else this.mode = "fallback"; break; } } private consumeArrayCharacter(frame: ArrayFrame, char: string) { if (JSON_WHITESPACE.test(char)) return; switch (frame.state) { case "first-value-or-end": if (char === "]") this.closeContainer(); else { frame.state = "value"; this.startValue(char, [...frame.path, frame.index]); } break; case "value": this.startValue(char, [...frame.path, frame.index]); break; case "comma-or-end": if (char === ",") { frame.index += 1; frame.state = "value"; } else if (char === "]") this.closeContainer(); else this.mode = "fallback"; break; } } private startValue(char: string, path: JSONPath) { if (char === "{") { this.writeValue(path, {}); this.frames.push({ kind: "object", path, state: "first-key-or-end", }); } else if (char === "[") { this.writeValue(path, []); this.frames.push({ kind: "array", path, state: "first-value-or-end", index: 0, }); } else if (char === '"') { this.writeValue(path, ""); this.startValueString(path); } else if (char === "t" || char === "f" || char === "n") { const value = char === "t" ? "true" : char === "f" ? "false" : "null"; this.writeValue( path, value === "true" ? true : value === "false" ? false : null, ); this.token = { kind: "literal", value, offset: 1 }; } else if (char === "-" || (char >= "0" && char <= "9")) { this.token = { kind: "number", path, value: char }; if (COMPLETE_NUMBER.test(char)) this.writeValue(path, Number(char)); } else { this.mode = "fallback"; } } private startKeyString() { this.token = { kind: "string", role: "key", value: "", escape: "none", unicode: "", }; } private startValueString(path: JSONPath) { this.token = { kind: "string", role: "value", path, value: "", escape: "none", unicode: "", }; } private consumeToken(char: string): boolean { const token = this.token!; if (token.kind === "string") return this.consumeString(token, char); if (token.kind === "number") return this.consumeNumber(token, char); return this.consumeLiteral(token, char); } private consumeString(token: StringToken, char: string): boolean { if (token.escape === "unicode") { if (!HEX_DIGIT.test(char)) { this.mode = "fallback"; return false; } token.unicode += char; if (token.unicode.length === 4) { token.value += String.fromCharCode(Number.parseInt(token.unicode, 16)); token.escape = "none"; token.unicode = ""; } return false; } if (token.escape === "single") { if (char === "u") { token.escape = "unicode"; return false; } const decoded = SINGLE_ESCAPES[char]; if (decoded === undefined) this.mode = "fallback"; else { token.value += decoded; token.escape = "none"; } return false; } if (char === "\\") { token.escape = "single"; return false; } if (char.charCodeAt(0) < 0x20) { this.mode = "fallback"; return false; } if (char !== '"') { token.value += char; return false; } this.token = undefined; if (token.role === "key") { const frame = this.frames.at(-1); if (!frame || frame.kind !== "object") { this.mode = "fallback"; return false; } if ( token.value === "__proto__" || (token.value === "prototype" && frame.path.at(-1) === "constructor") ) { this.mode = "fallback"; return false; } frame.key = token.value; frame.state = "colon"; } else { this.writeValue(token.path, token.value); this.finishValue(); } return false; } private consumeNumber(token: NumberToken, char: string): boolean { if ( (char >= "0" && char <= "9") || char === "e" || char === "E" || char === "+" || char === "-" || char === "." ) { const value = token.value + char; if (!PARTIAL_NUMBER.test(value)) { this.mode = "fallback"; return false; } token.value = value; if (COMPLETE_NUMBER.test(token.value)) { this.writeValue(token.path, Number(token.value)); } return false; } if (!COMPLETE_NUMBER.test(token.value)) { this.mode = "fallback"; return false; } this.token = undefined; this.finishValue(); return true; } private consumeLiteral(token: LiteralToken, char: string): boolean { if (char !== token.value[token.offset]) { this.mode = "fallback"; return false; } token.offset += 1; if (token.offset === token.value.length) { this.token = undefined; this.finishValue(); } return false; } private finishValue() { const frame = this.frames.at(-1); if (!frame || frame.state !== "value") { this.mode = "fallback"; return; } frame.state = "comma-or-end"; } private closeContainer() { this.frames.pop(); if (this.frames.length === 0) { this.mode = "complete"; } else { this.finishValue(); } } private writeValue(path: JSONPath, value: ReadonlyJSONValue) { try { this.root = writeAtPath( this.root, path, value, this.ownedContainers ?? new Set(), ); } catch { this.mode = "fallback"; } } }