Integrate role asset studio into custom world agent flow

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

View File

@@ -0,0 +1,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>
);
}