Integrate role asset studio into custom world agent flow

This commit is contained in:
2026-04-14 20:16:41 +08:00
parent 0981d6ee1b
commit bc2999ffb9
118 changed files with 31211 additions and 1232 deletions

View File

@@ -0,0 +1,131 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { AdventurePanel } from './AdventurePanel';
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../types';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createOption(functionId: string, actionText: string): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
function renderPanel(currentStory: StoryMoment, displayedOptions: StoryOption[]) {
return renderToStaticMarkup(
<AdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={false}
displayedOptions={displayedOptions}
hideOptions={false}
canRefreshOptions={false}
onRefreshOptions={() => undefined}
onChoice={() => undefined}
onOpenCharacter={() => undefined}
onOpenInventory={() => undefined}
playerCharacter={createCharacter()}
worldType={WorldType.WUXIA}
quests={[]}
questUi={{
acknowledgeQuestCompletion: () => undefined,
claimQuestReward: () => null,
}}
goalStack={{
northStarGoal: null,
activeGoal: null,
immediateStepGoal: null,
supportGoals: [],
}}
goalPulse={null}
onDismissGoalPulse={() => undefined}
battleRewardUi={{
reward: null,
dismiss: () => undefined,
}}
playerHp={100}
playerMaxHp={100}
playerMana={20}
playerMaxMana={20}
playerSkillCooldowns={{}}
inBattle={false}
currentNpcBattleMode={null}
statistics={{
playTimeMs: 0,
hostileNpcsDefeated: 0,
questsAccepted: 0,
questsCompleted: 0,
questsTurnedIn: 0,
itemsUsed: 0,
scenesTraveled: 0,
currentSceneName: '竹林古道',
playerCurrency: 0,
inventoryItemCount: 0,
inventoryStackCount: 0,
activeCompanionCount: 0,
rosterCompanionCount: 0,
}}
musicVolume={0.6}
onMusicVolumeChange={() => undefined}
onSaveAndExit={() => undefined}
/>,
);
}
test('adventure panel recognizes story_continue_adventure by function id instead of action text', () => {
const continueOption = createOption('story_continue_adventure', '查看后续');
const currentStory: StoryMoment = {
text: '你们交换完这一轮判断。',
options: [continueOption],
deferredOptions: [createOption('idle_explore_forward', '继续向前探索')],
};
const html = renderPanel(currentStory, [continueOption]);
expect(html).toContain('剧情推理完成,继续后显示新的冒险选项');
});
test('adventure panel does not show deferred hint for non-continue options with the same text', () => {
const misleadingOption = createOption('npc_chat', '查看后续');
const currentStory: StoryMoment = {
text: '你们交换完这一轮判断。',
options: [misleadingOption],
deferredOptions: [createOption('idle_explore_forward', '继续向前探索')],
};
const html = renderPanel(currentStory, [misleadingOption]);
expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项');
});

View File

@@ -14,8 +14,8 @@ interface CustomWorldResultViewProps {
progressLabel: string;
error: string | null;
onBack: () => void;
onEditSetting: () => void;
onRegenerate: () => void;
onEditSetting?: () => void;
onRegenerate?: () => void;
onContinueExpand?: () => void;
onSave: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
@@ -122,7 +122,7 @@ export function CustomWorldResultView({
const createTarget = useMemo(() => getCreateTargetByTab(activeTab), [activeTab]);
const createLabel = useMemo(() => getCreateLabelByTab(activeTab), [activeTab]);
const onRegenerate = () => {
if (isGenerating) return;
if (isGenerating || !triggerRegenerate) return;
const confirmed = window.confirm(
`确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息包括你修改和新增的所有内容。`,
@@ -198,8 +198,12 @@ export function CustomWorldResultView({
</div>
) : null}
<div className="flex items-center justify-end gap-3">
<SmallButton onClick={onEditSetting}></SmallButton>
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
{onEditSetting ? (
<SmallButton onClick={onEditSetting}></SmallButton>
) : null}
{triggerRegenerate ? (
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
) : null}
{profile.generationStatus === 'key_only' && onContinueExpand ? (
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>

View File

@@ -4,13 +4,17 @@ import {
ImagePlus,
RefreshCcw,
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
import {
type ChangeEvent,
type ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type CustomWorldNpc,
type CustomWorldPlayableNpc,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import {
@@ -29,7 +33,23 @@ import {
publishCharacterVisualAsset,
} from './asset-studio/characterAssetWorkflowPersistence';
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
type EditableCustomWorldRole = {
id: string;
name: string;
title: string;
role: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
templateCharacterId?: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
type CustomWorldAiActionConfig = {
animation: AnimationState;
@@ -298,7 +318,7 @@ function buildRoleCharacterBrief(
role.personality ? `角色性格:${role.personality}` : '',
role.motivation ? `角色动机:${role.motivation}` : '',
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
role.tags && role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
templateLabel ? `参考模板:${templateLabel}` : '',
]
.filter(Boolean)
@@ -319,13 +339,35 @@ export function CustomWorldRoleAssetStudioModal({
role,
roleKind,
onApply,
onPublishSuccess,
onClose,
syncBusy = false,
visualPointCost = 20,
animationPointCost = 60,
priorityTier = roleKind === 'playable' ? 'hero' : 'featured',
}: {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
onApply: (nextRole: EditableCustomWorldRole) => void;
onApply?: (nextRole: EditableCustomWorldRole) => void;
onPublishSuccess?: (
payload: {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
options?: {
closeAfterSync?: boolean;
},
) => void;
onClose: () => void;
syncBusy?: boolean;
visualPointCost?: number;
animationPointCost?: number;
priorityTier?: 'hero' | 'featured' | 'supporting';
}) {
const [workingRole, setWorkingRole] = useState<EditableCustomWorldRole>(role);
const [sourceMode, setSourceMode] =
useState<Exclude<CharacterVisualSourceMode, 'upload'>>(
role.imageSrc ? 'image-to-image' : 'text-to-image',
@@ -351,42 +393,66 @@ export function CustomWorldRoleAssetStudioModal({
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
const [isApplyingAnimations, setIsApplyingAnimations] = useState(false);
useEffect(() => {
setWorkingRole(role);
}, [role]);
const selectedTemplate =
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
roleKind === 'playable' && workingRole.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
(character) => character.id === workingRole.templateCharacterId,
) ?? null
: null;
const characterBriefText = useMemo(
() =>
buildRoleCharacterBrief(
role,
workingRole,
selectedTemplate
? `${selectedTemplate.name} / ${selectedTemplate.title}`
: undefined,
),
[role, selectedTemplate],
[workingRole, selectedTemplate],
);
const effectiveReferenceImages =
referenceImageDataUrls.length > 0
? referenceImageDataUrls
: role.imageSrc
? [role.imageSrc]
: workingRole.imageSrc
? [workingRole.imageSrc]
: [];
const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const previewImageSrc =
selectedVisualDraft?.imageSrc ??
role.imageSrc ??
workingRole.imageSrc ??
selectedTemplate?.portrait ??
'';
const selectedActionConfig =
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
CORE_ACTIONS[0];
const appliedActionCount = CORE_ACTIONS.filter(
(item) => role.animationMap?.[item.animation]?.basePath,
(item) =>
Boolean(
(workingRole.animationMap as Record<string, { basePath?: string }> | null)
?.[item.animation]?.basePath,
),
).length;
const visualCandidateCount = priorityTier === 'supporting' ? 1 : 2;
const confirmPointSpend = (params: {
kindLabel: string;
points: number;
description: string;
}) => {
if (typeof window === 'undefined' || typeof window.confirm !== 'function') {
return true;
}
return window.confirm(
`${params.kindLabel}预计消耗 ${params.points} 积分。\n${params.description}`,
);
};
const handleReferenceImageUpload = async (
event: ChangeEvent<HTMLInputElement>,
) => {
@@ -406,6 +472,16 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleGenerateVisuals = async () => {
if (
!confirmPointSpend({
kindLabel: '主图候选生成',
points: visualPointCost,
description: '这次是主图候选抽卡,不是最终发布。',
})
) {
return;
}
setIsGeneratingVisuals(true);
setVisualStatus(null);
@@ -418,12 +494,12 @@ export function CustomWorldRoleAssetStudioModal({
}
const result = await generateCharacterVisualCandidates({
characterId: role.id,
characterId: workingRole.id,
sourceMode,
promptText: visualPromptText,
characterBriefText,
referenceImageDataUrls: effectiveReferenceImages,
candidateCount: 3,
candidateCount: visualCandidateCount,
imageModel: 'wan2.7-image-pro',
size: '1024*1536',
});
@@ -450,7 +526,7 @@ export function CustomWorldRoleAssetStudioModal({
try {
const result = await publishCharacterVisualAsset({
characterId: role.id,
characterId: workingRole.id,
sourceMode,
promptText: visualPromptText,
selectedPreviewSource: selectedVisualDraft.imageSrc,
@@ -460,13 +536,25 @@ export function CustomWorldRoleAssetStudioModal({
updateCharacterOverride: false,
});
onApply(
mergeRole(role, {
imageSrc: result.portraitPath,
const nextRole = mergeRole(workingRole, {
imageSrc: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
});
setWorkingRole(nextRole);
onApply?.(nextRole);
onPublishSuccess?.(
{
roleId: workingRole.id,
portraitPath: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
}),
generatedAnimationSetId: null,
animationMap: null,
},
{
closeAfterSync: false,
},
);
setDraftAnimations({});
setAnimationStatus(null);
@@ -481,18 +569,18 @@ export function CustomWorldRoleAssetStudioModal({
};
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
if (!role.imageSrc || !role.generatedVisualAssetId) {
if (!workingRole.imageSrc || !workingRole.generatedVisualAssetId) {
throw new Error('请先应用主图,再生成动作。');
}
const result = await generateCharacterAnimationDraft({
characterId: role.id,
characterId: workingRole.id,
strategy: 'image-to-video',
animation: config.animation,
promptText: animationPromptText,
characterBriefText,
actionTemplateId: config.templateId,
visualSource: role.imageSrc,
visualSource: workingRole.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: config.frameCount,
@@ -525,6 +613,16 @@ export function CustomWorldRoleAssetStudioModal({
return;
}
if (
!confirmPointSpend({
kindLabel: '动作草稿生成',
points: animationPointCost,
description: '这次是动作草稿试片,不是最终发布。',
})
) {
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
@@ -545,6 +643,16 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleGenerateAllAnimations = async () => {
if (
!confirmPointSpend({
kindLabel: '核心动作生成',
points: animationPointCost,
description: '这次会生成核心动作草稿,发布前仍可继续调整。',
})
) {
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
@@ -570,7 +678,7 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleApplyAnimations = async () => {
if (!role.generatedVisualAssetId) {
if (!workingRole.generatedVisualAssetId) {
setAnimationStatus('请先应用主图,再应用动作。');
return;
}
@@ -601,22 +709,37 @@ export function CustomWorldRoleAssetStudioModal({
]),
);
const result = await publishCharacterAnimationAssets({
characterId: role.id,
visualAssetId: role.generatedVisualAssetId,
characterId: workingRole.id,
visualAssetId: workingRole.generatedVisualAssetId,
animations: payload,
updateCharacterOverride: false,
});
onApply(
mergeRole(role, {
const nextRole = mergeRole(workingRole, {
generatedAnimationSetId: result.animationSetId,
animationMap: {
...(role.animationMap ?? {}),
...((workingRole.animationMap ?? {}) as Record<string, unknown>),
...(result.animationMap as NonNullable<
EditableCustomWorldRole['animationMap']
>),
},
}),
});
setWorkingRole(nextRole);
onApply?.(nextRole);
onPublishSuccess?.(
{
roleId: workingRole.id,
portraitPath: workingRole.imageSrc ?? previewImageSrc,
generatedVisualAssetId: workingRole.generatedVisualAssetId ?? '',
generatedAnimationSetId: result.animationSetId,
animationMap: (nextRole.animationMap ?? null) as Record<
string,
unknown
> | null,
},
{
closeAfterSync: true,
},
);
setAnimationStatus('核心动作已应用到当前角色。');
} catch (error) {
@@ -637,7 +760,8 @@ export function CustomWorldRoleAssetStudioModal({
isGeneratingVisuals ||
isApplyingVisual ||
isGeneratingAnimations ||
isApplyingAnimations
isApplyingAnimations ||
syncBusy
}
>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.05fr)_minmax(22rem,0.95fr)]">
@@ -695,18 +819,21 @@ export function CustomWorldRoleAssetStudioModal({
</div>
</Field>
<div className="flex flex-wrap gap-3">
<StatusBadge tone="amber">
{visualPointCost}
</StatusBadge>
<ActionButton
icon={<ImagePlus className="h-4 w-4" />}
label={isGeneratingVisuals ? '生成中...' : '生成主图候选'}
onClick={() => void handleGenerateVisuals()}
disabled={isGeneratingVisuals}
disabled={isGeneratingVisuals || syncBusy}
tone="sky"
/>
<ActionButton
icon={<CheckCircle2 className="h-4 w-4" />}
label={isApplyingVisual ? '应用中...' : '应用主图'}
onClick={() => void handleApplyVisual()}
disabled={isApplyingVisual || !selectedVisualDraft}
disabled={isApplyingVisual || !selectedVisualDraft || syncBusy}
tone="green"
/>
</div>
@@ -723,7 +850,7 @@ export function CustomWorldRoleAssetStudioModal({
{previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
alt={workingRole.name}
className="max-h-[28rem] w-full object-contain"
/>
) : selectedTemplate ? (
@@ -811,7 +938,9 @@ export function CustomWorldRoleAssetStudioModal({
: `生成${selectedActionConfig?.label ?? '当前'}动作`
}
onClick={() => void handleGenerateSingleAnimation()}
disabled={isGeneratingAnimations || !role.imageSrc}
disabled={
isGeneratingAnimations || !workingRole.imageSrc || syncBusy
}
tone="sky"
/>
<ActionButton
@@ -820,16 +949,21 @@ export function CustomWorldRoleAssetStudioModal({
isGeneratingAnimations ? '生成中...' : '生成核心动作'
}
onClick={() => void handleGenerateAllAnimations()}
disabled={isGeneratingAnimations || !role.imageSrc}
disabled={
isGeneratingAnimations || !workingRole.imageSrc || syncBusy
}
/>
<ActionButton
icon={<CheckCircle2 className="h-4 w-4" />}
label={isApplyingAnimations ? '应用中...' : '应用动作'}
onClick={() => void handleApplyAnimations()}
disabled={isApplyingAnimations}
disabled={isApplyingAnimations || syncBusy}
tone="green"
/>
</div>
<div className="text-xs text-zinc-500">
稿 {animationPointCost}
</div>
{animationStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{animationStatus}
@@ -843,7 +977,7 @@ export function CustomWorldRoleAssetStudioModal({
<StatusBadge tone="amber">
{appliedActionCount}/{CORE_ACTIONS.length}
</StatusBadge>
{role.generatedVisualAssetId ? (
{workingRole.generatedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
@@ -853,7 +987,12 @@ export function CustomWorldRoleAssetStudioModal({
{CORE_ACTIONS.map((item) => {
const hasDraft = Boolean(draftAnimations[item.animation]);
const isApplied = Boolean(
role.animationMap?.[item.animation]?.basePath,
(
workingRole.animationMap as Record<
string,
{ basePath?: string }
> | null
)?.[item.animation]?.basePath,
);
return (
<div
@@ -896,17 +1035,22 @@ export function CustomWorldRoleAssetStudioModal({
state={selectedAnimation}
character={{
...selectedTemplate,
id: role.id,
name: role.name,
title: role.title,
portrait: role.imageSrc || selectedTemplate.portrait,
generatedVisualAssetId: role.generatedVisualAssetId,
id: workingRole.id,
name: workingRole.name,
title: workingRole.title,
portrait:
workingRole.imageSrc || selectedTemplate.portrait,
generatedVisualAssetId:
workingRole.generatedVisualAssetId ?? undefined,
generatedAnimationSetId:
role.generatedAnimationSetId,
animationMap: role.animationMap
workingRole.generatedAnimationSetId ?? undefined,
animationMap: workingRole.animationMap
? {
...(selectedTemplate.animationMap ?? {}),
...role.animationMap,
...(workingRole.animationMap as Record<
string,
unknown
>),
}
: selectedTemplate.animationMap,
}}
@@ -916,7 +1060,7 @@ export function CustomWorldRoleAssetStudioModal({
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
alt={workingRole.name}
className="max-h-[16rem] w-full object-contain"
/>
) : (
@@ -934,14 +1078,20 @@ export function CustomWorldRoleAssetStudioModal({
<div className="space-y-5">
<Section title="当前角色档案">
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="text-lg font-semibold text-white">{role.name}</div>
<div className="text-lg font-semibold text-white">
{workingRole.name}
</div>
<div className="mt-1 text-sm text-zinc-400">
{role.title} / {role.role}
{workingRole.title} / {workingRole.role}
</div>
<div className="mt-4 space-y-3 text-sm leading-7 text-zinc-300">
{role.description ? <div>{role.description}</div> : null}
{role.combatStyle ? <div>{role.combatStyle}</div> : null}
{role.tags.length > 0 ? <div>{role.tags.join('、')}</div> : null}
{workingRole.description ? <div>{workingRole.description}</div> : null}
{workingRole.combatStyle ? (
<div>{workingRole.combatStyle}</div>
) : null}
{workingRole.tags && workingRole.tags.length > 0 ? (
<div>{workingRole.tags.join('、')}</div>
) : null}
</div>
</div>
<Field label="自动提示词依据">
@@ -959,7 +1109,7 @@ export function CustomWorldRoleAssetStudioModal({
<div className="grid gap-3">
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm text-zinc-200"></div>
{role.generatedVisualAssetId ? (
{workingRole.generatedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
@@ -976,7 +1126,11 @@ export function CustomWorldRoleAssetStudioModal({
)}
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-500">
{priorityTier === 'hero'
? '主角级'
: priorityTier === 'featured'
? '重点角色'
: '支撑角色'}
</div>
</div>
</Section>

View File

@@ -47,7 +47,7 @@ type AuthStatus =
const allowDevGuestAutoAuth =
import.meta.env.DEV &&
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST !== 'false';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');

View File

@@ -0,0 +1,46 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
test('clarification panel shows pending questions and ready state', () => {
const pendingHtml = renderToStaticMarkup(
<CustomWorldAgentClarificationPanel
readiness={{
isReady: false,
completedKeys: ['world_hook'],
missingKeys: ['player_premise', 'core_conflict'],
}}
pendingClarifications={[
{
id: 'player_premise',
label: '玩家身份与开局',
question: '玩家是谁,故事开场时卡在什么处境里?',
targetKey: 'player_premise',
priority: 2,
},
]}
/>,
);
const readyHtml = renderToStaticMarkup(
<CustomWorldAgentClarificationPanel
readiness={{
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
}}
pendingClarifications={[]}
/>,
);
expect(pendingHtml).toContain('待补充问题');
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
expect(readyHtml).toContain('最小锚点已齐备,可以进入下一阶段');
});

View File

@@ -0,0 +1,64 @@
import type {
CreatorIntentReadiness,
CustomWorldPendingClarification,
} from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentClarificationPanelProps = {
pendingClarifications: CustomWorldPendingClarification[];
readiness: CreatorIntentReadiness;
};
export function CustomWorldAgentClarificationPanel({
pendingClarifications,
readiness,
}: CustomWorldAgentClarificationPanelProps) {
if (readiness.isReady) {
return (
<section className="rounded-[1.5rem] border border-emerald-300/18 bg-emerald-500/8 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-emerald-100/80">
</div>
<div className="mt-2 text-lg font-semibold text-white">
</div>
</section>
);
}
return (
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<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">
1 3
</div>
</div>
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
{pendingClarifications.length}
</span>
</div>
<div className="mt-4 space-y-2">
{pendingClarifications.slice(0, 3).map((item, index) => (
<div
key={item.id}
className="rounded-[1.15rem] border border-white/8 bg-white/5 px-3 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-white">
{index + 1}. {item.label}
</div>
<div className="text-[11px] text-zinc-500">P{item.priority}</div>
</div>
<div className="mt-2 text-sm leading-6 text-zinc-300">
{item.question}
</div>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,102 @@
import type { RefObject } from 'react';
import { useState } from 'react';
import type { SendCustomWorldAgentMessageRequest } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentComposerProps = {
disabled: boolean;
onSubmit: (payload: SendCustomWorldAgentMessageRequest) => void;
textareaRef?: RefObject<HTMLTextAreaElement | null>;
onSummaryClick?: () => void;
onAutoCompleteClick?: () => void;
showAutoComplete?: boolean;
};
function createClientMessageId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `client-message-${Date.now()}`;
}
export function CustomWorldAgentComposer({
disabled,
onSubmit,
textareaRef,
onSummaryClick,
onAutoCompleteClick,
showAutoComplete = true,
}: CustomWorldAgentComposerProps) {
const [text, setText] = useState('');
const submit = () => {
const nextText = text.trim();
if (!nextText || disabled) {
return;
}
onSubmit({
clientMessageId: createClientMessageId(),
text: nextText,
focusCardId: null,
selectedCardIds: [],
});
setText('');
};
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>
<textarea
ref={textareaRef}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
rows={2}
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"
/>
<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>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test } from 'vitest';
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
const CHARACTER_DETAIL: CustomWorldDraftCardDetail = {
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'publicMask',
label: '外显身份',
value: '守灯会里最熟悉旧航道的人。',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'publicMask', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
};
function DetailInteractionHarness() {
const [editMode, setEditMode] = useState(false);
const [generateMode, setGenerateMode] = useState<'character' | 'landmark' | null>(
null,
);
const [savedPayload, setSavedPayload] = useState<string>('');
return (
<>
<CustomWorldAgentDraftDetailPanel
detail={CHARACTER_DETAIL}
loading={false}
editMode={editMode}
onClose={() => {}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
setSavedPayload(JSON.stringify(sections));
setEditMode(false);
}}
onGenerateCharacter={() => {
setGenerateMode('character');
}}
onGenerateLandmark={() => {
setGenerateMode('landmark');
}}
onOpenRoleAssetStudio={() => {}}
/>
<CustomWorldGenerateEntityModal
open={generateMode !== null}
mode={generateMode ?? 'character'}
anchorCardTitle={CHARACTER_DETAIL.title}
onClose={() => {
setGenerateMode(null);
}}
onSubmit={() => {
setGenerateMode(null);
}}
/>
<div data-testid="saved-payload">{savedPayload}</div>
</>
);
}
test('draft detail panel supports edit save and opening generate modals', async () => {
const user = userEvent.setup();
render(<DetailInteractionHarness />);
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: '保存' }));
expect(screen.getByTestId('saved-payload').textContent).toContain(
'他像旧友,也像最早知道航道秘密的人。',
);
await user.click(screen.getByRole('button', { name: '新增角色' }));
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
expect(screen.getByText('当前参考卡')).toBeTruthy();
const closeButtons = screen.getAllByRole('button', { name: '关闭' });
await user.click(closeButtons[closeButtons.length - 1]!);
expect(screen.getByRole('button', { name: '角色资产' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '新增场景' }));
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
});

View File

@@ -0,0 +1,45 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
test('draft detail panel renders sections and warnings', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'thread-1',
kind: 'thread',
title: '谁掌握航道解释权',
sections: [
{
id: 'thread-type',
label: '线程类型',
value: '明线',
},
{
id: 'thread-conflict',
label: '冲突内容',
value: '守灯会与沉船商盟正在争夺航道解释权。',
},
],
linkedIds: ['character-1', 'landmark-1'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary', 'conflictType', 'stakes'],
warningMessages: ['这条线还缺少更明确的地点挂点。'],
}}
loading={false}
onClose={() => {}}
onStartEdit={() => {}}
onGenerateCharacter={() => {}}
onGenerateLandmark={() => {}}
/>,
);
expect(html).toContain('谁掌握航道解释权');
expect(html).toContain('线程类型');
expect(html).toContain('守灯会与沉船商盟');
expect(html).toContain('继续精修');
expect(html).toContain('编辑设定');
expect(html).toContain('新增角色');
});

View File

@@ -0,0 +1,204 @@
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel';
type CustomWorldAgentDraftDetailPanelProps = {
detail: CustomWorldDraftCardDetail | null;
loading: boolean;
busy?: boolean;
editMode?: boolean;
onClose: () => void;
onStartEdit?: () => void;
onCancelEdit?: () => void;
onSave?: (
sections: Array<{
sectionId: string;
value: string;
}>,
) => void;
onGenerateCharacter?: () => void;
onGenerateLandmark?: () => void;
onOpenRoleAssetStudio?: () => void;
};
function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) {
if (kind === 'world') return '世界总卡';
if (kind === 'camp') return '营地';
if (kind === 'faction') return '势力';
if (kind === 'character') return '角色';
if (kind === 'landmark') return '地点';
if (kind === 'thread') return '线程';
if (kind === 'chapter') return '第一幕';
return '草稿卡';
}
function ActionButton(props: {
label: string;
onClick?: () => void;
disabled?: boolean;
tone?: 'default' | 'sky';
}) {
const { label, onClick, disabled = false, tone = 'default' } = props;
if (!onClick) {
return null;
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-3 py-1.5 text-[11px] transition ${
tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
} disabled:cursor-not-allowed disabled:opacity-45`}
>
{label}
</button>
);
}
export function CustomWorldAgentDraftDetailPanel({
detail,
loading,
busy = false,
editMode = false,
onClose,
onStartEdit,
onCancelEdit,
onSave,
onGenerateCharacter,
onGenerateLandmark,
onOpenRoleAssetStudio,
}: CustomWorldAgentDraftDetailPanelProps) {
return (
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="flex items-start 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">
{loading ? '正在读取' : detail?.title || '选择一张草稿卡'}
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white"
>
</button>
</div>
{loading ? (
<div className="mt-4 rounded-[1.15rem] border border-white/8 bg-white/5 px-4 py-5 text-sm leading-7 text-zinc-300">
</div>
) : detail ? (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
{resolveKindLabel(detail.kind)}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
{detail.linkedIds.length}
</span>
{detail.editable ? (
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
</span>
) : null}
{detail.kind === 'character' && detail.assetStatusLabel ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
{detail.assetStatusLabel}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{!editMode && detail.editable ? (
<ActionButton
label="编辑设定"
onClick={onStartEdit}
disabled={busy}
/>
) : null}
{!editMode && detail.kind === 'character' ? (
<ActionButton
label="角色资产"
onClick={onOpenRoleAssetStudio}
disabled={busy}
tone="sky"
/>
) : null}
{!editMode ? (
<>
<ActionButton
label="新增角色"
onClick={onGenerateCharacter}
disabled={busy}
tone="sky"
/>
<ActionButton
label="新增场景"
onClick={onGenerateLandmark}
disabled={busy}
tone="sky"
/>
</>
) : null}
</div>
{editMode && onSave && onCancelEdit ? (
<CustomWorldDraftEditPanel
detail={detail}
disabled={busy}
onSave={onSave}
onCancel={onCancelEdit}
/>
) : (
<div className="space-y-2">
{detail.sections.map((section) => (
<div
key={section.id}
className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
>
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{section.label}
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-100">
{section.value}
</div>
</div>
))}
</div>
)}
{detail.warningMessages.length > 0 ? (
<div className="rounded-[1.15rem] border border-amber-300/20 bg-amber-500/10 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-amber-100">
</div>
<div className="mt-3 space-y-2">
{detail.warningMessages.map((message, index) => (
<div
key={`${detail.id}-warning-${index}`}
className="text-sm leading-7 text-amber-50"
>
{message}
</div>
))}
</div>
</div>
) : null}
</div>
) : (
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm leading-7 text-zinc-400">
稿稿
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,115 @@
import type { CustomWorldDraftCardSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentDraftDrawerProps = {
draftCards: CustomWorldDraftCardSummary[];
activeCardId?: string | null;
onSelectCard: (cardId: string) => void;
};
const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
'world',
'chapter',
'thread',
'faction',
'character',
'landmark',
'camp',
];
function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) {
if (kind === 'world') return '世界总卡';
if (kind === 'chapter') return '第一幕';
if (kind === 'thread') return '世界线程';
if (kind === 'faction') return '势力';
if (kind === 'character') return '关键角色';
if (kind === 'landmark') return '关键地点';
if (kind === 'camp') return '营地';
return '草稿卡';
}
export function CustomWorldAgentDraftDrawer({
draftCards,
activeCardId,
onSelectCard,
}: CustomWorldAgentDraftDrawerProps) {
const groupedCards = DRAWER_KIND_ORDER.map((kind) => ({
kind,
items: draftCards.filter((card) => card.kind === kind),
})).filter((group) => group.items.length > 0);
return (
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
稿
</div>
{groupedCards.length > 0 ? (
<div className="mt-3 space-y-4">
{groupedCards.map((group) => (
<section key={group.kind}>
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] tracking-[0.18em] text-zinc-400">
{resolveGroupLabel(group.kind)}
</div>
<div className="text-[11px] text-zinc-500">
{group.items.length}
</div>
</div>
<div className="mt-2 space-y-2">
{group.items.map((card) => {
const isActive = activeCardId === card.id;
return (
<button
key={card.id}
type="button"
onClick={() => onSelectCard(card.id)}
className={`w-full rounded-[1.2rem] border px-3 py-3 text-left transition ${
isActive
? 'border-sky-300/30 bg-sky-500/10'
: 'border-white/8 bg-white/5 hover:border-white/14'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-white">
{card.title}
</div>
{card.warningCount > 0 ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
{card.warningCount}
</span>
) : null}
</div>
<div className="mt-1 text-[11px] text-zinc-400">
{card.subtitle}
</div>
<div className="mt-2 text-sm leading-6 text-zinc-300">
{card.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
{card.linkedIds.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
{card.status === 'warning' ? '待精修' : '建议稿'}
</span>
{card.kind === 'character' && card.assetStatusLabel ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
{card.assetStatusLabel}
</span>
) : null}
</div>
</button>
);
})}
</div>
</section>
))}
</div>
) : (
<div className="mt-3 text-sm leading-7 text-zinc-400">
稿
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
type CustomWorldAgentHeaderProps = {
onBack: () => void;
};
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">
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:text-white"
>
</button>
<div className="text-sm font-semibold text-white">Agent</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentIntentSummaryPanel } from './CustomWorldAgentIntentSummaryPanel';
test('intent summary panel shows collected custom world anchors', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentIntentSummaryPanel
creatorIntent={{
sourceMode: 'freeform',
rawSettingText: '',
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: ['海岛'],
toneDirectives: ['冷峻'],
playerPremise: '玩家是失职返乡的守灯人。',
openingSituation: '开局站在即将熄灭的旧灯塔上。',
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
keyCharacters: [],
iconicElements: ['潮雾钟声'],
}}
readiness={{
isReady: false,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'iconic_element',
],
missingKeys: ['relationship_seed'],
}}
/>,
);
expect(html).toContain('已收集锚点');
expect(html).toContain('世界一句话');
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('5/6');
});

View File

@@ -0,0 +1,99 @@
import type { CreatorIntentReadiness } from '../../../packages/shared/src/contracts/customWorldAgent';
import {
evaluateCustomWorldCreatorIntentReadiness,
hasMeaningfulCustomWorldCreatorIntent,
normalizeCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
type CustomWorldAgentIntentSummaryPanelProps = {
creatorIntent: Record<string, unknown> | null;
readiness: CreatorIntentReadiness;
};
export function CustomWorldAgentIntentSummaryPanel({
creatorIntent,
readiness,
}: CustomWorldAgentIntentSummaryPanelProps) {
const intent = normalizeCustomWorldCreatorIntent(creatorIntent);
const resolvedReadiness =
readiness ?? evaluateCustomWorldCreatorIntentReadiness(intent);
const items = [
{
label: '世界一句话',
value: intent?.worldHook || '',
ready: resolvedReadiness.completedKeys.includes('world_hook'),
},
{
label: '玩家身份',
value: intent?.playerPremise || '',
ready: Boolean(intent?.playerPremise),
},
{
label: '开局处境',
value: intent?.openingSituation || '',
ready: Boolean(intent?.openingSituation),
},
{
label: '核心冲突',
value: intent?.coreConflicts.join('、') || '',
ready: resolvedReadiness.completedKeys.includes('core_conflict'),
},
{
label: '主题气质',
value:
[...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])]
.filter(Boolean)
.join('、') || '',
ready: resolvedReadiness.completedKeys.includes('theme_and_tone'),
},
{
label: '标志性要素',
value: intent?.iconicElements.join('、') || '',
ready: resolvedReadiness.completedKeys.includes('iconic_element'),
},
];
return (
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<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 ? '创作输入已齐备' : '继续收世界骨架'}
</div>
</div>
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
{resolvedReadiness.completedKeys.length}/6
</span>
</div>
{hasMeaningfulCustomWorldCreatorIntent(intent) ? (
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{items.map((item) => (
<div
key={item.label}
className={`rounded-[1.15rem] border px-3 py-3 ${
item.ready
? 'border-emerald-300/18 bg-emerald-500/8'
: 'border-white/8 bg-white/5'
}`}
>
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{item.label}
</div>
<div className="mt-2 text-sm leading-6 text-zinc-100">
{item.value || '待补充'}
</div>
</div>
))}
</div>
) : (
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm text-zinc-400">
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,90 @@
import { X } from 'lucide-react';
type CustomWorldAgentLauncherModalProps = {
isOpen: boolean;
seedText: string;
isBusy: boolean;
error: string | null;
onClose: () => void;
onSeedTextChange: (value: string) => void;
onConfirm: () => void;
};
export function CustomWorldAgentLauncherModal({
isOpen,
seedText,
isBusy,
error,
onClose,
onSeedTextChange,
onConfirm,
}: CustomWorldAgentLauncherModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-semibold text-white">
Agent
</div>
<div className="mt-1 text-xs text-zinc-400">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200">
Seed Text
</div>
<textarea
value={seedText}
onChange={(event) => onSeedTextChange(event.target.value)}
rows={7}
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
/>
</label>
{error ? (
<div className="mt-4 rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={onConfirm}
disabled={isBusy}
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? '处理中...' : '开始共创'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
type CustomWorldAgentLockBarProps = {
lockState: Record<string, unknown> | null;
};
function readLockedItems(lockState: Record<string, unknown> | null) {
if (!lockState) {
return [];
}
return Object.entries(lockState)
.flatMap(([key, value]) =>
Array.isArray(value)
? value.map((item) => `${key}:${String(item)}`)
: typeof value === 'string' && value.trim()
? [`${key}:${value.trim()}`]
: [],
)
.slice(0, 8);
}
export function CustomWorldAgentLockBar({
lockState,
}: CustomWorldAgentLockBarProps) {
const lockedItems = readLockedItems(lockState);
return (
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
{lockedItems.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{lockedItems.map((item) => (
<span
key={item}
className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100"
>
{item}
</span>
))}
</div>
) : (
<div className="mt-3 text-sm leading-7 text-zinc-400">
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react';
import type { CustomWorldAgentOperationRecord } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentOperationBannerProps = {
operation: CustomWorldAgentOperationRecord | null;
};
export function CustomWorldAgentOperationBanner({
operation,
}: CustomWorldAgentOperationBannerProps) {
const [visibleOperation, setVisibleOperation] =
useState<CustomWorldAgentOperationRecord | null>(operation);
useEffect(() => {
setVisibleOperation(operation);
if (operation?.status !== 'completed') {
return;
}
const timeoutId = window.setTimeout(() => {
setVisibleOperation((current) =>
current?.operationId === operation.operationId ? null : current,
);
}, 1200);
return () => window.clearTimeout(timeoutId);
}, [operation]);
if (!visibleOperation) {
return null;
}
const isFailed = visibleOperation.status === 'failed';
const isRunning =
visibleOperation.status === 'running' || visibleOperation.status === 'queued';
return (
<div
className={`rounded-[1.4rem] border px-4 py-4 ${
isFailed
? 'border-rose-400/20 bg-[#111318]/95'
: isRunning
? 'border-emerald-300/20 bg-[#111318]/95'
: 'border-emerald-300/20 bg-[#111318]/95'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{visibleOperation.phaseLabel}
</div>
<div className="text-xs text-zinc-300">
{Math.max(0, Math.min(100, Math.round(visibleOperation.progress)))}%
</div>
</div>
{visibleOperation.error ? (
<div className="mt-2 text-sm text-zinc-200">
{visibleOperation.error}
</div>
) : null}
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/10">
<div
className={`h-full rounded-full transition-[width] duration-300 ${
isFailed ? 'bg-rose-300' : 'bg-emerald-300'
}`}
style={{
width: `${Math.max(8, Math.min(100, visibleOperation.progress))}%`,
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import type { CustomWorldSuggestedAction } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentQuickActionsProps = {
suggestedActions: CustomWorldSuggestedAction[];
disabled: boolean;
canDraftFoundation: boolean;
showEntityActions?: boolean;
onRequestSummary: () => void;
onDraftFoundation: () => void;
onGenerateCharacter?: () => void;
onGenerateLandmark?: () => void;
onGenerateRoleAssets?: () => void;
showRoleAssetAction?: boolean;
onFocusSuggestedAction: (action?: CustomWorldSuggestedAction) => void;
};
function QuickActionButton(props: {
label: string;
onClick: () => void;
disabled: boolean;
tone?: 'default' | 'sky' | 'amber';
}) {
const { label, onClick, disabled, tone = 'default' } = props;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-[1.1rem] border px-4 py-3 text-left text-sm transition disabled:cursor-not-allowed disabled:opacity-45 ${
tone === 'amber'
? 'border-amber-300/20 bg-amber-500/10 text-amber-100 hover:text-white'
: tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-200 hover:text-white'
}`}
>
{label}
</button>
);
}
export function CustomWorldAgentQuickActions({
suggestedActions,
disabled,
canDraftFoundation,
showEntityActions = false,
onRequestSummary,
onDraftFoundation,
onGenerateCharacter,
onGenerateLandmark,
onGenerateRoleAssets,
showRoleAssetAction = false,
onFocusSuggestedAction,
}: CustomWorldAgentQuickActionsProps) {
const summaryAction = suggestedActions.find(
(action) => action.type === 'request_summary',
);
const draftAction = suggestedActions.find(
(action) => action.type === 'draft_foundation',
);
const refinementActions = suggestedActions.filter(
(action) =>
action.type !== 'request_summary' && action.type !== 'draft_foundation',
);
return (
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-3 flex flex-col gap-2">
<QuickActionButton
label={summaryAction?.label ?? '总结当前设定'}
onClick={onRequestSummary}
disabled={disabled}
tone="sky"
/>
{draftAction && canDraftFoundation ? (
<QuickActionButton
label={draftAction.label}
onClick={onDraftFoundation}
disabled={disabled}
tone="amber"
/>
) : null}
{showEntityActions && onGenerateCharacter ? (
<QuickActionButton
label="新增角色"
onClick={onGenerateCharacter}
disabled={disabled}
/>
) : null}
{showEntityActions && onGenerateLandmark ? (
<QuickActionButton
label="新增场景"
onClick={onGenerateLandmark}
disabled={disabled}
/>
) : null}
{showRoleAssetAction && onGenerateRoleAssets ? (
<QuickActionButton
label="生成角色主图与动作"
onClick={onGenerateRoleAssets}
disabled={disabled}
tone="amber"
/>
) : null}
{refinementActions.length > 0 ? (
refinementActions.slice(0, 2).map((action) => (
<QuickActionButton
key={action.id}
label={action.label}
onClick={() => onFocusSuggestedAction(action)}
disabled={disabled}
/>
))
) : !draftAction || !canDraftFoundation ? (
<QuickActionButton
label={showEntityActions ? '继续精修当前草稿' : '继续补充锚点'}
onClick={() => onFocusSuggestedAction()}
disabled={disabled}
/>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentSummaryPanelProps = {
session: CustomWorldAgentSessionSnapshot;
};
function readSummaryText(
draftProfile: Record<string, unknown> | null,
fallback: string,
) {
const title =
typeof draftProfile?.title === 'string' ? draftProfile.title.trim() : '';
const summary =
typeof draftProfile?.summary === 'string'
? draftProfile.summary.trim()
: '';
return {
title: title || '世界摘要待整理',
summary: summary || fallback,
};
}
export function CustomWorldAgentSummaryPanel({
session,
}: CustomWorldAgentSummaryPanelProps) {
const pendingCount = session.pendingClarifications.length;
const { title, summary } = readSummaryText(
session.draftProfile,
'第一阶段先收住世界锚点,后续阶段再把这里整理成更完整的世界底稿摘要。',
);
return (
<div className="rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-start 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">
{title}
</div>
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
{session.messages.length}
</span>
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
{pendingCount}
</span>
</div>
</div>
<div className="mt-3 text-sm leading-7 text-zinc-300">
{summary}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useRef } from 'react';
import type { CustomWorldAgentMessage } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentThreadProps = {
messages: CustomWorldAgentMessage[];
recommendedReplies?: string[];
onRecommendedReply?: (text: string) => void;
};
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,
}: CustomWorldAgentThreadProps) {
const bottomRef = useRef<HTMLDivElement | null>(null);
const lastAssistantMessageId = [...messages]
.reverse()
.find((message) => message.role === 'assistant')?.id;
useEffect(() => {
bottomRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
}, [messages]);
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">
{messages.length === 0 ? (
<div className="m-auto text-sm text-zinc-400">
</div>
) : (
<div className="space-y-3">
{messages.map((message) => {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
return (
<div
key={message.id}
className={`flex ${
isUser ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${
isUser
? 'border border-white/10 bg-white/10 text-zinc-50'
: isSystem
? 'border border-amber-300/16 bg-amber-500/10 text-amber-50'
: 'border border-white/10 bg-white/6 text-zinc-100'
}`}
>
<div className="whitespace-pre-wrap">{message.text}</div>
<div className="mt-2 text-[11px] text-zinc-400">
{formatMessageTime(message.createdAt)}
</div>
{!isUser &&
message.id === lastAssistantMessageId &&
recommendedReplies.length > 0 ? (
<div className="mt-3 flex flex-col gap-2">
{recommendedReplies.slice(0, 3).map((reply) => (
<button
key={reply}
type="button"
onClick={() => onRecommendedReply?.(reply)}
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-left text-xs leading-5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"
>
{reply}
</button>
))}
</div>
) : null}
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,471 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } 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 { 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',
creatorIntent: {},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
},
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
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,
},
],
pendingClarifications: [],
suggestedActions: [
{
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
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,
},
],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: '2026-04-14T10: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 () => {
const user = userEvent.setup();
const onExecuteAction = vi.fn();
const { rerender } = render(
<CustomWorldAgentWorkspace
session={baseSession}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenCalledWith(
baseSession.sessionId,
'world-foundation',
);
});
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: '保存' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'update_draft_card',
cardId: 'world-foundation',
sections: [
{
sectionId: 'title',
value: '潮雾列岛',
},
{
sectionId: 'summary',
value: '这是更新后的世界摘要。',
},
],
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenLastCalledWith(
baseSession.sessionId,
'character-1',
);
});
const [generateCharacterButton] = screen.getAllByRole('button', { name: '新增角色' });
await user.click(generateCharacterButton!);
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成角色' }));
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(
<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,
}}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(screen.getByText('顾潮音')).toBeTruthy();
});
expect(screen.getByText('角色资产工坊:沈砺')).toBeTruthy();
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' },
},
});
rerender(
<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,
}}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(screen.getAllByText('动作已就绪').length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,96 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
test('custom world agent workspace renders progress labels, action button and recommended replies', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentWorkspace
session={{
sessionId: 'custom-world-agent-session-1',
stage: 'object_refining',
focusCardId: 'world-foundation',
creatorIntent: {},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
},
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
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,
},
],
pendingClarifications: [],
suggestedActions: [
{
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定'],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: new Date().toISOString(),
}}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(html).toContain('首轮草稿会先确认这 6 项信息');
expect(html).toContain('世界核心');
expect(html).toContain('玩家开局');
expect(html).toContain('现在开始生成草稿');
expect(html).toContain('开始生成草稿');
expect(html).toContain('欢迎。当前底稿已经可以继续精修。');
});

View File

@@ -0,0 +1,702 @@
import { useEffect, useState } from 'react';
import type {
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
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>;
};
type CustomWorldAgentWorkspaceProps = {
session: CustomWorldAgentSessionSnapshot | null;
activeOperation: CustomWorldAgentOperationRecord | null;
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' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
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,
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">
</div>
);
}
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 === 'object_refining' || session.stage === 'visual_refining') &&
session.draftCards.length > 0;
const selectedCard = session.draftCards.find((card) => card.id === selectedCardId) ?? null;
const recommendedReplies = buildRecommendedReplies(session);
const selectedRoleAssetContext = resolveRoleAssetTarget(
session,
activeRoleAssetTargetId,
);
const openRoleAssetStudio = (roleId: string | null) => {
if (!roleId) {
return;
}
setRequestedRoleAssetTargetId(roleId);
onExecuteAction({
action: 'generate_role_assets',
roleIds: [roleId],
});
};
const submitTextMessage = (text: string) => {
onSubmitMessage({
clientMessageId: createClientMessageId(),
text,
focusCardId: selectedCardId,
selectedCardIds: selectedCardId ? [selectedCardId] : [],
});
};
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">
<CustomWorldAgentHeader onBack={onBack} />
<CustomWorldAgentReadinessBar
completedKeys={session.creatorIntentReadiness.completedKeys}
isReady={canStartDraft}
busy={isBusy}
onStartDraft={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
/>
<CustomWorldAgentOperationBanner operation={activeOperation} />
{showDraftWorkspace ? (
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[18rem_minmax(0,1fr)_24rem]">
<div className="flex min-h-0 flex-col gap-3 xl:overflow-hidden">
<CustomWorldAgentQuickActions
suggestedActions={session.suggestedActions}
disabled={isBusy}
canDraftFoundation={canStartDraft}
showEntityActions
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);
setDetailModalOpen(true);
return;
}
if (session.draftCards[0]) {
setSelectedCardId(session.draftCards[0].id);
setDetailModalOpen(true);
}
}}
/>
<div className="xl:min-h-0 xl:overflow-y-auto">
<CustomWorldAgentDraftDrawer
draftCards={session.draftCards}
activeCardId={selectedCardId}
onSelectCard={(cardId) => {
setSelectedCardId(cardId);
setDetailModalOpen(true);
}}
/>
</div>
</div>
<div className="hidden min-h-0 xl:block 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 className="flex min-h-0 flex-col gap-3">
<div className="h-[18rem] min-h-[18rem] xl:min-h-0 xl:flex-1">
<CustomWorldAgentThread
messages={session.messages}
recommendedReplies={recommendedReplies}
onRecommendedReply={handleRecommendedReply}
/>
</div>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
onSummaryClick={submitSummaryRequest}
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
showAutoComplete={showAutoCompleteButton}
/>
</div>
</div>
) : (
<>
<CustomWorldAgentThread
messages={session.messages}
recommendedReplies={recommendedReplies}
onRecommendedReply={handleRecommendedReply}
/>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
onSummaryClick={submitSummaryRequest}
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
showAutoComplete={showAutoCompleteButton}
/>
</>
)}
{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}
disabled={isBusy}
onClose={() => {
setGenerateEntityMode(null);
}}
onSubmit={({ count, promptText }) => {
if (!generateEntityMode) {
return;
}
onExecuteAction({
action:
generateEntityMode === 'character'
? 'generate_characters'
: 'generate_landmarks',
count,
promptText: promptText || null,
anchorCardIds: selectedCardId ? [selectedCardId] : [],
});
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);
}}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
type CustomWorldDraftCardDetailModalProps = {
open: boolean;
detail: CustomWorldDraftCardDetail | null;
loading: boolean;
busy?: boolean;
editMode?: boolean;
onClose: () => void;
onStartEdit?: () => void;
onCancelEdit?: () => void;
onSave?: (
sections: Array<{
sectionId: string;
value: string;
}>,
) => void;
onGenerateCharacter?: () => void;
onGenerateLandmark?: () => void;
onOpenRoleAssetStudio?: () => void;
};
export function CustomWorldDraftCardDetailModal({
open,
detail,
loading,
busy = false,
editMode = false,
onClose,
onStartEdit,
onCancelEdit,
onSave,
onGenerateCharacter,
onGenerateLandmark,
onOpenRoleAssetStudio,
}: CustomWorldDraftCardDetailModalProps) {
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-[95] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm xl:hidden">
<button
type="button"
aria-label="关闭卡片详情"
onClick={onClose}
className="absolute inset-0 cursor-default"
/>
<div className="relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto">
<CustomWorldAgentDraftDetailPanel
detail={detail}
loading={loading}
busy={busy}
editMode={editMode}
onClose={onClose}
onStartEdit={onStartEdit}
onCancelEdit={onCancelEdit}
onSave={onSave}
onGenerateCharacter={onGenerateCharacter}
onGenerateLandmark={onGenerateLandmark}
onOpenRoleAssetStudio={onOpenRoleAssetStudio}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
test('draft detail panel renders editable form in edit mode', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'publicMask',
label: '外显身份',
value: '守灯会里最熟悉旧航道的人。',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'publicMask', 'summary'],
warningMessages: [],
}}
loading={false}
editMode
onClose={() => {}}
onCancelEdit={() => {}}
onSave={() => {}}
/>,
);
expect(html).toContain('保存');
expect(html).toContain('取消');
expect(html).toContain('角色名');
expect(html).toContain('textarea');
});

View File

@@ -0,0 +1,136 @@
import { useEffect, useMemo, useState } from 'react';
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldDraftEditPanelProps = {
detail: CustomWorldDraftCardDetail;
disabled?: boolean;
onSave: (
sections: Array<{
sectionId: string;
value: string;
}>,
) => void;
onCancel: () => void;
};
function shouldUseTextarea(sectionId: string, value: string) {
return (
value.length > 28 ||
value.includes('\n') ||
sectionId === 'summary' ||
sectionId === 'tone' ||
sectionId === 'coreConflicts' ||
sectionId === 'hiddenHook' ||
sectionId === 'secret' ||
sectionId === 'stakes' ||
sectionId === 'openingEvent' ||
sectionId === 'understandingShift' ||
sectionId === 'description'
);
}
export function CustomWorldDraftEditPanel({
detail,
disabled = false,
onSave,
onCancel,
}: CustomWorldDraftEditPanelProps) {
const editableSections = useMemo(
() =>
detail.sections.filter((section) =>
detail.editableSectionIds.includes(section.id),
),
[detail],
);
const [draftValues, setDraftValues] = useState<Record<string, string>>(() =>
Object.fromEntries(
editableSections.map((section) => [section.id, section.value]),
),
);
useEffect(() => {
setDraftValues(
Object.fromEntries(editableSections.map((section) => [section.id, section.value])),
);
}, [editableSections]);
if (editableSections.length === 0) {
return null;
}
return (
<div className="space-y-3">
{editableSections.map((section) => {
const value = draftValues[section.id] ?? '';
const multiline = shouldUseTextarea(section.id, value);
return (
<label
key={section.id}
className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
>
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{section.label}
</div>
{multiline ? (
<textarea
value={value}
onChange={(event) => {
const nextValue = event.target.value;
setDraftValues((current) => ({
...current,
[section.id]: nextValue,
}));
}}
rows={4}
disabled={disabled}
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
) : (
<input
type="text"
value={value}
onChange={(event) => {
const nextValue = event.target.value;
setDraftValues((current) => ({
...current,
[section.id]: nextValue,
}));
}}
disabled={disabled}
className="mt-2 h-11 w-full rounded-[0.9rem] border border-white/10 bg-black/26 px-3 text-sm text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
)}
</label>
);
})}
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={disabled}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={() => {
onSave(
editableSections.map((section) => ({
sectionId: section.id,
value: draftValues[section.id] ?? '',
})),
);
}}
disabled={disabled}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
type CustomWorldGenerateEntityModalProps = {
open: boolean;
mode: 'character' | 'landmark';
anchorCardTitle?: string | null;
disabled?: boolean;
onClose: () => void;
onSubmit: (payload: {
count: number;
promptText: string;
}) => void;
};
export function CustomWorldGenerateEntityModal({
open,
mode,
anchorCardTitle,
disabled = false,
onClose,
onSubmit,
}: CustomWorldGenerateEntityModalProps) {
const [count, setCount] = useState(2);
const [promptText, setPromptText] = useState('');
useEffect(() => {
if (!open) {
return;
}
setCount(2);
setPromptText('');
}, [open, mode]);
if (!open) {
return null;
}
const title = mode === 'character' ? '新增角色' : '新增场景';
const submitLabel = mode === 'character' ? '生成角色' : '生成场景';
return (
<div className="fixed inset-0 z-[96] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center">
<button
type="button"
aria-label="关闭新增弹窗"
onClick={onClose}
className="absolute inset-0 cursor-default"
/>
<div className="relative z-10 w-full max-w-xl rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(8,10,14,0.96))] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
AI
</div>
<div className="mt-2 text-lg font-semibold text-white">{title}</div>
</div>
<button
type="button"
onClick={onClose}
disabled={disabled}
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
<div className="mt-4 space-y-4">
{anchorCardTitle ? (
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 text-sm text-zinc-100">{anchorCardTitle}</div>
</div>
) : null}
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-400"></div>
<div className="mt-3 flex gap-2">
{[1, 2, 3].map((value) => (
<button
key={value}
type="button"
onClick={() => setCount(value)}
disabled={disabled}
className={`rounded-full border px-4 py-2 text-sm transition ${
count === value
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
} disabled:cursor-not-allowed disabled:opacity-45`}
>
{value}
</button>
))}
</div>
</div>
<label className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
</div>
<textarea
value={promptText}
onChange={(event) => setPromptText(event.target.value)}
rows={5}
disabled={disabled}
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
</div>
<div className="mt-4 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={disabled}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={() => {
onSubmit({
count,
promptText: promptText.trim(),
});
}}
disabled={disabled}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
{submitLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const baseDraftItem: CustomWorldWorkSummary = {
workId: 'draft:session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '补齐关键锚点',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'object_refining',
stageLabel: '精修对象',
playableNpcCount: 3,
landmarkCount: 4,
sessionId: 'session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
};
test('creation hub reflects updated draft title summary and counts after rerender', () => {
const { rerender } = render(
<CustomWorldCreationHub
items={[baseDraftItem]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
expect(screen.getByText('角色 3')).toBeTruthy();
expect(screen.getByText('地点 4')).toBeTruthy();
rerender(
<CustomWorldCreationHub
items={[
{
...baseDraftItem,
title: '潮雾列岛·回潮版',
summary: '世界总卡和角色网已经继续长出了新的支线。',
playableNpcCount: 5,
landmarkCount: 6,
updatedAt: new Date('2026-04-14T10:10:00.000Z').toISOString(),
},
]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛·回潮版')).toBeTruthy();
expect(screen.getByText('世界总卡和角色网已经继续长出了新的支线。')).toBeTruthy();
expect(screen.getByText('角色 5')).toBeTruthy();
expect(screen.getByText('地点 6')).toBeTruthy();
});

View File

@@ -0,0 +1,44 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
test('creation hub draft card renders compiled work summary fields', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[
{
workId: 'draft:session-1',
sourceType: 'agent_session',
status: 'draft',
title: '一个被潮雾切开的列岛世界',
subtitle: '补齐关键锚点',
summary:
'玩家是失职返乡的守灯人 · 核心冲突:守灯会与沉船商盟争夺航道解释权',
coverImageSrc: null,
updatedAt: new Date('2026-04-13T12:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
sessionId: 'session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
});

View File

@@ -0,0 +1,154 @@
import { useMemo, useState } from 'react';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
CustomWorldWorkTabs,
} from './CustomWorldWorkTabs';
type CustomWorldCreationHubProps = {
items: CustomWorldWorkSummary[];
loading: boolean;
error: string | null;
onBack: () => void;
onRetry: () => void;
onCreateNew: () => void;
onResumeDraft: (sessionId: string) => void;
onEnterPublished: (profileId: string) => void;
};
function EmptyState({ title }: { title: string }) {
return (
<div className="flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
<div className="text-lg font-semibold text-white">{title}</div>
</div>
);
}
export function CustomWorldCreationHub({
items,
loading,
error,
onBack,
onRetry,
onCreateNew,
onResumeDraft,
onEnterPublished,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const draftCount = items.filter((item) => item.status === 'draft').length;
const publishedCount = items.filter(
(item) => item.status === 'published',
).length;
const filteredItems = useMemo(
() =>
items.filter((item) =>
activeFilter === 'all' ? true : item.status === activeFilter,
),
[activeFilter, items],
);
return (
<div
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="sticky top-0 z-20 -mx-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.88),rgba(10,12,18,0))] px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0">
<div className="flex items-start justify-between gap-3">
<div>
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
>
</button>
<div className="mt-4 text-[1.8rem] font-black leading-tight text-white sm:text-[2.3rem]">
</div>
</div>
<div className="hidden shrink-0 gap-2 sm:flex">
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
稿 {draftCount}
</span>
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
{publishedCount}
</span>
</div>
</div>
</div>
<div className="space-y-4">
<CustomWorldCreationStartCard onCreateNew={onCreateNew} />
<CustomWorldWorkTabs
activeFilter={activeFilter}
draftCount={draftCount}
publishedCount={publishedCount}
onChange={setActiveFilter}
/>
{error ? (
<div className="rounded-3xl border border-rose-400/18 bg-rose-500/10 px-4 py-4 text-sm leading-7 text-rose-100">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
>
</button>
</div>
) : null}
{loading ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="min-h-[12rem] rounded-[1.8rem] border border-white/8 bg-white/5 p-5"
>
<div className="h-4 w-20 rounded-full bg-white/10" />
<div className="mt-6 h-8 w-36 rounded-full bg-white/10" />
<div className="mt-4 h-4 w-full rounded-full bg-white/10" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-white/10" />
<div className="mt-8 flex gap-2">
<div className="h-7 w-20 rounded-full bg-white/10" />
<div className="h-7 w-20 rounded-full bg-white/10" />
</div>
</div>
))}
</div>
) : filteredItems.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={item.workId}
item={item}
onClick={() => {
if (item.status === 'draft' && item.sessionId) {
onResumeDraft(item.sessionId);
return;
}
if (item.status === 'published' && item.profileId) {
onEnterPublished(item.profileId);
}
}}
/>
))}
</div>
) : items.length === 0 ? (
<EmptyState title="还没有作品" />
) : (
<EmptyState title="当前筛选下没有内容" />
)}
</div>
</div>
);
}
export type { CustomWorldWorkFilter };

View File

@@ -0,0 +1,146 @@
import { X } from 'lucide-react';
import type { CustomWorldQuestion } from '../../../packages/shared/src/contracts/runtime';
type CustomWorldCreationLauncherModalProps = {
isOpen: boolean;
mode: 'create' | 'resume';
seedText: string;
seedTextLocked: boolean;
questions: CustomWorldQuestion[];
answers: Record<string, string>;
isBusy: boolean;
error: string | null;
lastError?: string | null;
primaryLabel: string;
onClose: () => void;
onSeedTextChange: (value: string) => void;
onAnswerChange: (questionId: string, value: string) => void;
onPrimaryAction: () => void;
};
export function CustomWorldCreationLauncherModal({
isOpen,
mode,
seedText,
seedTextLocked,
questions,
answers,
isBusy,
error,
lastError = null,
primaryLabel,
onClose,
onSeedTextChange,
onAnswerChange,
onPrimaryAction,
}: CustomWorldCreationLauncherModalProps) {
if (!isOpen) {
return null;
}
const unansweredQuestions = questions.filter((question) => !question.answer?.trim());
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-semibold text-white">
{mode === 'create' ? '新建作品' : '继续创作'}
</div>
<div className="mt-1 text-xs text-zinc-400">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="space-y-4">
<label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200">
</div>
<textarea
value={seedText}
onChange={(event) => onSeedTextChange(event.target.value)}
rows={seedTextLocked ? 4 : 6}
readOnly={seedTextLocked}
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
className={`w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 ${
seedTextLocked ? 'cursor-not-allowed opacity-75' : ''
}`}
/>
</label>
{unansweredQuestions.length > 0 ? (
<div className="space-y-3">
<div className="rounded-3xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-zinc-300">
</div>
{unansweredQuestions.map((question) => (
<label key={question.id} className="block">
<div className="mb-2 text-sm font-medium text-zinc-200">
{question.label}
</div>
<div className="mb-2 text-xs leading-6 text-zinc-400">
{question.question}
</div>
<textarea
value={answers[question.id] ?? question.answer ?? ''}
onChange={(event) =>
onAnswerChange(question.id, event.target.value)
}
rows={3}
placeholder="补充一句就可以。"
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
/>
</label>
))}
</div>
) : null}
{lastError ? (
<div className="rounded-3xl border border-amber-400/25 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
{lastError}
</div>
) : null}
{error ? (
<div className="rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={onPrimaryAction}
disabled={isBusy}
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? '处理中...' : primaryLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
type CustomWorldCreationStartCardProps = {
onCreateNew: () => void;
};
export function CustomWorldCreationStartCard({
onCreateNew,
}: CustomWorldCreationStartCardProps) {
return (
<div
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="relative overflow-hidden rounded-[1.75rem] border border-white/8 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.28),rgba(8,10,14,0.82))] px-5 py-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-2xl font-black text-white sm:text-3xl">
</div>
</div>
<button
type="button"
onClick={onCreateNew}
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 11,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
function formatUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '最近更新';
}
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
type CustomWorldWorkCardProps = {
item: CustomWorldWorkSummary;
onClick: () => void;
};
export function CustomWorldWorkCard({
item,
onClick,
}: CustomWorldWorkCardProps) {
const isDraft = item.status === 'draft';
const hasFoundationDraft =
item.playableNpcCount > 0 || item.landmarkCount > 0;
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
return (
<div
className="pixel-nine-slice pixel-panel relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 15,
})}
>
{item.coverImageSrc ? (
<img
src={item.coverImageSrc}
alt={item.title}
className="absolute inset-0 h-full w-full object-cover opacity-20"
style={{ imageRendering: 'pixelated' }}
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.82))]" />
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span
className={`rounded-full border px-3 py-1 text-[10px] tracking-[0.18em] ${
isDraft
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
}`}
>
{isDraft ? '草稿' : '已发布'}
</span>
{item.stageLabel ? (
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
{item.stageLabel}
</span>
) : null}
</div>
<div className="text-[11px] text-zinc-400">
{formatUpdatedAt(item.updatedAt)}
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-black text-white">
{item.title}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-zinc-400">
{item.subtitle}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-7 text-zinc-200/90">
{item.summary}
</div>
</div>
<div className="mt-auto flex items-center justify-between gap-3 pt-4">
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
{roleCountLabel} {item.playableNpcCount}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
{item.landmarkCount}
</span>
{item.roleVisualReadyCount ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] text-amber-100">
{item.roleVisualReadyCount}
</span>
) : null}
{item.roleAnimationReadyCount ? (
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{item.roleAnimationReadyCount}
</span>
) : null}
{item.roleAssetSummaryLabel ? (
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
{item.roleAssetSummaryLabel}
</span>
) : null}
</div>
<button
type="button"
onClick={onClick}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white"
>
{isDraft ? (hasFoundationDraft ? '继续精修' : '继续创作') : '进入世界'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
export type CustomWorldWorkFilter = 'all' | 'draft' | 'published';
const FILTER_OPTIONS: Array<{
id: CustomWorldWorkFilter;
label: string;
}> = [
{ id: 'all', label: '全部' },
{ id: 'draft', label: '草稿' },
{ id: 'published', label: '已发布' },
];
type CustomWorldWorkTabsProps = {
activeFilter: CustomWorldWorkFilter;
draftCount: number;
publishedCount: number;
onChange: (filter: CustomWorldWorkFilter) => void;
};
export function CustomWorldWorkTabs({
activeFilter,
draftCount,
publishedCount,
onChange,
}: CustomWorldWorkTabsProps) {
return (
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{FILTER_OPTIONS.map((option) => {
const count =
option.id === 'draft'
? draftCount
: option.id === 'published'
? publishedCount
: draftCount + publishedCount;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={`shrink-0 rounded-full border px-4 py-2 text-sm transition ${
activeFilter === option.id
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
: 'border-white/10 bg-black/18 text-zinc-300 hover:text-white'
}`}
>
{option.label} {count}
</button>
);
})}
</div>
);
}

View File

@@ -111,6 +111,9 @@ export function GameCanvasEntityLayer({
monsterAnchorMeters,
playerX,
}: GameCanvasEntityLayerProps) {
const shouldRenderPeacefulEncounter =
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
return (
<>
{companions.map(companion => {
@@ -327,8 +330,12 @@ export function GameCanvasEntityLayer({
);
})}
{encounter &&
{shouldRenderPeacefulEncounter &&
(() => {
if (!encounter) {
return null;
}
const isCampCompanionEncounter =
encounter.specialBehavior === 'initial_companion'
|| encounter.specialBehavior === 'camp_companion';

File diff suppressed because it is too large Load Diff