refactor: 收口前端 SSE 传输层
This commit is contained in:
@@ -16,6 +16,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-03 前端 SSE 客户端传输层统一收口
|
||||||
|
|
||||||
|
- 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush,导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。
|
||||||
|
- 决策:前端 SSE 传输层统一使用 `src/services/sseStream.ts`;`readSseStream` 负责事件边界、解码 flush、多行 data 和提前停止取消 reader,`readSseJsonStream` 负责 JSON object 事件解析与异常 JSON 静默跳过。业务 client 只保留领域事件归一化、结果聚合和中文错误文案,后续不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环。
|
||||||
|
- 影响范围:`src/services/sseStream.ts`、`src/services/aiService.ts`、`src/services/creation-agent/creationAgentSse.ts`、`src/services/creative-agent/creativeAgentSse.ts`、`src/services/visual-novel-runtime/visualNovelRuntimeSse.ts`、`src/services/rpg-entry/rpgProfileClient.ts`、前端 SSE 相关测试与架构文档。
|
||||||
|
- 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。
|
||||||
|
- 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`。
|
||||||
|
|
||||||
## 2026-06-03 最近创作只复用创作模板入口
|
## 2026-06-03 最近创作只复用创作模板入口
|
||||||
|
|
||||||
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。
|
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段
|
|||||||
|
|
||||||
AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md) 为最新口径:只吸收 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验,禁止迁入外部社区、支付、榜单、私有存档或回放。
|
AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md) 为最新口径:只吸收 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验,禁止迁入外部社区、支付、榜单、私有存档或回放。
|
||||||
|
|
||||||
|
前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`,事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。
|
||||||
|
|
||||||
## 推荐阅读顺序
|
## 推荐阅读顺序
|
||||||
|
|
||||||
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
|
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
|
||||||
|
|||||||
34
docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md
Normal file
34
docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# SSE 客户端传输层收口约定
|
||||||
|
|
||||||
|
更新时间:`2026-06-03`
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
前端多个服务 client 需要读取 Server-Sent Events,包括创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态。旧实现分别在各自文件里手写事件边界查找、`TextDecoder` 解码、JSON 解析和流结束 flush,容易出现 CRLF / LF 边界不一致、UTF-8 多字节字符尾部丢失、错误事件处理漂移,以及长连接达到最终状态后没有及时释放的问题。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
前端 SSE 的传输层统一收口到 `src/services/sseStream.ts`:
|
||||||
|
|
||||||
|
- `readSseStream` 负责读取 `Response.body`、识别 `\n\n` 与 `\r\n\r\n` 事件边界、合并多行 `data:`、flush `TextDecoder` 尾部缓冲,并支持事件处理函数返回 `false` 后取消 reader。
|
||||||
|
- `readSseJsonStream` 只在传输事件基础上解析 JSON object,空 data 与异常 JSON 继续按旧口径静默跳过。
|
||||||
|
- 各业务 client 只保留领域事件归一化、最终结果聚合和中文错误文案,不再重复实现 SSE 边界扫描、reader 循环或 UTF-8 flush。
|
||||||
|
- OpenAI 兼容流、`[DONE]` 哨兵或其它非 JSON SSE 可直接使用 `readSseStream`;业务 JSON 事件优先使用 `readSseJsonStream`。
|
||||||
|
|
||||||
|
## 落地范围
|
||||||
|
|
||||||
|
本次先收口以下客户端:
|
||||||
|
|
||||||
|
- `src/services/aiService.ts`
|
||||||
|
- `src/services/creation-agent/creationAgentSse.ts`
|
||||||
|
- `src/services/creative-agent/creativeAgentSse.ts`
|
||||||
|
- `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts`
|
||||||
|
- `src/services/rpg-entry/rpgProfileClient.ts`
|
||||||
|
|
||||||
|
后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。
|
||||||
|
- 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。
|
||||||
|
- `npm run typecheck` 不产生新的类型错误。
|
||||||
@@ -35,13 +35,14 @@ import type {
|
|||||||
TextStreamOptions,
|
TextStreamOptions,
|
||||||
} from './aiTypes';
|
} from './aiTypes';
|
||||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||||
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
|
||||||
import { parseLineListContent } from './llmParsers';
|
import { parseLineListContent } from './llmParsers';
|
||||||
import {
|
import {
|
||||||
buildStoryMomentFromRuntimeProjection,
|
buildStoryMomentFromRuntimeProjection,
|
||||||
getStoryRuntimeProjection,
|
getStoryRuntimeProjection,
|
||||||
resolveRuntimeStoryAction,
|
resolveRuntimeStoryAction,
|
||||||
} from './rpg-runtime/rpgRuntimeStoryClient';
|
} from './rpg-runtime/rpgRuntimeStoryClient';
|
||||||
|
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||||||
|
import { parseSseJsonObject, readSseJsonStream, readSseStream } from './sseStream';
|
||||||
|
|
||||||
const RUNTIME_API_BASE = '/api/runtime';
|
const RUNTIME_API_BASE = '/api/runtime';
|
||||||
|
|
||||||
@@ -108,81 +109,96 @@ async function requestPlainTextStream(
|
|||||||
throw new Error('streaming response body is unavailable');
|
throw new Error('streaming response body is unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder('utf-8');
|
|
||||||
let buffer = '';
|
|
||||||
let accumulatedText = '';
|
let accumulatedText = '';
|
||||||
|
|
||||||
for (;;) {
|
await readSseStream(response, ({ data }) => {
|
||||||
const { done, value } = await reader.read();
|
if (data === '[DONE]') {
|
||||||
if (done) {
|
return false;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
const parsed = parseSseJsonObject(data);
|
||||||
|
if (!parsed) {
|
||||||
while (buffer.includes('\n\n')) {
|
return;
|
||||||
const boundary = buffer.indexOf('\n\n');
|
|
||||||
const eventBlock = buffer.slice(0, boundary);
|
|
||||||
buffer = buffer.slice(boundary + 2);
|
|
||||||
|
|
||||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line.startsWith('data:')) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = line.slice(5).trim();
|
const delta = readPlainTextStreamDelta(parsed);
|
||||||
if (!data || data === '[DONE]') {
|
if (delta) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
const delta = parsed?.choices?.[0]?.delta?.content;
|
|
||||||
if (typeof delta === 'string' && delta.length > 0) {
|
|
||||||
accumulatedText += delta;
|
accumulatedText += delta;
|
||||||
options.onUpdate?.(accumulatedText);
|
options.onUpdate?.(accumulatedText);
|
||||||
}
|
}
|
||||||
} catch {
|
});
|
||||||
// Ignore malformed SSE frames.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return accumulatedText.trim();
|
return accumulatedText.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParsedSseEvent = {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
event: string | null;
|
return typeof value === 'object' && value !== null
|
||||||
data: string;
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPlainTextStreamDelta(parsed: Record<string, unknown>) {
|
||||||
|
const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
|
||||||
|
const firstChoice = asRecord(choices[0]);
|
||||||
|
const delta = asRecord(firstChoice?.delta);
|
||||||
|
const content = delta?.content;
|
||||||
|
return typeof content === 'string' ? content : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSseEventMessage(
|
||||||
|
parsed: Record<string, unknown>,
|
||||||
|
fallbackMessage: string,
|
||||||
|
) {
|
||||||
|
return typeof parsed.message === 'string' ? parsed.message : fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceNpcChatTurnResult(
|
||||||
|
parsed: Record<string, unknown>,
|
||||||
|
): NpcChatTurnResult {
|
||||||
|
return parsed as unknown as NpcChatTurnResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNpcReplyDelta(parsed: Record<string, unknown>) {
|
||||||
|
return typeof parsed.text === 'string' ? parsed.text : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNpcCompletedReply(result: NpcChatTurnResult) {
|
||||||
|
return typeof result.npcReply === 'string' ? result.npcReply : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readNpcChatTurnFromSse(
|
||||||
|
response: Response,
|
||||||
|
options: { onReplyUpdate?: (text: string) => void } = {},
|
||||||
|
): Promise<NpcChatTurnResult> {
|
||||||
|
let accumulatedReply = '';
|
||||||
|
const completedResultRef: { current: NpcChatTurnResult | null } = {
|
||||||
|
current: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseSseEventBlock(eventBlock: string): ParsedSseEvent | null {
|
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||||
let eventName: string | null = null;
|
if (eventName === 'reply_delta') {
|
||||||
const dataLines: string[] = [];
|
accumulatedReply = readNpcReplyDelta(parsed);
|
||||||
|
options.onReplyUpdate?.(accumulatedReply);
|
||||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
return;
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
eventName = line.slice(6).trim() || null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (line.startsWith('data:')) {
|
|
||||||
dataLines.push(line.slice(5).trim());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataLines.length === 0) {
|
if (eventName === 'complete') {
|
||||||
return null;
|
completedResultRef.current = coerceNpcChatTurnResult(parsed);
|
||||||
|
accumulatedReply = readNpcCompletedReply(completedResultRef.current);
|
||||||
|
options.onReplyUpdate?.(accumulatedReply);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (eventName === 'error') {
|
||||||
event: eventName,
|
throw new Error(readSseEventMessage(parsed, 'NPC 聊天续写失败'));
|
||||||
data: dataLines.join('\n'),
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
|
if (!completedResultRef.current) {
|
||||||
|
throw new Error('NPC 聊天续写结果为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
return completedResultRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateInitialStory(
|
export async function generateInitialStory(
|
||||||
@@ -508,72 +524,9 @@ export async function streamNpcChatTurn(
|
|||||||
throw new Error('streaming response body is unavailable');
|
throw new Error('streaming response body is unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
return readNpcChatTurnFromSse(response, {
|
||||||
const decoder = new TextDecoder('utf-8');
|
onReplyUpdate: options.onReplyUpdate,
|
||||||
let buffer = '';
|
});
|
||||||
let accumulatedReply = '';
|
|
||||||
let completedResult: NpcChatTurnResult | null = null;
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const parsedEvent = parseSseEventBlock(eventBlock);
|
|
||||||
if (!parsedEvent) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedEvent.data === '[DONE]') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedEvent.event === 'reply_delta') {
|
|
||||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
const nextText =
|
|
||||||
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
|
|
||||||
accumulatedReply = nextText;
|
|
||||||
options.onReplyUpdate?.(accumulatedReply);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedEvent.event === 'complete') {
|
|
||||||
completedResult = JSON.parse(parsedEvent.data) as NpcChatTurnResult;
|
|
||||||
accumulatedReply = completedResult.npcReply;
|
|
||||||
options.onReplyUpdate?.(accumulatedReply);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedEvent.event === 'error') {
|
|
||||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
throw new Error(
|
|
||||||
typeof payloadRecord.message === 'string'
|
|
||||||
? payloadRecord.message
|
|
||||||
: 'NPC 聊天续写失败',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!completedResult) {
|
|
||||||
throw new Error('NPC 聊天续写结果为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
return completedResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function streamNpcRecruitDialogue(
|
export async function streamNpcRecruitDialogue(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel';
|
import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
import type { TextStreamOptions } from '../aiTypes';
|
import type { TextStreamOptions } from '../aiTypes';
|
||||||
|
import { readSseJsonStream } from '../sseStream';
|
||||||
|
|
||||||
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||||
fallbackMessage: string;
|
fallbackMessage: string;
|
||||||
@@ -24,65 +25,6 @@ type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
|||||||
| null;
|
| 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[] = [];
|
|
||||||
|
|
||||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
eventName = line.slice(6).trim() || 'message';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.startsWith('data:')) {
|
|
||||||
dataLines.push(line.slice(5).trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
eventName,
|
|
||||||
data: dataLines.join('\n'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonObject(data: string) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type NormalizedCreationAgentSseEvent = NonNullable<
|
type NormalizedCreationAgentSseEvent = NonNullable<
|
||||||
CreationAgentSseOptions<unknown>['normalizeEvent']
|
CreationAgentSseOptions<unknown>['normalizeEvent']
|
||||||
> extends (eventName: string, parsed: Record<string, unknown>) => infer TResult
|
> extends (eventName: string, parsed: Record<string, unknown>) => infer TResult
|
||||||
@@ -147,71 +89,30 @@ export async function readCreationAgentSessionFromSse<TSession>(
|
|||||||
response: Response,
|
response: Response,
|
||||||
options: CreationAgentSseOptions<TSession>,
|
options: CreationAgentSseOptions<TSession>,
|
||||||
) {
|
) {
|
||||||
const streamBody = response.body;
|
|
||||||
if (!streamBody) {
|
|
||||||
throw new Error('streaming response body is unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = streamBody.getReader();
|
|
||||||
const decoder = new TextDecoder('utf-8');
|
|
||||||
const resolveSession =
|
const resolveSession =
|
||||||
options.resolveSession ??
|
options.resolveSession ??
|
||||||
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
||||||
let buffer = '';
|
|
||||||
let finalSession: TSession | null = null;
|
let finalSession: TSession | null = null;
|
||||||
const normalizeEvent =
|
const normalizeEvent =
|
||||||
options.normalizeEvent ?? normalizeDefaultCreationAgentEvent;
|
options.normalizeEvent ?? normalizeDefaultCreationAgentEvent;
|
||||||
|
|
||||||
const consumeBuffer = () => {
|
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||||
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 (!parsed) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const normalized = normalizeEvent(eventName, parsed);
|
const normalized = normalizeEvent(eventName, parsed);
|
||||||
|
|
||||||
if (normalized?.kind === 'reply_delta') {
|
if (normalized?.kind === 'reply_delta') {
|
||||||
options.onUpdate?.(normalized.text);
|
options.onUpdate?.(normalized.text);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized?.kind === 'session') {
|
if (normalized?.kind === 'session') {
|
||||||
finalSession = resolveSession(normalized.session);
|
finalSession = resolveSession(normalized.session);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized?.kind === 'error') {
|
if (normalized?.kind === 'error') {
|
||||||
throw new Error(normalized.message || options.fallbackMessage);
|
throw new Error(normalized.message || options.fallbackMessage);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
consumeBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
|
||||||
buffer += decoder.decode();
|
|
||||||
consumeBuffer();
|
|
||||||
|
|
||||||
if (!finalSession) {
|
if (!finalSession) {
|
||||||
throw new Error(options.incompleteMessage);
|
throw new Error(options.incompleteMessage);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CreativeDraftEditResult,
|
CreativeDraftEditResult,
|
||||||
} from '../../../packages/shared/src/contracts/creativeAgent';
|
} from '../../../packages/shared/src/contracts/creativeAgent';
|
||||||
import type { TextStreamOptions } from '../aiTypes';
|
import type { TextStreamOptions } from '../aiTypes';
|
||||||
|
import { readSseJsonStream } from '../sseStream';
|
||||||
|
|
||||||
type CreativeAgentSseOptions = TextStreamOptions & {
|
type CreativeAgentSseOptions = TextStreamOptions & {
|
||||||
fallbackMessage: string;
|
fallbackMessage: string;
|
||||||
@@ -16,65 +17,6 @@ type CreativeAgentSseResult = {
|
|||||||
draftEditResult: CreativeDraftEditResult | null;
|
draftEditResult: CreativeDraftEditResult | 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[] = [];
|
|
||||||
|
|
||||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
eventName = line.slice(6).trim() || 'message';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.startsWith('data:')) {
|
|
||||||
dataLines.push(line.slice(5).trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
eventName,
|
|
||||||
data: dataLines.join('\n'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonObject(data: string) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCreativeAgentSseEvent(
|
function normalizeCreativeAgentSseEvent(
|
||||||
eventName: string,
|
eventName: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
@@ -105,13 +47,9 @@ function normalizeCreativeAgentSseEvent(
|
|||||||
|
|
||||||
function handleParsedCreativeAgentEvent(
|
function handleParsedCreativeAgentEvent(
|
||||||
eventName: string,
|
eventName: string,
|
||||||
parsed: Record<string, unknown> | null,
|
parsed: Record<string, unknown>,
|
||||||
options: CreativeAgentSseOptions,
|
options: CreativeAgentSseOptions,
|
||||||
): Partial<CreativeAgentSseResult> | null {
|
): Partial<CreativeAgentSseResult> | null {
|
||||||
if (!parsed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedEvent = normalizeCreativeAgentSseEvent(eventName, parsed);
|
const normalizedEvent = normalizeCreativeAgentSseEvent(eventName, parsed);
|
||||||
if (normalizedEvent) {
|
if (normalizedEvent) {
|
||||||
options.onEvent?.(normalizedEvent);
|
options.onEvent?.(normalizedEvent);
|
||||||
@@ -168,36 +106,15 @@ export async function readCreativeAgentResultFromSse(
|
|||||||
response: Response,
|
response: Response,
|
||||||
options: CreativeAgentSseOptions,
|
options: CreativeAgentSseOptions,
|
||||||
): Promise<CreativeAgentSseResult> {
|
): Promise<CreativeAgentSseResult> {
|
||||||
const streamBody = response.body;
|
|
||||||
if (!streamBody) {
|
|
||||||
throw new Error('streaming response body is unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = streamBody.getReader();
|
|
||||||
const decoder = new TextDecoder('utf-8');
|
|
||||||
let buffer = '';
|
|
||||||
const result: CreativeAgentSseResult = {
|
const result: CreativeAgentSseResult = {
|
||||||
session: null,
|
session: null,
|
||||||
draftEditResult: null,
|
draftEditResult: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const consumeBuffer = () => {
|
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||||
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 nextResult = handleParsedCreativeAgentEvent(
|
const nextResult = handleParsedCreativeAgentEvent(
|
||||||
eventName,
|
eventName,
|
||||||
parseJsonObject(data),
|
parsed,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
if (nextResult?.session) {
|
if (nextResult?.session) {
|
||||||
@@ -206,21 +123,7 @@ export async function readCreativeAgentResultFromSse(
|
|||||||
if (nextResult?.draftEditResult) {
|
if (nextResult?.draftEditResult) {
|
||||||
result.draftEditResult = nextResult.draftEditResult;
|
result.draftEditResult = nextResult.draftEditResult;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
consumeBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
consumeBuffer();
|
|
||||||
|
|
||||||
if (!result.session) {
|
if (!result.session) {
|
||||||
throw new Error(options.incompleteMessage);
|
throw new Error(options.incompleteMessage);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ClaimProfileTaskRewardResponse,
|
||||||
ConfirmWechatProfileRechargeOrderResponse,
|
ConfirmWechatProfileRechargeOrderResponse,
|
||||||
CreateProfileRechargeOrderResponse,
|
CreateProfileRechargeOrderResponse,
|
||||||
ClaimProfileTaskRewardResponse,
|
|
||||||
PlatformBrowseHistoryBatchSyncRequest,
|
PlatformBrowseHistoryBatchSyncRequest,
|
||||||
PlatformBrowseHistoryResponse,
|
PlatformBrowseHistoryResponse,
|
||||||
PlatformBrowseHistoryWriteEntry,
|
PlatformBrowseHistoryWriteEntry,
|
||||||
ProfileDashboardSummary,
|
ProfileDashboardSummary,
|
||||||
ProfilePlayStatsResponse,
|
ProfilePlayStatsResponse,
|
||||||
ProfileReferralInviteCenterResponse,
|
|
||||||
ProfileRechargeCenterResponse,
|
ProfileRechargeCenterResponse,
|
||||||
|
ProfileReferralInviteCenterResponse,
|
||||||
ProfileSaveArchiveListResponse,
|
ProfileSaveArchiveListResponse,
|
||||||
ProfileSaveArchiveResumeResponse,
|
ProfileSaveArchiveResumeResponse,
|
||||||
ProfileTaskCenterResponse,
|
ProfileTaskCenterResponse,
|
||||||
@@ -24,10 +24,11 @@ import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
|||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import { fetchWithApiAuth } from '../apiClient';
|
import { fetchWithApiAuth } from '../apiClient';
|
||||||
import {
|
import {
|
||||||
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
|
||||||
requestRpgRuntimeJson,
|
requestRpgRuntimeJson,
|
||||||
|
RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||||
type RuntimeRequestOptions,
|
type RuntimeRequestOptions,
|
||||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||||
|
import { readSseJsonStream } from '../sseStream';
|
||||||
|
|
||||||
export type { RuntimeRequestOptions };
|
export type { RuntimeRequestOptions };
|
||||||
|
|
||||||
@@ -132,65 +133,6 @@ type RechargeOrderSseEvent =
|
|||||||
payload: { message: string };
|
payload: { message: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
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[] = [];
|
|
||||||
|
|
||||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
eventName = line.slice(6).trim() || 'message';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.startsWith('data:')) {
|
|
||||||
dataLines.push(line.slice(5).trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
eventName,
|
|
||||||
data: dataLines.join('\n'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonObject(data: string) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRechargeOrderSseEvent(
|
function normalizeRechargeOrderSseEvent(
|
||||||
eventName: string,
|
eventName: string,
|
||||||
parsed: Record<string, unknown>,
|
parsed: Record<string, unknown>,
|
||||||
@@ -264,81 +206,33 @@ export async function watchWechatRpgProfileRechargeOrder(
|
|||||||
throw new Error('streaming response body is unavailable');
|
throw new Error('streaming response body is unavailable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder('utf-8');
|
|
||||||
let buffer = '';
|
|
||||||
let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
let finalResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||||
let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
let lastResponse: ConfirmWechatProfileRechargeOrderResponse | null = null;
|
||||||
let streamDone = false;
|
|
||||||
|
|
||||||
const consumeBuffer = () => {
|
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||||
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 (!parsed) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const normalized = normalizeRechargeOrderSseEvent(eventName, parsed);
|
const normalized = normalizeRechargeOrderSseEvent(eventName, parsed);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized.type === 'order') {
|
if (normalized.type === 'order') {
|
||||||
lastResponse = normalized.payload;
|
lastResponse = normalized.payload;
|
||||||
if (normalized.payload.order.status !== 'pending') {
|
if (normalized.payload.order.status !== 'pending') {
|
||||||
finalResponse = normalized.payload;
|
finalResponse = normalized.payload;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized.type === 'done') {
|
if (normalized.type === 'done') {
|
||||||
streamDone = true;
|
|
||||||
if (!finalResponse && lastResponse) {
|
if (!finalResponse && lastResponse) {
|
||||||
finalResponse = lastResponse;
|
finalResponse = lastResponse;
|
||||||
}
|
}
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(normalized.payload.message || '订阅充值订单状态失败');
|
throw new Error(normalized.payload.message || '订阅充值订单状态失败');
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
consumeBuffer();
|
|
||||||
if (finalResponse) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (streamDone) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
consumeBuffer();
|
|
||||||
|
|
||||||
if (!finalResponse) {
|
|
||||||
if (lastResponse) {
|
|
||||||
finalResponse = lastResponse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!finalResponse) {
|
if (!finalResponse) {
|
||||||
throw new Error('充值订单状态流返回不完整');
|
throw new Error('充值订单状态流返回不完整');
|
||||||
|
|||||||
98
src/services/sseStream.test.ts
Normal file
98
src/services/sseStream.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { readSseJsonStream, readSseStream } from './sseStream';
|
||||||
|
|
||||||
|
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('readSseJsonStream flushes decoder tail and handles CRLF boundaries', async () => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const prefix = encoder.encode('event: reply_delta\r\ndata: {"text":"');
|
||||||
|
const replyBytes = encoder.encode('溪上春风');
|
||||||
|
const suffix = encoder.encode('"}\r\n\r\n');
|
||||||
|
const splitIndex = replyBytes.length - 1;
|
||||||
|
const events: Array<{ eventName: string; parsed: Record<string, unknown> }> =
|
||||||
|
[];
|
||||||
|
|
||||||
|
await readSseJsonStream(
|
||||||
|
createChunkedStreamResponse([
|
||||||
|
new Uint8Array([...prefix, ...replyBytes.slice(0, splitIndex)]),
|
||||||
|
new Uint8Array([...replyBytes.slice(splitIndex), ...suffix]),
|
||||||
|
]),
|
||||||
|
({ eventName, parsed }) => {
|
||||||
|
events.push({ eventName, parsed });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
eventName: 'reply_delta',
|
||||||
|
parsed: { text: '溪上春风' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readSseJsonStream skips malformed json and keeps valid LF events', async () => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const events: Array<{ eventName: string; parsed: Record<string, unknown> }> =
|
||||||
|
[];
|
||||||
|
|
||||||
|
await readSseJsonStream(
|
||||||
|
createChunkedStreamResponse([
|
||||||
|
encoder.encode(
|
||||||
|
'event: malformed\ndata: not-json\n\n' +
|
||||||
|
'event: ready\ndata: {"value":7}\n\n',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
({ eventName, parsed }) => {
|
||||||
|
events.push({ eventName, parsed });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
eventName: 'ready',
|
||||||
|
parsed: { value: 7 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readSseStream can stop early and cancel the reader', async () => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let cancelled = false;
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
'event: first\ndata: one\n\n' + 'event: second\ndata: two\n\n',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const events: string[] = [];
|
||||||
|
|
||||||
|
await readSseStream(new Response(stream), ({ eventName }) => {
|
||||||
|
events.push(eventName);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual(['first']);
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
});
|
||||||
168
src/services/sseStream.ts
Normal file
168
src/services/sseStream.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
export type SseStreamEvent = {
|
||||||
|
eventName: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SseJsonStreamEvent = SseStreamEvent & {
|
||||||
|
parsed: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SseEventBoundary = {
|
||||||
|
index: number;
|
||||||
|
length: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SseStreamEventHandler<TEvent extends SseStreamEvent> = (
|
||||||
|
event: TEvent,
|
||||||
|
) => void | boolean;
|
||||||
|
|
||||||
|
function findSseEventBoundary(buffer: string): SseEventBoundary | null {
|
||||||
|
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): SseStreamEvent | null {
|
||||||
|
let eventName = 'message';
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
eventName = line.slice(6).trim() || 'message';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = dataLines.join('\n');
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventName,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSseJsonObject(data: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data) as unknown;
|
||||||
|
return typeof parsed === 'object' && parsed !== null
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSseStream(
|
||||||
|
response: Response,
|
||||||
|
onEvent: SseStreamEventHandler<SseStreamEvent>,
|
||||||
|
) {
|
||||||
|
const streamBody = response.body;
|
||||||
|
if (!streamBody) {
|
||||||
|
throw new Error('streaming response body is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = streamBody.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
let shouldContinue = true;
|
||||||
|
let completed = false;
|
||||||
|
|
||||||
|
const consumeBuffer = () => {
|
||||||
|
for (;;) {
|
||||||
|
if (!shouldContinue) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = findSseEventBoundary(buffer);
|
||||||
|
if (!boundary) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventBlock = buffer.slice(0, boundary.index);
|
||||||
|
buffer = buffer.slice(boundary.index + boundary.length);
|
||||||
|
const event = parseSseEventBlock(eventBlock);
|
||||||
|
if (!event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onEvent(event) === false) {
|
||||||
|
shouldContinue = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
consumeBuffer();
|
||||||
|
if (!shouldContinue) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldContinue) {
|
||||||
|
// 流结束后 flush 解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
||||||
|
buffer += decoder.decode();
|
||||||
|
consumeBuffer();
|
||||||
|
completed = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!completed && typeof reader.cancel === 'function') {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
reader.releaseLock?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSseJsonStream(
|
||||||
|
response: Response,
|
||||||
|
onEvent: SseStreamEventHandler<SseJsonStreamEvent>,
|
||||||
|
) {
|
||||||
|
return readSseStream(response, (event) => {
|
||||||
|
const parsed = parseSseJsonObject(event.data);
|
||||||
|
if (!parsed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return onEvent({
|
||||||
|
...event,
|
||||||
|
parsed,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
VisualNovelRunSnapshot,
|
VisualNovelRunSnapshot,
|
||||||
VisualNovelRuntimeStreamEvent,
|
VisualNovelRuntimeStreamEvent,
|
||||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
|
import { readSseJsonStream } from '../sseStream';
|
||||||
|
|
||||||
type VisualNovelRuntimeSseOptions = {
|
type VisualNovelRuntimeSseOptions = {
|
||||||
fallbackMessage: string;
|
fallbackMessage: string;
|
||||||
@@ -9,65 +10,6 @@ type VisualNovelRuntimeSseOptions = {
|
|||||||
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
|
onEvent?: (event: VisualNovelRuntimeStreamEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
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[] = [];
|
|
||||||
|
|
||||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
eventName = line.slice(6).trim() || 'message';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (line.startsWith('data:')) {
|
|
||||||
dataLines.push(line.slice(5).trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
eventName,
|
|
||||||
data: dataLines.join('\n'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonObject(data: string) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeVisualNovelRuntimeEvent(
|
function normalizeVisualNovelRuntimeEvent(
|
||||||
eventName: string,
|
eventName: string,
|
||||||
parsed: Record<string, unknown>,
|
parsed: Record<string, unknown>,
|
||||||
@@ -115,59 +57,19 @@ export async function readVisualNovelRuntimeRunFromSse(
|
|||||||
response: Response,
|
response: Response,
|
||||||
options: VisualNovelRuntimeSseOptions,
|
options: VisualNovelRuntimeSseOptions,
|
||||||
) {
|
) {
|
||||||
const streamBody = response.body;
|
|
||||||
if (!streamBody) {
|
|
||||||
throw new Error('streaming response body is unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = streamBody.getReader();
|
|
||||||
const decoder = new TextDecoder('utf-8');
|
|
||||||
let buffer = '';
|
|
||||||
let finalRun: VisualNovelRunSnapshot | null = null;
|
let finalRun: VisualNovelRunSnapshot | null = null;
|
||||||
|
|
||||||
const consumeBuffer = () => {
|
await readSseJsonStream(response, ({ eventName, parsed }) => {
|
||||||
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 (!parsed) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = normalizeVisualNovelRuntimeEvent(eventName, parsed);
|
const event = normalizeVisualNovelRuntimeEvent(eventName, parsed);
|
||||||
if (!event) {
|
if (!event) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextRun = handleVisualNovelRuntimeEvent(event, options);
|
const nextRun = handleVisualNovelRuntimeEvent(event, options);
|
||||||
if (nextRun) {
|
if (nextRun) {
|
||||||
finalRun = nextRun;
|
finalRun = nextRun;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
consumeBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer += decoder.decode();
|
|
||||||
consumeBuffer();
|
|
||||||
|
|
||||||
if (!finalRun) {
|
if (!finalRun) {
|
||||||
throw new Error(options.incompleteMessage);
|
throw new Error(options.incompleteMessage);
|
||||||
|
|||||||
Reference in New Issue
Block a user