Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions ai-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,55 @@ function extractLiteRTText(response) {
.join('');
}

/**
* Consume LiteRT `sendMessageStreaming` output across browsers.
* Official API returns a `ReadableStream`. Chromium often supports
* `for await...of` on streams; Safari/WebKit frequently does not
* (`ReadableStream.prototype[Symbol.asyncIterator]` missing), which throws:
* `undefined is not a function (near '...s of a...')`.
* Prefer async iteration when present; otherwise use `getReader()`.
* @param {AsyncIterable|ReadableStream|Promise<AsyncIterable|ReadableStream>|null|undefined} streamLike
*/
export async function* iterateMessageStream(streamLike) {
let stream = streamLike;
if (stream != null && typeof stream.then === 'function') {
stream = await stream;
}
if (stream == null) return;

if (typeof stream[Symbol.asyncIterator] === 'function') {
yield* stream;
return;
}

if (typeof stream.getReader === 'function') {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield value;
}
} finally {
try {
reader.releaseLock();
} catch {
// ignore — lock may already be released after close/error
}
}
return;
}

if (typeof stream[Symbol.iterator] === 'function') {
yield* stream;
return;
}

throw new TypeError(
'LiteRT streaming response is not an async iterable or ReadableStream',
);
}

/** Strip accidental thinking / turn markers from streamed text (defense in depth). */
export function sanitizeModelReply(text) {
if (!text) return '';
Expand Down Expand Up @@ -1022,8 +1071,11 @@ export class AiChat {
let reply = '';

if (typeof conversation.sendMessageStreaming === 'function') {
const stream = conversation.sendMessageStreaming(userText);
for await (const chunk of stream) {
// Do not `for await` the raw return value — it is a ReadableStream, and
// Safari cannot async-iterate ReadableStream (see iterateMessageStream).
for await (const chunk of iterateMessageStream(
conversation.sendMessageStreaming(userText),
)) {
const delta = extractLiteRTText(chunk);
if (!delta) continue;
// Streaming chunks may be cumulative or incremental — prefer append of delta text pieces.
Expand Down
52 changes: 52 additions & 0 deletions tests/unit/guardrails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,58 @@ test.describe('Guardrails Unit', () => {
expect(result.createArgs.preface.messages[0].content).toContain('vd3-cbun');
});

test('AiChat LiteRT generate works when stream is ReadableStream without asyncIterator (Safari)', async ({ page }) => {
const result = await page.evaluate(async () => {
const mod = await import('/ai-chat.js');

// Reader-only stream — no Symbol.asyncIterator (Safari/WebKit shape).
const readerOnlyStream = (chunks) => {
let i = 0;
return {
getReader() {
return {
async read() {
if (i >= chunks.length) return { done: true, value: undefined };
return { done: false, value: chunks[i++] };
},
releaseLock() {},
};
},
};
};

const collected = [];
for await (const chunk of mod.iterateMessageStream(
readerOnlyStream([
{ content: [{ type: 'text', text: 'yo' }] },
{ content: [{ type: 'text', text: ' man' }] },
]),
)) {
collected.push(chunk);
}

const chat = new mod.AiChat({ modelId: 'gemma-4-E2B-it-web' });
chat._isLoaded = true;
chat.engine = {
createConversation: async () => ({
sendMessageStreaming: () => readerOnlyStream([
{ content: [{ type: 'text', text: 'Hey' }] },
{ content: [{ type: 'text', text: ' there' }] },
]),
delete: async () => {},
}),
};
chat._conversation = await chat.engine.createConversation();
const updates = [];
const reply = await chat.generate('yo man', (t) => updates.push(t));
return { collectedLen: collected.length, reply, updates };
});

expect(result.collectedLen).toBe(2);
expect(result.reply).toBe('Hey there');
expect(result.updates.at(-1)).toBe('Hey there');
});

test('AiChat Gemma 4 MLC payloads omit system role (WebLLM template limitation)', async ({ page }) => {
const result = await page.evaluate(async () => {
const mod = await import('/ai-chat.js');
Expand Down
Loading