feat: restore generation draft persistence

This commit is contained in:
2026-04-25 11:41:09 +08:00
60 changed files with 38221 additions and 382 deletions

View File

@@ -73,4 +73,19 @@ describe('CharacterAnimator portrait fallbacks', () => {
expect(image.style.transform).toContain('rotate(-90deg)');
expect(image.style.transform).toContain('scaleX(-1)');
});
it('uses generated portrait for movement when generated animation is missing', () => {
render(
<CharacterAnimator
state={AnimationState.RUN}
character={buildCharacter({generatedVisualAssetId: 'assetobj-role-main'})}
/>,
);
const image = screen.getByRole('img', {
name: /沈砺 run animation/i,
}) as HTMLImageElement;
expect(image.getAttribute('src')).toBe('/generated/portrait.png');
});
});

View File

@@ -107,6 +107,9 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
playbackRate = 1,
}) => {
const explicitConfig = character.animationMap?.[state];
const hasGeneratedPortraitOnly =
Boolean(character.generatedVisualAssetId && character.portrait?.trim())
&& !explicitConfig;
const usePortraitIdleFallback =
!explicitConfig && state === AnimationState.IDLE;
const usePortraitDeathFallback =
@@ -118,7 +121,7 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_ANIMATIONS[AnimationState.IDLE];
const fallbackToPortrait =
usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
hasGeneratedPortraitOnly || usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
const config = fallbackToPortrait ? PORTRAIT_FALLBACK_ANIMATION : baseConfig;
const startFrame =
typeof config.startFrame === 'number' && Number.isFinite(config.startFrame)

View File

@@ -341,6 +341,46 @@ function resolveSceneCardImage(params: {
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
}
function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) {
return sceneChapters.flatMap((chapter) =>
chapter.acts
.map((act, index) => ({
id: act.id.trim() || `${chapter.id}-act-${index}`,
title: act.title.trim() || `${index + 1}`,
imageSrc: act.backgroundImageSrc?.trim() || '',
}))
.filter((act) => act.imageSrc),
);
}
function SceneActPreviewStrip({
acts,
sceneName,
}: {
acts: Array<{ id: string; title: string; imageSrc: string }>;
sceneName: string;
}) {
if (acts.length <= 0) return null;
return (
<div className="flex w-full gap-1.5 overflow-x-auto pb-0.5">
{acts.map((act) => (
<div
key={act.id}
className="platform-subpanel h-12 w-[5.25rem] shrink-0 overflow-hidden rounded-xl"
title={act.title}
>
<ResolvedAssetImage
src={act.imageSrc}
alt={`${sceneName}-${act.title}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
);
}
function CatalogCard({
title,
description,
@@ -1015,6 +1055,7 @@ export function CustomWorldEntityCatalog({
sceneChapters: openingSceneChapters,
}),
sceneChapters: openingSceneChapters,
actPreviews: collectSceneActImagePreviews(openingSceneChapters),
searchText: [
buildOpeningSceneSearchText(profile, resolvedCampScene),
buildSceneChapterSearchText(openingSceneChapters, roleById),
@@ -1039,6 +1080,7 @@ export function CustomWorldEntityCatalog({
sceneChapters,
}),
sceneChapters,
actPreviews: collectSceneActImagePreviews(sceneChapters),
searchText: [
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
buildSceneChapterSearchText(sceneChapters, roleById),
@@ -1576,6 +1618,12 @@ export function CustomWorldEntityCatalog({
tone="landscape"
/>
}
actions={
<SceneActPreviewStrip
acts={scene.actPreviews}
sceneName={scene.name}
/>
}
disabled={scene.kind === 'camp' && isBulkDeleteMode}
/>
))

View File

@@ -262,6 +262,21 @@ const baseProfile = {
actGoal: '接住首幕压力',
transitionHook: '继续逼近钟楼深处。',
},
{
id: 'scene-act-2',
sceneId: 'landmark-1',
title: '钟楼回响',
summary: '第二幕把旧钟与暗线证据推到台前。',
stageCoverage: ['investigation'],
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-2.png',
backgroundAssetId: 'scene-asset-2',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: [],
advanceRule: 'after_clue_found',
actGoal: '找到旧钟证据',
transitionHook: '钟楼深处传来第二次回响。',
},
],
},
],
@@ -400,7 +415,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder',
expect(screen.getByText('已生成主图')).toBeTruthy();
});
test('landmark tab uses first act image as scene card preview and keeps chapter details out of list', async () => {
test('landmark tab previews every generated act image while keeping chapter details out of list', async () => {
const user = userEvent.setup();
render(<ResultViewHarness />);
@@ -414,6 +429,17 @@ test('landmark tab uses first act image as scene card preview and keeps chapter
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
'/generated-custom-world-scenes/scene-act-1.png',
);
expect(
(screen.getByRole('img', {
name: '沉钟栈桥-潮声逼近',
}) as HTMLImageElement).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-1.png');
expect(
(screen.getByRole('img', {
name: '沉钟栈桥-钟楼回响',
}) as HTMLImageElement).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-2.png');
});
test('readOnly result view hides edit and create actions for agent preview mode', async () => {
@@ -535,10 +561,9 @@ test('agent result view opens publish blocker dialog only when user clicks publi
await user.click(screen.getByRole('button', { name: '发布并进入世界' }));
expect(
screen.getByRole('dialog', { name: '发布前检查' }),
).toBeTruthy();
expect(screen.getByText(/ 2 /u)).toBeTruthy();
expect(screen.getByRole('dialog', { name: '发布作品' })).toBeTruthy();
expect(screen.getByText('发布检查')).toBeTruthy();
expect(screen.getByText('封面设置')).toBeTruthy();
expect(
screen.getByText(//u),
).toBeTruthy();

View File

@@ -10,11 +10,14 @@ import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(),
ensureAutoAuthUser: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
loginWithPhoneCode: vi.fn(),
resetPassword: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
consumeAuthCallbackResult: vi.fn(),
@@ -26,10 +29,13 @@ vi.mock('../../services/apiClient', () => ({
}));
vi.mock('../../services/authService', () => ({
authEntry: authMocks.authEntry,
bindWechatPhone: vi.fn(),
changePassword: authMocks.changePassword,
changePhoneNumber: vi.fn(),
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
getStoredLastLoginPhone: vi.fn(() => ''),
getAuthAuditLogs: vi.fn(),
getAuthLoginOptions: authMocks.getAuthLoginOptions,
getAuthRiskBlocks: vi.fn(),
@@ -40,8 +46,10 @@ vi.mock('../../services/authService', () => ({
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
setStoredLastLoginPhone: vi.fn(),
startWechatLogin: authMocks.startWechatLogin,
}));
@@ -86,6 +94,9 @@ beforeEach(() => {
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.resetPassword.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
@@ -203,13 +214,13 @@ test('auth gate opens a login modal for protected actions and resumes after logi
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '登录账号' });
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(dialog).toBeTruthy();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
@@ -220,7 +231,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(onAuthenticated).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth state refresh keeps mounted platform content and local tab state', async () => {
@@ -280,7 +291,7 @@ test('auth gate shows sms send feedback in the login modal', async () => {
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '登录账号' });
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
@@ -296,7 +307,48 @@ test('auth gate shows sms send feedback in the login modal', async () => {
});
expect(
within(dialog).getByText('短信请求已提交,请留意手机短信。验证码有效期约 5 分钟。'),
within(dialog).getByText('短信请求已提交,验证码有效期约 5 分钟。'),
).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
});
test('auth gate separates sms and password login by tabs', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'password'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(
within(dialog)
.getByRole('tab', { name: '短信登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(within(dialog).queryByLabelText('密码')).toBeNull();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(
within(dialog)
.getByRole('tab', { name: '密码登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号/邮箱'), '13800000000');
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
await waitFor(() => {
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
});
});

View File

@@ -10,6 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password';
type LoginScreenProps = {
isOpen: boolean;
@@ -72,6 +73,18 @@ export function LoginScreen({
const passwordLoginEnabled = availableLoginMethods.includes('password');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
useEffect(() => {
if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) {
setActiveLoginTab('password');
return;
}
if (activeLoginTab === 'password' && !passwordLoginEnabled && phoneLoginEnabled) {
setActiveLoginTab('phone');
}
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
useEffect(() => {
if (cooldownSeconds <= 0) {
@@ -152,8 +165,29 @@ export function LoginScreen({
onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)}
/>
) : (
<div className="flex flex-col gap-4 px-5 py-5">
{passwordLoginEnabled ? (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled && passwordLoginEnabled ? (
<div
className="grid grid-cols-2 gap-2"
role="tablist"
aria-label="登录方式"
>
<LoginTabButton
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
>
</LoginTabButton>
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
</div>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
@@ -162,14 +196,13 @@ export function LoginScreen({
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<span>/</span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
placeholder="手机号或邮箱"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
@@ -213,7 +246,7 @@ export function LoginScreen({
</form>
) : null}
{phoneLoginEnabled ? (
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
<PhoneCodeForm
phone={phone}
code={code}
@@ -226,7 +259,7 @@ export function LoginScreen({
hint={hint}
submitLabel="注册/登录"
enabled={phoneLoginEnabled}
showPhoneField={!passwordLoginEnabled}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
@@ -258,6 +291,35 @@ export function LoginScreen({
);
}
function LoginTabButton({
active,
children,
onClick,
}: {
active: boolean;
children: string;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
active
? 'text-[var(--platform-text-strong)]'
: 'text-[var(--platform-text-muted)]'
}`}
onClick={onClick}
>
<span>{children}</span>
{active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</button>
);
}
function PhoneCodeForm({
phone,
code,

View File

@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
@@ -127,5 +128,36 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
/>,
);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub opens persisted rpg drafts by card click', async () => {
const user = userEvent.setup();
const openedItems: CustomWorldWorkSummary[] = [];
const persistedDraft = {
...baseDraftItem,
workId: 'draft:profile-1',
sourceType: 'published_profile' as const,
sessionId: null,
profileId: 'profile-1',
title: '可继续整理的草稿',
};
render(
<CustomWorldCreationHub
items={[persistedDraft]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={(item) => {
openedItems.push(item);
}}
onEnterPublished={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: /稿/u }));
expect(openedItems).toEqual([persistedDraft]);
});

View File

@@ -171,10 +171,7 @@ export function CustomWorldCreationHub({
return;
}
if (
item.item.sourceType === 'agent_session' &&
item.item.sessionId
) {
if (item.item.status === 'draft') {
onOpenDraft(item.item);
return;
}

View File

@@ -76,7 +76,21 @@ export function CustomWorldWorkCard({
item.kind === 'rpg' ? item.item.coverCharacterImageSrcs : [];
return (
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem] xl:min-h-[12.25rem] xl:px-4 xl:py-3.5">
<div
role="button"
tabIndex={0}
aria-label={`${openActionLabel}${title}`}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
onOpen();
}}
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
>
<CustomWorldCoverArtwork
imageSrc={coverImageSrc}
title={title}
@@ -86,7 +100,7 @@ export function CustomWorldWorkCard({
className="platform-cover-artwork absolute inset-0"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full min-h-[12rem] flex-col xl:min-h-[10.75rem]">
<div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span
@@ -124,11 +138,14 @@ export function CustomWorldWorkCard({
{onDelete ? (
<button
type="button"
onClick={onDelete}
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : `删除作品《${title}`}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
className="pointer-events-auto relative z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
@@ -155,19 +172,19 @@ export function CustomWorldWorkCard({
</div>
</div>
<div className="mt-4 xl:mt-3">
<div className="mt-4 min-h-0 xl:mt-3">
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
{title}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{subtitle}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2 xl:line-clamp-2 xl:leading-6">
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
{summary}
</div>
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between xl:gap-2 xl:pt-3">
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
<div className="flex flex-wrap gap-2">
{isPuzzle ? (
<>
@@ -222,18 +239,14 @@ export function CustomWorldWorkCard({
)}
</div>
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
<button
type="button"
onClick={onOpen}
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
{openActionLabel}
</button>
{onExperience ? (
<button
type="button"
onClick={onExperience}
className="platform-button platform-button--secondary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
onClick={(event) => {
event.stopPropagation();
onExperience();
}}
className="platform-button platform-button--secondary pointer-events-auto relative z-30 min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
</button>

View File

@@ -1246,16 +1246,17 @@ export function PlatformEntryFlowShellImpl({
setDeletingCreationWorkId(work.workId);
platformBootstrap.setPlatformError(null);
const deleteTask = work.profileId
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
})
: work.sessionId
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
platformBootstrap.setCustomWorldWorkEntries(items);
const deleteTask =
work.sourceType === 'published_profile' && work.profileId
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
})
: Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。'));
: work.sourceType === 'agent_session' && work.sessionId
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
platformBootstrap.setCustomWorldWorkEntries(items);
})
: Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。'));
void deleteTask
.then(async () => {
@@ -2062,23 +2063,43 @@ export function PlatformEntryFlowShellImpl({
});
});
}}
onTestWorld={() => {
runProtectedAction(() => {
void enterWorldCoordinator
.enterWorldForTestFromCurrentResult()
.catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'进入作品测试失败。',
),
);
});
});
}}
onPublishWorld={async () => {
await enterWorldCoordinator.publishCurrentResult();
}}
onTestWorld={
sessionController.isAgentDraftResultView &&
sessionController.agentSession?.stage !== 'published'
? () => {
runProtectedAction(() => {
void enterWorldCoordinator
.enterWorldForTestFromCurrentResult()
.catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'进入作品测试失败。',
),
);
});
});
}
: undefined
}
onPublishWorld={
sessionController.isAgentDraftResultView &&
sessionController.agentSession?.stage !== 'published'
? async () => {
try {
await enterWorldCoordinator.publishCurrentResult();
} catch (error) {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'发布到广场失败。',
),
);
throw error;
}
}
: undefined
}
onGenerateEntity={
sessionController.isAgentDraftResultView
? async (kind) => {

View File

@@ -345,11 +345,8 @@ function buildDefaultSceneActBlueprint(params: {
title: actTitle,
summary: actSummary,
stageCoverage,
backgroundPromptText: compactTextList([
`${sceneLabel}${actTitle}背景`,
sceneSummary,
actSummary,
]).join(''),
// 幕背景画面描述应来自草稿生成阶段的大模型输出,前端缺失时只留空,避免展示规则拼接文本。
backgroundPromptText: '',
backgroundImageSrc: params.backgroundImageSrc || undefined,
encounterNpcIds,
primaryNpcId: encounterNpcIds[0] ?? '',
@@ -472,9 +469,7 @@ function sanitizeSceneChapterBlueprint(params: {
title: currentAct?.title?.trim() || fallbackAct.title,
summary: currentAct?.summary?.trim() || fallbackAct.summary,
stageCoverage: buildSceneActStageCoverage(index, targetActCount),
backgroundPromptText:
currentAct?.backgroundPromptText?.trim() ||
fallbackAct.backgroundPromptText,
backgroundPromptText: currentAct?.backgroundPromptText?.trim() || '',
backgroundImageSrc:
currentAct?.backgroundImageSrc?.trim() ||
params.fallbackImageSrc ||

View File

@@ -291,6 +291,7 @@ export function RpgCreationResultActionBar({
isPublishing={isPublishing}
onClose={() => setShowPublishBlockersDialog(false)}
onEditCover={() => {
setShowPublishBlockersDialog(false);
onOpenCoverEditor?.();
}}
onPublish={() => {

View File

@@ -1470,7 +1470,7 @@ test('big fish draft card restores the bound agent session and opens the result
throw new Error('Missing big fish draft card');
}
await user.click(within(card).getByRole('button', { name: //u }));
await user.click(card);
await waitFor(() => {
expect(getBigFishCreationSession).toHaveBeenCalledWith(
@@ -1522,6 +1522,70 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
});
test('refresh restores running draft generation progress instead of agent workspace', async () => {
window.history.replaceState(
null,
'',
'/?customWorldSessionId=custom-world-agent-session-1&customWorldOperationId=operation-draft-foundation-1&customWorldGenerationSource=agent-draft-foundation',
);
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'running',
phaseLabel: '生成世界底稿',
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
progress: 38,
error: null,
});
render(<TestWrapper withAuth />);
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
});
test('failed draft work continues on generation progress view instead of agent workspace', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '失败中的潮雾列岛',
subtitle: '生成失败待处理',
summary: '草稿生成过程中失败,需要继续处理。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '生成失败待处理',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
});
test('existing draft sessions open result page refinement instead of agent dialog', async () => {
const user = userEvent.setup();
@@ -1544,9 +1608,8 @@ test('existing draft sessions open result page refinement instead of agent dialo
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.getByText('已自动保存')).toBeTruthy();
expect(
screen.getByRole('button', { name: //u }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
},
{ timeout: 2500 },
);
@@ -1594,9 +1657,11 @@ test('agent result view shows publish blocker dialog before publish action when
await openNewRpgCreation(user);
const actionButton = await screen.findByRole('button', {
name: //u,
});
const actionButton = await screen.findByRole(
'button',
{ name: '发布' },
{ timeout: 5000 },
);
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
const publishWorldCallCountBeforeClick = vi
@@ -1609,8 +1674,9 @@ test('agent result view shows publish blocker dialog before publish action when
await user.click(actionButton);
expect(await screen.findByRole('dialog', { name: '发布前检查' })).toBeTruthy();
expect(screen.getByText(/ 1 /u)).toBeTruthy();
expect(await screen.findByRole('dialog', { name: '发布作品' })).toBeTruthy();
expect(screen.getByText('发布检查')).toBeTruthy();
expect(screen.getByText('封面设置')).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
const publishWorldCallCountAfterClick = vi
@@ -1623,7 +1689,7 @@ test('agent result view shows publish blocker dialog before publish action when
expect(publishWorldCallCountAfterClick).toBe(publishWorldCallCountBeforeClick);
});
test('agent draft result publishes before entering world and uses published preview profile', async () => {
test('agent draft result publishes to gallery from publish panel', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
@@ -1711,9 +1777,10 @@ test('agent draft result publishes before entering world and uses published prev
await openNewRpgCreation(user);
const actionButton = await screen.findByRole('button', {
name: //u,
name: '发布',
});
await user.click(actionButton);
await user.click(await screen.findByRole('button', { name: '发布到广场' }));
await waitFor(() => {
expect(executeRpgCreationAction).toHaveBeenCalledWith(
@@ -1723,23 +1790,78 @@ test('agent draft result publishes before entering world and uses published prev
}),
);
});
expect(handleCustomWorldSelect).not.toHaveBeenCalled();
});
test('agent draft result test button enters current draft without publish gate', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'completed',
phaseLabel: '世界底稿已生成',
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue({
...compiledAgentDraftSession,
stage: 'ready_to_publish',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: false,
canEnterWorld: false,
blockers: [
{
id: 'missing-cover-image',
code: 'MISSING_COVER_IMAGE',
message: '发布前需要补齐作品封面。',
},
],
},
});
function TestDraftWrapper() {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
return (
<AuthUiContext.Provider value={createAuthValue()}>
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={{} as GameState}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={() => {}}
handleStartNewGame={() => {}}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
</AuthUiContext.Provider>
);
}
render(<TestDraftWrapper />);
await openNewRpgCreation(user);
await user.click(await screen.findByRole('button', { name: '作品测试' }));
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({ name: '潮雾列岛' }),
);
});
expect(
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
payload?.action === 'publish_world',
),
).toBe(false);
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
name: '潮雾列岛·已发布',
summary: '发布完成后应直接使用已发布预览进入世界。',
}),
);
});
});
test('agent result view does not keep legacy publish blockers when preview uses anchorContent and sceneChapterBlueprints', async () => {
@@ -1879,12 +2001,13 @@ test('agent result view does not keep legacy publish blockers when preview uses
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
});
expect(screen.queryByText(/ 4 /u)).toBeNull();
const actionButton = screen.getByRole('button', {
name: //u,
name: '发布',
});
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
});

View File

@@ -22,6 +22,7 @@ type UseRpgCreationAgentOperationPollingParams = {
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
@@ -68,7 +69,15 @@ export function useRpgCreationAgentOperationPolling(
nextOperation.status === 'completed' ||
nextOperation.status === 'failed'
) {
persistAgentUiState(activeAgentSessionId, null);
persistAgentUiState(
activeAgentSessionId,
nextOperation.type === 'draft_foundation'
? activeAgentOperationId
: null,
nextOperation.type === 'draft_foundation'
? 'agent-draft-foundation'
: null,
);
await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null,
);

View File

@@ -50,6 +50,7 @@ type UseRpgCreationResultAutosaveParams = {
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,

View File

@@ -51,6 +51,9 @@ type PendingAgentUserMessage = {
message: CustomWorldAgentSessionSnapshot['messages'][number];
};
const AGENT_DRAFT_RESULT_AUTO_OPEN_MAX_ATTEMPTS = 12;
const AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS = 900;
export function useRpgCreationSessionController(
params: UseRpgCreationSessionControllerParams,
) {
@@ -162,12 +165,17 @@ export function useRpgCreationSessionController(
);
const persistAgentUiState = useCallback(
(nextSessionId: string | null, nextOperationId: string | null) => {
(
nextSessionId: string | null,
nextOperationId: string | null,
nextGenerationSource: CustomWorldGenerationViewSource = null,
) => {
setActiveAgentSessionId(nextSessionId);
setActiveAgentOperationId(nextOperationId);
writeCustomWorldAgentUiState({
activeSessionId: nextSessionId,
activeOperationId: nextOperationId,
customWorldGenerationSource: nextGenerationSource,
// 工作区 session 是按 userId 持久化的,恢复指针必须绑定当前登录用户,
// 避免切换账号或复用旧 URL 时反复请求不属于当前用户的 session 产生 404。
ownerUserId: nextSessionId ? userId : null,
@@ -211,6 +219,16 @@ export function useRpgCreationSessionController(
if (!hasRequestedInitialAgentWorkspaceAuthRef.current) {
hasRequestedInitialAgentWorkspaceAuthRef.current = true;
openLoginModal?.(() => {
if (
initialAgentUiStateRef.current.activeOperationId &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation'
) {
setCustomWorldGenerationViewSource('agent-draft-foundation');
setSelectionStage('custom-world-generating');
return;
}
setSelectionStage('agent-workspace');
});
}
@@ -228,6 +246,17 @@ export function useRpgCreationSessionController(
}
hasAppliedInitialAgentWorkspaceRef.current = true;
if (
initialAgentUiStateRef.current.activeOperationId &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation'
) {
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setSelectionStage('custom-world-generating');
return;
}
setSelectionStage('agent-workspace');
}, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]);
@@ -365,8 +394,23 @@ export function useRpgCreationSessionController(
}
let cancelled = false;
const timeoutId = window.setTimeout(() => {
void (async () => {
void (async () => {
for (
let attempt = 1;
attempt <= AGENT_DRAFT_RESULT_AUTO_OPEN_MAX_ATTEMPTS;
attempt += 1
) {
await new Promise((resolve) => {
window.setTimeout(
resolve,
AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS,
);
});
if (cancelled) {
return;
}
const latestSession = activeAgentSessionId
? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null,
@@ -382,10 +426,7 @@ export function useRpgCreationSessionController(
latestSession ?? agentSession,
);
if (!draftResultProfile) {
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setSelectionStage('agent-workspace');
return;
continue;
}
setGeneratedCustomWorldProfile(
@@ -395,12 +436,16 @@ export function useRpgCreationSessionController(
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('agent-draft');
setSelectionStage('custom-world-result');
})();
}, 900);
return;
}
if (!cancelled) {
setAgentDraftGenerationStartedAt(null);
}
})();
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [
activeAgentSessionId,
@@ -678,7 +723,11 @@ export function useRpgCreationSessionController(
payload,
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
persistAgentUiState(
activeAgentSessionId,
operation.operationId,
isDraftFoundationAction ? 'agent-draft-foundation' : null,
);
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
error,
@@ -694,7 +743,11 @@ export function useRpgCreationSessionController(
error: errorMessage,
}),
);
persistAgentUiState(activeAgentSessionId, null);
persistAgentUiState(
activeAgentSessionId,
null,
isDraftFoundationAction ? 'agent-draft-foundation' : null,
);
}
},
[activeAgentSessionId, persistAgentUiState, setSelectionStage],

View File

@@ -67,6 +67,7 @@ type UseRpgEntryLibraryDetailParams = {
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
@@ -244,7 +245,30 @@ export function useRpgEntryLibraryDetail(
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
try {
if (shouldOpenAgentWorkspace) {
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
const shouldResumeFailedGenerationView =
!nextProfile &&
//u.test(`${work.stageLabel ?? ''}${work.summary ?? ''}`);
if (shouldResumeFailedGenerationView) {
// 生成过程中失败的草稿要回到生成过程页承接错误处理,避免误回 Agent 对话。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
);
setGeneratedCustomWorldProfile(null);
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setPlatformTabToCreate();
setSelectionStage('custom-world-generating');
return;
}
if (shouldOpenAgentWorkspace && !nextProfile) {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(work.sessionId, null);
@@ -256,13 +280,16 @@ export function useRpgEntryLibraryDetail(
}
releaseAgentDraftResultAutoOpenSuppression();
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
if (!nextProfile) {
persistAgentUiState(work.sessionId, null);
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
);
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTabToCreate();
setSelectionStage('agent-workspace');
setCustomWorldGenerationViewSource('agent-draft-foundation');
setSelectionStage('custom-world-generating');
return;
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { normalizeCustomWorldProfileRecord } from './customWorldLibrary';
describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
it('保留草稿生成阶段产出的角色形象描述字段', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
playableNpcs: [
{
name: '岑灯',
title: '返乡守灯人',
role: '主角代理',
description: '追查旧案的人',
visualDescription: '瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
actionDescription: '抬灯照出雾中航线,侧身抽出卷边海图迅速标记。',
sceneVisualDescription: '旧灯塔石阶被潮水打湿,青白灯火照着雾中海图。',
},
],
storyNpcs: [
{
name: '议长甲',
title: '群岛议长',
role: '遮掩者',
description: '压住旧档的人',
visualDescription: '银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
actionDescription: '用印信压住卷宗,抬手示意巡海队封锁出口。',
sceneVisualDescription: '议会厅高窗外翻涌海雾,长桌尽头堆着封存卷宗。',
},
],
});
expect(profile?.playableNpcs[0]?.visualDescription).toBe(
'瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
);
expect(profile?.playableNpcs[0]?.actionDescription).toContain('抬灯');
expect(profile?.playableNpcs[0]?.sceneVisualDescription).toContain('旧灯塔');
expect(profile?.storyNpcs[0]?.visualDescription).toBe(
'银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
);
expect(profile?.storyNpcs[0]?.actionDescription).toContain('印信');
expect(profile?.storyNpcs[0]?.sceneVisualDescription).toContain('议会厅');
});
it('保留 Agent 发布门槛需要的顶层 worldHook 和 playerPremise', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
summary: '海雾会吞掉记错航线的人。',
worldHook: '在失真的海图上追查一场被篡改的沉船事故。',
playerPremise: '玩家是返乡调查旧案的守灯人。',
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '失灯港',
acts: [
{
id: 'act-1',
title: '第一幕',
summary: '玩家在雾港发现灯册被改写。',
},
],
},
],
});
expect(profile?.worldHook).toBe(
'在失真的海图上追查一场被篡改的沉船事故。',
);
expect(profile?.playerPremise).toBe('玩家是返乡调查旧案的守灯人。');
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
});
});

View File

@@ -683,6 +683,9 @@ function normalizePlayableNpc(
title,
role,
description: fallbackSource.description,
visualDescription: toText(value.visualDescription) || undefined,
actionDescription: toText(value.actionDescription) || undefined,
sceneVisualDescription: toText(value.sceneVisualDescription) || undefined,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
@@ -755,6 +758,9 @@ function normalizeStoryNpc(
title,
role,
description: fallbackSource.description,
visualDescription: toText(value.visualDescription) || undefined,
actionDescription: toText(value.actionDescription) || undefined,
sceneVisualDescription: toText(value.sceneVisualDescription) || undefined,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
@@ -1044,6 +1050,17 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
const summary = toText(value.summary);
const tone = toText(value.tone);
const playerGoal = toText(value.playerGoal);
const creatorIntentRecord = isRecord(value.creatorIntent)
? value.creatorIntent
: null;
const worldHook = toText(
value.worldHook,
toText(creatorIntentRecord?.worldHook, toText(value.summary, settingText || name)),
);
const playerPremise = toText(
value.playerPremise,
toText(creatorIntentRecord?.playerPremise, playerGoal),
);
const majorFactions = toStringArray(value.majorFactions);
const coreConflicts = toStringArray(value.coreConflicts);
const resolvedCoreConflicts =
@@ -1087,6 +1104,8 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
summary,
tone,
playerGoal,
worldHook,
playerPremise,
templateWorldType,
compatibilityTemplateWorldType,
majorFactions,

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { buildCustomWorldRoleOutlineBatchPrompt } from './customWorldPrompts';
const framework = {
settingText: '潮雾封锁的边境港城,旧灯塔下藏着失踪船队的线索。',
name: '潮雾港',
subtitle: '旧灯塔仍在雾里亮着',
summary: '玩家需要在港城各方势力间找到失踪船队真相。',
tone: '潮湿、悬疑、克制',
playerGoal: '找回失踪船队并决定港城秩序的走向。',
templateWorldType: 'custom',
compatibilityTemplateWorldType: 'custom',
majorFactions: ['守灯人', '走私船帮'],
coreConflicts: ['旧航道真相', '港城权力交接'],
camp: {
name: '旧灯塔营地',
description: '潮雾里的临时归处。',
dangerLevel: 'medium',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
};
describe('buildCustomWorldRoleOutlineBatchPrompt', () => {
it('requires model-generated visual descriptions for role drafts', () => {
const prompt = buildCustomWorldRoleOutlineBatchPrompt({
framework,
roleType: 'playable',
batchCount: 2,
});
expect(prompt).toContain('"visualDescription"');
expect(prompt).toContain('"actionDescription"');
expect(prompt).toContain('"sceneVisualDescription"');
expect(prompt).toContain('visualDescription 必须跟随本步骤直接生成');
expect(prompt).toContain('不能复制 description');
});
});

View File

@@ -494,6 +494,9 @@ export function buildCustomWorldRoleOutlineBatchPrompt(params: {
' "title": "称号",',
' "role": "身份",',
' "description": "极简定位描述",',
' "visualDescription": "专门用于主形象生成的外观描述",',
' "actionDescription": "专门用于动作生成的动作气质描述",',
' "sceneVisualDescription": "角色常出现的场景氛围描述",',
' "initialAffinity": 18,',
' "relationshipHooks": ["一个关系切入口"],',
' "tags": ["标签1", "标签2"]',
@@ -505,9 +508,12 @@ export function buildCustomWorldRoleOutlineBatchPrompt(params: {
`- 必须生成恰好 ${batchCount}${label}`,
'- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。',
'- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。',
'- 只保留name、title、role、description、initialAffinity、relationshipHooks、tags。',
'- 只保留name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。',
'- description 控制在 8 到 18 个汉字内,只写角色定位,不写外观。',
'- visualDescription 必须跟随本步骤直接生成,专门描述角色外观,包含轮廓、服饰 / 身体特征、携带物或材质气质,不能复制 description。',
'- actionDescription 专门描述动作气质sceneVisualDescription 专门描述角色常出现的场景氛围。',
'- relationshipHooks 最多 1 条tags 保持 1 到 2 个。',
'- description 控制在 8 到 18 个汉字内,title 和 role 尽量短。',
'- title 和 role 尽量短。',
'- initialAffinity 必须是 -40 到 90 的整数。',
roleType === 'playable'
? '- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。'
@@ -536,8 +542,9 @@ export function buildCustomWorldRoleOutlineBatchJsonRepairPrompt(params: {
forbiddenNames.length > 0
? `禁止使用这些重复名:${forbiddenNames.join('、')}`
: '',
'每个角色只包含name、title、role、description、initialAffinity、relationshipHooks、tags。',
'每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。',
'如果缺少字段字符串补空字符串relationshipHooks 和 tags 补空数组initialAffinity 补默认整数。',
'visualDescription 必须是独立外观描述,不能用 description 原文替代。',
'不要输出 backstory、skills、landmarks 或任何其他字段。',
'原始文本:',
responseText.trim(),

View File

@@ -28,7 +28,6 @@ import {
buildCustomWorldRoleBatchPrompt,
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
buildCustomWorldRoleOutlineBatchPrompt,
buildCustomWorldSceneImagePrompt,
buildCustomWorldStoryGraphJsonRepairPrompt,
buildCustomWorldStoryGraphPrompt,
buildCustomWorldThemePackJsonRepairPrompt,
@@ -1951,11 +1950,7 @@ export async function generateCustomWorldSceneImage({
size = '1280*720',
referenceImageSrc,
}: CustomWorldSceneImageRequest): Promise<CustomWorldSceneImageResult> {
const resolvedPrompt =
prompt?.trim() ||
buildCustomWorldSceneImagePrompt(profile, landmark, userPrompt, {
hasReferenceImage: Boolean(referenceImageSrc?.trim()),
});
const resolvedPrompt = prompt?.trim() || userPrompt?.trim() || '';
const resolvedNegativePrompt =
negativePrompt?.trim() || DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT;
const controller = new AbortController();
@@ -1975,9 +1970,25 @@ export async function generateCustomWorldSceneImage({
worldName: profile.name,
landmarkId: landmark.id,
landmarkName: landmark.name,
prompt: resolvedPrompt,
...(prompt?.trim() ? { prompt: prompt.trim() } : {}),
userPrompt: resolvedPrompt,
negativePrompt: resolvedNegativePrompt,
size,
profile: {
id: profile.id,
name: profile.name,
subtitle: profile.subtitle,
summary: profile.summary,
tone: profile.tone,
playerGoal: profile.playerGoal,
settingText: profile.settingText,
},
landmark: {
id: landmark.id,
name: landmark.name,
description: landmark.description,
dangerLevel: landmark.dangerLevel,
},
...(referenceImageSrc?.trim()
? { referenceImageSrc: referenceImageSrc.trim() }
: {}),

View File

@@ -45,6 +45,7 @@ test('custom world agent ui state reads from query first and persists to session
{
activeSessionId: 'session-1',
activeOperationId: 'operation-1',
customWorldGenerationSource: 'agent-draft-foundation',
ownerUserId: 'user-1',
},
env,
@@ -52,15 +53,20 @@ test('custom world agent ui state reads from query first and persists to session
expect(currentUrl).toContain('customWorldSessionId=session-1');
expect(currentUrl).toContain('customWorldOperationId=operation-1');
expect(currentUrl).toContain(
'customWorldGenerationSource=agent-draft-foundation',
);
expect(readCustomWorldAgentUiState(env)).toEqual({
activeSessionId: 'session-1',
activeOperationId: 'operation-1',
customWorldGenerationSource: 'agent-draft-foundation',
});
currentUrl = '/play';
expect(readCustomWorldAgentUiState(env)).toEqual({
activeSessionId: 'session-1',
activeOperationId: 'operation-1',
customWorldGenerationSource: 'agent-draft-foundation',
ownerUserId: 'user-1',
});

View File

@@ -2,6 +2,8 @@ import type { CustomWorldAgentUiState } from '../types';
export const CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY = 'customWorldSessionId';
export const CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY = 'customWorldOperationId';
export const CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY =
'customWorldGenerationSource';
export const CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY =
'genarrative.custom-world-agent-ui.v1';
@@ -50,6 +52,10 @@ function normalizeValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function normalizeGenerationSource(value: unknown) {
return value === 'agent-draft-foundation' ? value : null;
}
export function readCustomWorldAgentUiState(
env?: CustomWorldAgentUiEnvironment,
): CustomWorldAgentUiState {
@@ -62,9 +68,16 @@ export function readCustomWorldAgentUiState(
activeOperationId: normalizeValue(
params.get(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY),
),
customWorldGenerationSource: normalizeGenerationSource(
params.get(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY),
),
};
if (stateFromQuery.activeSessionId || stateFromQuery.activeOperationId) {
if (
stateFromQuery.activeSessionId ||
stateFromQuery.activeOperationId ||
stateFromQuery.customWorldGenerationSource
) {
return stateFromQuery;
}
@@ -80,6 +93,9 @@ export function readCustomWorldAgentUiState(
return {
activeSessionId: normalizeValue(parsed.activeSessionId),
activeOperationId: normalizeValue(parsed.activeOperationId),
customWorldGenerationSource: normalizeGenerationSource(
parsed.customWorldGenerationSource,
),
ownerUserId: normalizeValue(parsed.ownerUserId),
};
} catch {
@@ -95,10 +111,14 @@ export function writeCustomWorldAgentUiState(
const resolved = resolveEnvironment(env);
const activeSessionId = normalizeValue(state.activeSessionId);
const activeOperationId = normalizeValue(state.activeOperationId);
const customWorldGenerationSource = normalizeGenerationSource(
state.customWorldGenerationSource,
);
const ownerUserId = normalizeValue(state.ownerUserId);
const nextState: CustomWorldAgentUiState = {
activeSessionId,
activeOperationId,
customWorldGenerationSource,
ownerUserId,
};
@@ -116,6 +136,15 @@ export function writeCustomWorldAgentUiState(
params.delete(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY);
}
if (customWorldGenerationSource) {
params.set(
CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY,
customWorldGenerationSource,
);
} else {
params.delete(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY);
}
const search = params.toString();
const nextUrl = search
? `${resolved.location.pathname}?${search}`
@@ -124,7 +153,7 @@ export function writeCustomWorldAgentUiState(
}
if (resolved.sessionStorage) {
if (activeSessionId || activeOperationId) {
if (activeSessionId || activeOperationId || customWorldGenerationSource) {
resolved.sessionStorage.setItem(
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
JSON.stringify(nextState),

View File

@@ -29,6 +29,7 @@ export type CustomWorldCoverSourceType = 'default' | 'uploaded' | 'generated';
export type CustomWorldAgentUiState = {
activeSessionId?: string | null;
activeOperationId?: string | null;
customWorldGenerationSource?: 'agent-draft-foundation' | null;
ownerUserId?: string | null;
};
@@ -397,6 +398,16 @@ export interface CustomWorldProfile {
summary: string;
tone: string;
playerGoal: string;
/**
* 发布门槛直接读取的世界一句话钩子。
* Agent 结果页回写 session 时需要保留该字段,避免只剩 UI 归一化字段导致后端误判缺失。
*/
worldHook?: string | null;
/**
* 发布门槛直接读取的玩家身份与切入前提。
* 即使 creatorIntent / anchorContent 中已有结构化信息,也要保留顶层字段作为 SpacetimeDB 发布快照的稳定兼容槽位。
*/
playerPremise?: string | null;
cover?: CustomWorldCoverProfile | null;
templateWorldType: WorldTemplateType;
compatibilityTemplateWorldType?: WorldTemplateType | null;