1
This commit is contained in:
@@ -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, '');
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -18,9 +18,8 @@ import {
|
||||
CustomWorldEntityCatalog,
|
||||
type ResultTab,
|
||||
} from './CustomWorldEntityCatalog';
|
||||
import {
|
||||
import CustomWorldEntityEditorModal, {
|
||||
type CustomWorldEditorTarget,
|
||||
CustomWorldEntityEditorModal,
|
||||
} from './CustomWorldEntityEditorModal';
|
||||
|
||||
interface CustomWorldResultViewProps {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('提示词');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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('当前设定已齐备,可以进入下一阶段');
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ export function CustomWorldAgentClarificationPanel({
|
||||
下一阶段
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
最小锚点已齐备,可以进入下一阶段
|
||||
当前设定已齐备,可以进入下一阶段
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ export function CustomWorldAgentDraftDrawer({
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-400">
|
||||
最小锚点齐备后,世界底稿会先从这里长出来。
|
||||
当前设定收束后,世界底稿会先从这里长出来。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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('守灯会与沉船商盟争夺航道解释权');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function CustomWorldAgentQuickActions({
|
||||
))
|
||||
) : !draftAction || !canDraftFoundation ? (
|
||||
<QuickActionButton
|
||||
label={showEntityActions ? '继续精修当前草稿' : '继续补充锚点'}
|
||||
label={showEntityActions ? '继续精修当前草稿' : '继续补充设定'}
|
||||
onClick={() => onFocusSuggestedAction()}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function CustomWorldAgentSummaryPanel({
|
||||
const pendingCount = session.pendingClarifications.length;
|
||||
const { title, summary } = readSummaryText(
|
||||
session.draftProfile,
|
||||
'第一阶段先收住世界锚点,后续阶段再把这里整理成更完整的世界底稿摘要。',
|
||||
'第一阶段先收住世界设定,后续阶段再把这里整理成更完整的世界底稿摘要。',
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('快捷动作');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
105
src/components/custom-world-agent/EightAnchorProgressBar.tsx
Normal file
105
src/components/custom-world-agent/EightAnchorProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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="我的"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user