This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -8,6 +8,7 @@ import { beforeEach, expect, test, vi } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
@@ -32,6 +33,7 @@ import {
createRpgCreationSession,
executeRpgCreationAction,
getRpgCreationOperation,
getRpgCreationResultView,
getRpgCreationSession,
listRpgCreationWorks,
streamRpgCreationMessage,
@@ -101,6 +103,7 @@ vi.mock('../../services/rpg-creation', () => ({
createRpgCreationSession: vi.fn(),
executeRpgCreationAction: vi.fn(),
getRpgCreationOperation: vi.fn(),
getRpgCreationResultView: vi.fn(),
getRpgCreationSession: vi.fn(),
listRpgCreationWorks: vi.fn(),
streamRpgCreationMessage: vi.fn(),
@@ -523,6 +526,48 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
},
};
function buildResultViewForSession(
session: CustomWorldAgentSessionSnapshot,
): RpgCreationResultView {
const profile = session.resultPreview?.preview ?? null;
const isResultStage =
session.stage === 'object_refining' ||
session.stage === 'visual_refining' ||
session.stage === 'long_tail_review' ||
session.stage === 'ready_to_publish' ||
session.stage === 'published';
return {
session,
profile,
profileSource: profile ? 'result_preview' : 'none',
targetStage: profile && isResultStage
? 'custom-world-result'
: session.stage === 'error'
? 'custom-world-generating'
: 'agent-workspace',
generationViewSource: session.stage === 'error'
? 'agent-draft-foundation'
: null,
resultViewSource: profile && isResultStage ? 'agent-draft' : null,
canAutosaveLibrary: Boolean(profile && isResultStage),
canSyncResultProfile:
session.stage === 'object_refining' ||
session.stage === 'visual_refining' ||
session.stage === 'long_tail_review' ||
session.stage === 'ready_to_publish',
publishReady: Boolean(session.resultPreview?.publishReady),
canEnterWorld: Boolean(session.resultPreview?.canEnterWorld),
blockerCount: session.resultPreview?.blockers?.length ?? 0,
recoveryAction: profile && isResultStage
? 'open_result'
: session.stage === 'error'
? 'resume_generation'
: 'continue_agent',
recoveryReason: null,
};
}
type TestAuthValue = {
user: AuthUser | null;
canAccessProtectedData: boolean;
@@ -573,8 +618,11 @@ function TestWrapper({
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
onSelectWorld?: RpgEntryFlowShellProps['handleCustomWorldSelect'];
} = {}) {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
const [selectionStage, setSelectionStage] = useState<SelectionStage>(() =>
window.location.pathname === '/creation/rpg/agent'
? 'agent-workspace'
: 'platform',
);
const content = (
<RpgEntryFlowShell
@@ -600,7 +648,7 @@ function TestWrapper({
}
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
window.history.replaceState(null, '', '/');
window.sessionStorage.clear();
window.localStorage.clear();
@@ -667,6 +715,9 @@ beforeEach(() => {
vi.mocked(createRpgCreationSession).mockResolvedValue({
session: mockSession,
});
vi.mocked(getRpgCreationResultView).mockImplementation(async () =>
buildResultViewForSession(mockSession),
);
vi.mocked(createBigFishCreationSession).mockResolvedValue({
session: {
sessionId: 'big-fish-session-1',
@@ -757,9 +808,17 @@ beforeEach(() => {
level: 1,
name: '微光孢子',
oneLineFantasy: '像发光尘埃一样在深海漂浮。',
textDescription:
'微光孢子是机械深海生态中的起始个体,体型最小,会先漂浮试探并寻找可吞并目标。',
silhouetteDirection: '圆润微型机械球',
sizeRatio: 1,
visualDescription:
'带有浅色发光核心的微型机械鱼苗或孢子体,轮廓圆润,表现出弱小但灵动的初始形象。',
visualPromptSeed: 'deep sea glowing mechanical spore',
idleMotionDescription:
'待机时轻轻漂浮,身体和尾部做小幅摆动,像在适应深海水流。',
moveMotionDescription:
'移动时核心前探,尾部快速摆动推进,带出轻盈的游动轨迹。',
motionPromptSeed: 'soft floating mechanical spore',
mergeSourceLevel: null,
preyWindow: [1],
@@ -1168,6 +1227,9 @@ test('create tab opens compiled agent draft in result refinement page', async ()
},
]);
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(compiledAgentDraftSession),
);
render(<TestWrapper withAuth />);
@@ -1271,6 +1333,13 @@ test('create tab resumes agent workspace when session has no draft profile even
stage: 'clarifying',
draftProfile: null,
});
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession({
...mockSession,
stage: 'clarifying',
draftProfile: null,
}),
);
render(<TestWrapper withAuth />);
@@ -1316,13 +1385,13 @@ test('opening a compiled draft with a missing agent session falls back to create
])
.mockResolvedValueOnce([]);
vi.mocked(getRpgCreationSession).mockRejectedValueOnce(
new ApiClientError({
message: 'custom world agent session not found',
status: 404,
code: 'NOT_FOUND',
}),
);
const missingSessionError = new ApiClientError({
message: 'custom world agent session not found',
status: 404,
code: 'NOT_FOUND',
});
vi.mocked(getRpgCreationSession).mockRejectedValueOnce(missingSessionError);
vi.mocked(getRpgCreationResultView).mockRejectedValueOnce(missingSessionError);
render(<TestWrapper withAuth />);
@@ -1699,6 +1768,22 @@ test('restoring an agent workspace ignores a stored session owned by another use
expect(window.location.search).toBe('');
});
test('restoring an agent workspace ignores explicit session pointer without local owner after login', async () => {
window.history.replaceState(
null,
'',
'/?customWorldSessionId=custom-world-agent-session-legacy',
);
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(window.location.search).toBe('');
});
expect(getRpgCreationSession).not.toHaveBeenCalled();
});
test('refreshing platform home ignores stored agent workspace pointer without explicit restore path', async () => {
window.sessionStorage.setItem(
'genarrative.custom-world-agent-ui.v1',
@@ -2243,6 +2328,15 @@ test('failed draft work continues on generation progress view instead of agent w
},
]);
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue({
...buildResultViewForSession({
...mockSession,
stage: 'error',
}),
targetStage: 'custom-world-generating',
generationViewSource: 'agent-draft-foundation',
recoveryAction: 'resume_generation',
});
render(<TestWrapper withAuth />);
@@ -2267,6 +2361,9 @@ test('existing draft sessions open result page refinement instead of agent dialo
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(compiledAgentDraftSession),
);
render(<TestWrapper withAuth />);
@@ -2306,7 +2403,7 @@ test('agent result view shows publish blocker dialog before publish action when
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue({
const blockedSession = {
...compiledAgentDraftSession,
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
@@ -2319,7 +2416,11 @@ test('agent result view shows publish blocker dialog before publish action when
},
],
},
});
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(blockedSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(blockedSession),
);
render(<TestWrapper withAuth />);
@@ -2424,6 +2525,11 @@ test('agent draft result publishes to gallery from publish panel', async () => {
vi.mocked(getRpgCreationSession).mockImplementation(async () =>
hasPublishedWorld ? publishedSession : publishReadyDraftSession,
);
vi.mocked(getRpgCreationResultView).mockImplementation(async () =>
buildResultViewForSession(
hasPublishedWorld ? publishedSession : publishReadyDraftSession,
),
);
function PublishFlowWrapper() {
const [selectionStage, setSelectionStage] =
@@ -2482,7 +2588,7 @@ test('agent draft result test button enters current draft without publish gate',
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue({
const testDraftSession = {
...compiledAgentDraftSession,
stage: 'ready_to_publish',
resultPreview: {
@@ -2497,7 +2603,11 @@ test('agent draft result test button enters current draft without publish gate',
},
],
},
});
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(testDraftSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(testDraftSession),
);
function TestDraftWrapper() {
const [selectionStage, setSelectionStage] =
@@ -2582,7 +2692,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue({
const publishGateSession = {
...compiledAgentDraftSession,
stage: 'ready_to_publish',
resultPreview: {
@@ -2650,7 +2760,11 @@ test('agent result view does not keep legacy publish blockers when preview uses
],
},
},
});
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(publishGateSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(publishGateSession),
);
render(<TestWrapper withAuth />);
@@ -2809,6 +2923,9 @@ test('agent draft result back button returns to creation hub without syncing res
},
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(resultSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(resultSession),
);
render(<TestWrapper withAuth />);
@@ -2840,7 +2957,7 @@ test('agent draft result back button returns to creation hub without syncing res
expect(screen.queryByText('世界档案')).toBeNull();
});
test('agent draft result auto-save persists the latest profile from session draft without result sync action', async () => {
test('agent draft result auto-save syncs result profile before persisting backend result view', async () => {
const user = userEvent.setup();
const syncedSession = {
@@ -2940,6 +3057,35 @@ test('agent draft result auto-save persists the latest profile from session draf
},
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(syncedSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(syncedSession),
);
vi.mocked(executeRpgCreationAction).mockImplementation(async (_, payload) => ({
operation: {
operationId:
payload.action === 'sync_result_profile'
? 'operation-sync-result-profile-1'
: 'operation-draft-foundation-1',
type: payload.action,
status: 'queued',
phaseLabel: '已接收请求',
phaseDetail:
payload.action === 'sync_result_profile'
? '正在同步结果页档案。'
: '正在准备生成世界底稿。',
progress: 10,
error: null,
},
}));
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-sync-result-profile-1',
type: 'sync_result_profile',
status: 'completed',
phaseLabel: '结果页档案已同步',
phaseDetail: '服务端已根据最新结果页档案刷新会话预览。',
progress: 100,
error: null,
});
render(<TestWrapper withAuth />);
@@ -2978,7 +3124,7 @@ test('agent draft result auto-save persists the latest profile from session draf
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
).toBe(true);
});
test('agent draft result can open from server result preview without embedded legacyResultProfile', async () => {
@@ -3021,6 +3167,9 @@ test('agent draft result can open from server result preview without embedded le
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(previewOnlySession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(previewOnlySession),
);
render(<TestWrapper withAuth />);

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import type { CustomWorldProfile } from '../../types';
import {
normalizeRpgEntryAgentBackedProfile,
stringifyRpgEntryAgentBackedProfile,
} from './rpgEntryShared';
describe('rpgEntryShared profile save boundary', () => {
it('does not rewrite settingText from creatorIntent on the frontend', () => {
const profile = {
id: 'cwprof_test',
settingText: '结果页用户正在编辑的草稿文案',
creatorIntent: {
worldHook: '海图会在午夜改写群岛航路',
playerPremise: '玩家是失忆领航员',
openingSituation: '正在禁航区醒来',
themeKeywords: ['海雾'],
toneDirectives: ['悬疑'],
coreConflicts: ['议会隐瞒沉船真相'],
keyCharacters: [
{
name: '顾潮音',
role: '守灯人',
relationToPlayer: '旧识',
hiddenHook: '掌握伪造海图',
},
],
iconicElements: ['会说谎的罗盘'],
},
} as CustomWorldProfile;
expect(normalizeRpgEntryAgentBackedProfile(profile)).toBe(profile);
expect(
JSON.parse(stringifyRpgEntryAgentBackedProfile(profile)),
).toMatchObject({
settingText: '结果页用户正在编辑的草稿文案',
});
});
});

View File

@@ -5,13 +5,9 @@ import type {
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { ApiClientError, isTimeoutError } from '../../services/apiClient';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import type { CustomWorldProfile } from '../../types';
export function resolveRpgEntryErrorMessage(
error: unknown,
fallback: string,
) {
export function resolveRpgEntryErrorMessage(error: unknown, fallback: string) {
if (isTimeoutError(error)) {
if (//u.test(fallback)) {
return '开启拼图创作工作台超时,请确认运行时后端已启动后重试。';
@@ -68,24 +64,15 @@ export function buildOptimisticRpgEntryAgentMessage(
export function normalizeRpgEntryAgentBackedProfile(
profile: CustomWorldProfile,
) {
const foundationText = buildCustomWorldCreatorIntentFoundationText(
profile.creatorIntent,
).trim();
if (!foundationText || foundationText === profile.settingText.trim()) {
return profile;
}
return {
...profile,
settingText: foundationText,
} satisfies CustomWorldProfile;
// 中文注释:保存前 canonicalize 已迁到 server-rs
// 这里保留透传函数只为了兼容旧导入,不再改写正式 profile 字段。
return profile;
}
export function stringifyRpgEntryAgentBackedProfile(
profile: CustomWorldProfile,
) {
return JSON.stringify(normalizeRpgEntryAgentBackedProfile(profile));
return JSON.stringify(profile);
}
export function buildRpgEntryCreationHubFallbackItems(
@@ -123,13 +110,9 @@ export function buildRpgEntryCreationHubFallbackItems(
* 兼容创作链工作包已经接入的旧 helper 命名,避免本轮迁移波及其他并行改动。
*/
export const resolveRpgCreationErrorMessage = resolveRpgEntryErrorMessage;
export const createFailedAgentOperation =
createFailedRpgEntryAgentOperation;
export const buildOptimisticAgentMessage =
buildOptimisticRpgEntryAgentMessage;
export const normalizeAgentBackedProfile =
normalizeRpgEntryAgentBackedProfile;
export const stringifyAgentBackedProfile =
stringifyRpgEntryAgentBackedProfile;
export const createFailedAgentOperation = createFailedRpgEntryAgentOperation;
export const buildOptimisticAgentMessage = buildOptimisticRpgEntryAgentMessage;
export const normalizeAgentBackedProfile = normalizeRpgEntryAgentBackedProfile;
export const stringifyAgentBackedProfile = stringifyRpgEntryAgentBackedProfile;
export const buildCreationHubFallbackItems =
buildRpgEntryCreationHubFallbackItems;

View File

@@ -115,11 +115,6 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
describe('useRpgCreationEnterWorld', () => {
it('Agent 草稿测试进入游戏时优先使用结果页当前 profile而不是回退到会话快照', async () => {
const staleResultProfile = buildProfile({
id: 'session-profile',
name: '会话旧快照',
imageSrc: '/template/old-role.png',
});
const resultProfile = buildProfile({
id: 'draft-profile',
name: '结果页真相源',
@@ -128,16 +123,21 @@ describe('useRpgCreationEnterWorld', () => {
const handleCustomWorldSelect = vi.fn();
const setGeneratedCustomWorldProfile = vi.fn();
const executePublishWorld = vi.fn(async () => buildSession());
const syncAgentCreationResultView = vi.fn();
const syncAgentDraftResultProfile = vi.fn(async () => ({
profile: resultProfile,
view: null,
}));
function Harness() {
const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
generatedCustomWorldProfile: resultProfile,
agentSessionProfile: staleResultProfile,
agentSession: buildSession(),
handleCustomWorldSelect,
syncAgentDraftResultProfile,
executePublishWorld,
syncAgentCreationResultView,
setGeneratedCustomWorldProfile,
});

View File

@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
@@ -9,13 +9,17 @@ type UseRpgCreationEnterWorldParams = {
isAgentDraftResultView: boolean;
activeAgentSessionId: string | null;
generatedCustomWorldProfile: CustomWorldProfile | null;
agentSessionProfile: CustomWorldProfile | null;
agentSession: CustomWorldAgentSessionSnapshot | null;
handleCustomWorldSelect: (
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => void;
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
syncAgentDraftResultProfile: (
profile: CustomWorldProfile,
) => Promise<{ profile: CustomWorldProfile | null; view?: RpgCreationResultView | null }>;
executePublishWorld: () => Promise<unknown>;
syncAgentCreationResultView: (
sessionId: string,
) => Promise<RpgCreationResultView | null>;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
};
@@ -30,10 +34,10 @@ export function useRpgCreationEnterWorld(
isAgentDraftResultView,
activeAgentSessionId,
generatedCustomWorldProfile,
agentSessionProfile,
agentSession,
handleCustomWorldSelect,
syncAgentDraftResultProfile,
executePublishWorld,
syncAgentCreationResultView,
setGeneratedCustomWorldProfile,
} = params;
@@ -44,7 +48,7 @@ export function useRpgCreationEnterWorld(
// 中文注释:作品测试必须复用“结果页当前真相源”。
// 用户在结果页看到并可能继续编辑的是 generatedCustomWorldProfile
// 如果这里又回退成会话里的 agentSessionProfile,就会出现
// 如果这里又回退成 session 里的旧 preview,就会出现
// “结果页看起来已经是新版,但作品测试实际进入的是旧版快照”的错位。
if (isAgentDraftResultView && activeAgentSessionId) {
setGeneratedCustomWorldProfile(generatedCustomWorldProfile);
@@ -73,36 +77,47 @@ export function useRpgCreationEnterWorld(
return generatedCustomWorldProfile;
}
if (!agentSessionProfile) {
const syncedResult = await syncAgentDraftResultProfile(
generatedCustomWorldProfile,
);
const latestProfile =
syncedResult.profile ??
rpgCreationPreviewAdapter.buildPreviewFromResultView(syncedResult.view);
if (!latestProfile) {
return null;
}
setGeneratedCustomWorldProfile(agentSessionProfile);
setGeneratedCustomWorldProfile(latestProfile);
const latestSession = agentSession;
const canEnterPublishedWorld =
latestSession?.stage === 'published' &&
latestSession.resultPreview?.canEnterWorld;
syncedResult.view?.session.stage === 'published' &&
syncedResult.view.canEnterWorld;
if (canEnterPublishedWorld) {
return agentSessionProfile;
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
return (
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
latestProfile
);
}
const publishedSession = await executePublishWorld();
await executePublishWorld();
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
const publishedProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
agentSessionProfile;
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
latestProfile;
setGeneratedCustomWorldProfile(publishedProfile);
return publishedProfile;
}, [
activeAgentSessionId,
agentSession,
agentSessionProfile,
executePublishWorld,
generatedCustomWorldProfile,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
syncAgentDraftResultProfile,
syncAgentCreationResultView,
]);
const enterWorldFromCurrentResult = useCallback(async () => {

View File

@@ -4,9 +4,9 @@ import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import {
executeRpgCreationAction,
getRpgCreationOperation,
@@ -14,7 +14,6 @@ import {
} from '../../services/rpg-creation';
import type { CustomWorldProfile } from '../../types';
import {
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
stringifyAgentBackedProfile,
} from './rpgEntryShared';
@@ -27,7 +26,6 @@ import type {
type UseRpgCreationResultAutosaveParams = {
selectionStage: SelectionStage;
activeAgentSessionId: string | null;
agentSession: CustomWorldAgentSessionSnapshot | null;
generatedCustomWorldProfile: CustomWorldProfile | null;
isAgentDraftResultView: boolean;
userId: string | null | undefined;
@@ -55,8 +53,11 @@ type UseRpgCreationResultAutosaveParams = {
syncAgentSessionSnapshot: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
syncAgentCreationResultView: (
sessionId: string,
) => Promise<RpgCreationResultView | null>;
buildDraftResultProfile: (
session: CustomWorldAgentSessionSnapshot | null,
view: RpgCreationResultView | null,
) => CustomWorldProfile | null;
};
@@ -70,7 +71,6 @@ export function useRpgCreationResultAutosave(
const {
selectionStage,
activeAgentSessionId,
agentSession,
generatedCustomWorldProfile,
isAgentDraftResultView,
userId,
@@ -81,6 +81,7 @@ export function useRpgCreationResultAutosave(
refreshCustomWorldWorks,
persistAgentUiState,
syncAgentSessionSnapshot,
syncAgentCreationResultView,
buildDraftResultProfile,
} = params;
@@ -118,29 +119,33 @@ export function useRpgCreationResultAutosave(
return null;
}
const normalizedProfile = normalizeAgentBackedProfile(profile);
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
const requestId = latestAutoSaveRequestIdRef.current + 1;
latestAutoSaveRequestIdRef.current = requestId;
setCustomWorldAutoSaveState('saving');
setCustomWorldAutoSaveError(null);
try {
const mutation =
await upsertRpgWorldProfile(
normalizedProfile,
{
sourceAgentSessionId:
isAgentDraftResultView && activeAgentSessionId
? activeAgentSessionId
: null,
},
);
const mutation = await upsertRpgWorldProfile(profile, {
sourceAgentSessionId:
isAgentDraftResultView && activeAgentSessionId
? activeAgentSessionId
: null,
});
if (latestAutoSaveRequestIdRef.current !== requestId) {
return mutation;
}
lastAutoSavedProfileSignatureRef.current = profileSignature;
const canonicalProfile =
normalizeCustomWorldProfileRecord(mutation.entry.profile) ??
mutation.entry.profile;
// Agent 结果页的界面真相来自 result-view作品库响应只用于列表与签名回写
// 避免旧兼容响应缺字段时覆盖当前完整编辑态。
lastAutoSavedProfileSignatureRef.current = stringifyAgentBackedProfile(
isAgentDraftResultView ? profile : canonicalProfile,
);
if (!isAgentDraftResultView) {
setGeneratedCustomWorldProfile(canonicalProfile);
}
setSavedCustomWorldEntries(mutation.entries);
if (userId) {
void refreshCustomWorldWorks().catch(() => {});
@@ -174,73 +179,8 @@ export function useRpgCreationResultAutosave(
refreshCustomWorldWorks,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
userId,
],
);
const syncAgentDraftResultProfile = useCallback(
async (profile: CustomWorldProfile) => {
if (!activeAgentSessionId) {
return {
session: null,
profile: null,
} satisfies SyncedAgentDraftResult;
}
const normalizedProfile = normalizeAgentBackedProfile(profile);
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
const latestSessionProfile = buildDraftResultProfile(agentSession);
const latestSessionProfileSignature = latestSessionProfile
? stringifyAgentBackedProfile(latestSessionProfile)
: '';
const shouldRefreshPublishGate = Boolean(
agentSession?.resultPreview && !agentSession.resultPreview.publishReady,
);
if (
latestSessionProfileSignature === profileSignature &&
!shouldRefreshPublishGate
) {
latestAgentResultSyncSignatureRef.current = profileSignature;
return {
session: agentSession,
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
} satisfies SyncedAgentDraftResult;
}
if (
latestAgentResultSyncSignatureRef.current === profileSignature &&
!shouldRefreshPublishGate
) {
return {
session: agentSession,
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
} satisfies SyncedAgentDraftResult;
}
// Agent 结果页不再把前端 profile 回写到 session。
// 这里只刷新后端结果页快照,避免在采集/生成早期误触 sync_result_profile。
const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId);
const latestProfile = normalizeAgentBackedProfile(
buildDraftResultProfile(latestSession) ?? profile,
);
if (latestProfile) {
setGeneratedCustomWorldProfile(latestProfile);
}
latestAgentResultSyncSignatureRef.current =
stringifyAgentBackedProfile(latestProfile);
return {
session: latestSession,
profile: latestProfile,
} satisfies SyncedAgentDraftResult;
},
[
activeAgentSessionId,
agentSession,
buildDraftResultProfile,
setGeneratedCustomWorldProfile,
syncAgentSessionSnapshot,
userId,
],
);
@@ -290,6 +230,65 @@ export function useRpgCreationResultAutosave(
],
);
const syncAgentDraftResultProfile = useCallback(
async (profile: CustomWorldProfile) => {
if (!activeAgentSessionId) {
return {
session: null,
profile: null,
} satisfies SyncedAgentDraftResult;
}
const profileSignature = stringifyAgentBackedProfile(profile);
const currentView =
await syncAgentCreationResultView(activeAgentSessionId);
if (!currentView?.canSyncResultProfile) {
const latestProfile = buildDraftResultProfile(currentView) ?? profile;
if (latestProfile) {
setGeneratedCustomWorldProfile(latestProfile);
}
latestAgentResultSyncSignatureRef.current =
stringifyAgentBackedProfile(latestProfile);
return {
session: currentView?.session ?? null,
profile: latestProfile,
view: currentView,
} satisfies SyncedAgentDraftResult;
}
if (latestAgentResultSyncSignatureRef.current !== profileSignature) {
await executeAgentActionAndWait({
action: 'sync_result_profile',
profile: profile as unknown as Record<string, unknown>,
});
latestAgentResultSyncSignatureRef.current = profileSignature;
}
const latestView =
await syncAgentCreationResultView(activeAgentSessionId);
const latestProfile = buildDraftResultProfile(latestView) ?? profile;
if (latestProfile) {
setGeneratedCustomWorldProfile(latestProfile);
}
latestAgentResultSyncSignatureRef.current =
stringifyAgentBackedProfile(latestProfile);
return {
session: latestView?.session ?? null,
profile: latestProfile,
view: latestView,
} satisfies SyncedAgentDraftResult;
},
[
activeAgentSessionId,
buildDraftResultProfile,
executeAgentActionAndWait,
setGeneratedCustomWorldProfile,
syncAgentCreationResultView,
],
);
useEffect(
() => () => {
if (customWorldAutoSaveTimeoutRef.current !== null) {
@@ -313,7 +312,9 @@ export function useRpgCreationResultAutosave(
return;
}
const nextSignature = stringifyAgentBackedProfile(generatedCustomWorldProfile);
const nextSignature = stringifyAgentBackedProfile(
generatedCustomWorldProfile,
);
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
return;
}
@@ -328,14 +329,16 @@ export function useRpgCreationResultAutosave(
void (async () => {
isCustomWorldAutoSaveBusyRef.current = true;
try {
let latestProfileToSave = normalizeAgentBackedProfile(profileToSave);
let latestProfileToSave = profileToSave;
if (isAgentDraftResultView) {
const syncedResult =
await syncAgentDraftResultProfile(profileToSave);
if (syncedResult.view && !syncedResult.view.canAutosaveLibrary) {
setCustomWorldAutoSaveState('idle');
return;
}
// 作品库自动保存优先落同步后 session 重编译出的结果,避免继续保存旧的前端内存态。
latestProfileToSave = normalizeAgentBackedProfile(
syncedResult.profile ?? profileToSave,
);
latestProfileToSave = syncedResult.profile ?? profileToSave;
}
await saveGeneratedCustomWorld(latestProfileToSave);
} catch (error) {

View File

@@ -21,6 +21,7 @@ import {
import {
createRpgCreationSession,
executeRpgCreationAction,
getRpgCreationResultView,
getRpgCreationSession,
streamRpgCreationMessage,
} from '../../services/rpg-creation';
@@ -29,7 +30,6 @@ import type { CustomWorldProfile } from '../../types';
import {
buildOptimisticAgentMessage,
createFailedAgentOperation,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from './rpgEntryShared';
import type {
@@ -40,7 +40,9 @@ import type {
type UseRpgCreationSessionControllerParams = {
userId: string | null | undefined;
openLoginModal?: ((postLoginAction?: (() => void) | null) => void) | undefined;
openLoginModal?:
| ((postLoginAction?: (() => void) | null) => void)
| undefined;
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
enterCreateTab?: (() => void) | undefined;
@@ -70,12 +72,23 @@ export function useRpgCreationSessionController(
const shouldRestoreInitialAgentUiStateRef = useRef(
shouldRestoreCustomWorldAgentUiState(),
);
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
const isInitialAgentGenerationRestore =
Boolean(initialAgentUiStateRef.current.activeOperationId) &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation';
const canResolveInitialAgentSessionOwner =
!initialAgentSessionId ||
!userId ||
Boolean(initialAgentUiStateRef.current.ownerUserId) ||
isInitialAgentGenerationRestore;
const isInitialAgentUiStateOwnedByCurrentUser =
!initialAgentUiStateRef.current.ownerUserId ||
initialAgentUiStateRef.current.ownerUserId === userId;
canResolveInitialAgentSessionOwner &&
(!initialAgentUiStateRef.current.ownerUserId ||
initialAgentUiStateRef.current.ownerUserId === userId);
const isHydratingInitialAgentWorkspaceRef = useRef(
Boolean(
initialAgentUiStateRef.current.activeSessionId &&
initialAgentSessionId &&
shouldRestoreInitialAgentUiStateRef.current &&
isInitialAgentUiStateOwnedByCurrentUser,
),
@@ -115,9 +128,12 @@ export function useRpgCreationSessionController(
const [pendingAgentUserMessage, setPendingAgentUserMessage] =
useState<PendingAgentUserMessage | null>(null);
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [creationTypeError, setCreationTypeError] = useState<string | null>(null);
const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] =
useState<string | null>(null);
const [creationTypeError, setCreationTypeError] = useState<string | null>(
null,
);
const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] = useState<
string | null
>(null);
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<CustomWorldProfile | null>(null);
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
@@ -127,7 +143,10 @@ export function useRpgCreationSessionController(
useState<CustomWorldResultViewSource>(null);
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
useState<number | null>(null);
const pendingAgentUserMessageRef = useRef<PendingAgentUserMessage | null>(null);
const pendingAgentUserMessageRef = useRef<PendingAgentUserMessage | null>(
null,
);
const latestAgentResultViewOpenRequestIdRef = useRef(0);
useEffect(() => {
currentAgentSessionIdRef.current = agentSession?.sessionId ?? null;
@@ -191,35 +210,54 @@ export function useRpgCreationSessionController(
[userId],
);
const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => {
const requestId = latestAgentSessionSyncRequestIdRef.current + 1;
latestAgentSessionSyncRequestIdRef.current = requestId;
const nextSession = await getRpgCreationSession(sessionId);
const mergedSession = mergePendingAgentUserMessageIntoSession(nextSession);
const syncAgentSessionSnapshot = useCallback(
async (sessionId: string) => {
const requestId = latestAgentSessionSyncRequestIdRef.current + 1;
latestAgentSessionSyncRequestIdRef.current = requestId;
const nextSession = await getRpgCreationSession(sessionId);
const mergedSession =
mergePendingAgentUserMessageIntoSession(nextSession);
if (latestAgentSessionSyncRequestIdRef.current === requestId) {
setAgentSession(mergedSession);
const currentPendingAgentUserMessage = pendingAgentUserMessageRef.current;
const hasServerEchoedPendingMessage =
currentPendingAgentUserMessage?.sessionId === nextSession.sessionId &&
nextSession.messages.some(
(message) => message.id === currentPendingAgentUserMessage.message.id,
);
if (hasServerEchoedPendingMessage) {
setPendingAgentUserMessage(null);
if (latestAgentSessionSyncRequestIdRef.current === requestId) {
setAgentSession(mergedSession);
const currentPendingAgentUserMessage =
pendingAgentUserMessageRef.current;
const hasServerEchoedPendingMessage =
currentPendingAgentUserMessage?.sessionId === nextSession.sessionId &&
nextSession.messages.some(
(message) =>
message.id === currentPendingAgentUserMessage.message.id,
);
if (hasServerEchoedPendingMessage) {
setPendingAgentUserMessage(null);
}
}
}
return mergedSession;
}, [mergePendingAgentUserMessageIntoSession]);
return mergedSession;
},
[mergePendingAgentUserMessageIntoSession],
);
const syncAgentCreationResultView = useCallback(
async (sessionId: string) => {
const resultView = await getRpgCreationResultView(sessionId);
const mergedSession = mergePendingAgentUserMessageIntoSession(
resultView.session,
);
setAgentSession(mergedSession);
return {
...resultView,
session: mergedSession ?? resultView.session,
};
},
[mergePendingAgentUserMessageIntoSession],
);
useEffect(() => {
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
const initialAgentSessionId =
initialAgentUiStateRef.current.activeSessionId;
if (
!initialAgentSessionId ||
hasAppliedInitialAgentWorkspaceRef.current
) {
if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) {
return;
}
@@ -260,6 +298,20 @@ export function useRpgCreationSessionController(
return;
}
if (
!initialAgentUiStateRef.current.ownerUserId &&
!(
initialAgentUiStateRef.current.activeOperationId &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation'
)
) {
hasAppliedInitialAgentWorkspaceRef.current = true;
isHydratingInitialAgentWorkspaceRef.current = false;
persistAgentUiState(null, null);
return;
}
if (
initialAgentUiStateRef.current.ownerUserId &&
initialAgentUiStateRef.current.ownerUserId !== userId
@@ -283,7 +335,13 @@ export function useRpgCreationSessionController(
}
setSelectionStage('agent-workspace');
}, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]);
}, [
enterCreateTab,
openLoginModal,
persistAgentUiState,
setSelectionStage,
userId,
]);
useEffect(() => {
if (
@@ -365,7 +423,10 @@ export function useRpgCreationSessionController(
setAgentWorkspaceRestoreError(null);
} else {
setAgentWorkspaceRestoreError(
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
resolveRpgCreationErrorMessage(
error,
'读取 Agent 共创工作区失败。',
),
);
}
setAgentSession(null);
@@ -426,37 +487,32 @@ export function useRpgCreationSessionController(
attempt += 1
) {
await new Promise((resolve) => {
window.setTimeout(
resolve,
AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS,
);
window.setTimeout(resolve, AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS);
});
if (cancelled) {
return;
}
const latestSession = activeAgentSessionId
? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
const latestResultView = activeAgentSessionId
? await syncAgentCreationResultView(activeAgentSessionId).catch(
() => null,
)
: agentSession;
: null;
if (cancelled) {
return;
}
const draftResultProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession ?? agentSession,
rpgCreationPreviewAdapter.buildPreviewFromResultView(
latestResultView,
);
if (!draftResultProfile) {
continue;
}
setGeneratedCustomWorldProfile(
normalizeAgentBackedProfile(draftResultProfile),
);
setGeneratedCustomWorldProfile(draftResultProfile);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('agent-draft');
@@ -479,7 +535,7 @@ export function useRpgCreationSessionController(
customWorldGenerationViewSource,
selectionStage,
setSelectionStage,
syncAgentSessionSnapshot,
syncAgentCreationResultView,
]);
const agentDraftSettingPreview = useMemo(
@@ -490,25 +546,6 @@ export function useRpgCreationSessionController(
() => buildAgentDraftFoundationAnchorEntries(agentSession),
[agentSession],
);
const agentDraftResultProfile = useMemo(
() => rpgCreationPreviewAdapter.buildPreviewFromSession(agentSession),
[agentSession],
);
const shouldAutoOpenAgentDraftResult = useMemo(
() =>
Boolean(
agentDraftResultProfile &&
agentSession &&
(agentSession.stage === 'object_refining' ||
agentSession.stage === 'visual_refining' ||
agentSession.stage === 'long_tail_review' ||
agentSession.stage === 'ready_to_publish' ||
agentSession.stage === 'published') &&
agentSession.draftCards.length > 0,
),
[agentDraftResultProfile, agentSession],
);
const agentDraftGenerationProgress = useMemo(
() =>
buildAgentDraftFoundationGenerationProgress(
@@ -530,7 +567,11 @@ export function useRpgCreationSessionController(
: null;
useEffect(() => {
if (!shouldAutoOpenAgentDraftResult || !agentDraftResultProfile) {
if (
!agentSession ||
!activeAgentSessionId ||
agentSession.draftCards.length === 0
) {
return;
}
@@ -538,28 +579,63 @@ export function useRpgCreationSessionController(
return;
}
if (selectionStage === 'agent-workspace') {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
isAgentDraftResultAutoOpenSuppressedRef.current = false;
setSelectionStage('custom-world-result');
if (
selectionStage !== 'agent-workspace' &&
selectionStage !== 'custom-world-result'
) {
return;
}
if (
selectionStage === 'custom-world-result' &&
!generatedCustomWorldProfile
generatedCustomWorldProfile
) {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
isAgentDraftResultAutoOpenSuppressedRef.current = false;
return;
}
const requestId = latestAgentResultViewOpenRequestIdRef.current + 1;
latestAgentResultViewOpenRequestIdRef.current = requestId;
let cancelled = false;
void syncAgentCreationResultView(activeAgentSessionId)
.then((resultView) => {
if (
cancelled ||
latestAgentResultViewOpenRequestIdRef.current !== requestId ||
isAgentDraftResultAutoOpenSuppressedRef.current ||
resultView.targetStage !== 'custom-world-result'
) {
return;
}
const resultProfile =
rpgCreationPreviewAdapter.buildPreviewFromResultView(resultView);
if (!resultProfile) {
return;
}
setGeneratedCustomWorldProfile(resultProfile);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(
resultView.resultViewSource ?? 'agent-draft',
);
isAgentDraftResultAutoOpenSuppressedRef.current = false;
if (selectionStage === 'agent-workspace') {
setSelectionStage('custom-world-result');
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [
agentDraftResultProfile,
activeAgentSessionId,
agentSession,
generatedCustomWorldProfile,
selectionStage,
setSelectionStage,
shouldAutoOpenAgentDraftResult,
syncAgentCreationResultView,
]);
const openRpgAgentWorkspace = useCallback(
@@ -689,26 +765,26 @@ export function useRpgCreationSessionController(
kind: 'warning',
text: errorMessage,
});
setAgentSession((current) =>
{
const mergedCurrentSession = mergePendingAgentUserMessageIntoSession(
current,
pendingMessagePayload,
);
return mergedCurrentSession
? {
...mergedCurrentSession,
messages: [...mergedCurrentSession.messages, warningMessage],
updatedAt: warningMessage.createdAt,
}
: current;
},
);
setAgentSession((current) => {
const mergedCurrentSession = mergePendingAgentUserMessageIntoSession(
current,
pendingMessagePayload,
);
return mergedCurrentSession
? {
...mergedCurrentSession,
messages: [...mergedCurrentSession.messages, warningMessage],
updatedAt: warningMessage.createdAt,
}
: current;
});
setPendingAgentUserMessage(null);
setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null);
} finally {
if (activeAgentReplyAbortControllerRef.current === replyAbortController) {
if (
activeAgentReplyAbortControllerRef.current === replyAbortController
) {
activeAgentReplyAbortControllerRef.current = null;
}
if (!replyAbortController.signal.aborted) {
@@ -780,9 +856,7 @@ export function useRpgCreationSessionController(
const setNormalizedGeneratedCustomWorldProfile = useCallback(
(profile: CustomWorldProfile | null) => {
setGeneratedCustomWorldProfile(
profile ? normalizeAgentBackedProfile(profile) : null,
);
setGeneratedCustomWorldProfile(profile);
},
[],
);
@@ -807,7 +881,8 @@ export function useRpgCreationSessionController(
return {
initialAgentSessionId:
shouldRestoreInitialAgentUiStateRef.current
shouldRestoreInitialAgentUiStateRef.current &&
isInitialAgentUiStateOwnedByCurrentUser
? (initialAgentUiStateRef.current.activeSessionId ?? null)
: null,
isCreatingAgentSession,
@@ -837,7 +912,10 @@ export function useRpgCreationSessionController(
setAgentDraftGenerationStartedAt,
agentDraftSettingPreview,
agentDraftAnchorPreviewEntries,
agentDraftResultProfile,
agentDraftResultProfile:
rpgCreationPreviewAdapter.buildPreviewFromResultPreview(
agentSession?.resultPreview,
),
agentDraftGenerationProgress,
isAgentDraftGenerationView,
isAgentDraftResultView,
@@ -848,6 +926,7 @@ export function useRpgCreationSessionController(
releaseAgentDraftResultAutoOpenSuppression,
persistAgentUiState,
syncAgentSessionSnapshot,
syncAgentCreationResultView,
openRpgAgentWorkspace,
submitAgentMessage,
executeAgentAction,

View File

@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import { WorldType, type CustomWorldProfile } from '../../types';
import {
executeRpgCreationAction,
getRpgCreationOperation,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
@@ -109,16 +111,42 @@ function buildSession(
};
}
function buildResultView(
overrides: Partial<RpgCreationResultView> = {},
): RpgCreationResultView {
const session = overrides.session ?? buildSession();
return {
session,
profile: null,
profileSource: 'none',
targetStage: 'agent-workspace',
generationViewSource: null,
resultViewSource: null,
canAutosaveLibrary: false,
canSyncResultProfile: false,
publishReady: false,
canEnterWorld: false,
blockerCount: 0,
recoveryAction: 'continue_agent',
recoveryReason: null,
...overrides,
};
}
describe('RPG Agent 草稿恢复', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('作品摘要已有对象数量但 session 没有 draftProfile 时恢复 Agent 页面', async () => {
const syncAgentSessionSnapshot = vi.fn(async () =>
buildSession({
stage: 'clarifying',
draftProfile: null,
const syncAgentCreationResultView = vi.fn(async () =>
buildResultView({
session: buildSession({
stage: 'clarifying',
draftProfile: null,
}),
targetStage: 'agent-workspace',
recoveryAction: 'continue_agent',
}),
);
const setSelectionStage = vi.fn();
@@ -150,9 +178,9 @@ describe('RPG Agent 草稿恢复', () => {
refreshCustomWorldWorks: vi.fn(async () => []),
refreshPublishedGallery: vi.fn(async () => []),
persistAgentUiState,
syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
(session?.draftProfile as CustomWorldProfile | null) ?? null,
syncAgentCreationResultView,
buildDraftResultProfile: (view) =>
(view?.profile as CustomWorldProfile | null) ?? null,
suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
resetAutoSaveTrackingToIdle: vi.fn(),
@@ -183,7 +211,7 @@ describe('RPG Agent 草稿恢复', () => {
});
});
expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1');
expect(syncAgentCreationResultView).toHaveBeenCalledWith('agent-session-1');
expect(suppressAgentDraftResultAutoOpen).toHaveBeenCalled();
expect(persistAgentUiState).toHaveBeenCalledWith('agent-session-1', null);
expect(setGeneratedCustomWorldProfile).toHaveBeenLastCalledWith(null);
@@ -192,7 +220,7 @@ describe('RPG Agent 草稿恢复', () => {
expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result');
});
it('Agent 结果页自动保存只刷新 session draftProfile不触发 sync_result_profile', async () => {
it('Agent 结果页自动保存先回写 session,再保存后端 result-view profile', async () => {
const oldProfile = buildProfile('旧前端快照');
const latestProfile = {
...buildProfile('服务端草稿快照'),
@@ -203,6 +231,36 @@ describe('RPG Agent 草稿恢复', () => {
draftProfile: latestProfile as unknown as Record<string, unknown>,
});
const syncAgentSessionSnapshot = vi.fn(async () => latestSession);
const syncAgentCreationResultView = vi.fn(async () =>
buildResultView({
session: latestSession,
profile: latestProfile,
profileSource: 'result_preview',
targetStage: 'custom-world-result',
resultViewSource: 'agent-draft',
canAutosaveLibrary: true,
canSyncResultProfile: true,
recoveryAction: 'open_result',
}),
);
vi.mocked(executeRpgCreationAction).mockResolvedValue({
operation: {
operationId: 'operation-sync-result',
type: 'sync_result_profile',
status: 'running',
phaseLabel: '结果页同步中',
phaseDetail: '正在同步结果页。',
progress: 50,
},
});
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-sync-result',
type: 'sync_result_profile',
status: 'completed',
phaseLabel: '结果页已同步',
phaseDetail: '结果页已同步。',
progress: 100,
});
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
entry: {
@@ -230,16 +288,6 @@ describe('RPG Agent 草稿恢复', () => {
useRpgCreationResultAutosave({
selectionStage: 'custom-world-result',
activeAgentSessionId: 'agent-session-1',
agentSession: buildSession({
stage: 'object_refining',
draftProfile: oldProfile as unknown as Record<string, unknown>,
resultPreview: {
publishReady: false,
blockers: [],
qualityFindings: [],
sourceLabel: '旧预览',
} as never,
}),
generatedCustomWorldProfile: oldProfile,
isAgentDraftResultView: true,
userId: 'user-1',
@@ -250,8 +298,9 @@ describe('RPG Agent 草稿恢复', () => {
refreshCustomWorldWorks: vi.fn(async () => []),
persistAgentUiState: vi.fn(),
syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
(session?.draftProfile as CustomWorldProfile | null) ?? null,
syncAgentCreationResultView,
buildDraftResultProfile: (view) =>
(view?.profile as CustomWorldProfile | null) ?? null,
});
return null;
@@ -266,13 +315,23 @@ describe('RPG Agent 草稿恢复', () => {
vi.useRealTimers();
expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1');
expect(upsertRpgWorldProfile).toHaveBeenCalledWith(latestProfile, {
sourceAgentSessionId: 'agent-session-1',
expect(syncAgentCreationResultView).toHaveBeenCalledWith('agent-session-1');
expect(executeRpgCreationAction).toHaveBeenCalledWith('agent-session-1', {
action: 'sync_result_profile',
profile: expect.objectContaining({
id: oldProfile.id,
name: oldProfile.name,
}),
});
expect(
vi.mocked(executeRpgCreationAction).mock.calls.some(
([, payload]) => payload?.action === 'sync_result_profile',
),
).toBe(false);
expect(upsertRpgWorldProfile).toHaveBeenCalledWith(
expect.objectContaining({
id: latestProfile.id,
name: latestProfile.name,
summary: latestProfile.summary,
}),
{
sourceAgentSessionId: 'agent-session-1',
},
);
});
});

View File

@@ -1,9 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
@@ -22,10 +20,7 @@ import {
unpublishRpgEntryWorldProfile,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import type { CustomWorldProfile } from '../../types';
import {
normalizeRpgEntryAgentBackedProfile,
resolveRpgEntryErrorMessage,
} from './rpgEntryShared';
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
import type {
CustomWorldAutoSaveState,
CustomWorldGenerationViewSource,
@@ -48,18 +43,14 @@ type UseRpgEntryLibraryDetailParams = {
setSavedCustomWorldEntries: (
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
) => void;
setGeneratedCustomWorldProfile: (
profile: CustomWorldProfile | null,
) => void;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
setCustomWorldError: (error: string | null) => void;
setCustomWorldAutoSaveError: (error: string | null) => void;
setCustomWorldAutoSaveState: (state: CustomWorldAutoSaveState) => void;
setCustomWorldGenerationViewSource: (
source: CustomWorldGenerationViewSource,
) => void;
setCustomWorldResultViewSource: (
source: CustomWorldResultViewSource,
) => void;
setCustomWorldResultViewSource: (source: CustomWorldResultViewSource) => void;
setSelectionStage: (stage: SelectionStage) => void;
setPlatformTabToCreate: () => void;
setPlatformError: (error: string | null) => void;
@@ -73,11 +64,11 @@ type UseRpgEntryLibraryDetailParams = {
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
syncAgentCreationResultView: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
) => Promise<RpgCreationResultView | null>;
buildDraftResultProfile: (
session: CustomWorldAgentSessionSnapshot | null,
view: RpgCreationResultView | null,
) => CustomWorldProfile | null;
suppressAgentDraftResultAutoOpen: () => void;
releaseAgentDraftResultAutoOpenSuppression: () => void;
@@ -85,14 +76,6 @@ type UseRpgEntryLibraryDetailParams = {
markAutoSavedProfile: (profile: CustomWorldProfile) => void;
};
const AGENT_RESULT_STAGES = new Set([
'object_refining',
'visual_refining',
'long_tail_review',
'ready_to_publish',
'published',
]);
function isMissingRpgEntryAgentSessionError(error: unknown) {
return (
error instanceof ApiClientError &&
@@ -127,7 +110,7 @@ export function useRpgEntryLibraryDetail(
refreshCustomWorldWorks,
refreshPublishedGallery,
persistAgentUiState,
syncAgentSessionSnapshot,
syncAgentCreationResultView,
buildDraftResultProfile,
suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression,
@@ -225,11 +208,8 @@ export function useRpgEntryLibraryDetail(
setCustomWorldResultViewSource(null);
setCustomWorldError(null);
setGeneratedCustomWorldProfile(null);
const normalizedProfile = normalizeRpgEntryAgentBackedProfile(
entry.profile,
);
setGeneratedCustomWorldProfile(normalizedProfile);
markAutoSavedProfile(normalizedProfile);
setGeneratedCustomWorldProfile(entry.profile);
markAutoSavedProfile(entry.profile);
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
setCustomWorldError(null);
@@ -262,34 +242,28 @@ export function useRpgEntryLibraryDetail(
resetAutoSaveTrackingToIdle();
try {
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
const shouldOpenAgentWorkspace =
!latestSession?.draftProfile ||
!latestSession.stage ||
!AGENT_RESULT_STAGES.has(latestSession.stage);
const resultView = await syncAgentCreationResultView(work.sessionId);
const nextProfile = buildDraftResultProfile(resultView);
const shouldResumeFailedGenerationView =
!nextProfile &&
//u.test(`${work.stageLabel ?? ''}${work.summary ?? ''}`);
if (shouldResumeFailedGenerationView) {
if (resultView?.targetStage === 'custom-world-generating') {
// 生成过程中失败的草稿要回到生成过程页承接错误处理,避免误回 Agent 对话。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
resultView.generationViewSource ?? 'agent-draft-foundation',
);
setGeneratedCustomWorldProfile(null);
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldGenerationViewSource(
resultView.generationViewSource ?? 'agent-draft-foundation',
);
setCustomWorldResultViewSource(null);
setPlatformTabToCreate();
setSelectionStage('custom-world-generating');
return;
}
if (shouldOpenAgentWorkspace) {
if (resultView?.targetStage === 'agent-workspace') {
// 还没有服务端草稿真相源时只能恢复 Agent对象数量等摘要字段不能决定结果页入口。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(work.sessionId, null);
@@ -302,12 +276,10 @@ export function useRpgEntryLibraryDetail(
releaseAgentDraftResultAutoOpenSuppression();
if (!nextProfile) {
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
persistAgentUiState(work.sessionId, null, 'agent-draft-foundation');
setPlatformError(
'当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。',
);
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTabToCreate();
setCustomWorldGenerationViewSource('agent-draft-foundation');
setSelectionStage('custom-world-generating');
@@ -315,12 +287,12 @@ export function useRpgEntryLibraryDetail(
}
persistAgentUiState(work.sessionId, null);
setGeneratedCustomWorldProfile(
normalizeRpgEntryAgentBackedProfile(nextProfile),
setGeneratedCustomWorldProfile(nextProfile);
setCustomWorldResultViewSource(
resultView?.resultViewSource ?? 'agent-draft',
);
setCustomWorldResultViewSource('agent-draft');
setPlatformTabToCreate();
setSelectionStage('custom-world-result');
setSelectionStage(resultView?.targetStage ?? 'custom-world-result');
return;
} catch (error) {
if (isMissingRpgEntryAgentSessionError(error)) {
@@ -391,7 +363,7 @@ export function useRpgEntryLibraryDetail(
setSavedCustomWorldEntries,
setSelectionStage,
suppressAgentDraftResultAutoOpen,
syncAgentSessionSnapshot,
syncAgentCreationResultView,
userId,
],
);