Integrate role asset studio into custom world agent flow
This commit is contained in:
131
src/components/AdventurePanel.test.tsx
Normal file
131
src/components/AdventurePanel.test.tsx
Normal 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('剧情推理完成,继续后显示新的冒险选项');
|
||||
});
|
||||
@@ -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}>
|
||||
继续补全世界
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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('最小锚点已齐备,可以进入下一阶段');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
102
src/components/custom-world-agent/CustomWorldAgentComposer.tsx
Normal file
102
src/components/custom-world-agent/CustomWorldAgentComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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('新增角色');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
18
src/components/custom-world-agent/CustomWorldAgentHeader.tsx
Normal file
18
src/components/custom-world-agent/CustomWorldAgentHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
97
src/components/custom-world-agent/CustomWorldAgentThread.tsx
Normal file
97
src/components/custom-world-agent/CustomWorldAgentThread.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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('欢迎。当前底稿已经可以继续精修。');
|
||||
});
|
||||
702
src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx
Normal file
702
src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
136
src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx
Normal file
136
src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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('守灯会与沉船商盟争夺航道解释权');
|
||||
});
|
||||
154
src/components/custom-world-home/CustomWorldCreationHub.tsx
Normal file
154
src/components/custom-world-home/CustomWorldCreationHub.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
119
src/components/custom-world-home/CustomWorldWorkCard.tsx
Normal file
119
src/components/custom-world-home/CustomWorldWorkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/custom-world-home/CustomWorldWorkTabs.tsx
Normal file
52
src/components/custom-world-home/CustomWorldWorkTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user