chore: checkpoint local workspace changes

This commit is contained in:
2026-04-23 12:45:15 +08:00
parent 3eb9390e8f
commit a6cd9afcbb
47 changed files with 2154 additions and 529 deletions

View File

@@ -0,0 +1,53 @@
import { expect, test } from 'vitest';
import { readCreationAgentSessionFromSse } from './creationAgentSse';
function createChunkedStreamResponse(chunks: Uint8Array[]) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
},
});
}
test('readCreationAgentSessionFromSse flushes decoder tail and handles CRLF boundaries', async () => {
const encoder = new TextEncoder();
const prefix = encoder.encode('event: reply_delta\r\ndata: {"text":"');
const replyTextBytes = encoder.encode('你好,潮雾列岛');
const suffix = encoder.encode(
'"}\r\n\r\nevent: session\r\ndata: {"session":{"sessionId":"session-1","title":"世界共创"}}\r\n\r\n',
);
const splitIndex = replyTextBytes.length - 1;
const chunks = [
new Uint8Array([...prefix, ...replyTextBytes.slice(0, splitIndex)]),
new Uint8Array([...replyTextBytes.slice(splitIndex), ...suffix]),
];
const updates: string[] = [];
const session = await readCreationAgentSessionFromSse<{
sessionId: string;
title: string;
}>(createChunkedStreamResponse(chunks), {
fallbackMessage: '发送失败',
incompleteMessage: '结果不完整',
onUpdate: (text) => {
updates.push(text);
},
});
expect(updates).toEqual(['你好,潮雾列岛']);
expect(session).toEqual({
sessionId: 'session-1',
title: '世界共创',
});
});

View File

@@ -6,6 +6,34 @@ type CreationAgentSseOptions<TSession> = TextStreamOptions & {
resolveSession?: (rawSession: unknown) => TSession | null;
};
function findSseEventBoundary(buffer: string) {
const lfBoundary = buffer.indexOf('\n\n');
const crlfBoundary = buffer.indexOf('\r\n\r\n');
if (lfBoundary === -1 && crlfBoundary === -1) {
return null;
}
if (lfBoundary === -1) {
return {
index: crlfBoundary,
length: 4,
};
}
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
return {
index: lfBoundary,
length: 2,
};
}
return {
index: crlfBoundary,
length: 4,
};
}
function parseSseEventBlock(eventBlock: string) {
let eventName = 'message';
const dataLines: string[] = [];
@@ -62,10 +90,14 @@ export async function readCreationAgentSessionFromSse<TSession>(
buffer += decoder.decode(value, { stream: true });
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n');
const eventBlock = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
for (;;) {
const boundary = findSseEventBoundary(buffer);
if (!boundary) {
break;
}
const eventBlock = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary.length);
const { eventName, data } = parseSseEventBlock(eventBlock);
if (!data) {
@@ -97,6 +129,47 @@ export async function readCreationAgentSessionFromSse<TSession>(
}
}
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
buffer += decoder.decode();
for (;;) {
const boundary = findSseEventBoundary(buffer);
if (!boundary) {
break;
}
const eventBlock = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary.length);
const { eventName, data } = parseSseEventBlock(eventBlock);
if (!data) {
continue;
}
const parsed = parseJsonObject(data);
if (eventName === 'reply_delta' && parsed) {
const text = parsed.text;
if (typeof text === 'string') {
options.onUpdate?.(text);
}
continue;
}
if (eventName === 'session' && parsed?.session) {
finalSession = resolveSession(parsed.session);
continue;
}
if (eventName === 'error' && parsed) {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: options.fallbackMessage;
throw new Error(message);
}
}
if (!finalSession) {
throw new Error(options.incompleteMessage);
}

View File

@@ -18,6 +18,7 @@ export type RpgCreationRuntimeRequestOptions = {
retry?: ApiRetryOptions;
skipAuth?: boolean;
skipRefresh?: boolean;
timeoutMs?: number;
};
export function requestRpgCreationRuntimeJson<T>(
@@ -42,6 +43,7 @@ export function requestRpgCreationRuntimeJson<T>(
retry,
skipAuth: options.skipAuth,
skipRefresh: options.skipRefresh,
timeoutMs: options.timeoutMs,
},
);
}