1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 21:06:48 +08:00
parent 1c72066bab
commit 75944b1f1f
102 changed files with 9648 additions and 1540 deletions

View File

@@ -27,13 +27,13 @@ function createCharacter(): Character {
} as Character;
}
test('adventure panel treats negative affinity updates as relationship change system messages', () => {
test('adventure panel renders system turns without special relationship labels', () => {
const currentStory: StoryMoment = {
text: '你们的语气忽然冷了下来。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' },
{ speaker: 'system', text: '关系转冷 好感 -2', affinityDelta: -2 },
{ speaker: 'system', text: '这轮交谈先在这里收束。' },
],
options: [],
};
@@ -102,8 +102,9 @@ test('adventure panel treats negative affinity updates as relationship change sy
/>,
);
expect(html).toContain('关系变化');
expect(html).toContain('关系转冷 好感 -2');
expect(html).toContain('系统');
expect(html).toContain('这轮交谈先在这里收束。');
expect(html).not.toContain('关系变化');
});
test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => {

View File

@@ -157,9 +157,13 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
dialogue: [
{ speaker: 'player', text: '你刚才那句话是什么意思?' },
{ speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' },
{ speaker: 'system', text: '关系升温 好感 +3', affinityDelta: 3 },
],
options: [optionA, optionB, optionC],
npcAffinityEffect: {
eventId: 'effect-liu-1',
npcId: 'npc-liu',
delta: 3,
},
npcChatState: {
npcId: 'npc-liu',
npcName: '柳无声',
@@ -178,6 +182,7 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
expect(html).toContain('输入你想对 TA 说的话');
expect(html).toContain('发送');
expect(html).not.toContain('换一换');
expect(html).not.toContain('关系升温');
});
test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => {
@@ -243,3 +248,19 @@ test('adventure panel hides custom input and shows quest offer actions during np
expect(html).not.toContain('发送');
expect(html).not.toContain('输入你想对 TA 说的话');
});
test('adventure panel renders narrative story text without italics and hides option detail text', () => {
const option = createOption('idle_observe_signs', '观察风里残下的痕迹');
option.detailText = '这段说明不应该继续出现在 UI 里。';
const currentStory: StoryMoment = {
text: '风从桥洞里灌过来,你把注意力重新放回脚下与前路。',
options: [option],
};
const html = renderPanel(currentStory, [option]);
expect(html).toContain('font-serif');
expect(html).not.toContain('italic');
expect(html).toContain('text-[15px]');
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
});

View File

@@ -174,9 +174,7 @@ function getDialogueTurnBubbleClass(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
if (turn.speaker === 'system') {
return turn.affinityDelta && turn.affinityDelta > 0
? 'border-rose-400/30 bg-rose-500/12 text-rose-50'
: 'border-white/12 bg-white/[0.06] text-zinc-100';
return 'border-white/12 bg-white/[0.06] text-zinc-100';
}
if (turn.speaker === 'player') {
@@ -212,7 +210,7 @@ function getDialogueTurnLabel(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
if (turn.speaker === 'system') {
return typeof turn.affinityDelta === 'number' ? '关系变化' : '系统';
return '系统';
}
if (turn.speaker === 'player') {
@@ -1107,7 +1105,7 @@ export function AdventurePanel({
)}
</div>
) : (
<p className="font-serif text-sm italic leading-relaxed text-zinc-300">
<p className="font-serif text-[15px] leading-7 text-zinc-200 sm:text-base">
{currentStory.text}
</p>
)}
@@ -1192,9 +1190,6 @@ export function AdventurePanel({
hasDeferredAdventureOptions &&
isContinueAdventureOption(option);
const optionDisabled = option.disabled === true;
const compactOptionDetailText = option.disabledReason
? option.disabledReason
: getCompactOptionDetailText(option);
if (isDeferredContinueOption) {
return (
@@ -1210,7 +1205,7 @@ export function AdventurePanel({
>
<div className="flex items-center justify-between">
<span
className={`text-xs ${getOptionActionTextClass(option)}`}
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
@@ -1237,7 +1232,7 @@ export function AdventurePanel({
>
<div className="flex items-center justify-between">
<span
className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
@@ -1246,11 +1241,6 @@ export function AdventurePanel({
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
{!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{compactOptionDetailText}
</div>
)}
{!isNpcChatMode && option.goalAffordance?.label && (
<div
className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}

View File

@@ -20,7 +20,13 @@ import {
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types';
import {
AnimationState,
Character,
CustomWorldProfile,
type SceneActBlueprint,
type SceneChapterBlueprint,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
@@ -233,6 +239,104 @@ function PendingEntityCard({
);
}
function resolveSceneEntrySceneChapters(params: {
sceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
sceneId: string;
sceneName: string;
}) {
const sceneChapters = params.sceneChapters ?? [];
const normalizedSceneId = params.sceneId.trim();
const normalizedSceneName = params.sceneName.trim();
const directMatches = sceneChapters.filter(
(chapter) => chapter.sceneId.trim() === normalizedSceneId,
);
if (directMatches.length > 0) {
return directMatches;
}
const linkedMatches = sceneChapters.filter((chapter) =>
chapter.linkedLandmarkIds.some(
(landmarkId) => landmarkId.trim() === normalizedSceneId,
),
);
if (linkedMatches.length > 0) {
return linkedMatches;
}
return sceneChapters.filter((chapter) => {
const chapterTitle = chapter.title.trim();
return (
chapterTitle === normalizedSceneName ||
chapter.summary.includes(normalizedSceneName) ||
chapter.acts.some(
(act) =>
act.title.includes(normalizedSceneName) ||
act.summary.includes(normalizedSceneName),
)
);
});
}
function buildSceneActParticipantText(
act: SceneActBlueprint,
roleById: Map<
string,
| CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number]
>,
) {
const primaryRoleName = roleById.get(act.primaryNpcId)?.name?.trim() || '';
const supportRoleNames = act.encounterNpcIds
.filter((roleId) => roleId !== act.primaryNpcId)
.map((roleId) => roleById.get(roleId)?.name?.trim() || '')
.filter(Boolean);
return compactTextList([
primaryRoleName ? `主角色:${primaryRoleName}` : '',
supportRoleNames.length > 0
? `相遇角色:${supportRoleNames.join('、')}`
: '',
]).join('');
}
function buildSceneChapterSearchText(
sceneChapters: SceneChapterBlueprint[],
roleById: Map<
string,
| CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number]
>,
) {
return sceneChapters
.flatMap((chapter) => [
chapter.title,
chapter.summary,
...chapter.acts.flatMap((act) => [
act.title,
act.summary,
act.actGoal,
act.transitionHook,
buildSceneActParticipantText(act, roleById),
]),
])
.filter(Boolean)
.join(' ');
}
function resolveSceneCardImage(params: {
sceneImageSrc?: string | null;
sceneChapters: SceneChapterBlueprint[];
}) {
const firstActImageSrc =
params.sceneChapters
.flatMap((chapter) => chapter.acts)
.map((act) => act.backgroundImageSrc?.trim() || '')
.find(Boolean) || '';
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
}
function CatalogCard({
title,
description,
@@ -370,6 +474,10 @@ function resolvePlayableRolePreviewImage(
role: CustomWorldProfile['playableNpcs'][number],
previewCharacter: Character | null,
) {
if (role.imageSrc?.trim()) {
return role.imageSrc;
}
if (previewCharacter?.portrait?.trim()) {
return previewCharacter.portrait;
}
@@ -378,10 +486,6 @@ function resolvePlayableRolePreviewImage(
return previewCharacter.avatar;
}
if (role.imageSrc?.trim()) {
return role.imageSrc;
}
const template = role.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
@@ -796,6 +900,16 @@ export function CustomWorldEntityCatalog({
() => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])),
[profile.storyNpcs],
);
const roleById = useMemo(
() =>
new Map(
[...profile.playableNpcs, ...profile.storyNpcs].map((role) => [
role.id,
role,
]),
),
[profile.playableNpcs, profile.storyNpcs],
);
const landmarkById = useMemo(
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
[profile.landmarks],
@@ -876,22 +990,53 @@ export function CustomWorldEntityCatalog({
[profile.creatorIntent],
);
const filteredSceneEntries = useMemo(() => {
const openingSceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: resolvedCampScene.id,
sceneName: resolvedCampScene.name,
});
const openingSceneEntry = {
id: 'custom-world-opening-scene',
id: resolvedCampScene.id,
kind: 'camp' as const,
name: resolvedCampScene.name,
description: resolvedCampScene.description,
imageSrc: resolvedCampImageSrc,
searchText: buildOpeningSceneSearchText(profile, resolvedCampScene),
imageSrc: resolveSceneCardImage({
sceneImageSrc: resolvedCampImageSrc,
sceneChapters: openingSceneChapters,
}),
sceneChapters: openingSceneChapters,
searchText: [
buildOpeningSceneSearchText(profile, resolvedCampScene),
buildSceneChapterSearchText(openingSceneChapters, roleById),
]
.filter(Boolean)
.join(' '),
};
const landmarkEntries = filteredLandmarks.map((landmark) => ({
id: landmark.id,
kind: 'landmark' as const,
name: landmark.name,
description: landmark.description,
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
}));
const landmarkEntries = profile.landmarks.map((landmark) => {
const sceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: landmark.id,
sceneName: landmark.name,
});
return {
id: landmark.id,
kind: 'landmark' as const,
name: landmark.name,
description: landmark.description,
imageSrc: resolveSceneCardImage({
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
sceneChapters,
}),
sceneChapters,
searchText: [
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
buildSceneChapterSearchText(sceneChapters, roleById),
]
.filter(Boolean)
.join(' '),
};
});
const recentEntries = landmarkEntries.filter((entry) =>
recentLandmarkIdSet.has(entry.id),
);
@@ -909,13 +1054,13 @@ export function CustomWorldEntityCatalog({
);
}, [
deferredSearch,
filteredLandmarks,
landmarkById,
landmarkImageById,
profile,
recentLandmarkIdSet,
resolvedCampImageSrc,
resolvedCampScene,
roleById,
storyNpcById,
]);
@@ -1281,7 +1426,13 @@ export function CustomWorldEntityCatalog({
})
}
media={
previewCharacter ? (
role.imageSrc?.trim() ? (
<img
src={role.imageSrc}
alt={role.name}
className="h-full w-full object-cover object-top"
/>
) : previewCharacter ? (
<CharacterAnimator
state={AnimationState.RUN}
character={previewCharacter}
@@ -1414,51 +1565,48 @@ export function CustomWorldEntityCatalog({
<EmptyState title="当前没有符合搜索条件的场景。" />
) : (
filteredSceneEntries.map((scene, index) => (
<div
<CatalogCard
key={buildFallbackRenderKey(
scene.id,
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
)}
>
<CatalogCard
title={scene.name}
description={
scene.kind === 'camp'
? `开局场景 · ${scene.description}`
: scene.description
}
badge={
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
<NewBadge />
) : null
}
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
isSelected={
scene.kind === 'landmark' &&
selectedBulkIds.includes(scene.id)
}
onClick={() =>
scene.kind === 'camp'
? onEditTarget({ kind: 'camp' })
: isBulkDeleteMode
? toggleBulkSelected(scene.id)
: onEditTarget({
kind: 'landmark',
mode: 'edit',
id: scene.id,
})
}
media={
<ImageFrame
src={scene.imageSrc}
alt={scene.name}
fallbackLabel={scene.name.slice(0, 4) || '场景'}
tone="landscape"
/>
}
disabled={scene.kind === 'camp' && isBulkDeleteMode}
/>
</div>
title={scene.name}
description={
scene.kind === 'camp'
? `开局场景 · ${scene.description}`
: scene.description
}
badge={
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
<NewBadge />
) : null
}
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
isSelected={
scene.kind === 'landmark' &&
selectedBulkIds.includes(scene.id)
}
onClick={() =>
scene.kind === 'camp'
? onEditTarget({ kind: 'camp' })
: isBulkDeleteMode
? toggleBulkSelected(scene.id)
: onEditTarget({
kind: 'landmark',
mode: 'edit',
id: scene.id,
})
}
media={
<ImageFrame
src={scene.imageSrc}
alt={scene.name}
fallbackLabel={scene.name.slice(0, 4) || '场景'}
tone="landscape"
/>
}
disabled={scene.kind === 'camp' && isBulkDeleteMode}
/>
))
)}
</div>

View File

@@ -15,6 +15,7 @@ import {
type CustomWorldEditorTarget,
CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal';
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
vi.mock('../data/characterPresets', async () => {
const actual = await vi.importActual<typeof import('../data/characterPresets')>(
@@ -65,10 +66,6 @@ vi.mock('./game-shell/GameShellRuntime', () => ({
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
visualPromptText: '自动生成的形象提示词',
animationPromptText: '自动生成的动作提示词',
}),
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
generateCharacterVisualCandidates: vi.fn(),
publishCharacterVisualAsset: vi.fn(),
@@ -76,6 +73,11 @@ vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
publishCharacterAnimationAssets: vi.fn(),
}));
vi.mock('../services/customWorldCoverAssetService', () => ({
generateCustomWorldCoverImage: vi.fn(),
uploadCustomWorldCoverImage: vi.fn(),
}));
function createBackstoryReveal() {
return {
publicSummary: '公开背景',
@@ -261,10 +263,19 @@ function CampEditorFlowHarness() {
const [profile, setProfile] = useState<CustomWorldProfile>({
...createProfileWithLandmark(),
camp: {
id: 'custom-scene-camp',
name: '潮灯居',
description: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium',
imageSrc: '/generated-custom-world-scenes/original-camp.png',
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
connections: [
{
targetLandmarkId: 'landmark-1',
relativePosition: 'north',
summary: '北侧通往沉钟栈桥。',
},
],
},
});
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
@@ -273,6 +284,9 @@ function CampEditorFlowHarness() {
return (
<>
<pre data-testid="camp-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
@@ -293,6 +307,44 @@ function CampEditorFlowHarness() {
);
}
function CoverEditorFlowHarness() {
const [profile, setProfile] = useState<CustomWorldProfile>({
...createProfileWithLandmark(),
cover: {
sourceType: 'default',
imageSrc: null,
characterRoleIds: ['playable-1'],
},
});
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
kind: 'cover',
});
return (
<>
<pre data-testid="cover-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
onProfileChange={setProfile}
/>
</>
);
}
function readCoverHarnessProfile() {
const content = screen.getByTestId('cover-profile-json').textContent;
return JSON.parse(content || '{}') as CustomWorldProfile;
}
function readCampHarnessProfile() {
const content = screen.getByTestId('camp-profile-json').textContent;
return JSON.parse(content || '{}') as CustomWorldProfile;
}
test('playable角色打开AI工坊后不会自动关闭', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
@@ -506,6 +558,14 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
'/generated-custom-world-scenes/original-scene.png',
);
const firstActCard = getSceneActCard(0);
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第1幕')).toBeTruthy();
});
expect(screen.queryByText('场景图片')).toBeNull();
expect(screen.queryByText('场景内 NPC')).toBeNull();
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
@@ -523,22 +583,29 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
});
await waitFor(() => {
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
});
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存背景' }));
await waitFor(() => {
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
expect(screen.queryByText('配置幕背景第1幕')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
});
const savedProfile = readLandmarkHarnessProfile();
expect(savedProfile.landmarks[0]?.imageSrc).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
});
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
@@ -562,6 +629,14 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
'/generated-custom-world-scenes/original-camp.png',
);
const firstActCard = getSceneActCard(0);
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第1幕')).toBeTruthy();
});
expect(screen.queryByText('场景图片')).toBeNull();
expect(screen.queryByText('场景内 NPC')).toBeNull();
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('智能生成:潮灯居')).toBeTruthy();
@@ -579,22 +654,80 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
});
await waitFor(() => {
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
});
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存背景' }));
await waitFor(() => {
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
expect(screen.queryByText('配置幕背景第1幕')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
});
const savedProfile = readCampHarnessProfile();
expect(savedProfile.camp?.imageSrc).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
});
test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => {
const user = userEvent.setup();
render(<CampEditorFlowHarness />);
expect(screen.getByText('多幕配置')).toBeTruthy();
expect(screen.getByText('场景连接关系')).toBeTruthy();
expect(screen.queryByText('场景图片')).toBeNull();
expect(screen.queryByText('场景内 NPC')).toBeNull();
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3);
const firstActCard = getSceneActCard(0);
await user.click(within(firstActCard).getAllByTestId('scene-act-slot-button')[0]!);
await waitFor(() => {
expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await waitFor(() => {
expect(screen.queryByText('配置角色第1幕 · 主角色槽位')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑场景:潮灯居')).toBeNull();
});
const savedProfile = readCampHarnessProfile();
const openingSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'custom-scene-camp',
);
expect(savedProfile.camp?.sceneNpcIds).toHaveLength(3);
expect(savedProfile.camp?.sceneNpcIds).toEqual(
expect.arrayContaining(['story-1', 'story-2', 'story-3']),
);
expect(savedProfile.camp?.connections).toEqual([
{
targetLandmarkId: 'landmark-1',
relativePosition: 'north',
summary: '北侧通往沉钟栈桥。',
},
]);
expect(openingSceneChapter).toBeTruthy();
expect(openingSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2');
expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp');
});
test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => {
@@ -636,7 +769,7 @@ test('场景编辑器会在场景内展示槽位化多幕配置并保存', async
expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: /[\s\S]*/u }));
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await waitFor(() => {
@@ -679,7 +812,7 @@ test('场景多幕支持新增删除和调序', async () => {
await waitFor(() => {
expect(screen.getByText('配置角色第2幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: /[\s\S]*/u }));
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await user.click(within(secondActCard).getByRole('button', { name: '下移' }));
@@ -721,3 +854,83 @@ test('场景幕预览会打开当前幕运行时面板', async () => {
expect(screen.queryByText('幕预览运行时')).toBeNull();
});
});
test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async () => {
const uploadMock = vi
.mocked(customWorldCoverAssetService.uploadCustomWorldCoverImage)
.mockResolvedValue({
imageSrc: '/generated-custom-world-covers/world-1/uploaded/cover.webp',
assetId: 'custom-cover-upload-1',
sourceType: 'uploaded',
});
class MockFileReader {
result: string | null = null;
error: Error | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=';
this.onload?.();
}
}
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
naturalWidth = 1920;
naturalHeight = 1080;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
const user = userEvent.setup();
render(<CoverEditorFlowHarness />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).toBeTruthy();
if (!input) {
throw new Error('未找到封面上传输入框');
}
const file = new File(['cover'], 'cover.png', { type: 'image/png' });
await user.upload(input, file);
await waitFor(() => {
expect(screen.getByText('裁剪上传封面')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '确认裁剪并上传' }));
await waitFor(() => {
expect(uploadMock).toHaveBeenCalledTimes(1);
});
const uploadPayload = uploadMock.mock.calls[0]?.[0];
expect(uploadPayload?.worldName).toBe('潮雾群岛');
expect(uploadPayload?.cropRect.width).toBeGreaterThan(0);
expect(uploadPayload?.cropRect.height).toBeGreaterThan(0);
await waitFor(() => {
expect(screen.queryByText('裁剪上传封面')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑作品封面')).toBeNull();
});
const savedProfile = readCoverHarnessProfile();
expect(savedProfile.cover?.sourceType).toBe('uploaded');
expect(savedProfile.cover?.imageSrc).toBe(
'/generated-custom-world-covers/world-1/uploaded/cover.webp',
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -14,11 +14,11 @@ interface CustomWorldGenerationViewProps {
onRetry: () => void;
onInterrupt?: () => void;
backLabel?: string;
settingActionLabel?: string;
settingActionLabel?: string | null;
retryLabel?: string;
interruptLabel?: string;
settingTitle?: string;
settingDescription?: string;
settingDescription?: string | null;
progressTitle?: string;
activeBadgeLabel?: string;
pausedBadgeLabel?: string;
@@ -80,6 +80,11 @@ export function CustomWorldGenerationView({
const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? [];
const hasStructuredAnchors = anchorEntries.length > 0;
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
const normalizedSettingDescription = settingDescription?.trim() ?? '';
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
const hasSettingDescription = normalizedSettingDescription.length > 0;
const estimatedWaitText =
progress?.estimatedRemainingMs != null
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
@@ -207,13 +212,15 @@ export function CustomWorldGenerationView({
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? (
<>
<button
type="button"
onClick={onEditSetting}
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
>
{settingActionLabel}
</button>
{hasSettingActionLabel ? (
<button
type="button"
onClick={onEditSetting}
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
>
{normalizedSettingActionLabel}
</button>
) : null}
<button
type="button"
onClick={onRetry}
@@ -240,18 +247,22 @@ export function CustomWorldGenerationView({
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
{settingTitle}
</div>
<div className="mt-1 text-sm text-zinc-400">
{settingDescription}
</div>
{hasSettingDescription ? (
<div className="mt-1 text-sm text-zinc-400">
{normalizedSettingDescription}
</div>
) : null}
</div>
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{settingActionLabel}
</button>
{hasSettingActionLabel ? (
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{normalizedSettingActionLabel}
</button>
) : null}
</div>
{hasStructuredAnchors ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">

View File

@@ -220,6 +220,33 @@ const baseProfile = {
connections: [],
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '沉钟栈桥章节',
summary: '围绕沉钟栈桥推进的三幕结构。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-1',
sceneId: 'landmark-1',
title: '潮声逼近',
summary: '第一幕先把潮声与旧钟压上来。',
stageCoverage: ['opening'],
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-1.png',
backgroundAssetId: 'scene-asset-1',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '接住首幕压力',
transitionHook: '继续逼近钟楼深处。',
},
],
},
],
creatorIntent: null,
anchorPack: null,
lockState: null,
@@ -278,7 +305,7 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar
);
await waitFor(() => {
expect(screen.getByText('云止')).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
await waitFor(() => {
@@ -304,3 +331,70 @@ test('world basic setting renders eight anchor fields and hides legacy parsed/so
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(/线/u)).toBeTruthy();
});
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
const user = userEvent.setup();
const profile = {
...baseProfile,
playableNpcs: [
{
...createPlayableRole('playable-portrait', '云止'),
imageSrc: '/generated-characters/playable-portrait/master.png',
generatedVisualAssetId: 'visual-playable-portrait',
},
],
} as CustomWorldProfile;
render(
<CustomWorldResultView
profile={profile}
previewCharacters={[
{
id: 'playable-portrait',
name: '云止',
title: '同行者',
description: '预览角色',
backstory: '预览背景',
personality: '预览性格',
portrait: '/template/portrait.png',
avatar: '/template/avatar.png',
assetFolder: 'test',
assetVariant: 'Hero',
combatTags: [],
skills: [],
adventureOpenings: {},
} as never,
]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: //u }));
const portrait = screen.getByRole('img', { name: '云止' });
expect((portrait as HTMLImageElement).getAttribute('src')).toBe(
'/generated-characters/playable-portrait/master.png',
);
expect(screen.getByText('已生成主图')).toBeTruthy();
});
test('landmark tab uses first act image as scene card preview and keeps chapter details out of list', async () => {
const user = userEvent.setup();
render(<ResultViewHarness />);
await user.click(screen.getByRole('button', { name: /\s*2/u }));
expect(screen.queryByText('沉钟栈桥章节')).toBeNull();
expect(screen.queryByText('潮声逼近')).toBeNull();
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
'/generated-custom-world-scenes/scene-act-1.png',
);
});

View File

@@ -54,6 +54,159 @@ type PendingGeneratedEntity = {
type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
type CustomWorldAssetDebugEntry = {
id: string;
label: string;
imageSrc: string;
kind: 'playable' | 'story' | 'landmark' | 'scene-act';
};
type AssetDebugLoadStatus = 'loading' | 'loaded' | 'error';
const CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY = 'debugCustomWorldAssets';
const CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY =
'genarrative.debug.customWorldAssets';
function shouldEnableCustomWorldAssetDebugPanel() {
if (!import.meta.env.DEV || typeof window === 'undefined') {
return false;
}
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get(CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY) === '1') {
return true;
}
return (
window.localStorage.getItem(CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY) === '1'
);
}
function collectCustomWorldAssetDebugEntries(
profile: CustomWorldProfile,
): CustomWorldAssetDebugEntry[] {
const playableEntries = profile.playableNpcs
.map((role) => {
const imageSrc = role.imageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `playable:${role.id}`,
label: `${role.name}主形象`,
imageSrc,
kind: 'playable' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
);
const storyEntries = profile.storyNpcs
.map((role) => {
const imageSrc = role.imageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `story:${role.id}`,
label: `${role.name}场景角色主图`,
imageSrc,
kind: 'story' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
);
const landmarkEntries = profile.landmarks
.map((landmark) => {
const imageSrc = landmark.imageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `landmark:${landmark.id}`,
label: `${landmark.name}场景主图`,
imageSrc,
kind: 'landmark' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
);
const sceneActEntries =
profile.sceneChapterBlueprints?.flatMap((chapter) =>
chapter.acts
.map((act) => {
const imageSrc = act.backgroundImageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `scene-act:${chapter.id}:${act.id}`,
label: `${chapter.title || chapter.sceneId} / ${act.title}幕图`,
imageSrc,
kind: 'scene-act' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
),
) ?? [];
return [
...playableEntries,
...storyEntries,
...landmarkEntries,
...sceneActEntries,
];
}
function resolveAssetDebugStatusLabel(status: AssetDebugLoadStatus | undefined) {
if (status === 'loaded') {
return '已加载';
}
if (status === 'error') {
return '加载失败';
}
return '检测中';
}
function resolveAssetDebugSummary(profile: CustomWorldProfile) {
return [
{
label: '可扮演角色主图',
value: `${profile.playableNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.playableNpcs.length}`,
},
{
label: '场景角色主图',
value: `${profile.storyNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.storyNpcs.length}`,
},
{
label: '场景主图',
value: `${profile.landmarks.filter((landmark) => Boolean(landmark.imageSrc?.trim())).length}/${profile.landmarks.length}`,
},
{
label: '分幕图',
value: `${profile.sceneChapterBlueprints?.reduce(
(sum, chapter) =>
sum +
chapter.acts.filter((act) => Boolean(act.backgroundImageSrc?.trim()))
.length,
0,
) ?? 0}/${
profile.sceneChapterBlueprints?.reduce(
(sum, chapter) => sum + chapter.acts.length,
0,
) ?? 0
}`,
},
];
}
function SmallButton({
onClick,
children,
@@ -236,6 +389,22 @@ export function CustomWorldResultView({
null,
);
const pendingProgressTimerRef = useRef<number | null>(null);
const assetDebugEnabled = useMemo(
() => shouldEnableCustomWorldAssetDebugPanel(),
[],
);
const assetDebugEntries = useMemo(
() =>
assetDebugEnabled ? collectCustomWorldAssetDebugEntries(profile) : [],
[assetDebugEnabled, profile],
);
const assetDebugSummary = useMemo(
() => (assetDebugEnabled ? resolveAssetDebugSummary(profile) : []),
[assetDebugEnabled, profile],
);
const [assetDebugStatusMap, setAssetDebugStatusMap] = useState<
Record<string, AssetDebugLoadStatus>
>({});
const createTarget = useMemo(
() => getCreateTargetByTab(activeTab),
@@ -254,6 +423,59 @@ export function CustomWorldResultView({
useEffect(() => () => stopPendingProgressTimer(), []);
useEffect(() => {
if (!assetDebugEnabled) {
setAssetDebugStatusMap({});
return;
}
if (assetDebugEntries.length === 0) {
setAssetDebugStatusMap({});
return;
}
let cancelled = false;
const cleanupList: Array<() => void> = [];
setAssetDebugStatusMap(
Object.fromEntries(
assetDebugEntries.map((entry) => [entry.id, 'loading' as const]),
),
);
assetDebugEntries.forEach((entry) => {
const image = new Image();
const updateStatus = (status: AssetDebugLoadStatus) => {
if (cancelled) {
return;
}
setAssetDebugStatusMap((current) => {
if (current[entry.id] === status) {
return current;
}
return {
...current,
[entry.id]: status,
};
});
};
image.onload = () => updateStatus('loaded');
image.onerror = () => updateStatus('error');
image.src = entry.imageSrc;
cleanupList.push(() => {
image.onload = null;
image.onerror = null;
});
});
return () => {
cancelled = true;
cleanupList.forEach((cleanup) => cleanup());
};
}, [assetDebugEnabled, assetDebugEntries]);
const startPendingProgress = (kind: EntityGenerationKind) => {
stopPendingProgressTimer();
setPendingGeneratedEntity(createPendingGeneratedEntity(kind));
@@ -445,6 +667,77 @@ export function CustomWorldResultView({
{localGenerationError}
</div>
) : null}
{assetDebugEnabled ? (
<div className="platform-surface platform-surface--soft mt-3 px-3.5 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-bold tracking-[0.16em] text-white">
</div>
<div className="mt-1 text-xs leading-6 text-zinc-500">
</div>
</div>
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{assetDebugEntries.length}
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
{assetDebugSummary.map((entry) => (
<div
key={entry.label}
className="platform-subpanel rounded-2xl px-3 py-2"
>
<div className="text-[11px] text-zinc-500">{entry.label}</div>
<div className="mt-1 text-sm font-semibold text-white">
{entry.value}
</div>
</div>
))}
</div>
<div className="mt-3 space-y-2">
{assetDebugEntries.length > 0 ? (
assetDebugEntries.map((entry) => (
<div
key={entry.id}
className="platform-subpanel rounded-2xl px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{entry.label}
</div>
<div className="mt-1 break-all text-[11px] leading-5 text-zinc-400">
{entry.imageSrc}
</div>
</div>
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{resolveAssetDebugStatusLabel(
assetDebugStatusMap[entry.id],
)}
</div>
</div>
<div className="mt-2">
<a
href={entry.imageSrc}
target="_blank"
rel="noreferrer"
aria-label={`打开 ${entry.label}`}
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
>
</a>
</div>
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-3 py-3 text-sm text-zinc-400">
profile
</div>
)}
</div>
</div>
) : null}
<div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? (

View File

@@ -283,6 +283,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
!gameState.playerCharacter;
const collapseTopStage = gameState.currentScene === 'Selection';
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const visibleStoryForRender = visibleCurrentStory;
const dialogueIndicator = useMemo(() => {
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
@@ -431,6 +432,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
companions={canvasCompanionRenderStates}
npcStates={visibleGameState.npcStates}
dialogueIndicator={dialogueIndicator}
npcAffinityEffect={visibleStoryForRender?.npcAffinityEffect ?? null}
onEntitySelect={setSelectedSceneEntity}
onSceneNameClick={() => setIsMapOpen(true)}
sceneTransitionPhase={sceneTransitionPhase}
@@ -485,7 +487,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
</motion.div>
)}
{visibleGameState.playerCharacter && visibleCurrentStory && (
{visibleGameState.playerCharacter && visibleStoryForRender && (
<motion.div key="story-flow" initial={{opacity: 0}} animate={{opacity: 1}} className="flex h-full min-h-0 flex-col">
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
<button
@@ -562,7 +564,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
<AdventurePanel
aiError={aiError}
currentStory={visibleCurrentStory}
currentStory={visibleStoryForRender}
isLoading={isLoading}
displayedOptions={displayedOptions}
hideOptions={shouldHideStoryOptions}

View File

@@ -6,8 +6,6 @@ import { fetchJson } from '../../editor/shared/jsonClient';
export const CHARACTER_VISUAL_GENERATE_API_PATH =
ASSET_API_PATHS.characterVisualGenerate;
export const CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH =
ASSET_API_PATHS.characterPromptBundleGenerate;
export const CHARACTER_WORKFLOW_CACHE_API_PATH =
ASSET_API_PATHS.characterWorkflowCache;
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
@@ -47,29 +45,6 @@ export type CharacterVisualDraft = {
height: number;
};
export type CharacterPromptBundlePayload = {
roleKind: 'playable' | 'story';
characterName: string;
roleTitle?: string;
roleLabel?: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
characterBriefText: string;
};
export type CharacterPromptBundleResult = {
ok: true;
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
source: 'llm' | 'fallback';
model: string | null;
};
export type CharacterAssetWorkflowCache = {
characterId: string;
visualPromptText: string;
@@ -174,16 +149,6 @@ export async function generateCharacterVisualCandidates(
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
}
export async function generateCharacterPromptBundle(
payload: CharacterPromptBundlePayload,
) {
return postApiJson<CharacterPromptBundleResult>(
CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH,
payload,
'生成默认提示词失败',
);
}
export async function fetchCharacterWorkflowCache(characterId: string) {
return fetchJson<{
ok: true;

View File

@@ -44,7 +44,7 @@ export function CustomWorldAgentComposer({
return (
<div className="shrink-0">
<div className="platform-remap-surface relative">
<div className="platform-remap-surface platform-subpanel relative rounded-[1.5rem] p-1.5">
<textarea
ref={textareaRef}
value={text}
@@ -58,13 +58,13 @@ export function CustomWorldAgentComposer({
rows={2}
disabled={disabled}
placeholder="输入消息"
className="min-h-[5.5rem] w-full resize-none rounded-[1.35rem] border border-white/10 bg-[#111318]/92 px-4 pb-11 pr-18 pt-2.5 text-sm leading-5.5 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
className="platform-input min-h-[5.5rem] resize-none rounded-[1.2rem] pb-12 pr-20 pt-3 text-sm leading-5.5 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={submit}
disabled={disabled || !text.trim()}
className="absolute bottom-2.5 right-2.5 rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="platform-button platform-button--primary absolute bottom-3 right-3 h-9 min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
>
</button>

View File

@@ -4,11 +4,11 @@ type CustomWorldAgentHeaderProps = {
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
return (
<div className="platform-remap-surface flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<div className="platform-remap-surface platform-subpanel flex items-center rounded-[1.5rem] px-4 py-3">
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:text-white"
className="platform-button platform-button--ghost h-9 min-h-0 rounded-full px-3 py-1.5 text-[11px]"
>
</button>

View File

@@ -35,37 +35,41 @@ export function CustomWorldAgentOperationBanner({
const isFailed = visibleOperation.status === 'failed';
const isRunning =
visibleOperation.status === 'running' || visibleOperation.status === 'queued';
// 操作横幅直接复用平台状态横幅,亮暗主题都从同一套 token 取色。
const bannerToneClass = isFailed
? 'platform-banner--danger'
: isRunning
? 'platform-banner--info'
: 'platform-banner--success';
const progressFillStyle = isFailed
? { background: 'linear-gradient(90deg, #fb7185 0%, #f43f5e 100%)' }
: isRunning
? { background: 'var(--platform-button-primary-fill)' }
: { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' };
return (
<div
className={`platform-remap-surface rounded-[1.4rem] border px-4 py-4 ${
isFailed
? 'border-rose-400/20 bg-[#111318]/95'
: isRunning
? 'border-emerald-300/20 bg-[#111318]/95'
: 'border-emerald-300/20 bg-[#111318]/95'
}`}
className={`platform-remap-surface platform-banner rounded-[1.4rem] px-4 py-4 ${bannerToneClass}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
<div className="text-sm font-semibold">
{visibleOperation.phaseLabel}
</div>
<div className="text-xs text-zinc-300">
<div className="text-xs opacity-80">
{Math.max(0, Math.min(100, Math.round(visibleOperation.progress)))}%
</div>
</div>
{visibleOperation.error ? (
<div className="mt-2 text-sm text-zinc-200">
<div className="mt-2 text-sm opacity-90">
{visibleOperation.error}
</div>
) : null}
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/10">
<div className="platform-progress-track mt-3 h-2 overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-[width] duration-300 ${
isFailed ? 'bg-rose-300' : 'bg-emerald-300'
}`}
className="h-full rounded-full transition-[width] duration-300"
style={{
width: `${Math.max(8, Math.min(100, visibleOperation.progress))}%`,
...progressFillStyle,
}}
/>
</div>

View File

@@ -37,9 +37,9 @@ export function CustomWorldAgentThread({
}, [messages, streamingReplyText, isStreamingReply]);
return (
<div className="platform-remap-surface flex h-full min-h-0 flex-1 flex-col overflow-y-auto px-1 py-2 sm:px-2">
<div className="platform-remap-surface platform-subpanel flex h-full min-h-0 flex-1 flex-col overflow-y-auto rounded-[1.75rem] px-2 py-3 sm:px-3">
{messages.length === 0 ? (
<div className="m-auto text-sm text-zinc-400">
<div className="m-auto text-sm text-[var(--platform-text-soft)]">
</div>
) : (
@@ -47,6 +47,12 @@ export function CustomWorldAgentThread({
{messages.map((message, index) => {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
// 聊天气泡统一映射到平台主题 token避免亮色主题继续透出历史深色底。
const bubbleToneClass = isUser
? 'border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-text-strong)]'
: isSystem
? 'border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
: 'platform-subpanel text-[var(--platform-text-strong)]';
return (
<div
@@ -56,13 +62,7 @@ export function CustomWorldAgentThread({
}`}
>
<div
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${
isUser
? 'border border-white/10 bg-white/10 text-zinc-50'
: isSystem
? 'border border-amber-300/16 bg-amber-500/10 text-amber-50'
: 'border border-white/10 bg-white/6 text-zinc-100'
}`}
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
>
<div className="whitespace-pre-wrap">{message.text}</div>
{!isUser &&
@@ -74,7 +74,7 @@ export function CustomWorldAgentThread({
key={`recommended-reply-${replyIndex}-${reply}`}
type="button"
onClick={() => onRecommendedReply?.(reply)}
className="rounded-[0.95rem] border border-white/10 bg-white/5 px-2.5 py-1.5 text-left text-[11px] leading-4.5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"
className="platform-button platform-button--ghost min-h-0 justify-start rounded-[0.95rem] px-2.5 py-1.5 text-left text-[11px] leading-4.5 whitespace-normal"
>
{reply}
</button>
@@ -87,17 +87,17 @@ export function CustomWorldAgentThread({
})}
{isStreamingReply ? (
<div className="flex justify-start">
<div className="max-w-[90%] rounded-[1.4rem] border border-white/10 bg-white/6 px-4 py-3 text-sm leading-7 text-zinc-100 sm:max-w-[82%]">
<div className="platform-subpanel max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 text-[var(--platform-text-strong)] sm:max-w-[82%]">
{streamingReplyText ? (
<div className="whitespace-pre-wrap">
{streamingReplyText}
<span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-emerald-200/80 align-[-2px]" />
<span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-[var(--platform-cool-text)] align-[-2px]" />
</div>
) : (
<div className="flex items-center gap-1.5 py-1">
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
</div>
)}
</div>

View File

@@ -42,7 +42,7 @@ export function CustomWorldAgentWorkspace({
}: CustomWorldAgentWorkspaceProps) {
if (!session) {
return (
<div className="platform-remap-surface mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
<div className="platform-remap-surface platform-subpanel mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] px-6 py-8 text-center text-sm text-[var(--platform-text-soft)]">
</div>
);

View File

@@ -46,36 +46,42 @@ export function EightAnchorProgressBar({
const normalizedProgress = clampProgress(progressPercent);
const isCompleted = normalizedProgress >= 100;
const canQuickFill = currentTurn >= 2;
const progressFillStyle = isCompleted
? { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' }
: { background: 'var(--platform-button-primary-fill)' };
return (
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
<div className="platform-remap-surface platform-subpanel rounded-[1.75rem] p-4">
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold tracking-[0.14em] text-zinc-300">
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-text-base)]">
</div>
<div className="mt-1 text-sm text-zinc-400">
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{resolveProgressHint(normalizedProgress)}
</div>
</div>
<div className="text-lg font-semibold text-white">
<div className="text-lg font-semibold text-[var(--platform-text-strong)]">
{normalizedProgress}%
</div>
</div>
<div className="h-3 overflow-hidden rounded-full bg-white/8">
<div className="platform-progress-track h-3 overflow-hidden rounded-full">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,#d8ffd9_0%,#6ee7b7_45%,#34d399_100%)] transition-[width] duration-500"
style={{ width: `${Math.max(6, normalizedProgress)}%` }}
className="h-full rounded-full transition-[width] duration-500"
style={{
width: `${Math.max(6, normalizedProgress)}%`,
...progressFillStyle,
}}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<button
type="button"
onClick={onSummaryClick}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
@@ -84,7 +90,7 @@ export function EightAnchorProgressBar({
type="button"
onClick={onGenerateDraft}
disabled={disabled}
className="flex min-h-[3rem] items-center justify-center rounded-[1.1rem] border border-emerald-300/25 bg-emerald-500/12 px-4 py-3 text-sm font-semibold text-emerald-50 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="platform-button platform-button--primary min-h-[3rem] rounded-[1.1rem] px-4 py-3 text-sm disabled:cursor-not-allowed disabled:opacity-45"
>
稿
</button>
@@ -93,7 +99,7 @@ export function EightAnchorProgressBar({
type="button"
onClick={onQuickFill}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
>
</button>

View File

@@ -0,0 +1,128 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import {
AnimationState,
type Character,
type Encounter,
type SceneHostileNpc,
} from '../../types';
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'npc-liu',
kind: 'npc',
npcName: '柳无声',
npcDescription: '桥口旧识',
npcAvatar: '/npc-liu.png',
context: '断桥',
...overrides,
};
}
function createHostileNpc(overrides: Partial<SceneHostileNpc> = {}): SceneHostileNpc {
return {
id: 'npc-liu',
name: '柳无声',
action: '对峙',
description: '桥口旧识',
animation: 'idle',
xMeters: 3,
yOffset: 0,
facing: 'left',
attackRange: 1,
speed: 1,
hp: 10,
maxHp: 10,
encounter: createEncounter(),
...overrides,
};
}
function renderEntityLayer(effectNpcId: string | null) {
return renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={false}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={
effectNpcId
? {
eventId: 'effect-1',
npcId: effectNpcId,
delta: 3,
}
: null
}
sceneCombatants={[createHostileNpc()]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
}
describe('GameCanvasEntityLayer', () => {
it('renders affinity effect on the matching hostile npc', () => {
const html = renderEntityLayer('npc-liu');
expect(html).toContain('data-testid="npc-affinity-effect-npc-liu"');
expect(html).toContain('aria-label="好感度变化 +3"');
});
it('does not render affinity effect on a different npc', () => {
const html = renderEntityLayer('npc-other');
expect(html).not.toContain('npc-affinity-effect-npc-liu');
expect(html).not.toContain('好感度变化 +3');
});
});

View File

@@ -15,6 +15,7 @@ import {
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils';
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
import {
DialogueBubbleIcon,
type GameCanvasEntitySelection,
@@ -66,6 +67,11 @@ interface GameCanvasEntityLayerProps {
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
npcAffinityEffect?: {
eventId: string;
npcId: string;
delta: number;
} | null;
sceneCombatants: SceneHostileNpc[];
monsters: MonsterSpriteConfig[];
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
@@ -101,6 +107,7 @@ export function GameCanvasEntityLayer({
effectivePlayerAnimationState,
shouldShowPlayerDialogueIcon,
dialogueIndicator = null,
npcAffinityEffect = null,
sceneCombatants,
monsters,
getHostileNpcOuterLeft,
@@ -326,6 +333,10 @@ export function GameCanvasEntityLayer({
/>
</div>
)}
{/* 聊天好感变化要挂在当前角色形象上,而不是消息区里。 */}
{npcAffinityEffect?.npcId === (npcEncounter.id ?? npcEncounter.npcName) ? (
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton>
</div>
);
@@ -436,6 +447,10 @@ export function GameCanvasEntityLayer({
/>
</div>
)}
{/* 和平相遇态同样沿用角色形象上的好感浮出特效。 */}
{npcAffinityEffect?.npcId === (encounter.id ?? encounter.npcName) ? (
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton>
</div>
);

View File

@@ -39,6 +39,7 @@ export function GameCanvasRuntime({
activeCombatEffects = [],
companions = [],
dialogueIndicator = null,
npcAffinityEffect = null,
onEntitySelect = null,
onSceneNameClick = null,
sceneTransitionPhase = 'idle',
@@ -192,6 +193,7 @@ export function GameCanvasRuntime({
effectivePlayerAnimationState={effectivePlayerAnimationState}
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
dialogueIndicator={dialogueIndicator}
npcAffinityEffect={npcAffinityEffect}
sceneCombatants={sceneHostileNpcs}
monsters={monsters}
getHostileNpcOuterLeft={getHostileNpcOuterLeft}

View File

@@ -16,6 +16,7 @@ import {
Encounter,
SceneHostileNpc,
ScenePresetInfo,
StoryNpcAffinityEffect,
StoryEngineMemoryState,
WorldType,
} from '../../types';
@@ -54,6 +55,7 @@ export interface GameCanvasProps {
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
npcAffinityEffect?: StoryNpcAffinityEffect | null;
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
onSceneNameClick?: (() => void) | null;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
@@ -68,6 +70,10 @@ export const ENTITY_CONTAINER_REM = 7;
export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible';
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
export const GENERIC_NPC_SCENE_SCALE = 1.72;
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
objectPosition: 'center bottom',
};
export const DEFAULT_COMBAT_HP_TOP_PX = -18;
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;

View File

@@ -0,0 +1,59 @@
import { Heart } from 'lucide-react';
import { motion } from 'motion/react';
import type { StoryNpcAffinityEffect } from '../../types';
interface NpcAffinityEffectBadgeProps {
effect: StoryNpcAffinityEffect;
}
/**
* 聊天结算后的好感度浮出特效。
* 仅负责表现层,不承担任何数值计算。
*/
export function NpcAffinityEffectBadge({
effect,
}: NpcAffinityEffectBadgeProps) {
const isPositive = effect.delta > 0;
const deltaText = `${effect.delta > 0 ? '+' : ''}${effect.delta}`;
return (
<motion.div
key={effect.eventId}
initial={{ opacity: 0, y: 24, scale: 0.8 }}
animate={{ opacity: [0, 1, 1, 0], y: [24, -8, -26, -44], scale: [0.8, 1.08, 1, 0.92] }}
transition={{ duration: 1.45, ease: 'easeOut' }}
className="pointer-events-none absolute -top-14 left-1/2 z-[12] flex -translate-x-1/2 items-center gap-1 rounded-full border px-2.5 py-1 shadow-[0_10px_24px_rgba(0,0,0,0.35)] backdrop-blur-[2px]"
data-testid={`npc-affinity-effect-${effect.npcId}`}
aria-label={`好感度变化 ${deltaText}`}
>
{isPositive ? (
<>
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.3),transparent_60%)]" />
<div className="absolute -inset-1 rounded-full bg-rose-400/18 blur-md" />
<div className="relative flex items-center gap-1 text-rose-50">
<Heart className="h-3.5 w-3.5 fill-current" />
<span className="text-xs font-semibold tracking-[0.08em]">
{deltaText}
</span>
</div>
</>
) : (
<>
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),transparent_60%)]" />
<div className="absolute -inset-1 rounded-full bg-slate-400/15 blur-md" />
<div className="relative text-xs font-semibold tracking-[0.08em] text-slate-100">
{deltaText}
</div>
</>
)}
<div
className={`absolute inset-0 rounded-full border ${
isPositive
? 'border-rose-200/45 bg-rose-500/18'
: 'border-slate-200/35 bg-slate-700/30'
}`}
/>
</motion.div>
);
}

View File

@@ -56,7 +56,7 @@ const DESKTOP_PAGE_STAGE_CLASS =
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
<div className="mb-3">
<div className="text-[10px] font-semibold tracking-[0.26em] text-zinc-500">
<div className="text-[10px] font-semibold tracking-[0.26em] text-[var(--platform-text-soft)]">
{detail}
</div>
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
@@ -69,7 +69,7 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
function EmptyShelf({ text }: { text: string }) {
return (
<div
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`}
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-[var(--platform-text-base)]`}
>
{text}
</div>
@@ -101,7 +101,7 @@ function SaveArchivePreview({
)}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88">
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-[var(--platform-text-strong)]">
{label}
</span>
</div>
@@ -162,15 +162,15 @@ function WorldCard({
</span>
</div>
<div className="mt-auto">
<div className="line-clamp-1 text-xl font-black text-white">
<div className="line-clamp-1 text-xl font-black text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-zinc-300/85">
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-[color:color-mix(in_srgb,var(--platform-text-base)_85%,transparent)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-2 text-xs leading-5 text-zinc-200/90">
<div className="mt-2 line-clamp-2 text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_90%,transparent)]">
{entry.summaryText || '等待补充世界摘要。'}
</div>
<div className="mt-3 flex flex-wrap gap-2">
@@ -249,28 +249,28 @@ function CreationLibraryCard({
>
{statusLabel}
</span>
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-zinc-300">
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-[var(--platform-text-base)]">
<span className="truncate">{metaLabel}</span>
</span>
</div>
<div className="mt-auto min-w-0">
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-white sm:text-[1.12rem]">
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-[var(--platform-text-strong)] sm:text-[1.12rem]">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-zinc-300/84">
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-zinc-200/88 sm:text-xs">
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)] sm:text-xs">
{summaryText}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-zinc-100/90">
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-[color:color-mix(in_srgb,var(--platform-text-strong)_90%,transparent)]">
<span className="truncate">{primaryTag}</span>
</span>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200">
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-[var(--platform-text-base)]">
<span>
{entry.visibility === 'published' ? '进入世界' : '继续创作'}
</span>
@@ -306,21 +306,21 @@ function SaveArchiveCard({
<div className="relative z-10 flex h-full w-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="platform-pill platform-pill--cool">ARCHIVE</span>
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-[var(--platform-text-base)]">
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
</span>
</div>
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-white sm:text-xl">
<div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-xl">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-sm text-zinc-300">
<div className="mt-1 line-clamp-1 break-words text-sm text-[var(--platform-text-base)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-zinc-400 sm:text-sm">
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-[var(--platform-text-soft)] sm:text-sm">
{summaryText}
</div>
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
@@ -356,13 +356,11 @@ function PlatformTabButton({
onClick={onClick}
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`}
>
<span className="flex flex-col items-center justify-center gap-1">
<span className="platform-bottom-nav__button-content">
<span className="platform-bottom-nav__icon-shell">
<Icon className="platform-bottom-nav__icon h-[1.05rem] w-[1.05rem]" />
</span>
<span className="platform-bottom-nav__label text-[11px] font-semibold tracking-[0.18em]">
{label}
<Icon className="platform-bottom-nav__icon" />
</span>
<span className="platform-bottom-nav__label">{label}</span>
</span>
</button>
);
@@ -425,14 +423,14 @@ function DesktopTrendingItem({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
<span>{`${rank}`.padStart(2, '0')}</span>
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
</div>
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
<div className="mt-1 line-clamp-2 text-sm leading-6 text-zinc-300/86">
<div className="mt-1 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_86%,transparent)]">
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
</div>
<div className="mt-3 flex flex-wrap gap-2">
@@ -453,7 +451,7 @@ function DesktopTrendingItem({
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-zinc-500" />
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
@@ -584,7 +582,7 @@ function ProfileStatCard({
onClick={onClick ? () => onClick(cardKey) : undefined}
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
>
<div className="flex items-center gap-2 text-zinc-400">
<div className="flex items-center gap-2 text-[var(--platform-text-soft)]">
<Icon className="h-4 w-4" />
<span className="text-[11px] tracking-[0.16em]">{label}</span>
</div>
@@ -598,8 +596,8 @@ function ProfileStatCard({
function ProfileStatCardSkeleton() {
return (
<div className="platform-subpanel rounded-[1.35rem] px-4 py-3">
<div className="h-4 w-20 animate-pulse rounded-full bg-white/10" />
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-white/12" />
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
</div>
);
}
@@ -748,7 +746,7 @@ export function PlatformHomeView({
}}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,47,112,0.92),rgba(255,136,104,0.9))]" />
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<span className="platform-pill platform-pill--warm">
@@ -776,7 +774,7 @@ export function PlatformHomeView({
</button>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
@@ -833,7 +831,7 @@ export function PlatformHomeView({
onClick={onOpenCreateTypePicker}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.16),transparent_38%),radial-gradient(circle_at_right,rgba(255,201,172,0.18),transparent_30%),linear-gradient(180deg,rgba(255,90,141,0.88),rgba(255,144,105,0.88))]" />
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<span className="platform-pill platform-pill--cool w-fit">
CREATE
@@ -887,7 +885,7 @@ export function PlatformHomeView({
{authUi?.user ? (
<>
{saveError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{saveError}
</div>
) : null}
@@ -1060,7 +1058,7 @@ export function PlatformHomeView({
</>
)}
</div>
<div className="mt-3 text-[11px] text-zinc-500">
<div className="mt-3 text-[11px] text-[var(--platform-text-soft)]">
{dashboardError
? dashboardError
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
@@ -1090,10 +1088,12 @@ export function PlatformHomeView({
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="text-xs text-zinc-400"></div>
<div className="text-xs text-[var(--platform-text-soft)]">
</div>
</div>
</div>
<ChevronRight className="h-4 w-4 text-zinc-500" />
<ChevronRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
</button>
</section>
</>
@@ -1121,7 +1121,7 @@ export function PlatformHomeView({
activeTab === 'home' ? (
<div className={DESKTOP_PAGE_STAGE_CLASS}>
{platformError ? (
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
@@ -1147,8 +1147,7 @@ export function PlatformHomeView({
className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(115deg,rgba(255,31,111,0.94),rgba(255,109,104,0.8)_52%,rgba(255,164,124,0.9))]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_78%_24%,rgba(255,208,178,0.18),transparent_20%)]" />
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<span className="platform-pill platform-pill--warm">
@@ -1198,8 +1197,8 @@ export function PlatformHomeView({
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.34))]" />
</div>
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-zinc-300/82">
<span className="text-zinc-500">
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-[color:color-mix(in_srgb,var(--platform-text-base)_82%,transparent)]">
<span className="text-[var(--platform-text-soft)]">
{`${index + 1}`.padStart(2, '0')}
</span>
<span className="line-clamp-1">
@@ -1286,17 +1285,17 @@ export function PlatformHomeView({
<span className="platform-pill platform-pill--cool">
{hasSavedGame ? 'SAVE POINT' : 'START HERE'}
</span>
<ArrowRight className="h-4 w-4 text-zinc-400" />
<ArrowRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
</div>
<div className="mt-4 text-2xl font-semibold text-white">
<div className="mt-4 text-2xl font-semibold text-[var(--platform-text-strong)]">
{hasSavedGame ? snapshotWorldName : '从这里开启新的创作'}
</div>
<div className="mt-2 text-sm leading-7 text-zinc-300/84">
<div className="mt-2 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
{hasSavedGame
? `当前角色:${snapshotCharacterName}`
: '快速进入自定义世界创作,继续补齐设定、角色与核心冲突。'}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-6 text-zinc-400">
<div className="mt-3 line-clamp-3 text-sm leading-6 text-[var(--platform-text-soft)]">
{hasSavedGame
? snapshotDigest
: '先生成一版可玩的世界底稿,再继续编辑并发布。'}
@@ -1305,7 +1304,7 @@ export function PlatformHomeView({
</button>
<div className="mt-5">
<div className="text-[10px] font-semibold tracking-[0.24em] text-zinc-500">
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
{desktopLibraryPreview.length > 0
? '最近作品'
: historyEntries.length > 0
@@ -1325,10 +1324,10 @@ export function PlatformHomeView({
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-white">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
<div className="mt-1 text-sm text-zinc-400">
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '草稿待完善'}
@@ -1366,10 +1365,10 @@ export function PlatformHomeView({
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-white">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
<div className="mt-1 text-sm text-zinc-400">
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.authorDisplayName}
</div>
</div>
@@ -1434,7 +1433,7 @@ export function PlatformHomeView({
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
}}
>
<div className="platform-bottom-nav grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] px-1 py-1">
<div className="platform-bottom-nav grid grid-cols-4">
<PlatformTabButton
active={activeTab === 'home'}
label="首页"
@@ -1468,7 +1467,7 @@ export function PlatformHomeView({
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-5">
<PlatformBrandLogo className="shrink-0" decorative />
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400">
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)]">
<Search className="h-4 w-4 shrink-0" />
<span className="truncate text-sm">
@@ -1503,7 +1502,7 @@ export function PlatformHomeView({
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '进入账户'}
</span>
<span className="block truncate text-xs text-zinc-400">
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
{authUi?.user ? publicUserCode : '登录后同步创作与进度'}
</span>
</span>

View File

@@ -110,7 +110,7 @@ export function PlatformWorldDetailView({
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(125deg,rgba(255,31,111,0.78),rgba(255,138,115,0.52)_48%,rgba(255,255,255,0.08)_100%)]" />
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10">
<div className="flex flex-wrap items-center gap-2">
<span className="platform-pill platform-pill--warm">
@@ -151,12 +151,12 @@ export function PlatformWorldDetailView({
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4">
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-[var(--platform-text-strong)] sm:grid-cols-4">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
@@ -164,7 +164,7 @@ export function PlatformWorldDetailView({
</div>
</div>
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
@@ -172,7 +172,7 @@ export function PlatformWorldDetailView({
</div>
</div>
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
@@ -180,7 +180,7 @@ export function PlatformWorldDetailView({
</div>
</div>
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
@@ -190,19 +190,19 @@ export function PlatformWorldDetailView({
</div>
<div className="mt-5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{previewCharacters.map((character, index) => (
<div
key={character.id || `preview-character-${index}`}
className="platform-subpanel rounded-2xl px-3 py-3"
className="platform-subpanel rounded-2xl px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-white">
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
{character.title}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300">
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{character.description}
</div>
</div>
@@ -211,19 +211,19 @@ export function PlatformWorldDetailView({
</div>
<div className="mt-5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{previewLandmarks.map((landmark, index) => (
<div
key={landmark.id || `preview-landmark-${index}`}
className="platform-subpanel rounded-2xl px-3 py-3"
className="platform-subpanel rounded-2xl px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-white">
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
{landmark.name}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300">
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{landmark.description}
</div>
</div>
@@ -233,7 +233,7 @@ export function PlatformWorldDetailView({
</div>
<div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-4 flex flex-col gap-3">
@@ -275,7 +275,7 @@ export function PlatformWorldDetailView({
) : null}
</div>
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{error}
</div>
) : null}

View File

@@ -483,7 +483,8 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
expect(screen.getByText('当前锚点信息')).toBeTruthy();
expect(screen.getByText('当前世界信息')).toBeTruthy();
expect(screen.queryByText('回到工作区')).toBeNull();
expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText(/穿/u)).toBeTruthy();
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();

View File

@@ -43,9 +43,7 @@ import {
readCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import {
buildCustomWorldCreatorIntentFoundationText,
} from '../../services/customWorldCreatorIntent';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import {
hasPendingPlatformBrowseHistoryMigration,
markPlatformBrowseHistoryMigrated,
@@ -169,7 +167,7 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
function LazyPanelFallback({ label }: { label: string }) {
return (
<div className="flex h-full min-h-0 items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{label}
</div>
</div>
@@ -405,9 +403,8 @@ export function PreGameSelectionFlow({
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0
) {
nextEntries = await syncProfileBrowseHistory(
localHistoryEntries,
);
nextEntries =
await syncProfileBrowseHistory(localHistoryEntries);
markPlatformBrowseHistoryMigrated(authUi?.user);
}
@@ -472,12 +469,17 @@ export function PreGameSelectionFlow({
} else if (isAuthenticated) {
setSaveEntries([]);
setSaveError(
resolveErrorMessage(saveArchivesResult.reason, '读取存档列表失败。'),
resolveErrorMessage(
saveArchivesResult.reason,
'读取存档列表失败。',
),
);
}
const nextPlatformBootstrapUserId = authUi?.user?.id ?? null;
if (platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId) {
if (
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId
) {
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
if (!initialAgentUiStateRef.current.activeSessionId) {
setPlatformTab(
@@ -1333,7 +1335,7 @@ export function PreGameSelectionFlow({
>
{isDetailLoading || !selectedDetailEntry ? (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{detailError || '正在读取作品详情...'}
</div>
</div>
@@ -1419,7 +1421,7 @@ export function PreGameSelectionFlow({
/>
) : (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{isLoadingAgentSession
? '正在准备 Agent 共创工作区...'
: creationTypeError || '正在恢复创作工作区...'}
@@ -1452,14 +1454,10 @@ export function PreGameSelectionFlow({
onRetry={retryAgentDraftGeneration}
onInterrupt={undefined}
backLabel="返回工作区"
settingActionLabel="回到工作区"
settingActionLabel={null}
retryLabel="重新生成草稿"
settingTitle="当前锚点信息"
settingDescription={
isAgentDraftGenerationView
? '将按当前八锚点结构编译第一版世界底稿与草稿卡。'
: undefined
}
settingTitle="当前世界信息"
settingDescription={null}
progressTitle={
isAgentDraftGenerationView ? '世界草稿生成进度' : undefined
}