feat: upgrade investigation and presentation runtime
This commit is contained in:
@@ -1,17 +1,12 @@
|
||||
import { smoothStream, type TextStreamPart } from "ai";
|
||||
|
||||
type TokenSmootherOptions = {
|
||||
enabled: boolean;
|
||||
delayMs: number;
|
||||
locale: string;
|
||||
maxWaitMs?: number;
|
||||
sessionId: string;
|
||||
write: (event: string, data: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
type SmoothTextStreamPart = TextStreamPart<Record<string, never>>;
|
||||
type SmoothTextDeltaPart = Extract<SmoothTextStreamPart, { type: "text-delta" }>;
|
||||
type SmoothTextEndPart = Extract<SmoothTextStreamPart, { type: "text-end" }>;
|
||||
|
||||
const segmenters = new Map<string, Intl.Segmenter | null>();
|
||||
const CJK_SCRIPT_PATTERN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||
const CJK_PUNCTUATION_PATTERN = /[。!?!?,、;:,;:]/u;
|
||||
@@ -94,6 +89,7 @@ export function createTokenSmoother({
|
||||
enabled,
|
||||
delayMs,
|
||||
locale,
|
||||
maxWaitMs = 100,
|
||||
sessionId,
|
||||
write,
|
||||
}: TokenSmootherOptions) {
|
||||
@@ -104,69 +100,74 @@ export function createTokenSmoother({
|
||||
});
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
return {
|
||||
writeToken(content: string) {
|
||||
if (content) {
|
||||
emitChunk(content);
|
||||
}
|
||||
},
|
||||
async flush() {
|
||||
return;
|
||||
},
|
||||
};
|
||||
let buffer = "";
|
||||
let bufferedAt = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let scheduledAt = 0;
|
||||
let closed = false;
|
||||
let releasedChars = 0;
|
||||
const barriers: Array<{ target: number; resolve: () => void }> = [];
|
||||
|
||||
function clearTimer() {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
|
||||
const textPartId = `smooth-text-${sessionId}`;
|
||||
const stream = smoothStream<Record<string, never>>({
|
||||
delayInMs: delayMs,
|
||||
chunking: (buffer) => detectTokenSmoothingChunk(buffer, locale),
|
||||
})({ tools: {} });
|
||||
const writer = stream.writable.getWriter();
|
||||
const reader = stream.readable.getReader();
|
||||
let closed = false;
|
||||
let writeChain = Promise.resolve();
|
||||
const readPromise = (async () => {
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) {
|
||||
return;
|
||||
}
|
||||
if (result.value.type === "text-delta") {
|
||||
emitChunk(result.value.text);
|
||||
}
|
||||
}
|
||||
})();
|
||||
function release(content: string) {
|
||||
buffer = buffer.slice(content.length);
|
||||
// The short-tail deadline starts at the last visible output. It does not
|
||||
// force a whole paragraph out while regular chunks are still available.
|
||||
bufferedAt = buffer ? Date.now() : 0;
|
||||
emitChunk(content);
|
||||
releasedChars += content.length;
|
||||
while (barriers.length && barriers[0].target <= releasedChars) barriers.shift()!.resolve();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (!buffer) return;
|
||||
const now = Date.now();
|
||||
const remaining = Math.max(0, maxWaitMs - (now - bufferedAt));
|
||||
const wait = detectTokenSmoothingChunk(buffer, locale) || barriers.length ? delayMs : remaining;
|
||||
// More input may make a chunk ready sooner, but must never postpone an
|
||||
// already scheduled release. A stage boundary uses the same pacing.
|
||||
if (timer !== undefined && scheduledAt <= now + wait) return;
|
||||
clearTimer();
|
||||
scheduledAt = now + wait;
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
const chunk = detectTokenSmoothingChunk(buffer, locale) ?? buffer;
|
||||
const boundary = barriers[0]?.target;
|
||||
// Later narration must not be merged across a pending UI/tool boundary.
|
||||
release(boundary === undefined ? chunk : chunk.slice(0, boundary - releasedChars));
|
||||
schedule();
|
||||
}, wait);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (!buffer) return;
|
||||
const target = releasedChars + buffer.length;
|
||||
await new Promise<void>((resolve) => {
|
||||
barriers.push({ target, resolve });
|
||||
schedule();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
writeToken(content: string) {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
writeChain = writeChain.then(() =>
|
||||
writer.write({
|
||||
type: "text-delta",
|
||||
id: textPartId,
|
||||
text: content,
|
||||
} satisfies SmoothTextDeltaPart),
|
||||
);
|
||||
if (!content || closed) return;
|
||||
if (!enabled) { emitChunk(content); return; }
|
||||
if (!buffer) bufferedAt = Date.now();
|
||||
buffer += content;
|
||||
schedule();
|
||||
},
|
||||
async flush() {
|
||||
if (closed) {
|
||||
await readPromise;
|
||||
return;
|
||||
}
|
||||
// Stage boundaries drain narration without ending future text output.
|
||||
flush,
|
||||
async close({ immediate = false }: { immediate?: boolean } = {}) {
|
||||
closed = true;
|
||||
await writeChain;
|
||||
await writer.write({
|
||||
type: "text-end",
|
||||
id: textPartId,
|
||||
} satisfies SmoothTextEndPart);
|
||||
await writer.close();
|
||||
await readPromise;
|
||||
if (immediate) {
|
||||
clearTimer();
|
||||
if (buffer) release(buffer);
|
||||
} else await flush();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user