This commit is contained in:
2026-04-18 13:05:29 +08:00
parent 09d4c0c31b
commit 5032701c38
77 changed files with 8538 additions and 2413 deletions

View File

@@ -8,6 +8,7 @@ interface CharacterAnimatorProps {
className?: string;
style?: React.CSSProperties;
imageClassName?: string;
playbackRate?: number;
}
const DEFAULT_ANIMATIONS: Record<AnimationState, CharacterAnimationConfig> = {
@@ -42,6 +43,7 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
className,
style,
imageClassName,
playbackRate = 1,
}) => {
const [frameIndex, setFrameIndex] = useState(1);
const config =
@@ -51,6 +53,13 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
DEFAULT_ANIMATIONS[AnimationState.IDLE];
const startFrame = config.startFrame ?? 1;
const frameCount = config.frames;
const fps =
typeof config.fps === 'number' && Number.isFinite(config.fps)
? Math.max(1, config.fps)
: 10;
const effectivePlaybackRate = Number.isFinite(playbackRate)
? Math.max(0.1, playbackRate)
: 1;
const animationSignature = [
state,
config.basePath ?? '',
@@ -60,6 +69,8 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
config.extension ?? 'png',
startFrame,
frameCount,
fps,
effectivePlaybackRate,
].join('::');
useEffect(() => {
@@ -73,10 +84,16 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
setFrameIndex(prev => {
return prev >= endFrame ? startFrame : prev + 1;
});
}, 100);
}, Math.max(40, Math.round(1000 / (fps * effectivePlaybackRate))));
return () => window.clearInterval(interval);
}, [animationSignature, frameCount, startFrame]);
}, [
animationSignature,
effectivePlaybackRate,
fps,
frameCount,
startFrame,
]);
const frameNumber = frameIndex.toString().padStart(2, '0');
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');

View File

@@ -1,3 +1,7 @@
import type {
EightAnchorContent,
KeyRelationshipValue,
} from '../../packages/shared/src/contracts/customWorldAgent';
import {
type ReactNode,
useDeferredValue,
@@ -13,10 +17,7 @@ import {
resolveCustomWorldLandmarkImageMap,
} from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
buildCustomWorldCreatorIntentFoundationText,
normalizeCustomWorldCreatorIntent,
} from '../services/customWorldCreatorIntent';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
@@ -348,6 +349,226 @@ function compactTextList(values: Array<string | null | undefined>) {
return values.map((value) => value?.trim() ?? '').filter(Boolean);
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toTextArray(value: unknown) {
return Array.isArray(value)
? value.map((item) => toText(item)).filter(Boolean)
: [];
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function buildRelationshipSeedText(value: unknown) {
const record = toRecord(value);
if (!record) {
return '';
}
return compactTextList([
toText(record.name),
toText(record.role),
toText(record.relationToPlayer)
? `与玩家:${toText(record.relationToPlayer)}`
: '',
toText(record.hiddenHook) ? `代价/暗线:${toText(record.hiddenHook)}` : '',
]).join('');
}
function buildKeyRelationshipText(value: KeyRelationshipValue) {
return compactTextList([
value.pairs,
value.relationshipType,
value.secretOrCost ? `代价/秘密:${value.secretOrCost}` : '',
]).join('');
}
function buildAnchorContentFromProfileFallback(
profile: CustomWorldProfile,
): EightAnchorContent {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const relationshipSeed = creatorIntent?.keyCharacters[0] ?? null;
return {
worldPromise: {
hook:
creatorIntent?.worldHook ||
profile.anchorPack?.worldSummary ||
profile.summary,
differentiator: profile.subtitle || profile.settingText,
desiredExperience:
compactTextList([
creatorIntent?.toneDirectives.join('、') || '',
profile.tone,
]).join('') || profile.tone,
},
playerFantasy: {
playerRole: creatorIntent?.playerPremise || profile.playerGoal,
corePursuit: profile.playerGoal,
fearOfLoss:
relationshipSeed?.hiddenHook ||
creatorIntent?.coreConflicts[0] ||
profile.coreConflicts[0] ||
'',
},
themeBoundary: {
toneKeywords: compactTextList([
creatorIntent?.themeKeywords.join('、') || '',
creatorIntent?.toneDirectives.join('、') || '',
]),
aestheticDirectives: compactTextList([profile.tone, profile.subtitle]),
forbiddenDirectives: creatorIntent?.forbiddenDirectives ?? [],
},
playerEntryPoint: {
openingIdentity: creatorIntent?.playerPremise || '',
openingProblem:
creatorIntent?.openingSituation || profile.coreConflicts[0] || '',
entryMotivation: profile.playerGoal,
},
coreConflict: {
surfaceConflicts:
creatorIntent?.coreConflicts.length
? creatorIntent.coreConflicts
: profile.coreConflicts,
hiddenCrisis:
relationshipSeed?.hiddenHook ||
profile.summary ||
profile.settingText,
firstTouchedConflict:
creatorIntent?.openingSituation ||
profile.coreConflicts[0] ||
profile.playerGoal,
},
keyRelationships: relationshipSeed
? [
{
pairs: compactTextList([
relationshipSeed.name,
relationshipSeed.role,
]).join(' · '),
relationshipType: relationshipSeed.relationToPlayer || '',
secretOrCost: relationshipSeed.hiddenHook || '',
},
]
: [],
hiddenLines: {
hiddenTruths: compactTextList([
relationshipSeed?.hiddenHook || '',
profile.summary,
]),
misdirectionHints: compactTextList([
profile.subtitle,
profile.majorFactions[0] || '',
]),
revealPacing:
creatorIntent?.openingSituation ||
profile.coreConflicts[0] ||
profile.playerGoal,
},
iconicElements: {
iconicMotifs:
creatorIntent?.iconicElements.length
? creatorIntent.iconicElements
: compactTextList([
profile.anchorPack?.motifDirectives.join('、') || '',
profile.landmarks[0]?.name || '',
]),
institutionsOrArtifacts: compactTextList([
profile.camp?.name || '',
profile.majorFactions[0] || '',
]),
hardRules: compactTextList([profile.playerGoal, profile.coreConflicts[0] || '']),
},
};
}
function getProfileAnchorContent(profile: CustomWorldProfile) {
const anchorContentRecord = profile.anchorContent;
if (!anchorContentRecord) {
return buildAnchorContentFromProfileFallback(profile);
}
const worldPromiseRecord = toRecord(anchorContentRecord.worldPromise);
const playerFantasyRecord = toRecord(anchorContentRecord.playerFantasy);
const themeBoundaryRecord = toRecord(anchorContentRecord.themeBoundary);
const playerEntryPointRecord = toRecord(anchorContentRecord.playerEntryPoint);
const coreConflictRecord = toRecord(anchorContentRecord.coreConflict);
const hiddenLinesRecord = toRecord(anchorContentRecord.hiddenLines);
const iconicElementsRecord = toRecord(anchorContentRecord.iconicElements);
return {
worldPromise: worldPromiseRecord
? {
hook: toText(worldPromiseRecord.hook),
differentiator: toText(worldPromiseRecord.differentiator),
desiredExperience: toText(worldPromiseRecord.desiredExperience),
}
: null,
playerFantasy: playerFantasyRecord
? {
playerRole: toText(playerFantasyRecord.playerRole),
corePursuit: toText(playerFantasyRecord.corePursuit),
fearOfLoss: toText(playerFantasyRecord.fearOfLoss),
}
: null,
themeBoundary: themeBoundaryRecord
? {
toneKeywords: toTextArray(themeBoundaryRecord.toneKeywords),
aestheticDirectives: toTextArray(
themeBoundaryRecord.aestheticDirectives,
),
forbiddenDirectives: toTextArray(themeBoundaryRecord.forbiddenDirectives),
}
: null,
playerEntryPoint: playerEntryPointRecord
? {
openingIdentity: toText(playerEntryPointRecord.openingIdentity),
openingProblem: toText(playerEntryPointRecord.openingProblem),
entryMotivation: toText(playerEntryPointRecord.entryMotivation),
}
: null,
coreConflict: coreConflictRecord
? {
surfaceConflicts: toTextArray(coreConflictRecord.surfaceConflicts),
hiddenCrisis: toText(coreConflictRecord.hiddenCrisis),
firstTouchedConflict: toText(coreConflictRecord.firstTouchedConflict),
}
: null,
keyRelationships: Array.isArray(anchorContentRecord.keyRelationships)
? anchorContentRecord.keyRelationships
.map((entry) => toRecord(entry))
.filter(Boolean)
.map((entry) => ({
pairs: toText(entry?.pairs),
relationshipType: toText(entry?.relationshipType),
secretOrCost: toText(entry?.secretOrCost),
}))
: [],
hiddenLines: hiddenLinesRecord
? {
hiddenTruths: toTextArray(hiddenLinesRecord.hiddenTruths),
misdirectionHints: toTextArray(hiddenLinesRecord.misdirectionHints),
revealPacing: toText(hiddenLinesRecord.revealPacing),
}
: null,
iconicElements: iconicElementsRecord
? {
iconicMotifs: toTextArray(iconicElementsRecord.iconicMotifs),
institutionsOrArtifacts: toTextArray(
iconicElementsRecord.institutionsOrArtifacts,
),
hardRules: toTextArray(iconicElementsRecord.hardRules),
}
: null,
} satisfies EightAnchorContent;
}
function buildOpeningSceneSearchText(
profile: CustomWorldProfile,
campScene: ReturnType<typeof resolveCustomWorldCampScene>,
@@ -365,71 +586,85 @@ function buildOpeningSceneSearchText(
function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const relationshipSeed = creatorIntent?.keyCharacters[0];
const relationshipText = relationshipSeed
? compactTextList([
relationshipSeed.name,
relationshipSeed.role,
relationshipSeed.relationToPlayer
? `与玩家:${relationshipSeed.relationToPlayer}`
: '',
relationshipSeed.hiddenHook
? `暗线:${relationshipSeed.hiddenHook}`
: '',
]).join(' · ')
: '';
const themeToneText = compactTextList([
creatorIntent?.themeKeywords.join('、') || '',
creatorIntent?.toneDirectives.join('、') || '',
]).join(' / ');
const playerOpeningText = compactTextList([
creatorIntent?.playerPremise || '',
creatorIntent?.openingSituation || '',
]).join('');
const anchorContent = getProfileAnchorContent(profile);
const fallbackRelationshipText =
buildRelationshipSeedText(creatorIntent?.keyCharacters[0]) ||
profile.playableNpcs[0]?.relationshipHooks.join('') ||
profile.storyNpcs[0]?.relationshipHooks.join('') ||
'';
return [
{
id: 'world-hook',
label: '世界一句话',
value:
creatorIntent?.worldHook ||
profile.anchorPack?.worldSummary ||
profile.summary,
id: 'world-promise',
label: '世界承诺',
value: compactTextList([
anchorContent.worldPromise?.hook || '',
anchorContent.worldPromise?.differentiator || '',
anchorContent.worldPromise?.desiredExperience || '',
]).join(''),
},
{
id: 'player-opening',
label: '玩家开局',
value: playerOpeningText || profile.playerGoal,
id: 'player-fantasy',
label: '玩家幻想',
value: compactTextList([
anchorContent.playerFantasy?.playerRole || '',
anchorContent.playerFantasy?.corePursuit || '',
anchorContent.playerFantasy?.fearOfLoss || '',
]).join(''),
},
{
id: 'theme-tone',
label: '主题气质',
value: themeToneText || profile.tone,
id: 'theme-boundary',
label: '主题边界',
value: compactTextList([
anchorContent.themeBoundary?.toneKeywords.join('、') || '',
anchorContent.themeBoundary?.aestheticDirectives.join('、') || '',
anchorContent.themeBoundary?.forbiddenDirectives.length
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
: '',
]).join(''),
},
{
id: 'player-entry-point',
label: '玩家切入口',
value: compactTextList([
anchorContent.playerEntryPoint?.openingIdentity || '',
anchorContent.playerEntryPoint?.openingProblem || '',
anchorContent.playerEntryPoint?.entryMotivation || '',
]).join(''),
},
{
id: 'core-conflict',
label: '核心冲突',
value:
creatorIntent?.coreConflicts.join('') ||
profile.coreConflicts.join('') ||
profile.summary,
value: compactTextList([
anchorContent.coreConflict?.surfaceConflicts.join('') || '',
anchorContent.coreConflict?.hiddenCrisis || '',
anchorContent.coreConflict?.firstTouchedConflict || '',
]).join(''),
},
{
id: 'relationship-seed',
id: 'key-relationships',
label: '关键关系',
value:
relationshipText ||
profile.playableNpcs[0]?.relationshipHooks.join('') ||
profile.storyNpcs[0]?.relationshipHooks.join('') ||
'待补充',
anchorContent.keyRelationships.map(buildKeyRelationshipText).join('\n') ||
fallbackRelationshipText,
},
{
id: 'hidden-lines',
label: '暗线与揭示',
value: compactTextList([
anchorContent.hiddenLines?.hiddenTruths.join('、') || '',
anchorContent.hiddenLines?.misdirectionHints.join('、') || '',
anchorContent.hiddenLines?.revealPacing || '',
]).join(''),
},
{
id: 'iconic-elements',
label: '标志元素',
value:
creatorIntent?.iconicElements.join('、') ||
profile.anchorPack?.motifDirectives.join('、') ||
'待补充',
value: compactTextList([
anchorContent.iconicElements?.iconicMotifs.join('、') || '',
anchorContent.iconicElements?.institutionsOrArtifacts.join('、') || '',
anchorContent.iconicElements?.hardRules.join('、') || '',
]).join(''),
},
];
}
@@ -594,12 +829,6 @@ export function CustomWorldEntityCatalog({
() => buildStructuredFoundationEntries(profile),
[profile],
);
const structuredFoundationSourceText = useMemo(
() =>
buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() ||
profile.settingText.trim(),
[profile.creatorIntent, profile.settingText],
);
const normalizedCreatorIntent = useMemo(
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
[profile.creatorIntent],
@@ -907,9 +1136,6 @@ export function CustomWorldEntityCatalog({
}
>
<div className="space-y-3">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{structuredFoundationEntries.map((entry) => (
<div
@@ -919,22 +1145,12 @@ export function CustomWorldEntityCatalog({
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label}
</div>
<div className="mt-2 text-sm leading-7 text-zinc-100">
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-100">
{entry.value || '待补充'}
</div>
</div>
))}
</div>
{structuredFoundationSourceText ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-200">
{structuredFoundationSourceText}
</div>
</div>
) : null}
</div>
</Section>
</>

View File

@@ -1,4 +1,5 @@
import type { ChangeEvent } from 'react';
import type { CSSProperties } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -70,6 +71,18 @@ interface CustomWorldEntityEditorModalProps {
onProfileChange: (profile: CustomWorldProfile) => void;
}
function getAnimationPreviewFrameStyle(
_config: CharacterAnimationConfig | null | undefined,
targetSize: number,
) {
return {
width: `${targetSize}px`,
height: `${targetSize}px`,
maxWidth: '100%',
maxHeight: '100%',
} satisfies CSSProperties;
}
const [
BACKSTORY_UNLOCK_AFFINITY_EASED,
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
@@ -2051,6 +2064,10 @@ function RoleSkillEditorModal({
},
} satisfies Character;
}, [draft.actionPreviewConfig, role]);
const actionPreviewFrameStyle = useMemo(
() => getAnimationPreviewFrameStyle(draft.actionPreviewConfig, 320),
[draft.actionPreviewConfig],
);
const handleGenerateAction = async () => {
if (!role.imageSrc || !role.generatedVisualAssetId) {
@@ -2090,14 +2107,15 @@ function RoleSkillEditorModal({
visualSource: role.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
lastFrameImageDataUrl: role.imageSrc,
frameCount: 8,
fps: 10,
durationSeconds: 3,
loop: false,
useChromaKey: true,
resolution: '720P',
resolution: '480P',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v',
videoModel: 'wan2.2-kf2v-flash',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
@@ -2155,9 +2173,9 @@ function RoleSkillEditorModal({
>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="flex min-h-[10rem] items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
<div className="flex min-h-[20rem] items-center justify-center rounded-2xl border border-white/10 bg-black/25 p-3">
{previewCharacter && draft.actionPreviewConfig ? (
<div className="h-40 w-40">
<div style={actionPreviewFrameStyle}>
<CharacterAnimator
state={AnimationState.ATTACK}
character={previewCharacter}
@@ -4297,7 +4315,7 @@ function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
};
}
export function CustomWorldEntityEditorModal({
function CustomWorldEntityEditorModal({
profile,
target,
onClose,
@@ -4422,3 +4440,6 @@ export function CustomWorldEntityEditorModal({
/>
) : null;
}
export { CustomWorldEntityEditorModal };
export default CustomWorldEntityEditorModal;

View File

@@ -1,10 +1,12 @@
import { motion } from 'motion/react';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
interface CustomWorldGenerationViewProps {
settingText: string;
anchorEntries?: CustomWorldStructuredAnchorEntry[];
progress: CustomWorldGenerationProgress | null;
isGenerating: boolean;
error: string | null;
@@ -22,6 +24,7 @@ interface CustomWorldGenerationViewProps {
activeBadgeLabel?: string;
pausedBadgeLabel?: string;
idleBadgeLabel?: string;
structuredEmptyText?: string;
}
function formatDuration(ms: number) {
@@ -47,6 +50,7 @@ function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
export function CustomWorldGenerationView({
settingText,
anchorEntries = [],
progress,
isGenerating,
error,
@@ -64,9 +68,11 @@ export function CustomWorldGenerationView({
activeBadgeLabel = '世界建设中',
pausedBadgeLabel = '生成已暂停',
idleBadgeLabel = '等待操作',
structuredEmptyText = '正在整理当前设定结构,请稍后。',
}: CustomWorldGenerationViewProps) {
const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? [];
const hasStructuredAnchors = anchorEntries.length > 0;
const estimatedWaitText =
progress?.estimatedRemainingMs != null
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
@@ -100,36 +106,6 @@ export function CustomWorldGenerationView({
</div>
<div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1">
<section
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
{settingTitle}
</div>
<div className="mt-1 text-sm text-zinc-400">
{settingDescription}
</div>
</div>
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{settingActionLabel}
</button>
</div>
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText}
</div>
</section>
<section
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
@@ -265,6 +241,54 @@ export function CustomWorldGenerationView({
) : null}
</div>
</section>
<section
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
{settingTitle}
</div>
<div className="mt-1 text-sm text-zinc-400">
{settingDescription}
</div>
</div>
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{settingActionLabel}
</button>
</div>
{hasStructuredAnchors ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{anchorEntries.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4"
>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label}
</div>
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-100">
{entry.value}
</div>
</div>
))}
</div>
) : (
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText || structuredEmptyText}
</div>
)}
</section>
</div>
</div>
);

View File

@@ -26,6 +26,7 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
vi.mock('./CustomWorldEntityEditorModal', () => ({
CustomWorldEntityEditorModal: () => null,
default: () => null,
}));
async function loadAiService() {
@@ -165,6 +166,50 @@ const baseProfile = {
description: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium',
},
anchorContent: {
worldPromise: {
hook: '被海雾反复改写航路的群岛世界。',
differentiator: '旧灯塔与禁航令共同决定谁能活着穿过去。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的守灯人继承者。',
corePursuit: '查清沉钟异动与失控航路的真相。',
fearOfLoss: '失去家族留下的最后航路坐标。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['热血少年漫'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '首夜就撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守潮盟与沉钟会争夺航路解释权'],
hiddenCrisis: '有人借假航灯持续清洗整片群岛的旧证据',
firstTouchedConflict: '玩家回港当夜就被卷进禁航区封锁',
},
keyRelationships: [
{
pairs: '玩家 vs 沈砺',
relationshipType: '旧友互疑',
secretOrCost: '他掌握沉船夜的关键视角',
},
],
hiddenLines: {
hiddenTruths: ['沉钟异动和旧案灭口是同一条线'],
misdirectionHints: ['表面看像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
},
landmarks: [
{
id: 'landmark-1',
@@ -242,3 +287,20 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar
expect(screen.getAllByText('新').length).toBeGreaterThan(0);
});
test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => {
render(<ResultViewHarness />);
expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText('玩家幻想')).toBeTruthy();
expect(screen.getByText('主题边界')).toBeTruthy();
expect(screen.getByText('玩家切入口')).toBeTruthy();
expect(screen.getByText('核心冲突')).toBeTruthy();
expect(screen.getByText('关键关系')).toBeTruthy();
expect(screen.getByText('暗线与揭示')).toBeTruthy();
expect(screen.getByText('标志元素')).toBeTruthy();
expect(screen.queryByText('解析字段')).toBeNull();
expect(screen.queryByText('锚点原文')).toBeNull();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(/线/u)).toBeTruthy();
});

View File

@@ -18,9 +18,8 @@ import {
CustomWorldEntityCatalog,
type ResultTab,
} from './CustomWorldEntityCatalog';
import {
import CustomWorldEntityEditorModal, {
type CustomWorldEditorTarget,
CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal';
interface CustomWorldResultViewProps {

View File

@@ -3,6 +3,7 @@ import {
RefreshCcw,
} from 'lucide-react';
import {
type CSSProperties,
type ChangeEvent,
type ReactNode,
useEffect,
@@ -14,11 +15,11 @@ import { createPortal } from 'react-dom';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type CharacterAnimationConfig,
type Character,
} from '../types';
import {
buildAnimationClipFromVideoSource,
normalizeMasterVisualSourceToDataUrl,
readFileAsDataUrl,
} from './asset-studio/characterAssetWorkflowModel';
import {
@@ -27,7 +28,6 @@ import {
type CharacterVisualDraft,
fetchCharacterWorkflowCache,
generateCharacterAnimationDraft,
generateCharacterPromptBundle,
generateCharacterVisualCandidates,
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
@@ -41,6 +41,9 @@ type EditableCustomWorldRole = {
name: string;
title: string;
role: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string;
backstory?: string;
personality?: string;
@@ -112,6 +115,25 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
},
];
const DEFAULT_ANIMATION_PLAYBACK_RATE = 0.75;
const MIN_ANIMATION_PLAYBACK_RATE = 0.25;
const MAX_ANIMATION_PLAYBACK_RATE = 1.5;
function clampAnimationPlaybackRate(value: number) {
if (!Number.isFinite(value)) {
return DEFAULT_ANIMATION_PLAYBACK_RATE;
}
return Math.min(
MAX_ANIMATION_PLAYBACK_RATE,
Math.max(MIN_ANIMATION_PLAYBACK_RATE, value),
);
}
function roundAnimationFps(value: number) {
return Math.round(value * 100) / 100;
}
function ModalShell({
title,
subtitle,
@@ -357,6 +379,86 @@ function hasGeneratedAnimation(
return Boolean(entry?.basePath || entry?.spriteSheetPath);
}
function getAnimationPreviewFrameStyle(
_config: CharacterAnimationConfig | null | undefined,
targetSize: number,
) {
return {
width: `${targetSize}px`,
height: `${targetSize}px`,
maxWidth: '100%',
maxHeight: '100%',
} satisfies CSSProperties;
}
function getAnimationPreviewViewportStyle(size: number) {
return {
width: `${size}px`,
height: `${size}px`,
maxWidth: '100%',
} satisfies CSSProperties;
}
function resolveAnimationPlaybackRate(
actionConfig: CustomWorldAiActionConfig | undefined,
animationConfig: CharacterAnimationConfig | null | undefined,
) {
if (!actionConfig) {
return DEFAULT_ANIMATION_PLAYBACK_RATE;
}
const configuredFps =
typeof animationConfig?.fps === 'number' &&
Number.isFinite(animationConfig.fps)
? animationConfig.fps
: null;
if (!configuredFps) {
return DEFAULT_ANIMATION_PLAYBACK_RATE;
}
return clampAnimationPlaybackRate(configuredFps / actionConfig.fps);
}
function applyPlaybackRateToAnimationMap(params: {
animationMap: Record<string, unknown> | undefined;
animation: AnimationState;
actionConfig: CustomWorldAiActionConfig;
playbackRate: number;
}) {
const { animationMap, animation, actionConfig, playbackRate } = params;
if (!animationMap) {
return animationMap;
}
const currentConfig = animationMap[animation];
if (!currentConfig || typeof currentConfig !== 'object') {
return animationMap;
}
const currentAnimationConfig = currentConfig as CharacterAnimationConfig;
const nextFps = roundAnimationFps(
Math.max(1, actionConfig.fps * clampAnimationPlaybackRate(playbackRate)),
);
const currentFps =
typeof currentAnimationConfig.fps === 'number' &&
Number.isFinite(currentAnimationConfig.fps)
? roundAnimationFps(currentAnimationConfig.fps)
: null;
if (currentFps === nextFps) {
return animationMap;
}
return {
...animationMap,
[animation]: {
...currentAnimationConfig,
fps: nextFps,
},
};
}
function buildAnimationPreviewCharacter(params: {
workingRole: EditableCustomWorldRole;
selectedTemplate: (typeof ROLE_TEMPLATE_CHARACTERS)[number] | null;
@@ -399,7 +501,6 @@ export function CustomWorldRoleAssetStudioModal({
syncBusy = false,
visualPointCost = 20,
animationPointCost = 60,
priorityTier = roleKind === 'playable' ? 'hero' : 'featured',
}: {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
@@ -420,7 +521,6 @@ export function CustomWorldRoleAssetStudioModal({
syncBusy?: boolean;
visualPointCost?: number;
animationPointCost?: number;
priorityTier?: 'hero' | 'featured' | 'supporting';
}) {
const [workingRole, setWorkingRole] = useState<EditableCustomWorldRole>(role);
const baseRole = useMemo<EditableCustomWorldRole>(
@@ -429,6 +529,9 @@ export function CustomWorldRoleAssetStudioModal({
name: role.name,
title: role.title,
role: role.role,
visualDescription: role.visualDescription,
actionDescription: role.actionDescription,
sceneVisualDescription: role.sceneVisualDescription,
description: role.description,
backstory: role.backstory,
personality: role.personality,
@@ -450,13 +553,16 @@ export function CustomWorldRoleAssetStudioModal({
role.generatedVisualAssetId,
role.id,
role.imageSrc,
role.actionDescription,
role.motivation,
role.name,
role.personality,
role.role,
role.sceneVisualDescription,
role.tags,
role.templateCharacterId,
role.title,
role.visualDescription,
],
);
const initialPromptBundle = useMemo(
@@ -481,8 +587,14 @@ export function CustomWorldRoleAssetStudioModal({
const [animationPromptText, setAnimationPromptText] = useState(
initialPromptBundle.animationPromptText,
);
const [animationStatus, setAnimationStatus] = useState<string | null>(null);
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
const [animationStatusByKey, setAnimationStatusByKey] = useState<
Partial<Record<AnimationState, string | null>>
>({});
const [generatingAnimationMap, setGeneratingAnimationMap] = useState<
Partial<Record<AnimationState, boolean>>
>({});
const [animationPreviewPlaybackRate, setAnimationPreviewPlaybackRate] =
useState(DEFAULT_ANIMATION_PLAYBACK_RATE);
const [saveStatus, setSaveStatus] = useState<string | null>(null);
const [isSavingToRole, setIsSavingToRole] = useState(false);
const [isHydratingCache, setIsHydratingCache] = useState(true);
@@ -503,37 +615,6 @@ export function CustomWorldRoleAssetStudioModal({
),
[workingRole, selectedTemplate],
);
const promptSeedKey = useMemo(
() =>
[
roleKind,
workingRole.name,
workingRole.title,
workingRole.role,
workingRole.description ?? '',
workingRole.backstory ?? '',
workingRole.personality ?? '',
workingRole.motivation ?? '',
workingRole.combatStyle ?? '',
(workingRole.tags ?? []).join('|'),
selectedTemplate?.name ?? '',
selectedTemplate?.title ?? '',
].join('||'),
[
roleKind,
selectedTemplate?.name,
selectedTemplate?.title,
workingRole.name,
workingRole.title,
workingRole.role,
workingRole.description,
workingRole.backstory,
workingRole.personality,
workingRole.motivation,
workingRole.combatStyle,
workingRole.tags,
],
);
const roleSnapshotKey = useMemo(
() =>
[
@@ -558,8 +639,8 @@ export function CustomWorldRoleAssetStudioModal({
const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const previewImageSrc =
selectedVisualDraft?.imageSrc ??
workingRole.imageSrc ??
selectedVisualDraft?.imageSrc ??
selectedTemplate?.portrait ??
'';
const hasGeneratedVisualPreview = Boolean(
@@ -576,6 +657,23 @@ export function CustomWorldRoleAssetStudioModal({
}),
[selectedTemplate, workingRole],
);
const selectedAnimationConfig = previewCharacter?.animationMap?.[
selectedAnimation
] as CharacterAnimationConfig | undefined;
const selectedAnimationStatus = animationStatusByKey[selectedAnimation] ?? null;
const isSelectedAnimationGenerating =
generatingAnimationMap[selectedAnimation] === true;
const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some(
(value) => value === true,
);
const animationPreviewFrameStyle = useMemo(
() => getAnimationPreviewFrameStyle(selectedAnimationConfig, 440),
[selectedAnimationConfig],
);
const animationPreviewViewportStyle = useMemo(
() => getAnimationPreviewViewportStyle(440),
[],
);
const visualSourceMode =
referenceImageDataUrls.length > 0 ? 'image-to-image' : 'text-to-image';
@@ -589,7 +687,9 @@ export function CustomWorldRoleAssetStudioModal({
setSelectedVisualDraftId('');
setSelectedAnimation(CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE);
setVisualStatus(null);
setAnimationStatus(null);
setAnimationStatusByKey({});
setGeneratingAnimationMap({});
setAnimationPreviewPlaybackRate(DEFAULT_ANIMATION_PLAYBACK_RATE);
setSaveStatus(null);
setIsHydratingCache(true);
@@ -643,64 +743,6 @@ export function CustomWorldRoleAssetStudioModal({
roleSnapshotKey,
]);
useEffect(() => {
if (!characterBriefText.trim()) {
return;
}
let cancelled = false;
void generateCharacterPromptBundle({
roleKind,
characterName: workingRole.name,
roleTitle: workingRole.title,
roleLabel: workingRole.role,
description: workingRole.description,
backstory: workingRole.backstory,
personality: workingRole.personality,
motivation: workingRole.motivation,
combatStyle: workingRole.combatStyle,
tags: workingRole.tags,
characterBriefText,
})
.then((result) => {
if (cancelled) {
return;
}
setVisualPromptText((current) =>
current.trim() && current !== initialPromptBundle.visualPromptText
? current
: result.visualPromptText,
);
setAnimationPromptText((current) =>
current.trim() && current !== initialPromptBundle.animationPromptText
? current
: result.animationPromptText,
);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [
characterBriefText,
initialPromptBundle.animationPromptText,
initialPromptBundle.visualPromptText,
promptSeedKey,
roleKind,
workingRole.backstory,
workingRole.combatStyle,
workingRole.description,
workingRole.motivation,
workingRole.name,
workingRole.personality,
workingRole.role,
workingRole.tags,
workingRole.title,
]);
useEffect(() => {
if (isHydratingCache) {
return;
@@ -742,6 +784,15 @@ export function CustomWorldRoleAssetStudioModal({
workingRole.imageSrc,
]);
useEffect(() => {
setAnimationPreviewPlaybackRate(
resolveAnimationPlaybackRate(
selectedActionConfig,
selectedAnimationConfig,
),
);
}, [selectedActionConfig, selectedAnimationConfig]);
const confirmPointSpend = (params: {
kindLabel: string;
points: number;
@@ -774,38 +825,35 @@ export function CustomWorldRoleAssetStudioModal({
event.target.value = '';
};
const applyVisualDraftToWorkflow = async (
draft: CharacterVisualDraft,
draftList = visualDrafts,
) => {
const normalizedVisual = await normalizeMasterVisualSourceToDataUrl(
draft.imageSrc,
{
applyChromaKey: true,
},
);
const result = await publishCharacterVisualAsset({
characterId: workingRole.id,
sourceMode: visualSourceMode,
promptText: visualPromptText,
selectedPreviewSource: normalizedVisual.dataUrl,
previewSources: [normalizedVisual.dataUrl],
width: normalizedVisual.width,
height: normalizedVisual.height,
updateCharacterOverride: false,
});
const applyVisualDraftToWorkflow = async (draft: CharacterVisualDraft) => {
setIsApplyingVisual(true);
try {
const result = await publishCharacterVisualAsset({
characterId: workingRole.id,
sourceMode: visualSourceMode,
promptText: visualPromptText,
selectedPreviewSource: draft.imageSrc,
previewSources: [draft.imageSrc],
width: draft.width,
height: draft.height,
updateCharacterOverride: false,
});
const nextRole = mergeRole(workingRole, {
imageSrc: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
});
setWorkingRole(nextRole);
setSelectedVisualDraftId(draft.id);
setAnimationStatus(null);
setSaveStatus(null);
setVisualStatus('角色形象已更新,可继续生成动作。');
const nextRole = mergeRole(workingRole, {
imageSrc: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
});
setWorkingRole(nextRole);
setSelectedVisualDraftId(draft.id);
setAnimationStatusByKey({});
setGeneratingAnimationMap({});
setSaveStatus(null);
setVisualStatus('角色形象已更新,可继续生成动作。');
} finally {
setIsApplyingVisual(false);
}
};
const handleGenerateVisuals = async () => {
@@ -835,7 +883,7 @@ export function CustomWorldRoleAssetStudioModal({
});
setVisualDrafts(result.drafts);
if (result.drafts[0]) {
await applyVisualDraftToWorkflow(result.drafts[0], result.drafts);
await applyVisualDraftToWorkflow(result.drafts[0]);
setVisualStatus('角色形象已生成,如不满意可直接重新生成。');
} else {
setSelectedVisualDraftId('');
@@ -855,6 +903,8 @@ export function CustomWorldRoleAssetStudioModal({
throw new Error('请先生成角色形象,再生成动作。');
}
const isLoopAction = config.loop;
const result = await generateCharacterAnimationDraft({
characterId: workingRole.id,
strategy: 'image-to-video',
@@ -865,14 +915,15 @@ export function CustomWorldRoleAssetStudioModal({
visualSource: workingRole.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
lastFrameImageDataUrl: isLoopAction ? undefined : workingRole.imageSrc,
frameCount: config.frameCount,
fps: config.fps,
durationSeconds: config.durationSeconds,
loop: config.loop,
useChromaKey: true,
resolution: '720P',
resolution: isLoopAction ? '720P' : '480P',
imageSequenceModel: 'wan2.7-image-pro',
videoModel: 'wan2.7-i2v',
videoModel: isLoopAction ? 'wan2.6-i2v-flash' : 'wan2.2-kf2v-flash',
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
} satisfies CharacterAnimationGenerationPayload);
@@ -887,6 +938,8 @@ export function CustomWorldRoleAssetStudioModal({
loop: config.loop,
frameCount: config.frameCount,
applyChromaKey: true,
sampleStartRatio: config.loop ? 0.12 : 0,
sampleEndRatio: config.loop ? 0.94 : 1,
});
};
@@ -895,6 +948,12 @@ export function CustomWorldRoleAssetStudioModal({
return;
}
const actionConfig = selectedActionConfig;
const requestedPlaybackRate = animationPreviewPlaybackRate;
if (generatingAnimationMap[actionConfig.animation]) {
return;
}
if (
!confirmPointSpend({
kindLabel: '动作草稿生成',
@@ -905,16 +964,22 @@ export function CustomWorldRoleAssetStudioModal({
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
setGeneratingAnimationMap((current) => ({
...current,
[actionConfig.animation]: true,
}));
setAnimationStatusByKey((current) => ({
...current,
[actionConfig.animation]: null,
}));
try {
const clip = await generateActionClip(selectedActionConfig);
const clip = await generateActionClip(actionConfig);
const result = await publishCharacterAnimationAssets({
characterId: workingRole.id,
visualAssetId: workingRole.generatedVisualAssetId!,
animations: {
[selectedActionConfig.animation]: {
[actionConfig.animation]: {
framesDataUrls: clip.frames,
fps: clip.fps,
loop: clip.loop,
@@ -926,24 +991,38 @@ export function CustomWorldRoleAssetStudioModal({
updateCharacterOverride: false,
});
const nextRole = mergeRole(workingRole, {
generatedAnimationSetId: result.animationSetId,
animationMap: {
...((workingRole.animationMap ?? {}) as Record<string, unknown>),
...(result.animationMap as NonNullable<
EditableCustomWorldRole['animationMap']
>),
},
});
setWorkingRole(nextRole);
setSaveStatus(null);
setAnimationStatus(`${selectedActionConfig.label} 动作已更新。`);
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '生成角色动作失败。',
setWorkingRole((current) =>
mergeRole(current, {
generatedAnimationSetId: result.animationSetId,
animationMap: applyPlaybackRateToAnimationMap({
animationMap: {
...((current.animationMap ?? {}) as Record<string, unknown>),
...(result.animationMap as NonNullable<
EditableCustomWorldRole['animationMap']
>),
},
animation: actionConfig.animation,
actionConfig,
playbackRate: requestedPlaybackRate,
}),
}),
);
setSaveStatus(null);
setAnimationStatusByKey((current) => ({
...current,
[actionConfig.animation]: `${actionConfig.label} 动作已更新。`,
}));
} catch (error) {
setAnimationStatusByKey((current) => ({
...current,
[actionConfig.animation]:
error instanceof Error ? error.message : '生成角色动作失败。',
}));
} finally {
setIsGeneratingAnimations(false);
setGeneratingAnimationMap((current) => ({
...current,
[actionConfig.animation]: false,
}));
}
};
@@ -999,7 +1078,7 @@ export function CustomWorldRoleAssetStudioModal({
isHydratingCache ||
isGeneratingVisuals ||
isApplyingVisual ||
isGeneratingAnimations ||
hasAnyGeneratingAnimations ||
isSavingToRole ||
syncBusy
}
@@ -1109,20 +1188,25 @@ export function CustomWorldRoleAssetStudioModal({
<Section title="动作">
<div className="space-y-4">
<div className="rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] p-4">
<div className="flex min-h-[16rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="flex min-h-[28rem] items-center justify-center rounded-2xl border border-white/10 bg-black/20 p-4">
{previewCharacter && hasGeneratedAnimation(workingRole, selectedAnimation) ? (
<div className="h-[220px] w-[220px]">
<CharacterAnimator
state={selectedAnimation}
character={previewCharacter}
className="h-full w-full"
/>
<div
className="flex items-center justify-center"
style={animationPreviewViewportStyle}
>
<div style={animationPreviewFrameStyle}>
<CharacterAnimator
state={selectedAnimation}
character={previewCharacter}
className="h-full w-full"
/>
</div>
</div>
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={workingRole.name}
className="max-h-[16rem] w-full object-contain"
className="max-h-[28rem] w-full object-contain"
/>
) : (
<div className="px-4 text-sm text-zinc-500"></div>
@@ -1130,10 +1214,53 @@ export function CustomWorldRoleAssetStudioModal({
</div>
</div>
<Field label="预览速度">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<input
type="range"
min="0.25"
max="1.5"
step="0.05"
value={animationPreviewPlaybackRate}
onChange={(event) => {
const nextPlaybackRate = clampAnimationPlaybackRate(
Number.parseFloat(event.target.value) ||
DEFAULT_ANIMATION_PLAYBACK_RATE,
);
setAnimationPreviewPlaybackRate(nextPlaybackRate);
setSaveStatus(null);
setWorkingRole((current) => {
const nextAnimationMap = applyPlaybackRateToAnimationMap({
animationMap: current.animationMap as
| Record<string, unknown>
| undefined,
animation: selectedAnimation,
actionConfig: selectedActionConfig,
playbackRate: nextPlaybackRate,
});
return nextAnimationMap === current.animationMap
? current
: mergeRole(current, {
animationMap: nextAnimationMap,
});
});
}}
className="w-full accent-sky-400"
/>
<div className="mt-2 flex items-center justify-between text-[11px] text-zinc-400">
<span>0.25x</span>
<span>{animationPreviewPlaybackRate.toFixed(2)}x</span>
<span>1.50x</span>
</div>
</div>
</Field>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{CORE_ACTIONS.map((item) => {
const isSelected = item.animation === selectedAnimation;
const isReady = hasGeneratedAnimation(workingRole, item.animation);
const isGenerating = generatingAnimationMap[item.animation] === true;
return (
<button
key={item.animation}
@@ -1151,11 +1278,17 @@ export function CustomWorldRoleAssetStudioModal({
{item.label}
</div>
<div className="mt-1 text-[11px] text-zinc-400">
{isSelected ? '当前预览' : '点击切换'}
{isGenerating
? '后台生成中'
: isSelected
? '当前预览'
: '点击切换'}
</div>
</div>
<StatusBadge tone={isReady ? 'green' : 'zinc'}>
{isReady ? '已生成' : '待生成'}
<StatusBadge
tone={isGenerating ? 'amber' : isReady ? 'green' : 'zinc'}
>
{isGenerating ? '生成中' : isReady ? '已生成' : '待生成'}
</StatusBadge>
</div>
</button>
@@ -1175,11 +1308,11 @@ export function CustomWorldRoleAssetStudioModal({
<div className="flex flex-wrap gap-3">
<ActionButton
icon={<RefreshCcw className="h-4 w-4" />}
label={isGeneratingAnimations ? '生成中...' : '生成动作'}
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
subLabel={`消耗${animationPointCost}叙世币`}
onClick={() => void handleGenerateAnimation()}
disabled={
isGeneratingAnimations ||
isSelectedAnimationGenerating ||
!workingRole.imageSrc ||
!workingRole.generatedVisualAssetId ||
syncBusy
@@ -1188,9 +1321,9 @@ export function CustomWorldRoleAssetStudioModal({
/>
</div>
{animationStatus ? (
{selectedAnimationStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{animationStatus}
{selectedAnimationStatus}
</div>
) : null}
</div>

View File

@@ -726,16 +726,60 @@ function applyGreenScreenAlpha(
const blue = pixels[index + 2] ?? 0;
const alpha = pixels[index + 3] ?? 0;
const greenLead = green - Math.max(red, blue);
const greenRatio = green / Math.max(1, red + blue);
if (alpha === 0) {
continue;
}
if (green > 96 && greenLead > 34) {
const fade = Math.max(0, 255 - greenLead * 5);
pixels[index + 3] = Math.min(alpha, fade);
if (greenLead > 60) {
pixels[index + 3] = 0;
if (green > 72 && greenLead > 20 && greenRatio > 0.72) {
let nextAlpha = Math.min(alpha, Math.max(0, 255 - greenLead * 6));
if (green > 120 && greenLead > 48 && greenRatio > 1.12) {
nextAlpha = 0;
}
pixels[index + 3] = nextAlpha;
if (nextAlpha > 0) {
pixels[index + 1] = Math.min(
green,
Math.max(red, blue) + Math.max(6, Math.round(greenLead * 0.18)),
);
}
}
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
const alpha = pixels[index + 3] ?? 0;
if (alpha === 0) {
continue;
}
const red = pixels[index] ?? 0;
const green = pixels[index + 1] ?? 0;
const blue = pixels[index + 2] ?? 0;
const neighborAlphaValues = [
x > 0 ? (pixels[index - 1] ?? 255) : 255,
x + 1 < width ? (pixels[index + 7] ?? 255) : 255,
y > 0 ? (pixels[index - width * 4 + 3] ?? 255) : 255,
y + 1 < height ? (pixels[index + width * 4 + 3] ?? 255) : 255,
];
const touchesTransparentEdge = neighborAlphaValues.some(
(value) => value < 16,
);
if (!touchesTransparentEdge) {
continue;
}
if (green > Math.max(red, blue) + 4) {
pixels[index + 1] = Math.max(
Math.max(red, blue),
green - Math.round((green - Math.max(red, blue)) * 0.8),
);
}
}
}
@@ -866,6 +910,8 @@ export async function buildAnimationClipFromVideoSource(
frameWidth?: number;
frameHeight?: number;
applyChromaKey?: boolean;
sampleStartRatio?: number;
sampleEndRatio?: number;
},
) {
const video = await loadVideoFromSource(videoSource);
@@ -877,6 +923,15 @@ export async function buildAnimationClipFromVideoSource(
2,
options.frameCount ?? Math.round(duration * Math.max(1, options.fps)),
);
const sampleStartRatio = Math.min(
0.85,
Math.max(0, options.sampleStartRatio ?? 0),
);
const sampleEndRatio = Math.min(
1,
Math.max(sampleStartRatio + 0.05, options.sampleEndRatio ?? 1),
);
const sampleWindowDuration = duration * (sampleEndRatio - sampleStartRatio);
const { canvas, context } = createCanvas(frameWidth, frameHeight);
const frames: string[] = [];
@@ -884,7 +939,10 @@ export async function buildAnimationClipFromVideoSource(
const progress = options.loop
? frameIndex / derivedFrameCount
: frameIndex / Math.max(1, derivedFrameCount - 1);
const targetTime = Math.min(duration - 0.001, duration * progress);
const targetTime = Math.min(
duration - 0.001,
duration * sampleStartRatio + sampleWindowDuration * progress,
);
await seekVideo(video, targetTime);
@@ -912,6 +970,84 @@ export async function buildAnimationClipFromVideoSource(
} satisfies DraftAnimationClip;
}
async function buildReferenceVideoFromFrameSources(
frameSources: string[],
options: {
fps?: number;
width?: number;
height?: number;
repeatLoops?: number;
} = {},
) {
if (typeof MediaRecorder === 'undefined') {
throw new Error('当前浏览器不支持 MediaRecorder无法生成参考视频。');
}
const images = await Promise.all(
frameSources.map((frameSource) => loadImageFromSource(frameSource)),
);
const width = options.width ?? GENERATED_FRAME_WIDTH;
const height = options.height ?? GENERATED_FRAME_HEIGHT;
const fps = Math.max(1, options.fps ?? 8);
const repeatLoops = Math.max(1, options.repeatLoops ?? 2);
const { canvas, context } = createCanvas(width, height);
const stream = canvas.captureStream(fps);
const mimeType = pickRecordMimeType();
const recorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
const stopPromise = new Promise<Blob>((resolve) => {
recorder.onstop = () => {
resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
};
});
recorder.start();
for (let loopIndex = 0; loopIndex < repeatLoops; loopIndex += 1) {
for (const image of images) {
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width,
height: canvas.height,
});
await waitFrame(Math.max(40, Math.round(1000 / fps)));
}
}
await waitFrame(80);
recorder.stop();
const blob = await stopPromise;
return blobToDataUrl(blob);
}
export async function buildReferenceVideoFromMasterAnimation(
masterSource: string,
animation: AnimationState,
options: {
fps?: number;
repeatLoops?: number;
width?: number;
height?: number;
} = {},
) {
const clip = await buildAnimationClipFromMaster(masterSource, animation);
return buildReferenceVideoFromFrameSources(clip.frames, {
fps: options.fps ?? clip.fps,
repeatLoops: options.repeatLoops ?? 2,
width: options.width ?? clip.frameWidth,
height: options.height ?? clip.frameHeight,
});
}
function getCharacterAnimationConfig(
character: Character,
animation: AnimationState,

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
describe('buildDefaultRolePromptBundle', () => {
it('prefers model-generated role descriptions instead of rule-based assembly', () => {
const result = buildDefaultRolePromptBundle({
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
visualDescription:
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
actionDescription:
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
sceneVisualDescription:
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
description: '熟悉裂潮边路的灰炬向导。',
});
expect(result.visualPromptText).toBe(
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
);
expect(result.animationPromptText).toBe(
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
);
expect(result.scenePromptText).toBe(
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
);
});
it('falls back to compact role descriptions without reintroducing built-in prompt rules', () => {
const result = buildDefaultRolePromptBundle({
name: '顾潮音',
title: '港口守望者',
role: '场景角色',
description: '总在潮雾港高处盯着来往船影的守望者。',
personality: '寡言、敏锐、先看人再开口。',
combatStyle: '长枪封线后借高差压制。',
motivation: '想在港口旧秩序彻底崩掉前找出新的站位。',
backstory: '他把许多没说出口的旧案痕迹留在港口高处。',
tags: ['潮雾港', '守望', '旧案'],
});
expect(result.visualPromptText).toContain('总在潮雾港高处盯着来往船影的守望者。');
expect(result.animationPromptText).toContain('长枪封线后借高差压制。');
expect(result.scenePromptText).toContain('他把许多没说出口的旧案痕迹留在港口高处。');
expect(result.visualPromptText).not.toContain('2D 横版 RPG');
expect(result.visualPromptText).not.toContain('纯绿色绿幕');
expect(result.visualPromptText).not.toContain('提示词');
});
});

View File

@@ -2,6 +2,9 @@ export type PromptDefaultRole = {
name: string;
title: string;
role: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string;
backstory?: string;
personality?: string;
@@ -20,57 +23,52 @@ function cleanSeedText(value: string | undefined, maxLength: number) {
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
}
function compactDescription(parts: Array<string | undefined>, maxLength: number) {
return parts
.map((item) => cleanSeedText(item, maxLength))
.filter(Boolean)
.join(' ')
.slice(0, maxLength);
}
export function buildDefaultRolePromptBundle(
role: PromptDefaultRole,
): CustomWorldRolePromptBundle {
const characterName = cleanSeedText(role.name, 40) || '该角色';
const roleAnchor =
[cleanSeedText(role.title, 60), cleanSeedText(role.role, 60)]
.filter(Boolean)
.join(' / ') || '关键角色';
const descriptionAnchor =
cleanSeedText(role.description, 220) ||
cleanSeedText(role.backstory, 260) ||
cleanSeedText(role.personality, 160) ||
'识别度鲜明';
const combatAnchor =
cleanSeedText(role.combatStyle, 180) ||
cleanSeedText(role.motivation, 180) ||
'动作重心稳定';
const tagAnchor =
role.tags && role.tags.length > 0
? `保留 ${role.tags.slice(0, 8).join('、')} 的角色识别点。`
: '';
const roleLabel = [cleanSeedText(role.name, 40), cleanSeedText(role.title, 40)]
.filter(Boolean)
.join('');
const fallbackVisualDescription = compactDescription(
[
roleLabel || cleanSeedText(role.role, 40),
role.description,
role.personality,
role.tags && role.tags.length > 0 ? role.tags.slice(0, 8).join('、') : '',
],
220,
);
const fallbackActionDescription = compactDescription(
[
role.actionDescription,
role.combatStyle,
role.motivation,
role.personality,
],
180,
);
const generatedSceneDescription = cleanSeedText(role.sceneVisualDescription, 220);
const fallbackSceneDescription = compactDescription(
[
role.backstory,
role.description,
role.motivation,
],
220,
);
return {
visualPromptText: [
`${characterName}${roleAnchor}`,
'单人全身2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。',
`外观气质围绕:${descriptionAnchor}`,
`动作识别点参考:${combatAnchor}`,
tagAnchor,
'构图干净,主体明确,不做正面立绘,不做夸张透视。',
]
.filter(Boolean)
.join(' '),
animationPromptText: [
`${characterName}核心动作试片。`,
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯自然。',
`动作气质参考:${combatAnchor}`,
role.personality ? `角色状态补充:${cleanSeedText(role.personality, 160)}` : '',
'起手清楚,发力明确,收招干净,避免漂移、乱摆和形体变形。',
]
.filter(Boolean)
.join(' '),
scenePromptText: [
`${characterName}关联主场景,适合作为首次登场区域或常驻活动空间。`,
'16:9 横版 RPG 场景背景,上半部分突出中远景氛围,下半部分是清晰可站立地面。',
`场景叙事气质围绕:${descriptionAnchor}`,
role.backstory ? `环境背景可埋入:${cleanSeedText(role.backstory, 260)}` : '',
role.motivation ? `场景目标暗示可参考:${cleanSeedText(role.motivation, 160)}` : '',
'整体风格统一克制,适合作为剧情探索与战斗底图。',
]
.filter(Boolean)
.join(' '),
visualPromptText:
cleanSeedText(role.visualDescription, 220) || fallbackVisualDescription,
animationPromptText: fallbackActionDescription,
scenePromptText: generatedSceneDescription || fallbackSceneDescription,
};
}

View File

@@ -42,5 +42,5 @@ test('clarification panel shows pending questions and ready state', () => {
expect(pendingHtml).toContain('待补充问题');
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
expect(readyHtml).toContain('最小锚点已齐备,可以进入下一阶段');
expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段');
});

View File

@@ -19,7 +19,7 @@ export function CustomWorldAgentClarificationPanel({
</div>
<div className="mt-2 text-lg font-semibold text-white">
</div>
</section>
);

View File

@@ -7,9 +7,6 @@ type CustomWorldAgentComposerProps = {
disabled: boolean;
onSubmit: (payload: SendCustomWorldAgentMessageRequest) => void;
textareaRef?: RefObject<HTMLTextAreaElement | null>;
onSummaryClick?: () => void;
onAutoCompleteClick?: () => void;
showAutoComplete?: boolean;
};
function createClientMessageId() {
@@ -27,9 +24,6 @@ export function CustomWorldAgentComposer({
disabled,
onSubmit,
textareaRef,
onSummaryClick,
onAutoCompleteClick,
showAutoComplete = true,
}: CustomWorldAgentComposerProps) {
const [text, setText] = useState('');
@@ -49,28 +43,8 @@ export function CustomWorldAgentComposer({
};
return (
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<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"
>
</button>
{showAutoComplete ? (
<button
type="button"
onClick={onAutoCompleteClick}
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"
>
</button>
) : null}
</div>
<div className="shrink-0 rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
<div className="relative">
<textarea
ref={textareaRef}
value={text}
@@ -81,21 +55,19 @@ export function CustomWorldAgentComposer({
submit();
}
}}
rows={2}
rows={3}
disabled={disabled}
placeholder="输入消息"
className="w-full resize-none rounded-[1.35rem] border border-white/10 bg-black/30 px-4 py-2.5 text-sm leading-6 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
className="w-full resize-none rounded-[1.35rem] border border-white/10 bg-black/30 px-4 pb-12 pr-20 pt-3 text-sm leading-6 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
/>
<div className="flex justify-end">
<button
type="button"
onClick={submit}
disabled={disabled || !text.trim()}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
<button
type="button"
onClick={submit}
disabled={disabled || !text.trim()}
className="absolute bottom-3 right-3 rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
</div>
);

View File

@@ -107,7 +107,7 @@ export function CustomWorldAgentDraftDrawer({
</div>
) : (
<div className="mt-3 text-sm leading-7 text-zinc-400">
稿
稿
</div>
)}
</div>

View File

@@ -4,7 +4,7 @@ type CustomWorldAgentHeaderProps = {
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
return (
<div className="flex items-center justify-between gap-3 rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<div className="flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<button
type="button"
onClick={onBack}
@@ -12,7 +12,6 @@ export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps)
>
</button>
<div className="text-sm font-semibold text-white">Agent</div>
</div>
);
}

View File

@@ -32,7 +32,7 @@ test('intent summary panel shows collected custom world anchors', () => {
/>,
);
expect(html).toContain('已收集锚点');
expect(html).toContain('已收集设定');
expect(html).toContain('世界一句话');
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');

View File

@@ -58,7 +58,7 @@ export function CustomWorldAgentIntentSummaryPanel({
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'}
@@ -91,7 +91,7 @@ export function CustomWorldAgentIntentSummaryPanel({
</div>
) : (
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm text-zinc-400">
</div>
)}
</section>

View File

@@ -121,7 +121,7 @@ export function CustomWorldAgentQuickActions({
))
) : !draftAction || !canDraftFoundation ? (
<QuickActionButton
label={showEntityActions ? '继续精修当前草稿' : '继续补充锚点'}
label={showEntityActions ? '继续精修当前草稿' : '继续补充设定'}
onClick={() => onFocusSuggestedAction()}
disabled={disabled}
/>

View File

@@ -27,7 +27,7 @@ export function CustomWorldAgentSummaryPanel({
const pendingCount = session.pendingClarifications.length;
const { title, summary } = readSummaryText(
session.draftProfile,
'第一阶段先收住世界锚点,后续阶段再把这里整理成更完整的世界底稿摘要。',
'第一阶段先收住世界设定,后续阶段再把这里整理成更完整的世界底稿摘要。',
);
return (

View File

@@ -63,3 +63,31 @@ test('filters empty recommended replies and avoids duplicate key warnings', () =
expect(duplicateKeyCalls).toHaveLength(0);
});
test('renders a streaming assistant bubble without timestamps', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
render(
<CustomWorldAgentThread
messages={[
{
id: 'message-1',
role: 'user',
kind: 'chat',
text: '我想做一个潮湿压抑的海上世界。',
createdAt: '2026-04-16T10:01:00.000Z',
relatedOperationId: null,
},
]}
streamingReplyText="那我先顺着这个方向收一下,开场时你更想让玩家撞上什么麻烦"
isStreamingReply
/>,
);
expect(
screen.getByText(//u),
).toBeTruthy();
expect(screen.queryByText('10:01')).toBeNull();
});

View File

@@ -6,24 +6,16 @@ type CustomWorldAgentThreadProps = {
messages: CustomWorldAgentMessage[];
recommendedReplies?: string[];
onRecommendedReply?: (text: string) => void;
streamingReplyText?: string;
isStreamingReply?: boolean;
};
function formatMessageTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function CustomWorldAgentThread({
messages,
recommendedReplies = [],
onRecommendedReply,
streamingReplyText = '',
isStreamingReply = false,
}: CustomWorldAgentThreadProps) {
const bottomRef = useRef<HTMLDivElement | null>(null);
const visibleRecommendedReplies = [
@@ -42,10 +34,10 @@ export function CustomWorldAgentThread({
behavior: 'smooth',
block: 'end',
});
}, [messages]);
}, [messages, streamingReplyText, isStreamingReply]);
return (
<div className="flex min-h-[20rem] flex-1 flex-col overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
<div className="flex h-full min-h-0 flex-1 flex-col overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
{messages.length === 0 ? (
<div className="m-auto text-sm text-zinc-400">
@@ -73,9 +65,6 @@ export function CustomWorldAgentThread({
}`}
>
<div className="whitespace-pre-wrap">{message.text}</div>
<div className="mt-2 text-[11px] text-zinc-400">
{formatMessageTime(message.createdAt)}
</div>
{!isUser &&
index === lastAssistantMessageIndex &&
visibleRecommendedReplies.length > 0 ? (
@@ -96,6 +85,24 @@ export function CustomWorldAgentThread({
</div>
);
})}
{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%]">
{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]" />
</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" />
</div>
)}
</div>
</div>
) : null}
<div ref={bottomRef} />
</div>
)}

View File

@@ -1,483 +1,161 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
vi.mock('../../services/aiService', () => ({
getCustomWorldAgentCardDetail: vi.fn(),
}));
vi.mock('../CustomWorldRoleAssetStudioModal', () => ({
CustomWorldRoleAssetStudioModal: ({
role,
onPublishSuccess,
}: {
role: { name: string };
onPublishSuccess?: (
payload: {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
options?: { closeAfterSync?: boolean },
) => void;
}) => (
<div>
<div>{role.name}</div>
<button
type="button"
onClick={() =>
onPublishSuccess?.(
{
roleId: 'character-1',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
},
{
closeAfterSync: true,
},
)
}
>
</button>
</div>
),
}));
const detailById: Record<string, CustomWorldDraftCardDetail> = {
'world-foundation': {
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
sections: [
{
id: 'title',
label: '标题',
value: '潮雾列岛',
},
{
id: 'summary',
label: '摘要',
value: '这是第一版世界底稿。',
},
],
linkedIds: ['thread-1', 'character-1'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary'],
warningMessages: [],
},
'character-1': {
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
},
'character-2': {
id: 'character-2',
kind: 'character',
title: '顾潮音',
sections: [
{
id: 'name',
label: '角色名',
value: '顾潮音',
},
{
id: 'summary',
label: '角色摘要',
value: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
},
};
const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
stage: 'object_refining',
focusCardId: 'world-foundation',
currentTurn: 4,
anchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有通路都要向未知代价借路。',
desiredExperience: '压迫、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的旧航路继承人。',
corePursuit: '查清沉船夜背后的真相。',
fearOfLoss: '一旦失败,就会再次失去唯一还活着的旧友。',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
progressPercent: 58,
lastAssistantReply: '世界和玩家视角已经有了,下一步我想把最明面的冲突钉住。',
stage: 'collecting_intent',
focusCardId: null,
creatorIntent: {},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
isReady: false,
completedKeys: ['world_hook', 'player_premise'],
missingKeys: ['theme_and_tone', 'core_conflict', 'relationship_seed', 'iconic_element'],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
},
draftProfile: null,
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
text: '当前底稿已经可以继续精修。',
createdAt: new Date().toISOString(),
kind: 'chat',
text: '先告诉我你想做一个怎样的世界。',
createdAt: '2026-04-17T12:00:00.000Z',
relatedOperationId: null,
},
],
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与航道争夺',
summary: '世界总卡已经生成。',
status: 'warning',
linkedIds: ['thread-1', 'character-1'],
warningCount: 1,
},
{
id: 'character-1',
kind: 'character',
title: '沈砺',
subtitle: '守灯会旧友',
summary: '他最了解旧航道,也最可能先背叛。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
draftCards: [],
pendingClarifications: [],
suggestedActions: [
{
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
recommendedReplies: [
'现在开始生成草稿',
'先总结一下当前设定',
'我还想再补充一点',
],
suggestedActions: [],
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [
{
roleId: 'character-1',
roleName: '沈砺',
roleKind: 'story',
priorityTier: 'featured',
portraitPath: null,
generatedVisualAssetId: null,
generatedAnimationSetId: null,
status: 'missing',
missingAnimations: ['idle', 'run', 'attack', 'hurt', 'die'],
nextPointCost: 20,
},
],
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: '2026-04-14T10:00:00.000Z',
updatedAt: '2026-04-17T12:00:00.000Z',
};
beforeEach(() => {
vi.mocked(getCustomWorldAgentCardDetail).mockImplementation(
async (_sessionId, cardId): Promise<CustomWorldDraftCardDetail> =>
detailById[cardId] ?? detailById['world-foundation']!,
);
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
});
test('workspace loads detail, saves edits, opens generate actions, and reflects updated drawer cards', async () => {
test('workspace sends summary request from progress area', async () => {
const user = userEvent.setup();
const onExecuteAction = vi.fn();
const onSubmitMessage = vi.fn();
const { rerender } = render(
render(
<CustomWorldAgentWorkspace
session={baseSession}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
onSubmitMessage={onSubmitMessage}
onExecuteAction={() => {}}
/>,
);
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenCalledWith(
baseSession.sessionId,
'world-foundation',
);
});
await user.click(screen.getByRole('button', { name: '总结当前设定' }));
expect(screen.getByText('卡片详情')).toBeTruthy();
expect(screen.queryByPlaceholderText('输入消息')).toBeNull();
expect(screen.queryByText('当前底稿已经可以继续精修。')).toBeNull();
expect(onSubmitMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: '请总结一下当前已经成形的世界设定。',
}),
);
});
await user.click(screen.getByRole('button', { name: '编辑设定' }));
const summaryInput = screen.getByLabelText('摘要');
await user.clear(summaryInput);
await user.type(summaryInput, '这是更新后的世界摘要。');
await user.click(screen.getByRole('button', { name: '保存' }));
test('workspace enables quick fill after at least two turns and submits quick fill request', async () => {
const user = userEvent.setup();
const onSubmitMessage = vi.fn();
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'update_draft_card',
cardId: 'world-foundation',
sections: [
{
sectionId: 'title',
value: '潮雾列岛',
},
{
sectionId: 'summary',
value: '这是更新后的世界摘要。',
},
],
});
render(
<CustomWorldAgentWorkspace
session={baseSession}
activeOperation={null}
onBack={() => {}}
onSubmitMessage={onSubmitMessage}
onExecuteAction={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenLastCalledWith(
baseSession.sessionId,
'character-1',
);
});
await user.click(screen.getByRole('button', { name: '补全剩余设定' }));
const [generateCharacterButton] = screen.getAllByRole('button', {
name: '新增角色',
});
await user.click(generateCharacterButton!);
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成角色' }));
expect(onSubmitMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: '请补全剩余设定。',
quickFillRequested: true,
}),
);
});
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_characters',
count: 2,
promptText: null,
anchorCardIds: ['character-1'],
});
const [generateLandmarkButton] = screen.getAllByRole('button', {
name: '新增场景',
});
await user.click(generateLandmarkButton!);
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成场景' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_landmarks',
count: 2,
promptText: null,
anchorCardIds: ['character-1'],
});
const [openRoleAssetsButton] = screen.getAllByRole('button', {
name: '角色资产',
});
await user.click(openRoleAssetsButton!);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_role_assets',
roleIds: ['character-1'],
});
rerender(
test('workspace hides quick fill before two turns', () => {
render(
<CustomWorldAgentWorkspace
session={{
...baseSession,
stage: 'visual_refining',
draftCards: [
...baseSession.draftCards,
{
id: 'character-2',
kind: 'character',
title: '顾潮音',
subtitle: '回潮记录员',
summary: '她会把每一次海雾异常都记到连自己都不愿复看的本子里。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
updatedAt: '2026-04-14T10:05:00.000Z',
}}
activeOperation={{
operationId: 'operation-role-assets',
type: 'generate_role_assets',
status: 'completed',
phaseLabel: '角色资产工坊已就绪',
phaseDetail: '可以开始生成角色主图与动作。',
progress: 100,
error: null,
currentTurn: 1,
}}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
onExecuteAction={() => {}}
/>,
);
await waitFor(() => {
expect(screen.getByText('顾潮音')).toBeTruthy();
});
expect(screen.getByText('角色资产工坊:沈砺')).toBeTruthy();
expect(screen.queryByRole('button', { name: '补全剩余设定' })).toBeNull();
});
await user.click(screen.getByRole('button', { name: '模拟同步角色资产' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'sync_role_assets',
roleId: 'character-1',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
});
test('workspace exposes draft action when progress reaches 100', async () => {
const user = userEvent.setup();
const onExecuteAction = vi.fn();
rerender(
render(
<CustomWorldAgentWorkspace
session={{
...baseSession,
stage: 'visual_refining',
draftCards: [
{
...baseSession.draftCards[0]!,
},
{
...baseSession.draftCards[1]!,
subtitle: '守灯会旧友 / 动作已就绪',
assetStatus: 'complete',
assetStatusLabel: '动作已就绪',
},
],
assetCoverage: {
roleAssets: [
{
roleId: 'character-1',
roleName: '沈砺',
roleKind: 'story',
priorityTier: 'featured',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
status: 'complete',
missingAnimations: [],
nextPointCost: 0,
},
],
sceneAssets: [],
allRoleAssetsReady: true,
allSceneAssetsReady: false,
},
draftProfile: {
...baseSession.draftProfile,
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
imageSrc: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
},
],
},
}}
activeOperation={{
operationId: 'operation-sync-role-assets',
type: 'sync_role_assets',
status: 'completed',
phaseLabel: '角色资产已同步',
phaseDetail: '角色资产已经写回草稿。',
progress: 100,
error: null,
progressPercent: 100,
stage: 'foundation_review',
}}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(screen.getAllByText('动作已就绪').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: '生成游戏设定草稿' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'draft_foundation',
});
});

View File

@@ -3,73 +3,59 @@ import { expect, test } from 'vitest';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
test('custom world agent workspace renders draft workspace instead of chat after draft cards appear', () => {
test('custom world agent workspace renders minimum loop chat layout', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentWorkspace
session={{
sessionId: 'custom-world-agent-session-1',
stage: 'object_refining',
focusCardId: 'world-foundation',
currentTurn: 3,
anchorContent: {
worldPromise: {
hook: '一个被潮雾改写航线秩序的群岛世界。',
differentiator: '所有人都要为每一次借路付出代价。',
desiredExperience: '压迫、悬疑、带一点海上传奇感',
},
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: null,
},
progressPercent: 42,
lastAssistantReply: '我先把世界底色收住了,接下来想确认玩家会怎么被卷进来。',
stage: 'collecting_intent',
focusCardId: null,
creatorIntent: {},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
isReady: false,
completedKeys: ['world_hook'],
missingKeys: [
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
},
draftProfile: null,
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
text: '欢迎。当前底稿已经可以继续精修。',
kind: 'chat',
text: '先告诉我你想做一个怎样的世界。',
createdAt: new Date().toISOString(),
relatedOperationId: null,
},
],
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与航道争夺',
summary: '世界总卡已经生成。',
status: 'warning',
linkedIds: ['thread-1', 'character-1'],
warningCount: 1,
},
{
id: 'character-1',
kind: 'character',
title: '沈砺',
subtitle: '守灯会旧友',
summary: '他最了解旧航道,也最可能先背叛。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
draftCards: [],
pendingClarifications: [],
suggestedActions: [
{
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定'],
suggestedActions: [],
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
@@ -81,17 +67,20 @@ test('custom world agent workspace renders draft workspace instead of chat after
}}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(html).toContain('卡片详情');
expect(html).toContain('快捷动作');
expect(html).toContain('草稿抽屉');
expect(html).not.toContain('首轮草稿会先确认这 6 项信息');
expect(html).not.toContain('现在开始生成草稿');
expect(html).not.toContain('欢迎。当前底稿已经可以继续精修。');
expect(html).not.toContain('输入消息');
expect(html).toContain('创作进度');
expect(html).toContain('42%');
expect(html).toContain('输入消息');
expect(html).toContain('总结当前设定');
expect(html).toContain('补全剩余设定');
expect(html).not.toContain('Agent');
expect(html).not.toContain('刷新');
expect(html).not.toContain('当前轮次');
expect(html).not.toContain('当前状态');
expect(html).not.toContain('草稿抽屉');
expect(html).not.toContain('快捷动作');
});

View File

@@ -1,60 +1,25 @@
import { useEffect, useState } from 'react';
import type {
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
import { CustomWorldAgentComposer } from './CustomWorldAgentComposer';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
import { CustomWorldAgentDraftDrawer } from './CustomWorldAgentDraftDrawer';
import { CustomWorldAgentHeader } from './CustomWorldAgentHeader';
import { CustomWorldAgentOperationBanner } from './CustomWorldAgentOperationBanner';
import { CustomWorldAgentQuickActions } from './CustomWorldAgentQuickActions';
import { CustomWorldAgentThread } from './CustomWorldAgentThread';
import { CustomWorldDraftCardDetailModal } from './CustomWorldDraftCardDetailModal';
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
type WorkspaceRoleAssetTarget = {
id: string;
name: string;
title: string;
role: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
import { EightAnchorProgressBar } from './EightAnchorProgressBar';
type CustomWorldAgentWorkspaceProps = {
session: CustomWorldAgentSessionSnapshot | null;
activeOperation: CustomWorldAgentOperationRecord | null;
streamingReplyText?: string;
isStreamingReply?: boolean;
onBack: () => void;
onRefresh: () => void;
onSubmitMessage: (payload: SendCustomWorldAgentMessageRequest) => void;
onExecuteAction: (payload: CustomWorldAgentActionRequest) => void;
};
const TOTAL_READINESS_STEPS = 6;
const READINESS_ITEMS = [
{ key: 'world_hook', label: '世界核心' },
{ key: 'player_premise', label: '玩家开局' },
{ key: 'theme_and_tone', label: '主题气质' },
{ key: 'core_conflict', label: '核心冲突' },
{ key: 'relationship_seed', label: '关键关系' },
{ key: 'iconic_element', label: '标志元素' },
] as const;
function createClientMessageId() {
if (
typeof crypto !== 'undefined' &&
@@ -66,284 +31,15 @@ function createClientMessageId() {
return `client-message-${Date.now()}`;
}
function resolveInitialCardId(session: CustomWorldAgentSessionSnapshot | null) {
if (!session || session.draftCards.length === 0) {
return null;
}
return (
session.focusCardId ||
session.draftCards.find((card) => card.kind === 'world')?.id ||
session.draftCards[0]?.id ||
null
);
}
function buildRecommendedReplies(session: CustomWorldAgentSessionSnapshot) {
return session.recommendedReplies;
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
: [];
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function resolveRoleAssetTarget(
session: CustomWorldAgentSessionSnapshot | null,
roleId: string | null,
) {
if (!session || !roleId) {
return null;
}
const draftProfile = toRecord(session.draftProfile);
if (!draftProfile) {
return null;
}
const playableRole = toRecordArray(draftProfile.playableNpcs).find(
(item) => toText(item.id) === roleId,
);
const storyRole = toRecordArray(draftProfile.storyNpcs).find(
(item) => toText(item.id) === roleId,
);
const role = playableRole ?? storyRole;
if (!role) {
return null;
}
const assetSummary =
session.assetCoverage.roleAssets.find((entry) => entry.roleId === roleId) ??
null;
return {
role: {
id: roleId,
name: toText(role.name) || '未命名角色',
title: toText(role.title) || toText(role.role) || '关键角色',
role: toText(role.role) || toText(role.title) || '关键角色',
description: toText(role.summary),
backstory: toText(role.hiddenHook) || undefined,
personality: toText(role.publicMask) || undefined,
motivation: toText(role.relationToPlayer) || undefined,
combatStyle: toText(role.role) || undefined,
tags: Array.isArray(role.threadIds)
? role.threadIds
.map((item) => toText(item))
.filter(Boolean)
.slice(0, 4)
: [],
imageSrc: toText(role.imageSrc) || undefined,
generatedVisualAssetId: toText(role.generatedVisualAssetId) || undefined,
generatedAnimationSetId:
toText(role.generatedAnimationSetId) || undefined,
animationMap: toRecord(role.animationMap) ?? undefined,
} satisfies WorkspaceRoleAssetTarget,
roleKind: playableRole ? ('playable' as const) : ('story' as const),
assetSummary,
};
}
function CustomWorldAgentReadinessBar(props: {
completedKeys: string[];
isReady: boolean;
busy: boolean;
onStartDraft: () => void;
}) {
const { completedKeys, isReady, busy, onStartDraft } = props;
const completedKeySet = new Set(completedKeys);
const completedCount = READINESS_ITEMS.filter((item) =>
completedKeySet.has(item.key),
).length;
return (
<div className="rounded-[1.35rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold tracking-[0.12em] text-zinc-300">
稿 6
</div>
</div>
<div className="text-xs text-zinc-400">
{Math.min(completedCount, TOTAL_READINESS_STEPS)}/
{TOTAL_READINESS_STEPS}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2 sm:grid-cols-6">
{READINESS_ITEMS.map((item) => (
<div
key={item.key}
className={`rounded-2xl border px-2.5 py-2 text-center text-[11px] ${
completedKeySet.has(item.key)
? 'border-emerald-300/25 bg-emerald-500/10 text-emerald-100'
: 'border-white/10 bg-black/18 text-zinc-500'
}`}
>
{item.label}
</div>
))}
</div>
<div className="flex items-center justify-between gap-3 sm:justify-end">
{isReady ? (
<button
type="button"
onClick={onStartDraft}
disabled={busy}
className="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"
>
{busy ? '生成中' : '开始生成草稿'}
</button>
) : null}
</div>
</div>
</div>
);
}
export function CustomWorldAgentWorkspace({
session,
activeOperation,
streamingReplyText = '',
isStreamingReply = false,
onBack,
onSubmitMessage,
onExecuteAction,
}: CustomWorldAgentWorkspaceProps) {
const [selectedCardId, setSelectedCardId] = useState<string | null>(() =>
resolveInitialCardId(session),
);
const [detail, setDetail] = useState<CustomWorldDraftCardDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [autoCompleteConfirmOpen, setAutoCompleteConfirmOpen] = useState(false);
const [generateEntityMode, setGenerateEntityMode] = useState<
'character' | 'landmark' | null
>(null);
const [requestedRoleAssetTargetId, setRequestedRoleAssetTargetId] = useState<
string | null
>(null);
const [activeRoleAssetTargetId, setActiveRoleAssetTargetId] = useState<
string | null
>(null);
const [showRoleAssetStudio, setShowRoleAssetStudio] = useState(false);
const [closeRoleAssetStudioAfterSync, setCloseRoleAssetStudioAfterSync] =
useState(false);
useEffect(() => {
if (!session) {
setSelectedCardId(null);
return;
}
const availableCardIds = new Set(session.draftCards.map((card) => card.id));
if (session.focusCardId && availableCardIds.has(session.focusCardId)) {
setSelectedCardId(session.focusCardId);
return;
}
setSelectedCardId((current) => {
if (current && availableCardIds.has(current)) {
return current;
}
return resolveInitialCardId(session);
});
}, [session]);
useEffect(() => {
setEditMode(false);
}, [detail?.id]);
useEffect(() => {
if (!requestedRoleAssetTargetId || !activeOperation) {
return;
}
if (activeOperation.type !== 'generate_role_assets') {
return;
}
if (activeOperation.status === 'completed') {
setActiveRoleAssetTargetId(requestedRoleAssetTargetId);
setShowRoleAssetStudio(true);
setRequestedRoleAssetTargetId(null);
setDetailModalOpen(false);
return;
}
if (activeOperation.status === 'failed') {
setRequestedRoleAssetTargetId(null);
}
}, [activeOperation, requestedRoleAssetTargetId]);
useEffect(() => {
if (!activeOperation || activeOperation.type !== 'sync_role_assets') {
return;
}
if (activeOperation.status === 'completed') {
if (closeRoleAssetStudioAfterSync) {
setShowRoleAssetStudio(false);
}
setCloseRoleAssetStudioAfterSync(false);
return;
}
if (activeOperation.status === 'failed') {
setCloseRoleAssetStudioAfterSync(false);
}
}, [activeOperation, closeRoleAssetStudioAfterSync]);
useEffect(() => {
if (!session?.sessionId || !selectedCardId) {
setDetail(null);
setDetailLoading(false);
return;
}
let cancelled = false;
setDetailLoading(true);
void getCustomWorldAgentCardDetail(session.sessionId, selectedCardId)
.then((nextDetail) => {
if (cancelled) {
return;
}
setDetail(nextDetail);
})
.catch(() => {
if (cancelled) {
return;
}
setDetail(null);
})
.finally(() => {
if (!cancelled) {
setDetailLoading(false);
}
});
return () => {
cancelled = true;
};
}, [selectedCardId, session?.sessionId, session?.updatedAt]);
if (!session) {
return (
<div className="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">
@@ -354,339 +50,58 @@ export function CustomWorldAgentWorkspace({
const isBusy =
activeOperation?.status === 'queued' ||
activeOperation?.status === 'running';
const canStartDraft =
session.creatorIntentReadiness.isReady &&
session.stage === 'foundation_review';
const showAutoCompleteButton =
!session.creatorIntentReadiness.isReady &&
session.creatorIntentReadiness.completedKeys.includes('world_hook');
const showDraftWorkspace =
session.stage !== 'foundation_review' && session.draftCards.length > 0;
const showAgentConversation = !showDraftWorkspace;
const selectedCard =
session.draftCards.find((card) => card.id === selectedCardId) ?? null;
const recommendedReplies = buildRecommendedReplies(session);
const selectedRoleAssetContext = resolveRoleAssetTarget(
session,
activeRoleAssetTargetId,
);
activeOperation?.status === 'running' ||
isStreamingReply;
const openRoleAssetStudio = (roleId: string | null) => {
if (!roleId) {
return;
}
setRequestedRoleAssetTargetId(roleId);
onExecuteAction({
action: 'generate_role_assets',
roleIds: [roleId],
});
};
const submitTextMessage = (text: string) => {
const submitMessage = (text: string, quickFillRequested = false) => {
onSubmitMessage({
clientMessageId: createClientMessageId(),
text,
focusCardId: selectedCardId,
selectedCardIds: selectedCardId ? [selectedCardId] : [],
quickFillRequested,
focusCardId: null,
selectedCardIds: [],
});
};
const submitSummaryRequest = () => {
submitTextMessage(
showDraftWorkspace
? '帮我总结当前世界底稿,并指出下一步最值得精修的卡片。'
: '帮我总结当前设定,并指出下一步最值得补的世界锚点。',
);
};
const submitAutoCompleteRequest = () => {
submitTextMessage(
session.creatorIntentReadiness.isReady
? '基于当前设定,帮我自动补强还可以更清晰的细节。'
: '请根据当前信息自动补全还缺的设定,并给我一版默认方案。',
);
setAutoCompleteConfirmOpen(false);
};
const handleRecommendedReply = (reply: string) => {
if (canStartDraft && reply.includes('生成草稿')) {
onExecuteAction({
action: 'draft_foundation',
});
return;
}
submitTextMessage(reply);
};
const openGenerateModal = (mode: 'character' | 'landmark') => {
setGenerateEntityMode(mode);
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1500px] flex-col gap-3">
{!showDraftWorkspace ? <CustomWorldAgentHeader onBack={onBack} /> : null}
{!showDraftWorkspace ? (
<CustomWorldAgentReadinessBar
completedKeys={session.creatorIntentReadiness.completedKeys}
isReady={canStartDraft}
busy={isBusy}
onStartDraft={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
/>
) : null}
<CustomWorldAgentOperationBanner operation={activeOperation} />
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<CustomWorldAgentHeader onBack={onBack} />
{showDraftWorkspace ? (
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[20rem_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col gap-3 xl:overflow-hidden">
<CustomWorldAgentQuickActions
suggestedActions={session.suggestedActions}
disabled={isBusy}
canDraftFoundation={canStartDraft}
showEntityActions
showSummaryAction={false}
showRoleAssetAction={selectedCard?.kind === 'character'}
onRequestSummary={submitSummaryRequest}
onDraftFoundation={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
onGenerateCharacter={() => {
openGenerateModal('character');
}}
onGenerateLandmark={() => {
openGenerateModal('landmark');
}}
onGenerateRoleAssets={() => {
openRoleAssetStudio(selectedCardId);
}}
onFocusSuggestedAction={(action) => {
if (action?.targetId) {
setSelectedCardId(action.targetId);
return;
}
if (session.draftCards[0]) {
setSelectedCardId(session.draftCards[0].id);
}
}}
/>
<div className="xl:min-h-0 xl:overflow-y-auto">
<CustomWorldAgentDraftDrawer
draftCards={session.draftCards}
activeCardId={selectedCardId}
onSelectCard={(cardId) => {
setSelectedCardId(cardId);
}}
/>
</div>
</div>
<div className="min-h-0 xl:overflow-y-auto">
<CustomWorldAgentDraftDetailPanel
detail={detail}
loading={detailLoading}
busy={isBusy}
editMode={editMode}
onClose={() => {
setSelectedCardId(null);
setDetailModalOpen(false);
setEditMode(false);
}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
if (!detail) {
return;
}
setEditMode(false);
onExecuteAction({
action: 'update_draft_card',
cardId: detail.id,
sections,
});
}}
onGenerateCharacter={() => {
openGenerateModal('character');
}}
onGenerateLandmark={() => {
openGenerateModal('landmark');
}}
onOpenRoleAssetStudio={() => {
openRoleAssetStudio(detail?.id ?? selectedCardId);
}}
/>
</div>
</div>
) : (
<>
{showAgentConversation ? (
<>
<CustomWorldAgentThread
messages={session.messages}
recommendedReplies={recommendedReplies}
onRecommendedReply={handleRecommendedReply}
/>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
onSummaryClick={submitSummaryRequest}
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
showAutoComplete={showAutoCompleteButton}
/>
</>
) : null}
</>
)}
{autoCompleteConfirmOpen ? (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-[1.5rem] border border-white/10 bg-[#111318] px-5 py-5 shadow-[0_20px_60px_rgba(0,0,0,0.45)]">
<div className="text-base font-semibold text-white">
</div>
<div className="mt-3 text-sm leading-7 text-zinc-300">
</div>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setAutoCompleteConfirmOpen(false)}
className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
>
</button>
<button
type="button"
onClick={submitAutoCompleteRequest}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 transition hover:text-white"
>
</button>
</div>
</div>
</div>
) : null}
<CustomWorldDraftCardDetailModal
open={detailModalOpen}
detail={detail}
loading={detailLoading}
busy={isBusy}
editMode={editMode}
onClose={() => {
setDetailModalOpen(false);
setEditMode(false);
}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
if (!detail) {
return;
}
setEditMode(false);
setDetailModalOpen(false);
onExecuteAction({
action: 'update_draft_card',
cardId: detail.id,
sections,
});
}}
onGenerateCharacter={() => {
setDetailModalOpen(false);
openGenerateModal('character');
}}
onGenerateLandmark={() => {
setDetailModalOpen(false);
openGenerateModal('landmark');
}}
onOpenRoleAssetStudio={() => {
setDetailModalOpen(false);
openRoleAssetStudio(detail?.id ?? selectedCardId);
}}
/>
<CustomWorldGenerateEntityModal
open={generateEntityMode !== null}
mode={generateEntityMode ?? 'character'}
anchorCardTitle={selectedCard?.title ?? detail?.title ?? null}
<EightAnchorProgressBar
currentTurn={session.currentTurn}
progressPercent={session.progressPercent}
disabled={isBusy}
onClose={() => {
setGenerateEntityMode(null);
onSummaryClick={() => {
submitMessage('请总结一下当前已经成形的世界设定。');
}}
onSubmit={({ count, promptText }) => {
if (!generateEntityMode) {
return;
}
onQuickFill={() => {
submitMessage('请补全剩余设定。', true);
}}
onGenerateDraft={() => {
onExecuteAction({
action:
generateEntityMode === 'character'
? 'generate_characters'
: 'generate_landmarks',
count,
promptText: promptText || null,
anchorCardIds: selectedCardId ? [selectedCardId] : [],
action: 'draft_foundation',
});
setGenerateEntityMode(null);
}}
/>
{showRoleAssetStudio && selectedRoleAssetContext ? (
<CustomWorldRoleAssetStudioModal
role={selectedRoleAssetContext.role}
roleKind={selectedRoleAssetContext.roleKind}
priorityTier={
selectedRoleAssetContext.assetSummary?.priorityTier ??
(selectedRoleAssetContext.roleKind === 'playable'
? 'hero'
: 'featured')
}
visualPointCost={
selectedRoleAssetContext.assetSummary?.status === 'missing'
? (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20)
: 20
}
animationPointCost={
selectedRoleAssetContext.assetSummary?.status === 'missing'
? 60
: (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60)
}
syncBusy={
activeOperation?.type === 'sync_role_assets' &&
(activeOperation.status === 'queued' ||
activeOperation.status === 'running')
}
onPublishSuccess={(payload, options) => {
setCloseRoleAssetStudioAfterSync(Boolean(options?.closeAfterSync));
onExecuteAction({
action: 'sync_role_assets',
...payload,
});
}}
onClose={() => {
setShowRoleAssetStudio(false);
setCloseRoleAssetStudioAfterSync(false);
}}
/>
{activeOperation?.type !== 'process_message' ? (
<CustomWorldAgentOperationBanner operation={activeOperation} />
) : null}
<div className="min-h-0 flex-1 overflow-hidden">
<div className="h-full min-h-[18rem] lg:min-h-0">
<CustomWorldAgentThread
messages={session.messages}
streamingReplyText={streamingReplyText}
isStreamingReply={isStreamingReply}
/>
</div>
</div>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
type EightAnchorProgressBarProps = {
currentTurn: number;
progressPercent: number;
disabled: boolean;
onSummaryClick: () => void;
onQuickFill: () => void;
onGenerateDraft: () => void;
};
function clampProgress(progressPercent: number) {
if (!Number.isFinite(progressPercent)) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(progressPercent)));
}
function resolveProgressHint(progressPercent: number) {
if (progressPercent >= 100) {
return '当前设定已经收束完成,可以进入草稿生成';
}
if (progressPercent >= 75) {
return '正在收束成一版可进入草稿的世界底子';
}
if (progressPercent >= 45) {
return '世界方向已经成形,继续补关键骨架';
}
if (progressPercent >= 15) {
return '先把玩家视角、开局和冲突线钉稳';
}
return '先抓住这个世界最关键的方向';
}
export function EightAnchorProgressBar({
currentTurn,
progressPercent,
disabled,
onSummaryClick,
onQuickFill,
onGenerateDraft,
}: EightAnchorProgressBarProps) {
const normalizedProgress = clampProgress(progressPercent);
const isCompleted = normalizedProgress >= 100;
const canQuickFill = currentTurn >= 2;
return (
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 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>
<div className="mt-1 text-sm text-zinc-400">
{resolveProgressHint(normalizedProgress)}
</div>
</div>
<div className="text-lg font-semibold text-white">
{normalizedProgress}%
</div>
</div>
<div className="h-3 overflow-hidden rounded-full bg-white/8">
<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)}%` }}
/>
</div>
<div className="flex 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"
>
</button>
{isCompleted ? (
<button
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"
>
稿
</button>
) : canQuickFill ? (
<button
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"
>
</button>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -36,7 +36,7 @@ import {
resolvePlatformWorldLeadPortrait,
} from './platformWorldPresentation';
export type PlatformHomeTab = 'home' | 'create' | 'discover' | 'profile';
export type PlatformHomeTab = 'home' | 'create' | 'profile';
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
@@ -443,8 +443,6 @@ export function PlatformHomeView({
const tabIcons = {
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
create: '/Icons/01_Scroll.png',
discover:
"/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/321_Compass.png",
profile: '/UI/Icon_Eq_Head.png',
} as const;
const recentPlayItems = savedSnapshot
@@ -599,49 +597,6 @@ export function PlatformHomeView({
);
}
if (activeTab === 'discover') {
content = (
<div className="space-y-4 pb-2">
<section
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] tracking-[0.2em] text-zinc-100">
DISCOVER
</div>
<div className="mt-4 text-3xl font-black text-white"></div>
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-300">
便
</div>
</section>
<section>
<SectionHeader title="最近上新" detail="先看广场里的新内容" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取推荐内容..." />
) : latestEntries.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{latestEntries.map((entry: CustomWorldGalleryCard) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:discover`}
entry={entry}
badge={formatPlatformWorldTime(entry.publishedAt)}
metaLabel={describePlatformThemeLabel(entry.themeMode)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="发现频道暂时还没有可展示的内容。" />
)}
</section>
</div>
);
}
if (activeTab === 'profile') {
content = (
<div className="space-y-4 pb-2">
@@ -918,7 +873,7 @@ export function PlatformHomeView({
))}
</div>
) : (
<EmptyShelf text="你最近还没有浏览过作品详情,去首页或发现逛一逛吧。" />
<EmptyShelf text="你最近还没有浏览过作品详情,去首页逛一逛吧。" />
)}
</section>
@@ -988,7 +943,7 @@ export function PlatformHomeView({
className="mt-4 border-t border-white/5 pt-3"
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
>
<div className="grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1">
<div className="grid h-14 grid-cols-3 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1">
<PlatformTabButton
active={activeTab === 'home'}
label="首页"
@@ -1001,12 +956,6 @@ export function PlatformHomeView({
iconSrc={tabIcons.create}
onClick={() => onTabChange('create')}
/>
<PlatformTabButton
active={activeTab === 'discover'}
label="发现"
iconSrc={tabIcons.discover}
onClick={() => onTabChange('discover')}
/>
<PlatformTabButton
active={activeTab === 'profile'}
label="我的"

View File

@@ -48,6 +48,7 @@ export function PlatformWorldDetailView({
onStartGame,
onContinueEdit,
onPublish,
onDelete,
onUnpublish,
}: {
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
@@ -57,17 +58,21 @@ export function PlatformWorldDetailView({
onStartGame: () => void;
onContinueEdit?: (() => void) | null;
onPublish?: (() => void) | null;
onDelete?: (() => void) | null;
onUnpublish?: (() => void) | null;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const previewCharacters = buildCustomWorldPlayableCharacters(entry.profile).slice(
0,
3,
);
const previewCharacters = buildCustomWorldPlayableCharacters(
entry.profile,
).slice(0, 3);
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
const tags = [
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
...new Set(
buildPlatformWorldTags(entry)
.map((tag) => tag.trim())
.filter(Boolean),
),
].slice(0, 3);
return (
@@ -89,7 +94,10 @@ export function PlatformWorldDetailView({
<div className="space-y-4 pb-2">
<div
className="pixel-nine-slice relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
{coverImage ? (
<img
@@ -150,7 +158,10 @@ export function PlatformWorldDetailView({
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
@@ -160,13 +171,17 @@ export function PlatformWorldDetailView({
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-2 text-lg font-bold">{entry.playableNpcCount}</div>
<div className="mt-2 text-lg font-bold">
{entry.playableNpcCount}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-2 text-lg font-bold">{entry.landmarkCount}</div>
<div className="mt-2 text-lg font-bold">
{entry.landmarkCount}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
@@ -231,7 +246,10 @@ export function PlatformWorldDetailView({
<div
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 14 })}
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
@@ -265,6 +283,14 @@ export function PlatformWorldDetailView({
disabled={isMutating}
/>
) : null}
{onDelete ? (
<ActionButton
label="删除作品"
onClick={onDelete}
tone="danger"
disabled={isMutating}
/>
) : 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">

View File

@@ -11,10 +11,12 @@ import {
executeCustomWorldAgentAction,
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
streamCustomWorldAgentMessage,
} from '../../services/aiService';
import type { AuthUser } from '../../services/authService';
import {
clearProfileBrowseHistory,
deleteCustomWorldProfile,
getProfileDashboard,
listCustomWorldGallery,
listCustomWorldLibrary,
@@ -35,11 +37,12 @@ vi.mock('../../services/aiService', () => ({
generateCustomWorldProfile: vi.fn(),
getCustomWorldAgentOperation: vi.fn(),
getCustomWorldAgentSession: vi.fn(),
sendCustomWorldAgentMessage: vi.fn(),
streamCustomWorldAgentMessage: vi.fn(),
}));
vi.mock('../../services/storageService', () => ({
clearProfileBrowseHistory: vi.fn(),
deleteCustomWorldProfile: vi.fn(),
getCustomWorldGalleryDetail: vi.fn(),
getProfileDashboard: vi.fn(),
listCustomWorldGallery: vi.fn(),
@@ -78,6 +81,53 @@ vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
const mockSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
currentTurn: 0,
anchorContent: {
worldPromise: {
hook: '被海雾吞没的旧航路群岛。',
differentiator: '灯塔与禁航令共同决定谁能穿过死潮。',
desiredExperience: '压抑、潮湿、悬疑',
},
playerFantasy: {
playerRole: '玩家是被迫返乡的守灯人继承者。',
corePursuit: '查清沉船夜与假航灯的关系。',
fearOfLoss: '失去家族最后一条可信航线。',
},
themeBoundary: {
toneKeywords: ['压抑', '悬疑'],
aestheticDirectives: ['潮湿群岛', '冷雾港口'],
forbiddenDirectives: ['轻喜冒险'],
},
playerEntryPoint: {
openingIdentity: '返乡守灯人继承者',
openingProblem: '回港首夜撞见禁航区假航灯重亮',
entryMotivation: '阻止更多船只误入死潮',
},
coreConflict: {
surfaceConflicts: ['守灯会与航运公会争夺航路解释权'],
hiddenCrisis: '有人在借假航灯持续清洗旧案证据',
firstTouchedConflict: '玩家返乡当夜就被卷进封航冲突',
},
keyRelationships: [
{
pairs: '玩家 vs 沈砺',
relationshipType: '旧友互疑',
secretOrCost: '他知道沉船夜的另一半真相',
},
],
hiddenLines: {
hiddenTruths: ['沉船夜与假航灯骗局属于同一操盘链条'],
misdirectionHints: ['表面像海雾自然失控'],
revealPacing: '先见异常,再见旧案,再见操盘者',
},
iconicElements: {
iconicMotifs: ['假航灯', '沉钟回响'],
institutionsOrArtifacts: ['旧灯塔', '禁航碑'],
hardRules: ['错误航灯会把船引进必死水域'],
},
},
progressPercent: 0,
lastAssistantReply: '先告诉我你想做一个怎样的 RPG 世界。',
stage: 'clarifying',
focusCardId: null,
creatorIntent: {},
@@ -180,6 +230,7 @@ beforeEach(() => {
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
entry: {
ownerUserId: 'user-1',
@@ -226,6 +277,7 @@ beforeEach(() => {
error: null,
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession);
vi.mocked(streamCustomWorldAgentMessage).mockResolvedValue(mockSession);
});
test('create tab opens game type modal, keeps AIRP and visual novel locked, and enters agent workspace for RPG', async () => {
@@ -284,6 +336,10 @@ 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.getByText(/穿/u)).toBeTruthy();
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
});
test('existing draft sessions enter the legacy result layout directly', async () => {
@@ -448,6 +504,60 @@ test('profile tab loads server browse history and can clear it after confirmatio
});
expect(
screen.getByText('你最近还没有浏览过作品详情,去首页或发现逛一逛吧。'),
screen.getByText('你最近还没有浏览过作品详情,去首页逛一逛吧。'),
).toBeTruthy();
});
test('owned world detail can delete a work and return to the create tab list', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
vi.mocked(listCustomWorldLibrary).mockResolvedValue([
{
ownerUserId: 'user-1',
profileId: 'world-delete-1',
profile: {
id: 'world-delete-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试删除流程的作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'draft',
publishedAt: null,
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '用于测试删除流程的作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
},
]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
render(<TestWrapper />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(await screen.findByText('潮雾列岛'));
await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => {
expect(deleteCustomWorldProfile).toHaveBeenCalledWith('world-delete-1');
});
await waitFor(() => {
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
});
expect(
screen.getByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。'),
).toBeTruthy();
});

View File

@@ -10,6 +10,7 @@ import {
} from 'react';
import type {
CustomWorldAgentMessage,
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
@@ -27,10 +28,11 @@ import {
executeCustomWorldAgentAction,
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
sendCustomWorldAgentMessage,
streamCustomWorldAgentMessage,
} from '../../services/aiService';
import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult';
import {
buildAgentDraftFoundationAnchorEntries,
buildAgentDraftFoundationGenerationProgress,
buildAgentDraftFoundationSettingText,
isDraftFoundationOperation,
@@ -55,6 +57,7 @@ import {
} from '../../services/platformBrowseHistory';
import {
clearProfileBrowseHistory,
deleteCustomWorldProfile,
getCustomWorldGalleryDetail,
getProfileDashboard,
listCustomWorldGallery,
@@ -66,10 +69,7 @@ import {
upsertCustomWorldProfile,
upsertProfileBrowseHistory,
} from '../../services/storageService';
import {
type CustomWorldProfile,
type GameState,
} from '../../types';
import { type CustomWorldProfile, type GameState } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView';
@@ -141,6 +141,16 @@ function createFailedAgentOperation(params: {
};
}
function buildOptimisticAgentMessage(
payload: Pick<CustomWorldAgentMessage, 'id' | 'role' | 'kind' | 'text'>,
): CustomWorldAgentMessage {
return {
...payload,
createdAt: new Date().toISOString(),
relatedOperationId: null,
};
}
function buildAgentSeedTextFromProfile(profile: CustomWorldProfile) {
return (
buildCustomWorldCreatorIntentGenerationText(profile.creatorIntent).trim() ||
@@ -215,6 +225,8 @@ export function PreGameSelectionFlow({
useState<CustomWorldAgentSessionSnapshot | null>(null);
const [agentOperation, setAgentOperation] =
useState<CustomWorldAgentOperationRecord | null>(null);
const [streamingAgentReplyText, setStreamingAgentReplyText] = useState('');
const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false);
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [platformError, setPlatformError] = useState<string | null>(null);
@@ -457,6 +469,8 @@ export function PreGameSelectionFlow({
if (!activeAgentSessionId) {
setAgentSession(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
return;
}
@@ -479,6 +493,8 @@ export function PreGameSelectionFlow({
);
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
persistAgentUiState(null, null);
setPlatformTab('create');
setSelectionStage('platform');
@@ -636,6 +652,10 @@ export function PreGameSelectionFlow({
() => buildAgentDraftFoundationSettingText(agentSession),
[agentSession],
);
const agentDraftAnchorPreviewEntries = useMemo(
() => buildAgentDraftFoundationAnchorEntries(agentSession),
[agentSession],
);
const agentDraftResultProfile = useMemo(
() => buildCustomWorldProfileFromAgentDraft(agentSession),
[agentSession],
@@ -794,23 +814,63 @@ export function PreGameSelectionFlow({
return;
}
const optimisticUserMessage = buildOptimisticAgentMessage({
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.text.trim(),
});
setAgentOperation(null);
persistAgentUiState(activeAgentSessionId, null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(true);
setAgentSession((current) =>
current
? {
...current,
messages: [...current.messages, optimisticUserMessage],
updatedAt: optimisticUserMessage.createdAt,
}
: current,
);
try {
const { operation } = await sendCustomWorldAgentMessage(
const nextSession = await streamCustomWorldAgentMessage(
activeAgentSessionId,
payload,
{
onUpdate: (text) => {
setStreamingAgentReplyText(text);
},
},
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
setAgentSession(nextSession);
setAgentOperation(null);
setStreamingAgentReplyText('');
} catch (error) {
const errorMessage = resolveErrorMessage(error, '发送共创消息失败。');
setAgentOperation(
createFailedAgentOperation({
type: 'process_message',
phaseLabel: '发送消息失败',
error: errorMessage,
}),
setAgentSession((current) =>
current
? {
...current,
messages: [
...current.messages,
buildOptimisticAgentMessage({
id: `message-error-${Date.now()}`,
role: 'assistant',
kind: 'warning',
text: errorMessage,
}),
],
updatedAt: new Date().toISOString(),
}
: current,
);
setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null);
} finally {
setIsStreamingAgentReply(false);
}
};
@@ -858,6 +918,8 @@ export function PreGameSelectionFlow({
const leaveAgentWorkspace = () => {
setPlatformTab('create');
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setGeneratedCustomWorldProfile(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
@@ -1058,11 +1120,7 @@ export function PreGameSelectionFlow({
customWorldAutoSaveTimeoutRef.current = null;
}
};
}, [
generatedCustomWorldProfile,
saveGeneratedCustomWorld,
selectionStage,
]);
}, [generatedCustomWorldProfile, saveGeneratedCustomWorld, selectionStage]);
const openSavedCustomWorldEditor = (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
@@ -1070,7 +1128,8 @@ export function PreGameSelectionFlow({
setSelectedDetailEntry(entry);
const normalizedProfile = normalizeAgentBackedProfile(entry.profile);
setGeneratedCustomWorldProfile(normalizedProfile);
lastAutoSavedProfileSignatureRef.current = JSON.stringify(normalizedProfile);
lastAutoSavedProfileSignatureRef.current =
JSON.stringify(normalizedProfile);
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
setCustomWorldError(null);
@@ -1129,6 +1188,36 @@ export function PreGameSelectionFlow({
}
};
const handleDeleteSelectedWorld = async () => {
if (!selectedDetailEntry || isMutatingDetail) {
return;
}
const confirmed = window.confirm(
`确认删除作品《${selectedDetailEntry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`,
);
if (!confirmed) {
return;
}
setIsMutatingDetail(true);
setDetailError(null);
try {
const entries = await deleteCustomWorldProfile(
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(entries);
setSelectedDetailEntry(null);
setPlatformTab('create');
setSelectionStage('platform');
setPublishedGalleryEntries(await listCustomWorldGallery());
} catch (error) {
setDetailError(resolveErrorMessage(error, '删除自定义世界失败。'));
} finally {
setIsMutatingDetail(false);
}
};
const isSelectedWorldOwned = Boolean(
selectedDetailEntry &&
savedCustomWorldEntries.some(
@@ -1228,6 +1317,9 @@ export function PreGameSelectionFlow({
? handleUnpublishSelectedWorld
: null
}
onDelete={
isSelectedWorldOwned ? handleDeleteSelectedWorld : null
}
/>
)}
</motion.div>
@@ -1250,13 +1342,9 @@ export function PreGameSelectionFlow({
<CustomWorldAgentWorkspace
session={agentSession}
activeOperation={agentOperation}
streamingReplyText={streamingAgentReplyText}
isStreamingReply={isStreamingAgentReply}
onBack={leaveAgentWorkspace}
onRefresh={() => {
if (!activeAgentSessionId) {
return;
}
void syncAgentSessionSnapshot(activeAgentSessionId);
}}
onSubmitMessage={(payload) => {
void submitAgentMessage(payload);
}}
@@ -1290,6 +1378,7 @@ export function PreGameSelectionFlow({
>
<CustomWorldGenerationView
settingText={activeGenerationSettingText}
anchorEntries={agentDraftAnchorPreviewEntries}
progress={activeGenerationProgress}
isGenerating={isActiveGenerationRunning}
error={activeGenerationError}
@@ -1300,10 +1389,10 @@ export function PreGameSelectionFlow({
backLabel="返回工作区"
settingActionLabel="回到工作区"
retryLabel="重新生成草稿"
settingTitle="当前共创设定"
settingTitle="当前锚点信息"
settingDescription={
isAgentDraftGenerationView
? '这批锚点会被整理成第一版世界底稿与草稿卡。'
? '将按当前八锚点结构编译第一版世界底稿与草稿卡。'
: undefined
}
progressTitle={
@@ -1385,7 +1474,6 @@ export function PreGameSelectionFlow({
void openRpgAgentWorkspace();
}}
/>
</>
);
}

View File

@@ -1,3 +1,4 @@
import type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent';
import {
isRecord,
readStoredJson,
@@ -551,6 +552,11 @@ function normalizeCharacterAnimationConfig(
const extension = toText(value.extension);
const file = toText(value.file);
const basePath = toText(value.basePath);
const frameWidth = toOptionalInteger(value.frameWidth);
const frameHeight = toOptionalInteger(value.frameHeight);
const fps = toOptionalNumber(value.fps);
const loop = typeof value.loop === 'boolean' ? value.loop : undefined;
const previewVideoPath = toText(value.previewVideoPath);
return {
folder,
@@ -560,6 +566,11 @@ function normalizeCharacterAnimationConfig(
...(extension ? { extension } : {}),
...(file ? { file } : {}),
...(basePath ? { basePath } : {}),
...(frameWidth ? { frameWidth: Math.max(1, frameWidth) } : {}),
...(frameHeight ? { frameHeight: Math.max(1, frameHeight) } : {}),
...(fps ? { fps: Math.max(1, fps) } : {}),
...(typeof loop === 'boolean' ? { loop } : {}),
...(previewVideoPath ? { previewVideoPath } : {}),
};
}
@@ -974,6 +985,9 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
threadContracts:
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
anchorContent: preserveStructuredRecord<EightAnchorContent>(
value.anchorContent,
),
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
anchorPack:
value.anchorPack && typeof value.anchorPack === 'object'

View File

@@ -383,6 +383,7 @@ export function normalizeCustomWorldLandmarks(params: {
id: landmark.id,
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel,
imageSrc: landmark.imageSrc,
narrativeResidues: landmark.narrativeResidues,

View File

@@ -629,6 +629,104 @@ export async function sendCustomWorldAgentMessage(
);
}
export async function streamCustomWorldAgentMessage(
sessionId: string,
payload: SendCustomWorldAgentMessageRequest,
options: TextStreamOptions = {},
) {
const response = await fetchWithApiAuth(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/messages/stream`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, '发送共创消息失败'));
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalSession: CustomWorldAgentSessionSnapshot | null = null;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n');
const eventBlock = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
let eventName = 'message';
const dataLines: string[] = [];
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.startsWith('event:')) {
eventName = line.slice(6).trim() || 'message';
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
if (dataLines.length === 0) {
continue;
}
const data = dataLines.join('\n');
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(data) as Record<string, unknown>;
} catch {
parsed = null;
}
if (eventName === 'reply_delta' && parsed) {
const text = parsed.text;
if (typeof text === 'string') {
options.onUpdate?.(text);
}
continue;
}
if (eventName === 'session' && parsed?.session) {
finalSession = parsed.session as CustomWorldAgentSessionSnapshot;
continue;
}
if (eventName === 'error' && parsed) {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: '发送共创消息失败';
throw new Error(message);
}
}
}
if (!finalSession) {
throw new Error('共创消息流式结果不完整');
}
return finalSession;
}
export async function executeCustomWorldAgentAction(
sessionId: string,
payload: CustomWorldAgentActionRequest,

View File

@@ -1,3 +1,4 @@
import type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
@@ -93,6 +94,9 @@ export interface CustomWorldGenerationRoleOutline {
title: string;
role: string;
description: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
initialAffinity: number;
relationshipHooks: string[];
tags: string[];
@@ -107,6 +111,7 @@ export interface CustomWorldGenerationLandmarkConnectionOutline {
export interface CustomWorldGenerationLandmarkOutline {
name: string;
description: string;
visualDescription?: string;
dangerLevel: string;
sceneNpcNames: string[];
connections: CustomWorldGenerationLandmarkConnectionOutline[];
@@ -148,6 +153,12 @@ function toFiniteInteger(value: unknown) {
: undefined;
}
function toFiniteNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? (value.filter((item) => item && typeof item === 'object') as Array<
@@ -198,6 +209,11 @@ function normalizeGeneratedAnimationConfig(
const extension = toText(item.extension);
const file = toText(item.file);
const basePath = toText(item.basePath);
const frameWidth = toFiniteInteger(item.frameWidth);
const frameHeight = toFiniteInteger(item.frameHeight);
const fps = toFiniteNumber(item.fps);
const loop = typeof item.loop === 'boolean' ? item.loop : undefined;
const previewVideoPath = toText(item.previewVideoPath);
return {
folder,
@@ -207,6 +223,11 @@ function normalizeGeneratedAnimationConfig(
...(extension ? { extension } : {}),
...(file ? { file } : {}),
...(basePath ? { basePath } : {}),
...(frameWidth ? { frameWidth: Math.max(1, frameWidth) } : {}),
...(frameHeight ? { frameHeight: Math.max(1, frameHeight) } : {}),
...(fps ? { fps: Math.max(1, fps) } : {}),
...(typeof loop === 'boolean' ? { loop } : {}),
...(previewVideoPath ? { previewVideoPath } : {}),
};
}
@@ -762,6 +783,9 @@ export function buildCustomWorldRawProfileFromFramework(
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
@@ -771,6 +795,9 @@ export function buildCustomWorldRawProfileFromFramework(
title: npc.title,
role: npc.role,
description: npc.description,
visualDescription: npc.visualDescription,
actionDescription: npc.actionDescription,
sceneVisualDescription: npc.sceneVisualDescription,
initialAffinity: npc.initialAffinity,
relationshipHooks: [...npc.relationshipHooks],
tags: [...npc.tags],
@@ -778,6 +805,7 @@ export function buildCustomWorldRawProfileFromFramework(
landmarks: framework.landmarks.map((landmark) => ({
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel,
sceneNpcNames: [...landmark.sceneNpcNames],
connections: landmark.connections.map((connection) => ({
@@ -812,6 +840,9 @@ function normalizeRoleProfile(
title,
role,
description: toText(item.description),
visualDescription: toText(item.visualDescription),
actionDescription: toText(item.actionDescription),
sceneVisualDescription: toText(item.sceneVisualDescription),
backstory: toText(item.backstory),
personality: toText(item.personality),
motivation: toText(item.motivation) || toText(item.description),
@@ -923,6 +954,9 @@ function normalizeRoleOutlineList(
description:
toText(item.description) ||
truncateText(`${name || title}在世界中以${role}身份活动。`, 36),
visualDescription: toText(item.visualDescription) || undefined,
actionDescription: toText(item.actionDescription) || undefined,
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
initialAffinity: normalizeInitialAffinity(
item.initialAffinity,
options.defaultAffinity,
@@ -973,6 +1007,7 @@ function normalizeLandmarkOutlineList(value: unknown) {
description:
toText(item.description) ||
truncateText(`${name}暗藏新的局势变化。`, 40),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || 'medium',
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
@@ -1033,6 +1068,7 @@ function normalizeLandmarkDraftList(value: unknown) {
id: toText(item.id) || createEntryId('landmark', name, index),
name,
description: toText(item.description),
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel),
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
@@ -1165,6 +1201,10 @@ export function normalizeCustomWorldProfile(
item.storyGraph && typeof item.storyGraph === 'object'
? (item.storyGraph as WorldStoryGraph)
: null,
anchorContent:
item.anchorContent && typeof item.anchorContent === 'object'
? (item.anchorContent as EightAnchorContent)
: null,
creatorIntent: normalizeCustomWorldCreatorIntent(item.creatorIntent),
anchorPack:
item.anchorPack && typeof item.anchorPack === 'object'

View File

@@ -13,6 +13,31 @@ afterEach(() => {
const session: CustomWorldAgentSessionSnapshot = {
sessionId: 'session-1',
currentTurn: 6,
anchorContent: {
worldPromise: {
hook: '被海雾吞没的旧航路群岛',
differentiator: '灯塔与禁航令共同决定谁能活着穿过去。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家回到群岛调查沉船真相。',
corePursuit: '找出失控航路背后的真相。',
fearOfLoss: '失去最后一个还能对上旧案的人。',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: {
iconicMotifs: ['会移动的海雾'],
institutionsOrArtifacts: ['旧灯塔'],
hardRules: [],
},
},
progressPercent: 100,
lastAssistantReply: '八锚点已经齐备,可以进入游戏设定草稿生成。',
stage: 'object_refining',
focusCardId: null,
creatorIntent: {

View File

@@ -231,6 +231,7 @@ export function buildCustomWorldProfileFromAgentDraft(
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
}
: undefined,
anchorContent: session.anchorContent,
creatorIntent: session.creatorIntent,
anchorPack: session.anchorPack,
lockState: session.lockState,

View File

@@ -22,6 +22,31 @@ const baseOperation: CustomWorldAgentOperationRecord = {
const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'session-1',
currentTurn: 8,
anchorContent: {
worldPromise: {
hook: '海雾、旧灯塔和失控航路交织的边缘群岛',
differentiator: '每次借路都要向海雾付出新的代价。',
desiredExperience: '压抑、悬疑、潮湿',
},
playerFantasy: {
playerRole: '玩家刚回到群岛,准备调查父亲沉船的真相。',
corePursuit: '查清沉船夜和禁航区异动的因果。',
fearOfLoss: '再失去唯一还敢接近真相的人。',
},
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: [],
hiddenLines: null,
iconicElements: {
iconicMotifs: ['会移动的海雾'],
institutionsOrArtifacts: ['旧灯塔'],
hardRules: [],
},
},
progressPercent: 100,
lastAssistantReply: '八锚点已经收束完成,可以进入游戏设定草稿生成。',
stage: 'foundation_review',
focusCardId: null,
creatorIntent: {
@@ -121,11 +146,12 @@ test('builds readable draft setting text from creator intent first', () => {
expect(settingText).toContain('标志元素');
});
test('falls back to latest user message when creator intent is unavailable', () => {
test('falls back to anchor content when creator intent is unavailable', () => {
const settingText = buildAgentDraftFoundationSettingText({
...baseSession,
creatorIntent: null,
});
expect(settingText).toBe('我想做一个被海雾吞没的旧航路世界。');
expect(settingText).toContain('世界承诺');
expect(settingText).toContain('玩家幻想');
});

View File

@@ -1,6 +1,7 @@
import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
EightAnchorContent,
} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldGenerationProgress,
@@ -11,6 +12,187 @@ import {
normalizeCustomWorldCreatorIntent,
} from './customWorldCreatorIntent';
export type CustomWorldStructuredAnchorEntry = {
id: string;
label: string;
value: string;
};
function joinText(items: Array<string | null | undefined>) {
return items.filter(Boolean).join('');
}
function buildEightAnchorFoundationText(anchorContent: EightAnchorContent) {
return [
anchorContent.worldPromise
? `世界承诺:${joinText([
anchorContent.worldPromise.hook,
anchorContent.worldPromise.differentiator,
anchorContent.worldPromise.desiredExperience,
])}`
: '',
anchorContent.playerFantasy
? `玩家幻想:${joinText([
anchorContent.playerFantasy.playerRole,
anchorContent.playerFantasy.corePursuit,
anchorContent.playerFantasy.fearOfLoss,
])}`
: '',
anchorContent.themeBoundary
? `主题边界:${joinText([
anchorContent.themeBoundary.toneKeywords.join('、'),
anchorContent.themeBoundary.aestheticDirectives.join('、'),
anchorContent.themeBoundary.forbiddenDirectives.join('、'),
])}`
: '',
anchorContent.playerEntryPoint
? `玩家切入口:${joinText([
anchorContent.playerEntryPoint.openingIdentity,
anchorContent.playerEntryPoint.openingProblem,
anchorContent.playerEntryPoint.entryMotivation,
])}`
: '',
anchorContent.coreConflict
? `核心冲突:${joinText([
anchorContent.coreConflict.surfaceConflicts.join('、'),
anchorContent.coreConflict.hiddenCrisis,
anchorContent.coreConflict.firstTouchedConflict,
])}`
: '',
anchorContent.keyRelationships.length > 0
? `关键关系:${anchorContent.keyRelationships
.map((entry) =>
joinText([entry.pairs, entry.relationshipType, entry.secretOrCost]),
)
.join('')}`
: '',
anchorContent.hiddenLines
? `暗线与揭示:${joinText([
anchorContent.hiddenLines.hiddenTruths.join('、'),
anchorContent.hiddenLines.misdirectionHints.join('、'),
anchorContent.hiddenLines.revealPacing,
])}`
: '',
anchorContent.iconicElements
? `标志元素:${joinText([
anchorContent.iconicElements.iconicMotifs.join('、'),
anchorContent.iconicElements.institutionsOrArtifacts.join('、'),
anchorContent.iconicElements.hardRules.join('、'),
])}`
: '',
]
.filter(Boolean)
.join('\n');
}
export function buildAgentDraftFoundationAnchorEntries(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!session) {
return [];
}
const anchorContent = session.anchorContent;
return [
{
id: 'world-promise',
label: '世界承诺',
value: anchorContent.worldPromise
? joinText([
anchorContent.worldPromise.hook,
anchorContent.worldPromise.differentiator,
anchorContent.worldPromise.desiredExperience,
])
: '',
},
{
id: 'player-fantasy',
label: '玩家幻想',
value: anchorContent.playerFantasy
? joinText([
anchorContent.playerFantasy.playerRole,
anchorContent.playerFantasy.corePursuit,
anchorContent.playerFantasy.fearOfLoss,
])
: '',
},
{
id: 'theme-boundary',
label: '主题边界',
value: anchorContent.themeBoundary
? joinText([
anchorContent.themeBoundary.toneKeywords.join('、'),
anchorContent.themeBoundary.aestheticDirectives.join('、'),
anchorContent.themeBoundary.forbiddenDirectives.length > 0
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
: '',
])
: '',
},
{
id: 'player-entry-point',
label: '玩家切入口',
value: anchorContent.playerEntryPoint
? joinText([
anchorContent.playerEntryPoint.openingIdentity,
anchorContent.playerEntryPoint.openingProblem,
anchorContent.playerEntryPoint.entryMotivation,
])
: '',
},
{
id: 'core-conflict',
label: '核心冲突',
value: anchorContent.coreConflict
? joinText([
anchorContent.coreConflict.surfaceConflicts.join('、'),
anchorContent.coreConflict.hiddenCrisis,
anchorContent.coreConflict.firstTouchedConflict,
])
: '',
},
{
id: 'key-relationships',
label: '关键关系',
value:
anchorContent.keyRelationships.length > 0
? anchorContent.keyRelationships
.map((entry) =>
joinText([
entry.pairs,
entry.relationshipType,
entry.secretOrCost ? `代价/秘密:${entry.secretOrCost}` : '',
]),
)
.join('\n')
: '',
},
{
id: 'hidden-lines',
label: '暗线与揭示',
value: anchorContent.hiddenLines
? joinText([
anchorContent.hiddenLines.hiddenTruths.join('、'),
anchorContent.hiddenLines.misdirectionHints.join('、'),
anchorContent.hiddenLines.revealPacing,
])
: '',
},
{
id: 'iconic-elements',
label: '标志元素',
value: anchorContent.iconicElements
? joinText([
anchorContent.iconicElements.iconicMotifs.join('、'),
anchorContent.iconicElements.institutionsOrArtifacts.join('、'),
anchorContent.iconicElements.hardRules.join('、'),
])
: '',
},
].filter((entry) => entry.value.trim());
}
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
{
id: 'queue',
@@ -192,5 +374,11 @@ export function buildAgentDraftFoundationSettingText(
.reverse()
.find((message) => message.role === 'user' && message.text.trim());
return latestUserMessage?.text.trim() ?? '正在整理当前共创设定。';
const anchorSettingText = buildEightAnchorFoundationText(session.anchorContent);
return (
anchorSettingText ||
latestUserMessage?.text.trim() ||
'正在整理当前共创设定。'
);
}

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
import {
clearProfileBrowseHistory,
listProfileBrowseHistory,
syncProfileBrowseHistory,
upsertProfileBrowseHistory,
} from './storageService';
vi.mock('./apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('storageService browse history routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ entries: [] });
});
it('reads browse history from the runtime profile route', async () => {
await listProfileBrowseHistory();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({ method: 'GET' }),
'读取浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
});
it('writes browse history through the runtime profile route', async () => {
await upsertProfileBrowseHistory({
ownerUserId: 'user-1',
profileId: 'profile-1',
worldName: '测试世界',
subtitle: '测试副标题',
summaryText: '测试摘要',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试作者',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'写入浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
it('syncs browse history through the runtime profile route', async () => {
await syncProfileBrowseHistory([
{
ownerUserId: 'user-1',
profileId: 'profile-1',
worldName: '测试世界',
subtitle: '测试副标题',
summaryText: '测试摘要',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试作者',
},
]);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'同步浏览历史失败',
expect.any(Object),
);
});
it('clears browse history through the runtime profile route', async () => {
await clearProfileBrowseHistory();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({ method: 'DELETE' }),
'清空浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});

View File

@@ -61,28 +61,6 @@ function requestRuntimeJson<T>(
);
}
function requestProfileJson<T>(
path: string,
init: RequestInit,
fallbackMessage: string,
options: RuntimeRequestOptions = {},
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry =
options.retry ??
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
return requestJson<T>(
`/api/profile${path}`,
{
...init,
signal: options.signal,
},
fallbackMessage,
{ retry },
);
}
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
'/save/snapshot',
@@ -315,8 +293,8 @@ export async function getCustomWorldGalleryDetail(
export async function listProfileBrowseHistory(
options: RuntimeRequestOptions = {},
) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
'/browse-history',
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/profile/browse-history',
{ method: 'GET' },
'读取浏览历史失败',
options,
@@ -329,8 +307,8 @@ export async function upsertProfileBrowseHistory(
entry: PlatformBrowseHistoryWriteEntry,
options: RuntimeRequestOptions = {},
) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
'/browse-history',
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/profile/browse-history',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -347,8 +325,8 @@ export async function syncProfileBrowseHistory(
entries: PlatformBrowseHistoryWriteEntry[],
options: RuntimeRequestOptions = {},
) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
'/browse-history',
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/profile/browse-history',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -366,8 +344,8 @@ export async function syncProfileBrowseHistory(
export async function clearProfileBrowseHistory(
options: RuntimeRequestOptions = {},
) {
const response = await requestProfileJson<PlatformBrowseHistoryResponse>(
'/browse-history',
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
'/profile/browse-history',
{ method: 'DELETE' },
'清空浏览历史失败',
options,

View File

@@ -405,6 +405,7 @@ export default function QwenSpriteSheetTool() {
setSheetStatus(null);
try {
if (actionGenerationMode === 'image-to-video') {
const isLoopAction = actionTemplate.loop;
const result = await generateCharacterAnimationDraft({
characterId: assetKey || 'qwen-sprite-tool',
strategy: 'image-to-video',
@@ -413,20 +414,21 @@ export default function QwenSpriteSheetTool() {
visualSource: selectedMasterSource,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
lastFrameImageDataUrl: isLoopAction ? undefined : selectedMasterSource,
frameCount: 16,
fps: actionTemplate.defaultFps,
durationSeconds: FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS,
loop: actionTemplate.loop,
useChromaKey,
resolution: FIXED_IMAGE_TO_VIDEO_RESOLUTION,
resolution: isLoopAction ? '720P' : FIXED_IMAGE_TO_VIDEO_RESOLUTION,
imageSequenceModel: 'wan2.7-image-pro',
videoModel: FIXED_IMAGE_TO_VIDEO_MODEL,
videoModel: isLoopAction ? 'wan2.6-i2v-flash' : FIXED_IMAGE_TO_VIDEO_MODEL,
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
});
if (result.strategy !== 'image-to-video') {
throw new Error('图生视频接口返回了非预期结果。');
throw new Error('动作生成接口返回了非预期结果。');
}
const clip = await buildAnimationClipFromVideoSource(
@@ -439,6 +441,8 @@ export default function QwenSpriteSheetTool() {
frameWidth: GENERATED_FRAME_WIDTH,
frameHeight: GENERATED_FRAME_HEIGHT,
applyChromaKey: useChromaKey,
sampleStartRatio: actionTemplate.loop ? 0.12 : 0,
sampleEndRatio: actionTemplate.loop ? 0.94 : 1,
},
);
const composedSheet = await composeSpriteSheetFromFrames(clip.frames, {

View File

@@ -40,6 +40,11 @@ export interface CharacterAnimationConfig {
extension?: string;
file?: string;
basePath?: string;
frameWidth?: number;
frameHeight?: number;
fps?: number;
loop?: boolean;
previewVideoPath?: string;
}
export type SkillStyle = 'burst' | 'steady' | 'mobility' | 'finisher' | 'projectile';

View File

@@ -1,3 +1,4 @@
import type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent';
import type {
ItemAttributeResonance,
RoleAttributeProfile,
@@ -219,6 +220,9 @@ export interface CustomWorldRoleProfile {
title: string;
role: string;
description: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
backstory: string;
personality: string;
motivation: string;
@@ -318,6 +322,7 @@ export interface CustomWorldLandmark {
id: string;
name: string;
description: string;
visualDescription?: string;
dangerLevel: string;
imageSrc?: string;
sceneNpcIds: string[];
@@ -347,6 +352,7 @@ export interface CustomWorldProfile {
storyGraph?: WorldStoryGraph | null;
knowledgeFacts?: KnowledgeFact[] | null;
threadContracts?: ThreadContract[] | null;
anchorContent?: EightAnchorContent | null;
creatorIntent?: CustomWorldCreatorIntent | null;
anchorPack?: CustomWorldAnchorPack | null;
lockState?: CustomWorldLockState | null;