refactor: 补齐草稿与SSE收口

This commit is contained in:
2026-06-04 06:26:09 +08:00
parent c93b8fb570
commit bbb9269bab
8 changed files with 433 additions and 76 deletions

View File

@@ -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<string | null | undefined>) =>
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,

View File

@@ -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> = {},
): 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,
};
}

View File

@@ -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;

View 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);
});
});

View File

@@ -1,5 +1,6 @@
import type {TextStreamOptions} from './aiTypes';
import { fetchWithApiAuth } from './apiClient';
import { parseSseJsonObject, readSseStream } from './sseStream';
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
@@ -44,6 +45,26 @@ function resolveHeaders(headers?: HeadersInit) {
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 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) {