From bbb9269bab8ccf76c90b742d969d47f24aa745a3 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 06:26:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=A1=A5=E9=BD=90=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E4=B8=8ESSE=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 9 +- ...DraftGenerationShelfModel收口计划-2026-06-03.md | 2 +- ...架构】SSE客户端传输层收口约定-2026-06-03.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 64 ++++--- .../platformDraftGenerationShelfModel.test.ts | 167 ++++++++++++++++++ .../platformDraftGenerationShelfModel.ts | 147 +++++++++++++++ src/services/llmClient.test.ts | 51 ++++++ src/services/llmClient.ts | 67 ++++--- 8 files changed, 433 insertions(+), 76 deletions(-) create mode 100644 src/services/llmClient.test.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 0f6852e8..01124161 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -20,7 +20,8 @@ - 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。 - 决策:继续扩展 `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`。 - 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`。 @@ -114,9 +115,9 @@ ## 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` 通过。 +- 决策:前端 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/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/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`。 ## 2026-06-03 平台入口公开作品流身份规则收口 diff --git a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md index da131994..2c508c5f 100644 --- a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md @@ -15,7 +15,7 @@ - `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。 - `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。 - `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**。 `PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和上述玩法草稿打开优先级。 diff --git a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md index 0948019a..de90be6b 100644 --- a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md +++ b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md @@ -24,11 +24,13 @@ - `src/services/creative-agent/creativeAgentSse.ts` - `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts` - `src/services/rpg-entry/rpgProfileClient.ts` +- `src/services/llmClient.ts` 后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。 ## 验收 - `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。 +- `src/services/llmClient.test.ts` 覆盖 OpenAI 兼容文本流、异常 JSON 跳过和 `[DONE]` 后提前停止。 - 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。 - `npm run typecheck` 不产生新的类型错误。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a76165f2..46b7a8d3 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -439,7 +439,6 @@ import { type DraftGenerationNoticeMap, type DraftGenerationNoticeStatus, getGenerationNoticeShelfKeys, - hasDraftGenerationNoticeStatus, hasUnreadDraftGenerationUpdates, mergeBigFishWorkSummary, mergePuzzleWorkSummary, @@ -448,10 +447,12 @@ import { type PendingDraftShelfMap, type PendingDraftShelfMetadata, resolveBigFishDraftOpenIntent, + resolveJumpHopDraftOpenIntent, resolveMatch3DDraftOpenIntent, resolvePuzzleDraftOpenIntent, resolveSquareHoleDraftOpenIntent, resolveVisualNovelDraftOpenIntent, + resolveWoodenFishDraftOpenIntent, } from './platformDraftGenerationShelfModel'; import { canExposePublicWork, @@ -2010,11 +2011,6 @@ export function PlatformEntryFlowShellImpl({ activePuzzleGenerationSessionIdRef.current === sessionId ); }, []); - const isDraftNoticeFailed = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => - hasDraftGenerationNoticeStatus(draftGenerationNotices, kind, ids, 'failed'), - [draftGenerationNotices], - ); const ensureEnoughDraftGenerationPointsFromServer = useCallback( async (pointsCost: number) => { try { @@ -9920,12 +9916,17 @@ export function PlatformEntryFlowShellImpl({ const openJumpHopDraft = useCallback( async (item: JumpHopWorkSummaryResponse) => { - const noticeIds = [item.workId, item.profileId, item.sourceSessionId]; - const hasFailedNotice = isDraftNoticeFailed('jump-hop', noticeIds); - const sessionId = normalizeCreationUrlValue(item.sourceSessionId); - markDraftNoticeSeen(collectDraftNoticeKeys('jump-hop', noticeIds)); + const openIntent = resolveJumpHopDraftOpenIntent({ + item, + notices: draftGenerationNotices, + 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); return; } @@ -9933,18 +9934,14 @@ export function PlatformEntryFlowShellImpl({ setJumpHopError(null); setPublicWorkDetailError(null); setIsJumpHopBusy(true); - if ( - hasFailedNotice && - sessionId === jumpHopSession?.sessionId && - jumpHopGenerationState?.phase === 'failed' - ) { + if (openIntent.type === 'active-failed-generation') { enterCreateTab(); setSelectionStage('jump-hop-generating'); setIsJumpHopBusy(false); return; } - if (item.generationStatus === 'generating' && !hasFailedNotice) { + if (openIntent.type === 'restore-generating') { const pendingSession = buildJumpHopPendingSession(item); setJumpHopSession(pendingSession); setJumpHopRun(null); @@ -9981,8 +9978,8 @@ export function PlatformEntryFlowShellImpl({ } }, [ + draftGenerationNotices, enterCreateTab, - isDraftNoticeFailed, jumpHopGenerationState?.phase, jumpHopSession?.sessionId, markDraftNoticeSeen, @@ -10016,13 +10013,18 @@ export function PlatformEntryFlowShellImpl({ const openWoodenFishDraft = useCallback( async (item: WoodenFishWorkSummaryResponse) => { - const noticeIds = [item.workId, item.profileId, item.sourceSessionId]; - const hasFailedNotice = isDraftNoticeFailed('wooden-fish', noticeIds); - const sessionId = - normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; - markDraftNoticeSeen(collectDraftNoticeKeys('wooden-fish', noticeIds)); + const openIntent = resolveWoodenFishDraftOpenIntent({ + item, + notices: draftGenerationNotices, + generation: { + activeSessionId: woodenFishSession?.sessionId, + hasActiveGenerationFailure: + woodenFishGenerationState?.phase === 'failed', + }, + }); + markDraftNoticeSeen(openIntent.noticeKeys); - if (item.publicationStatus === 'published') { + if (openIntent.type === 'open-published-detail') { void openWoodenFishPublicWorkDetail(item.profileId); return; } @@ -10030,18 +10032,14 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishError(null); setPublicWorkDetailError(null); setIsWoodenFishBusy(true); - if ( - hasFailedNotice && - sessionId === woodenFishSession?.sessionId && - woodenFishGenerationState?.phase === 'failed' - ) { + if (openIntent.type === 'active-failed-generation') { enterCreateTab(); setSelectionStage('wooden-fish-generating'); setIsWoodenFishBusy(false); return; } - if (item.generationStatus === 'generating' && !hasFailedNotice) { + if (openIntent.type === 'restore-generating') { const pendingSession = buildWoodenFishPendingSession(item); setWoodenFishSession(pendingSession); setWoodenFishRun(null); @@ -10083,16 +10081,14 @@ export function PlatformEntryFlowShellImpl({ resolveRpgCreationErrorMessage(error, '读取敲木鱼草稿失败。'), ); enterCreateTab(); - setSelectionStage( - hasFailedNotice ? 'wooden-fish-workspace' : 'wooden-fish-generating', - ); + setSelectionStage(openIntent.failureFallbackStage); } finally { setIsWoodenFishBusy(false); } }, [ + draftGenerationNotices, enterCreateTab, - isDraftNoticeFailed, markDraftNoticeSeen, openWoodenFishPublicWorkDetail, woodenFishGenerationState?.phase, diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts index 156d2196..7242e7b1 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from 'vitest'; 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 { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; 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 { buildCreationWorkShelfRuntimeState, @@ -19,10 +21,12 @@ import { mergeBigFishWorkSummary, mergePuzzleWorkSummary, resolveBigFishDraftOpenIntent, + resolveJumpHopDraftOpenIntent, resolveMatch3DDraftOpenIntent, resolvePuzzleDraftOpenIntent, resolveSquareHoleDraftOpenIntent, resolveVisualNovelDraftOpenIntent, + resolveWoodenFishDraftOpenIntent, } from './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', () => { const pending = buildPendingPuzzleWorks( { @@ -604,3 +723,51 @@ function buildVisualNovelWork( ...overrides, }; } + +function buildJumpHopWork( + overrides: Partial = {}, +): 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 { + 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, + }; +} diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts index dc5b5220..de16f098 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -203,6 +203,43 @@ export type VisualNovelDraftOpenIntent = 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( kind: CreationWorkShelfKind, 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( 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: { item: CreationWorkShelfItem; notices: DraftGenerationNoticeMap; diff --git a/src/services/llmClient.test.ts b/src/services/llmClient.test.ts new file mode 100644 index 00000000..a46df049 --- /dev/null +++ b/src/services/llmClient.test.ts @@ -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({ + 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); + }); +}); diff --git a/src/services/llmClient.ts b/src/services/llmClient.ts index 04a972fe..5182cc35 100644 --- a/src/services/llmClient.ts +++ b/src/services/llmClient.ts @@ -1,5 +1,6 @@ import type {TextStreamOptions} from './aiTypes'; import { fetchWithApiAuth } from './apiClient'; +import { parseSseJsonObject, readSseStream } from './sseStream'; const ENV: Partial = import.meta.env ?? {}; @@ -44,6 +45,26 @@ function resolveHeaders(headers?: HeadersInit) { return nextHeaders; } +function readLlmStreamDeltaContent(parsed: Record) { + 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 IS_SERVER_RUNTIME = typeof window === 'undefined'; const SERVER_API_KEY = @@ -291,48 +312,20 @@ export async function streamPlainTextCompletion( return fallbackText; } - const reader = response.body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buffer = ''; let accumulatedText = ''; - for (;;) { - const {done, value} = await reader.read(); - if (done) { - break; + await readSseStream(response, ({ data }) => { + if (data === '[DONE]') { + return false; } - 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 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; - options.onUpdate?.(accumulatedText); - } - } catch { - // Ignore malformed SSE frames and continue consuming the stream. - } - } + const parsed = parseSseJsonObject(data); + const delta = parsed ? readLlmStreamDeltaContent(parsed) : null; + if (delta) { + accumulatedText += delta; + options.onUpdate?.(accumulatedText); } - } + }); return accumulatedText.trim(); } catch (error) {