chore: checkpoint local workspace changes
This commit is contained in:
53
src/services/creation-agent/creationAgentSse.test.ts
Normal file
53
src/services/creation-agent/creationAgentSse.test.ts
Normal 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: '世界共创',
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user