refactor: 补齐草稿与SSE收口
This commit is contained in:
@@ -20,7 +20,8 @@
|
|||||||
|
|
||||||
- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。
|
- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。
|
||||||
- 决策:继续扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,新增 `resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)` 与 `resolveVisualNovelDraftOpenIntent(...)`;平台壳只按 intent 执行 notice seen、详情打开、恢复 session、读取 work detail、清生成态和切 stage 副作用。
|
- 决策:继续扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,新增 `resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)` 与 `resolveVisualNovelDraftOpenIntent(...)`;平台壳只按 intent 执行 notice seen、详情打开、恢复 session、读取 work detail、清生成态和切 stage 副作用。
|
||||||
- 影响范围:创作中心作品架打开方洞挑战 / 大鱼吃小鱼 / 视觉小说草稿、创作 URL 恢复时强制打开草稿、生成中回到生成页和视觉小说结果页恢复。
|
- 追加决策:跳一跳与敲木鱼草稿打开也归入同一 Draft Generation Shelf Model,新增 `resolveJumpHopDraftOpenIntent(...)` 与 `resolveWoodenFishDraftOpenIntent(...)`;壳层只按 intent 执行已发布详情、失败生成页恢复、持久化 generating 恢复、读取 detail 和敲木鱼失败 fallback stage 副作用。
|
||||||
|
- 影响范围:创作中心作品架打开方洞挑战 / 大鱼吃小鱼 / 视觉小说 / 跳一跳 / 敲木鱼草稿、创作 URL 恢复时强制打开草稿、生成中回到生成页和视觉小说结果页恢复。
|
||||||
- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。
|
- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck`、`npm run check:encoding`。
|
||||||
- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。
|
- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。
|
||||||
|
|
||||||
@@ -114,9 +115,9 @@
|
|||||||
## 2026-06-03 前端 SSE 客户端传输层统一收口
|
## 2026-06-03 前端 SSE 客户端传输层统一收口
|
||||||
|
|
||||||
- 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush,导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。
|
- 背景:创作 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 循环。
|
- 决策:前端 SSE 传输层统一使用 `src/services/sseStream.ts`;`readSseStream` 负责事件边界、解码 flush、多行 data 和提前停止取消 reader,`readSseJsonStream` 负责 JSON object 事件解析与异常 JSON 静默跳过。业务 client 只保留领域事件归一化、结果聚合和中文错误文案,OpenAI 兼容文本流通过 `readSseStream` 处理 `[DONE]` 哨兵,后续不得复制 `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 相关测试与架构文档。
|
- 影响范围:`src/services/sseStream.ts`、`src/services/aiService.ts`、`src/services/llmClient.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` 通过。
|
- 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/llmClient.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`。
|
- 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`。
|
||||||
|
|
||||||
## 2026-06-03 平台入口公开作品流身份规则收口
|
## 2026-06-03 平台入口公开作品流身份规则收口
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
- `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。
|
- `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。
|
||||||
- `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。
|
- `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。
|
||||||
- `mergePuzzleWorkSummary(current, updated)` 与 `mergeBigFishWorkSummary(current, updated)`:统一作品详情更新后回填作品架和当前详情的身份匹配规则。
|
- `mergePuzzleWorkSummary(current, updated)` 与 `mergeBigFishWorkSummary(current, updated)`:统一作品详情更新后回填作品架和当前详情的身份匹配规则。
|
||||||
- `resolvePuzzleDraftOpenIntent(...)`、`resolveMatch3DDraftOpenIntent(...)`、`resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)` 与 `resolveVisualNovelDraftOpenIntent(...)`:统一拼图、抓大鹅、方洞挑战、大鱼吃小鱼和视觉小说草稿打开时的已发布详情、缺 session、ready 未读试玩、失败 / active / background 生成页、当前结果页、持久化 generating 恢复和普通草稿恢复优先级。
|
- `resolvePuzzleDraftOpenIntent(...)`、`resolveMatch3DDraftOpenIntent(...)`、`resolveSquareHoleDraftOpenIntent(...)`、`resolveBigFishDraftOpenIntent(...)`、`resolveVisualNovelDraftOpenIntent(...)`、`resolveJumpHopDraftOpenIntent(...)` 与 `resolveWoodenFishDraftOpenIntent(...)`:统一拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说、跳一跳和敲木鱼草稿打开时的已发布详情、缺 session、ready 未读试玩、失败 / active / background 生成页、当前结果页、持久化 generating 恢复、失败 fallback stage 和普通草稿恢复优先级。
|
||||||
- `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)`、`isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**。
|
- `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)`、`isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**。
|
||||||
|
|
||||||
`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和上述玩法草稿打开优先级。
|
`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和上述玩法草稿打开优先级。
|
||||||
|
|||||||
@@ -24,11 +24,13 @@
|
|||||||
- `src/services/creative-agent/creativeAgentSse.ts`
|
- `src/services/creative-agent/creativeAgentSse.ts`
|
||||||
- `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts`
|
- `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts`
|
||||||
- `src/services/rpg-entry/rpgProfileClient.ts`
|
- `src/services/rpg-entry/rpgProfileClient.ts`
|
||||||
|
- `src/services/llmClient.ts`
|
||||||
|
|
||||||
后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。
|
后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
- `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。
|
- `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。
|
||||||
|
- `src/services/llmClient.test.ts` 覆盖 OpenAI 兼容文本流、异常 JSON 跳过和 `[DONE]` 后提前停止。
|
||||||
- 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。
|
- 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。
|
||||||
- `npm run typecheck` 不产生新的类型错误。
|
- `npm run typecheck` 不产生新的类型错误。
|
||||||
|
|||||||
@@ -439,7 +439,6 @@ import {
|
|||||||
type DraftGenerationNoticeMap,
|
type DraftGenerationNoticeMap,
|
||||||
type DraftGenerationNoticeStatus,
|
type DraftGenerationNoticeStatus,
|
||||||
getGenerationNoticeShelfKeys,
|
getGenerationNoticeShelfKeys,
|
||||||
hasDraftGenerationNoticeStatus,
|
|
||||||
hasUnreadDraftGenerationUpdates,
|
hasUnreadDraftGenerationUpdates,
|
||||||
mergeBigFishWorkSummary,
|
mergeBigFishWorkSummary,
|
||||||
mergePuzzleWorkSummary,
|
mergePuzzleWorkSummary,
|
||||||
@@ -448,10 +447,12 @@ import {
|
|||||||
type PendingDraftShelfMap,
|
type PendingDraftShelfMap,
|
||||||
type PendingDraftShelfMetadata,
|
type PendingDraftShelfMetadata,
|
||||||
resolveBigFishDraftOpenIntent,
|
resolveBigFishDraftOpenIntent,
|
||||||
|
resolveJumpHopDraftOpenIntent,
|
||||||
resolveMatch3DDraftOpenIntent,
|
resolveMatch3DDraftOpenIntent,
|
||||||
resolvePuzzleDraftOpenIntent,
|
resolvePuzzleDraftOpenIntent,
|
||||||
resolveSquareHoleDraftOpenIntent,
|
resolveSquareHoleDraftOpenIntent,
|
||||||
resolveVisualNovelDraftOpenIntent,
|
resolveVisualNovelDraftOpenIntent,
|
||||||
|
resolveWoodenFishDraftOpenIntent,
|
||||||
} from './platformDraftGenerationShelfModel';
|
} from './platformDraftGenerationShelfModel';
|
||||||
import {
|
import {
|
||||||
canExposePublicWork,
|
canExposePublicWork,
|
||||||
@@ -2010,11 +2011,6 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
activePuzzleGenerationSessionIdRef.current === sessionId
|
activePuzzleGenerationSessionIdRef.current === sessionId
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
const isDraftNoticeFailed = useCallback(
|
|
||||||
(kind: CreationWorkShelfKind, ids: Array<string | null | undefined>) =>
|
|
||||||
hasDraftGenerationNoticeStatus(draftGenerationNotices, kind, ids, 'failed'),
|
|
||||||
[draftGenerationNotices],
|
|
||||||
);
|
|
||||||
const ensureEnoughDraftGenerationPointsFromServer = useCallback(
|
const ensureEnoughDraftGenerationPointsFromServer = useCallback(
|
||||||
async (pointsCost: number) => {
|
async (pointsCost: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -9920,12 +9916,17 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const openJumpHopDraft = useCallback(
|
const openJumpHopDraft = useCallback(
|
||||||
async (item: JumpHopWorkSummaryResponse) => {
|
async (item: JumpHopWorkSummaryResponse) => {
|
||||||
const noticeIds = [item.workId, item.profileId, item.sourceSessionId];
|
const openIntent = resolveJumpHopDraftOpenIntent({
|
||||||
const hasFailedNotice = isDraftNoticeFailed('jump-hop', noticeIds);
|
item,
|
||||||
const sessionId = normalizeCreationUrlValue(item.sourceSessionId);
|
notices: draftGenerationNotices,
|
||||||
markDraftNoticeSeen(collectDraftNoticeKeys('jump-hop', noticeIds));
|
generation: {
|
||||||
|
activeSessionId: jumpHopSession?.sessionId,
|
||||||
|
hasActiveGenerationFailure: jumpHopGenerationState?.phase === 'failed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
markDraftNoticeSeen(openIntent.noticeKeys);
|
||||||
|
|
||||||
if (item.publicationStatus === 'published') {
|
if (openIntent.type === 'open-published-detail') {
|
||||||
void openJumpHopPublicWorkDetail(item.profileId);
|
void openJumpHopPublicWorkDetail(item.profileId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9933,18 +9934,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setJumpHopError(null);
|
setJumpHopError(null);
|
||||||
setPublicWorkDetailError(null);
|
setPublicWorkDetailError(null);
|
||||||
setIsJumpHopBusy(true);
|
setIsJumpHopBusy(true);
|
||||||
if (
|
if (openIntent.type === 'active-failed-generation') {
|
||||||
hasFailedNotice &&
|
|
||||||
sessionId === jumpHopSession?.sessionId &&
|
|
||||||
jumpHopGenerationState?.phase === 'failed'
|
|
||||||
) {
|
|
||||||
enterCreateTab();
|
enterCreateTab();
|
||||||
setSelectionStage('jump-hop-generating');
|
setSelectionStage('jump-hop-generating');
|
||||||
setIsJumpHopBusy(false);
|
setIsJumpHopBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.generationStatus === 'generating' && !hasFailedNotice) {
|
if (openIntent.type === 'restore-generating') {
|
||||||
const pendingSession = buildJumpHopPendingSession(item);
|
const pendingSession = buildJumpHopPendingSession(item);
|
||||||
setJumpHopSession(pendingSession);
|
setJumpHopSession(pendingSession);
|
||||||
setJumpHopRun(null);
|
setJumpHopRun(null);
|
||||||
@@ -9981,8 +9978,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
draftGenerationNotices,
|
||||||
enterCreateTab,
|
enterCreateTab,
|
||||||
isDraftNoticeFailed,
|
|
||||||
jumpHopGenerationState?.phase,
|
jumpHopGenerationState?.phase,
|
||||||
jumpHopSession?.sessionId,
|
jumpHopSession?.sessionId,
|
||||||
markDraftNoticeSeen,
|
markDraftNoticeSeen,
|
||||||
@@ -10016,13 +10013,18 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const openWoodenFishDraft = useCallback(
|
const openWoodenFishDraft = useCallback(
|
||||||
async (item: WoodenFishWorkSummaryResponse) => {
|
async (item: WoodenFishWorkSummaryResponse) => {
|
||||||
const noticeIds = [item.workId, item.profileId, item.sourceSessionId];
|
const openIntent = resolveWoodenFishDraftOpenIntent({
|
||||||
const hasFailedNotice = isDraftNoticeFailed('wooden-fish', noticeIds);
|
item,
|
||||||
const sessionId =
|
notices: draftGenerationNotices,
|
||||||
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
|
generation: {
|
||||||
markDraftNoticeSeen(collectDraftNoticeKeys('wooden-fish', noticeIds));
|
activeSessionId: woodenFishSession?.sessionId,
|
||||||
|
hasActiveGenerationFailure:
|
||||||
|
woodenFishGenerationState?.phase === 'failed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
markDraftNoticeSeen(openIntent.noticeKeys);
|
||||||
|
|
||||||
if (item.publicationStatus === 'published') {
|
if (openIntent.type === 'open-published-detail') {
|
||||||
void openWoodenFishPublicWorkDetail(item.profileId);
|
void openWoodenFishPublicWorkDetail(item.profileId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -10030,18 +10032,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setWoodenFishError(null);
|
setWoodenFishError(null);
|
||||||
setPublicWorkDetailError(null);
|
setPublicWorkDetailError(null);
|
||||||
setIsWoodenFishBusy(true);
|
setIsWoodenFishBusy(true);
|
||||||
if (
|
if (openIntent.type === 'active-failed-generation') {
|
||||||
hasFailedNotice &&
|
|
||||||
sessionId === woodenFishSession?.sessionId &&
|
|
||||||
woodenFishGenerationState?.phase === 'failed'
|
|
||||||
) {
|
|
||||||
enterCreateTab();
|
enterCreateTab();
|
||||||
setSelectionStage('wooden-fish-generating');
|
setSelectionStage('wooden-fish-generating');
|
||||||
setIsWoodenFishBusy(false);
|
setIsWoodenFishBusy(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.generationStatus === 'generating' && !hasFailedNotice) {
|
if (openIntent.type === 'restore-generating') {
|
||||||
const pendingSession = buildWoodenFishPendingSession(item);
|
const pendingSession = buildWoodenFishPendingSession(item);
|
||||||
setWoodenFishSession(pendingSession);
|
setWoodenFishSession(pendingSession);
|
||||||
setWoodenFishRun(null);
|
setWoodenFishRun(null);
|
||||||
@@ -10083,16 +10081,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
resolveRpgCreationErrorMessage(error, '读取敲木鱼草稿失败。'),
|
resolveRpgCreationErrorMessage(error, '读取敲木鱼草稿失败。'),
|
||||||
);
|
);
|
||||||
enterCreateTab();
|
enterCreateTab();
|
||||||
setSelectionStage(
|
setSelectionStage(openIntent.failureFallbackStage);
|
||||||
hasFailedNotice ? 'wooden-fish-workspace' : 'wooden-fish-generating',
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsWoodenFishBusy(false);
|
setIsWoodenFishBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
draftGenerationNotices,
|
||||||
enterCreateTab,
|
enterCreateTab,
|
||||||
isDraftNoticeFailed,
|
|
||||||
markDraftNoticeSeen,
|
markDraftNoticeSeen,
|
||||||
openWoodenFishPublicWorkDetail,
|
openWoodenFishPublicWorkDetail,
|
||||||
woodenFishGenerationState?.phase,
|
woodenFishGenerationState?.phase,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||||
|
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||||
|
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||||
import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf';
|
import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf';
|
||||||
import {
|
import {
|
||||||
buildCreationWorkShelfRuntimeState,
|
buildCreationWorkShelfRuntimeState,
|
||||||
@@ -19,10 +21,12 @@ import {
|
|||||||
mergeBigFishWorkSummary,
|
mergeBigFishWorkSummary,
|
||||||
mergePuzzleWorkSummary,
|
mergePuzzleWorkSummary,
|
||||||
resolveBigFishDraftOpenIntent,
|
resolveBigFishDraftOpenIntent,
|
||||||
|
resolveJumpHopDraftOpenIntent,
|
||||||
resolveMatch3DDraftOpenIntent,
|
resolveMatch3DDraftOpenIntent,
|
||||||
resolvePuzzleDraftOpenIntent,
|
resolvePuzzleDraftOpenIntent,
|
||||||
resolveSquareHoleDraftOpenIntent,
|
resolveSquareHoleDraftOpenIntent,
|
||||||
resolveVisualNovelDraftOpenIntent,
|
resolveVisualNovelDraftOpenIntent,
|
||||||
|
resolveWoodenFishDraftOpenIntent,
|
||||||
} from './platformDraftGenerationShelfModel';
|
} from './platformDraftGenerationShelfModel';
|
||||||
|
|
||||||
describe('platformDraftGenerationShelfModel', () => {
|
describe('platformDraftGenerationShelfModel', () => {
|
||||||
@@ -280,6 +284,121 @@ describe('platformDraftGenerationShelfModel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveJumpHopDraftOpenIntent handles published, failed current generation, generating and detail states', () => {
|
||||||
|
expect(
|
||||||
|
resolveJumpHopDraftOpenIntent({
|
||||||
|
item: buildJumpHopWork({ publicationStatus: 'published' }),
|
||||||
|
notices: {},
|
||||||
|
generation: emptyGenerationFacts(),
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'open-published-detail',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveJumpHopDraftOpenIntent({
|
||||||
|
item: buildJumpHopWork(),
|
||||||
|
notices: {
|
||||||
|
'jump-hop:jump-hop-session-base': {
|
||||||
|
status: 'failed',
|
||||||
|
seen: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generation: emptyGenerationFacts({
|
||||||
|
activeSessionId: 'jump-hop-session-base',
|
||||||
|
hasActiveGenerationFailure: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'active-failed-generation',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveJumpHopDraftOpenIntent({
|
||||||
|
item: buildJumpHopWork({ generationStatus: 'generating' }),
|
||||||
|
notices: {},
|
||||||
|
generation: emptyGenerationFacts(),
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'restore-generating',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveJumpHopDraftOpenIntent({
|
||||||
|
item: buildJumpHopWork({ generationStatus: 'generating' }),
|
||||||
|
notices: {
|
||||||
|
'jump-hop:jump-hop-session-base': {
|
||||||
|
status: 'failed',
|
||||||
|
seen: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generation: emptyGenerationFacts(),
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'load-detail',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveWoodenFishDraftOpenIntent uses profile fallback and failure fallback stage', () => {
|
||||||
|
expect(
|
||||||
|
resolveWoodenFishDraftOpenIntent({
|
||||||
|
item: buildWoodenFishWork({
|
||||||
|
sourceSessionId: null,
|
||||||
|
generationStatus: 'generating',
|
||||||
|
}),
|
||||||
|
notices: {
|
||||||
|
'wooden-fish:wooden-fish-profile-base': {
|
||||||
|
status: 'failed',
|
||||||
|
seen: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generation: emptyGenerationFacts({
|
||||||
|
activeSessionId: 'wooden-fish-profile-base',
|
||||||
|
hasActiveGenerationFailure: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'active-failed-generation',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveWoodenFishDraftOpenIntent({
|
||||||
|
item: buildWoodenFishWork({ generationStatus: 'generating' }),
|
||||||
|
notices: {},
|
||||||
|
generation: emptyGenerationFacts(),
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'restore-generating',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveWoodenFishDraftOpenIntent({
|
||||||
|
item: buildWoodenFishWork(),
|
||||||
|
notices: {
|
||||||
|
'wooden-fish:wooden-fish-session-base': {
|
||||||
|
status: 'failed',
|
||||||
|
seen: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generation: emptyGenerationFacts(),
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'load-detail',
|
||||||
|
failureFallbackStage: 'wooden-fish-workspace',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveWoodenFishDraftOpenIntent({
|
||||||
|
item: buildWoodenFishWork(),
|
||||||
|
notices: {},
|
||||||
|
generation: emptyGenerationFacts(),
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: 'load-detail',
|
||||||
|
failureFallbackStage: 'wooden-fish-generating',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => {
|
test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => {
|
||||||
const pending = buildPendingPuzzleWorks(
|
const pending = buildPendingPuzzleWorks(
|
||||||
{
|
{
|
||||||
@@ -604,3 +723,51 @@ function buildVisualNovelWork(
|
|||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildJumpHopWork(
|
||||||
|
overrides: Partial<JumpHopWorkSummaryResponse> = {},
|
||||||
|
): JumpHopWorkSummaryResponse {
|
||||||
|
return {
|
||||||
|
runtimeKind: 'jump-hop',
|
||||||
|
workId: 'jump-hop-work-base',
|
||||||
|
profileId: 'jump-hop-profile-base',
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
sourceSessionId: 'jump-hop-session-base',
|
||||||
|
workTitle: '潮雾跳一跳',
|
||||||
|
workDescription: '潮雾港口跳一跳。',
|
||||||
|
themeTags: [],
|
||||||
|
difficulty: 'standard',
|
||||||
|
stylePreset: 'minimal-blocks',
|
||||||
|
coverImageSrc: null,
|
||||||
|
publicationStatus: 'draft',
|
||||||
|
playCount: 0,
|
||||||
|
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||||
|
publishedAt: null,
|
||||||
|
publishReady: false,
|
||||||
|
generationStatus: 'ready',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWoodenFishWork(
|
||||||
|
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
|
||||||
|
): WoodenFishWorkSummaryResponse {
|
||||||
|
return {
|
||||||
|
runtimeKind: 'wooden-fish',
|
||||||
|
workId: 'wooden-fish-work-base',
|
||||||
|
profileId: 'wooden-fish-profile-base',
|
||||||
|
ownerUserId: 'user-1',
|
||||||
|
sourceSessionId: 'wooden-fish-session-base',
|
||||||
|
workTitle: '潮雾敲木鱼',
|
||||||
|
workDescription: '潮雾港口敲木鱼。',
|
||||||
|
themeTags: ['敲木鱼'],
|
||||||
|
coverImageSrc: null,
|
||||||
|
publicationStatus: 'draft',
|
||||||
|
playCount: 0,
|
||||||
|
updatedAt: '2026-06-03T08:00:00.000Z',
|
||||||
|
publishedAt: null,
|
||||||
|
publishReady: false,
|
||||||
|
generationStatus: 'ready',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -203,6 +203,43 @@ export type VisualNovelDraftOpenIntent =
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type JumpHopDraftOpenIntent =
|
||||||
|
| {
|
||||||
|
type: 'open-published-detail';
|
||||||
|
noticeKeys: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'active-failed-generation';
|
||||||
|
noticeKeys: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'restore-generating';
|
||||||
|
noticeKeys: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'load-detail';
|
||||||
|
noticeKeys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WoodenFishDraftOpenIntent =
|
||||||
|
| {
|
||||||
|
type: 'open-published-detail';
|
||||||
|
noticeKeys: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'active-failed-generation';
|
||||||
|
noticeKeys: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'restore-generating';
|
||||||
|
noticeKeys: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'load-detail';
|
||||||
|
noticeKeys: string[];
|
||||||
|
failureFallbackStage: 'wooden-fish-workspace' | 'wooden-fish-generating';
|
||||||
|
};
|
||||||
|
|
||||||
export function buildDraftNoticeKey(
|
export function buildDraftNoticeKey(
|
||||||
kind: CreationWorkShelfKind,
|
kind: CreationWorkShelfKind,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -505,6 +542,26 @@ export function buildSquareHoleDraftOpenNoticeKeys(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildJumpHopDraftOpenNoticeKeys(
|
||||||
|
item: JumpHopWorkSummaryResponse,
|
||||||
|
) {
|
||||||
|
return collectDraftNoticeKeys('jump-hop', [
|
||||||
|
item.workId,
|
||||||
|
item.profileId,
|
||||||
|
item.sourceSessionId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWoodenFishDraftOpenNoticeKeys(
|
||||||
|
item: WoodenFishWorkSummaryResponse,
|
||||||
|
) {
|
||||||
|
return collectDraftNoticeKeys('wooden-fish', [
|
||||||
|
item.workId,
|
||||||
|
item.profileId,
|
||||||
|
item.sourceSessionId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildVisualNovelDraftOpenNoticeKeys(
|
export function buildVisualNovelDraftOpenNoticeKeys(
|
||||||
item: VisualNovelWorkSummary,
|
item: VisualNovelWorkSummary,
|
||||||
) {
|
) {
|
||||||
@@ -817,6 +874,96 @@ export function resolveVisualNovelDraftOpenIntent(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveJumpHopDraftOpenIntent(params: {
|
||||||
|
item: JumpHopWorkSummaryResponse;
|
||||||
|
notices: DraftGenerationNoticeMap;
|
||||||
|
generation: Pick<
|
||||||
|
DraftOpenGenerationFacts,
|
||||||
|
'activeSessionId' | 'hasActiveGenerationFailure'
|
||||||
|
>;
|
||||||
|
}): JumpHopDraftOpenIntent {
|
||||||
|
const { item, notices, generation } = params;
|
||||||
|
const noticeKeys = buildJumpHopDraftOpenNoticeKeys(item);
|
||||||
|
|
||||||
|
if (item.publicationStatus === 'published') {
|
||||||
|
return { type: 'open-published-detail', noticeKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
const noticeIds = [item.workId, item.profileId, item.sourceSessionId];
|
||||||
|
const sourceSessionId = normalizeDraftNoticeId(item.sourceSessionId);
|
||||||
|
const hasFailedNotice = hasDraftGenerationNoticeStatus(
|
||||||
|
notices,
|
||||||
|
'jump-hop',
|
||||||
|
noticeIds,
|
||||||
|
'failed',
|
||||||
|
);
|
||||||
|
const isCurrentSession =
|
||||||
|
sourceSessionId === normalizeDraftNoticeId(generation.activeSessionId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasFailedNotice &&
|
||||||
|
isCurrentSession &&
|
||||||
|
generation.hasActiveGenerationFailure
|
||||||
|
) {
|
||||||
|
return { type: 'active-failed-generation', noticeKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPersistedDraftGenerating(item.generationStatus) && !hasFailedNotice) {
|
||||||
|
return { type: 'restore-generating', noticeKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'load-detail', noticeKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWoodenFishDraftOpenIntent(params: {
|
||||||
|
item: WoodenFishWorkSummaryResponse;
|
||||||
|
notices: DraftGenerationNoticeMap;
|
||||||
|
generation: Pick<
|
||||||
|
DraftOpenGenerationFacts,
|
||||||
|
'activeSessionId' | 'hasActiveGenerationFailure'
|
||||||
|
>;
|
||||||
|
}): WoodenFishDraftOpenIntent {
|
||||||
|
const { item, notices, generation } = params;
|
||||||
|
const noticeKeys = buildWoodenFishDraftOpenNoticeKeys(item);
|
||||||
|
|
||||||
|
if (item.publicationStatus === 'published') {
|
||||||
|
return { type: 'open-published-detail', noticeKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
const noticeIds = [item.workId, item.profileId, item.sourceSessionId];
|
||||||
|
const sourceSessionId =
|
||||||
|
normalizeDraftNoticeId(item.sourceSessionId) ??
|
||||||
|
normalizeDraftNoticeId(item.profileId);
|
||||||
|
const hasFailedNotice = hasDraftGenerationNoticeStatus(
|
||||||
|
notices,
|
||||||
|
'wooden-fish',
|
||||||
|
noticeIds,
|
||||||
|
'failed',
|
||||||
|
);
|
||||||
|
const isCurrentSession =
|
||||||
|
sourceSessionId === normalizeDraftNoticeId(generation.activeSessionId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasFailedNotice &&
|
||||||
|
isCurrentSession &&
|
||||||
|
generation.hasActiveGenerationFailure
|
||||||
|
) {
|
||||||
|
return { type: 'active-failed-generation', noticeKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPersistedDraftGenerating(item.generationStatus) && !hasFailedNotice) {
|
||||||
|
return { type: 'restore-generating', noticeKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'load-detail',
|
||||||
|
noticeKeys,
|
||||||
|
failureFallbackStage: hasFailedNotice
|
||||||
|
? 'wooden-fish-workspace'
|
||||||
|
: 'wooden-fish-generating',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCreationWorkShelfRuntimeState(params: {
|
export function buildCreationWorkShelfRuntimeState(params: {
|
||||||
item: CreationWorkShelfItem;
|
item: CreationWorkShelfItem;
|
||||||
notices: DraftGenerationNoticeMap;
|
notices: DraftGenerationNoticeMap;
|
||||||
|
|||||||
51
src/services/llmClient.test.ts
Normal file
51
src/services/llmClient.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { streamPlainTextCompletion } from './llmClient';
|
||||||
|
|
||||||
|
function createSseResponse(body: string) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(body));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('llmClient streamPlainTextCompletion', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads OpenAI compatible SSE through the shared stream reader', async () => {
|
||||||
|
const onUpdate = vi.fn();
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
createSseResponse(
|
||||||
|
[
|
||||||
|
'data: {"choices":[{"delta":{"content":"溪上"}}]}\r\n\r\n',
|
||||||
|
'data: not-json\r\n\r\n',
|
||||||
|
'data: {"choices":[{"delta":{"content":"春风"}}]}\r\n\r\n',
|
||||||
|
'data: [DONE]\r\n\r\n',
|
||||||
|
'data: {"choices":[{"delta":{"content":"不应读取"}}]}\r\n\r\n',
|
||||||
|
].join(''),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const result = await streamPlainTextCompletion('system', 'user', {
|
||||||
|
onUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('溪上春风');
|
||||||
|
expect(onUpdate).toHaveBeenNthCalledWith(1, '溪上');
|
||||||
|
expect(onUpdate).toHaveBeenNthCalledWith(2, '溪上春风');
|
||||||
|
expect(onUpdate).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {TextStreamOptions} from './aiTypes';
|
import type {TextStreamOptions} from './aiTypes';
|
||||||
import { fetchWithApiAuth } from './apiClient';
|
import { fetchWithApiAuth } from './apiClient';
|
||||||
|
import { parseSseJsonObject, readSseStream } from './sseStream';
|
||||||
|
|
||||||
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
||||||
|
|
||||||
@@ -44,6 +45,26 @@ function resolveHeaders(headers?: HeadersInit) {
|
|||||||
return nextHeaders;
|
return nextHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readLlmStreamDeltaContent(parsed: Record<string, unknown>) {
|
||||||
|
const choices = parsed.choices;
|
||||||
|
if (!Array.isArray(choices)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [firstChoice] = choices;
|
||||||
|
if (typeof firstChoice !== 'object' || firstChoice === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = (firstChoice as {delta?: unknown}).delta;
|
||||||
|
if (typeof delta !== 'object' || delta === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (delta as {content?: unknown}).content;
|
||||||
|
return typeof content === 'string' && content.length > 0 ? content : null;
|
||||||
|
}
|
||||||
|
|
||||||
const NODE_ENV = getNodeEnv();
|
const NODE_ENV = getNodeEnv();
|
||||||
const IS_SERVER_RUNTIME = typeof window === 'undefined';
|
const IS_SERVER_RUNTIME = typeof window === 'undefined';
|
||||||
const SERVER_API_KEY =
|
const SERVER_API_KEY =
|
||||||
@@ -291,48 +312,20 @@ export async function streamPlainTextCompletion(
|
|||||||
return fallbackText;
|
return fallbackText;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
const delta = parsed ? readLlmStreamDeltaContent(parsed) : null;
|
||||||
while (buffer.includes('\n\n')) {
|
if (delta) {
|
||||||
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();
|
|
||||||
if (!data || data === '[DONE]') {
|
|
||||||
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 and continue consuming the stream.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return accumulatedText.trim();
|
return accumulatedText.trim();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user