1
This commit is contained in:
@@ -42,5 +42,5 @@ test('clarification panel shows pending questions and ready state', () => {
|
||||
|
||||
expect(pendingHtml).toContain('待补充问题');
|
||||
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
|
||||
expect(readyHtml).toContain('最小锚点已齐备,可以进入下一阶段');
|
||||
expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段');
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ export function CustomWorldAgentClarificationPanel({
|
||||
下一阶段
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
最小锚点已齐备,可以进入下一阶段
|
||||
当前设定已齐备,可以进入下一阶段
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,6 @@ type CustomWorldAgentComposerProps = {
|
||||
disabled: boolean;
|
||||
onSubmit: (payload: SendCustomWorldAgentMessageRequest) => void;
|
||||
textareaRef?: RefObject<HTMLTextAreaElement | null>;
|
||||
onSummaryClick?: () => void;
|
||||
onAutoCompleteClick?: () => void;
|
||||
showAutoComplete?: boolean;
|
||||
};
|
||||
|
||||
function createClientMessageId() {
|
||||
@@ -27,9 +24,6 @@ export function CustomWorldAgentComposer({
|
||||
disabled,
|
||||
onSubmit,
|
||||
textareaRef,
|
||||
onSummaryClick,
|
||||
onAutoCompleteClick,
|
||||
showAutoComplete = true,
|
||||
}: CustomWorldAgentComposerProps) {
|
||||
const [text, setText] = useState('');
|
||||
|
||||
@@ -49,28 +43,8 @@ export function CustomWorldAgentComposer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSummaryClick}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
总结当前设定
|
||||
</button>
|
||||
{showAutoComplete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAutoCompleteClick}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
自动补全剩余设定
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
@@ -81,21 +55,19 @@ export function CustomWorldAgentComposer({
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
rows={3}
|
||||
disabled={disabled}
|
||||
placeholder="输入消息"
|
||||
className="w-full resize-none rounded-[1.35rem] border border-white/10 bg-black/30 px-4 py-2.5 text-sm leading-6 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="w-full resize-none rounded-[1.35rem] border border-white/10 bg-black/30 px-4 pb-12 pr-20 pt-3 text-sm leading-6 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={disabled || !text.trim()}
|
||||
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={disabled || !text.trim()}
|
||||
className="absolute bottom-3 right-3 rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ export function CustomWorldAgentDraftDrawer({
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-400">
|
||||
最小锚点齐备后,世界底稿会先从这里长出来。
|
||||
当前设定收束后,世界底稿会先从这里长出来。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ type CustomWorldAgentHeaderProps = {
|
||||
|
||||
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
|
||||
<div className="flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -12,7 +12,6 @@ export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps)
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className="text-sm font-semibold text-white">Agent</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ test('intent summary panel shows collected custom world anchors', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('已收集锚点');
|
||||
expect(html).toContain('已收集设定');
|
||||
expect(html).toContain('世界一句话');
|
||||
expect(html).toContain('一个被潮雾切开的列岛世界');
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
|
||||
@@ -58,7 +58,7 @@ export function CustomWorldAgentIntentSummaryPanel({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
已收集锚点
|
||||
已收集设定
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'}
|
||||
@@ -91,7 +91,7 @@ export function CustomWorldAgentIntentSummaryPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm text-zinc-400">
|
||||
还在收集你的世界锚点
|
||||
还在收集你的世界设定
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function CustomWorldAgentQuickActions({
|
||||
))
|
||||
) : !draftAction || !canDraftFoundation ? (
|
||||
<QuickActionButton
|
||||
label={showEntityActions ? '继续精修当前草稿' : '继续补充锚点'}
|
||||
label={showEntityActions ? '继续精修当前草稿' : '继续补充设定'}
|
||||
onClick={() => onFocusSuggestedAction()}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function CustomWorldAgentSummaryPanel({
|
||||
const pendingCount = session.pendingClarifications.length;
|
||||
const { title, summary } = readSummaryText(
|
||||
session.draftProfile,
|
||||
'第一阶段先收住世界锚点,后续阶段再把这里整理成更完整的世界底稿摘要。',
|
||||
'第一阶段先收住世界设定,后续阶段再把这里整理成更完整的世界底稿摘要。',
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -63,3 +63,31 @@ test('filters empty recommended replies and avoids duplicate key warnings', () =
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('renders a streaming assistant bubble without timestamps', () => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
render(
|
||||
<CustomWorldAgentThread
|
||||
messages={[
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '我想做一个潮湿压抑的海上世界。',
|
||||
createdAt: '2026-04-16T10:01:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
]}
|
||||
streamingReplyText="那我先顺着这个方向收一下,开场时你更想让玩家撞上什么麻烦"
|
||||
isStreamingReply
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/那我先顺着这个方向收一下/u),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('10:01')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -6,24 +6,16 @@ type CustomWorldAgentThreadProps = {
|
||||
messages: CustomWorldAgentMessage[];
|
||||
recommendedReplies?: string[];
|
||||
onRecommendedReply?: (text: string) => void;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
};
|
||||
|
||||
function formatMessageTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentThread({
|
||||
messages,
|
||||
recommendedReplies = [],
|
||||
onRecommendedReply,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
}: CustomWorldAgentThreadProps) {
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const visibleRecommendedReplies = [
|
||||
@@ -42,10 +34,10 @@ export function CustomWorldAgentThread({
|
||||
behavior: 'smooth',
|
||||
block: 'end',
|
||||
});
|
||||
}, [messages]);
|
||||
}, [messages, streamingReplyText, isStreamingReply]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[20rem] flex-1 flex-col overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="m-auto text-sm text-zinc-400">
|
||||
暂无消息
|
||||
@@ -73,9 +65,6 @@ export function CustomWorldAgentThread({
|
||||
}`}
|
||||
>
|
||||
<div className="whitespace-pre-wrap">{message.text}</div>
|
||||
<div className="mt-2 text-[11px] text-zinc-400">
|
||||
{formatMessageTime(message.createdAt)}
|
||||
</div>
|
||||
{!isUser &&
|
||||
index === lastAssistantMessageIndex &&
|
||||
visibleRecommendedReplies.length > 0 ? (
|
||||
@@ -96,6 +85,24 @@ export function CustomWorldAgentThread({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isStreamingReply ? (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[90%] rounded-[1.4rem] border border-white/10 bg-white/6 px-4 py-3 text-sm leading-7 text-zinc-100 sm:max-w-[82%]">
|
||||
{streamingReplyText ? (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{streamingReplyText}
|
||||
<span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-emerald-200/80 align-[-2px]" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.2s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.1s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,483 +1,161 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldDraftCardDetail,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
getCustomWorldAgentCardDetail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../CustomWorldRoleAssetStudioModal', () => ({
|
||||
CustomWorldRoleAssetStudioModal: ({
|
||||
role,
|
||||
onPublishSuccess,
|
||||
}: {
|
||||
role: { name: string };
|
||||
onPublishSuccess?: (
|
||||
payload: {
|
||||
roleId: string;
|
||||
portraitPath: string;
|
||||
generatedVisualAssetId: string;
|
||||
generatedAnimationSetId?: string | null;
|
||||
animationMap?: Record<string, unknown> | null;
|
||||
},
|
||||
options?: { closeAfterSync?: boolean },
|
||||
) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div>角色资产工坊:{role.name}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onPublishSuccess?.(
|
||||
{
|
||||
roleId: 'character-1',
|
||||
portraitPath: '/generated/character-1.png',
|
||||
generatedVisualAssetId: 'visual-character-1',
|
||||
generatedAnimationSetId: 'animation-set-character-1',
|
||||
animationMap: {
|
||||
idle: { basePath: '/generated/character-1/idle' },
|
||||
run: { basePath: '/generated/character-1/run' },
|
||||
attack: { basePath: '/generated/character-1/attack' },
|
||||
hurt: { basePath: '/generated/character-1/hurt' },
|
||||
die: { basePath: '/generated/character-1/die' },
|
||||
},
|
||||
},
|
||||
{
|
||||
closeAfterSync: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
模拟同步角色资产
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const detailById: Record<string, CustomWorldDraftCardDetail> = {
|
||||
'world-foundation': {
|
||||
id: 'world-foundation',
|
||||
kind: 'world',
|
||||
title: '潮雾列岛',
|
||||
sections: [
|
||||
{
|
||||
id: 'title',
|
||||
label: '标题',
|
||||
value: '潮雾列岛',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: '摘要',
|
||||
value: '这是第一版世界底稿。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-1', 'character-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['title', 'summary'],
|
||||
warningMessages: [],
|
||||
},
|
||||
'character-1': {
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
title: '沈砺',
|
||||
sections: [
|
||||
{
|
||||
id: 'name',
|
||||
label: '角色名',
|
||||
value: '沈砺',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: '角色摘要',
|
||||
value: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['name', 'summary'],
|
||||
warningMessages: [],
|
||||
assetStatus: 'missing',
|
||||
assetStatusLabel: '待生成主图',
|
||||
},
|
||||
'character-2': {
|
||||
id: 'character-2',
|
||||
kind: 'character',
|
||||
title: '顾潮音',
|
||||
sections: [
|
||||
{
|
||||
id: 'name',
|
||||
label: '角色名',
|
||||
value: '顾潮音',
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
label: '角色摘要',
|
||||
value: '她总像比所有人更早知道海雾会往哪一侧压下来。',
|
||||
},
|
||||
],
|
||||
linkedIds: ['thread-1'],
|
||||
locked: false,
|
||||
editable: true,
|
||||
editableSectionIds: ['name', 'summary'],
|
||||
warningMessages: [],
|
||||
assetStatus: 'missing',
|
||||
assetStatusLabel: '待生成主图',
|
||||
},
|
||||
};
|
||||
|
||||
const baseSession: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
stage: 'object_refining',
|
||||
focusCardId: 'world-foundation',
|
||||
currentTurn: 4,
|
||||
anchorContent: {
|
||||
worldPromise: {
|
||||
hook: '一个被潮雾改写航线秩序的群岛世界。',
|
||||
differentiator: '所有通路都要向未知代价借路。',
|
||||
desiredExperience: '压迫、潮湿、悬疑',
|
||||
},
|
||||
playerFantasy: {
|
||||
playerRole: '玩家是被迫返乡的旧航路继承人。',
|
||||
corePursuit: '查清沉船夜背后的真相。',
|
||||
fearOfLoss: '一旦失败,就会再次失去唯一还活着的旧友。',
|
||||
},
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
progressPercent: 58,
|
||||
lastAssistantReply: '世界和玩家视角已经有了,下一步我想把最明面的冲突钉住。',
|
||||
stage: 'collecting_intent',
|
||||
focusCardId: null,
|
||||
creatorIntent: {},
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: [],
|
||||
isReady: false,
|
||||
completedKeys: ['world_hook', 'player_premise'],
|
||||
missingKeys: ['theme_and_tone', 'core_conflict', 'relationship_seed', 'iconic_element'],
|
||||
},
|
||||
anchorPack: {},
|
||||
lockState: {},
|
||||
draftProfile: {
|
||||
name: '潮雾列岛',
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'character-1',
|
||||
name: '沈砺',
|
||||
title: '守灯会旧友',
|
||||
role: '航道向导',
|
||||
publicMask: '守灯会里最熟悉旧航道的人。',
|
||||
hiddenHook: '暗地里正在为沉船商盟引路。',
|
||||
relationToPlayer: '旧友兼宿敌',
|
||||
threadIds: ['thread-1'],
|
||||
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
},
|
||||
draftProfile: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'summary',
|
||||
text: '当前底稿已经可以继续精修。',
|
||||
createdAt: new Date().toISOString(),
|
||||
kind: 'chat',
|
||||
text: '先告诉我你想做一个怎样的世界。',
|
||||
createdAt: '2026-04-17T12:00:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
],
|
||||
draftCards: [
|
||||
{
|
||||
id: 'world-foundation',
|
||||
kind: 'world',
|
||||
title: '潮雾列岛',
|
||||
subtitle: '旧灯塔与航道争夺',
|
||||
summary: '世界总卡已经生成。',
|
||||
status: 'warning',
|
||||
linkedIds: ['thread-1', 'character-1'],
|
||||
warningCount: 1,
|
||||
},
|
||||
{
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
title: '沈砺',
|
||||
subtitle: '守灯会旧友',
|
||||
summary: '他最了解旧航道,也最可能先背叛。',
|
||||
status: 'suggested',
|
||||
linkedIds: ['thread-1'],
|
||||
warningCount: 0,
|
||||
},
|
||||
],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [
|
||||
{
|
||||
id: 'request-summary',
|
||||
type: 'request_summary',
|
||||
label: '总结当前世界底稿',
|
||||
targetId: null,
|
||||
},
|
||||
],
|
||||
recommendedReplies: [
|
||||
'现在开始生成草稿',
|
||||
'先总结一下当前设定',
|
||||
'我还想再补充一点',
|
||||
],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [
|
||||
{
|
||||
roleId: 'character-1',
|
||||
roleName: '沈砺',
|
||||
roleKind: 'story',
|
||||
priorityTier: 'featured',
|
||||
portraitPath: null,
|
||||
generatedVisualAssetId: null,
|
||||
generatedAnimationSetId: null,
|
||||
status: 'missing',
|
||||
missingAnimations: ['idle', 'run', 'attack', 'hurt', 'die'],
|
||||
nextPointCost: 20,
|
||||
},
|
||||
],
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
updatedAt: '2026-04-14T10:00:00.000Z',
|
||||
updatedAt: '2026-04-17T12:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getCustomWorldAgentCardDetail).mockImplementation(
|
||||
async (_sessionId, cardId): Promise<CustomWorldDraftCardDetail> =>
|
||||
detailById[cardId] ?? detailById['world-foundation']!,
|
||||
);
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
});
|
||||
|
||||
test('workspace loads detail, saves edits, opens generate actions, and reflects updated drawer cards', async () => {
|
||||
test('workspace sends summary request from progress area', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExecuteAction = vi.fn();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={baseSession}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onRefresh={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
onSubmitMessage={onSubmitMessage}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getCustomWorldAgentCardDetail).toHaveBeenCalledWith(
|
||||
baseSession.sessionId,
|
||||
'world-foundation',
|
||||
);
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '总结当前设定' }));
|
||||
|
||||
expect(screen.getByText('卡片详情')).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText('输入消息')).toBeNull();
|
||||
expect(screen.queryByText('当前底稿已经可以继续精修。')).toBeNull();
|
||||
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '请总结一下当前已经成形的世界设定。',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '编辑设定' }));
|
||||
const summaryInput = screen.getByLabelText('摘要');
|
||||
await user.clear(summaryInput);
|
||||
await user.type(summaryInput, '这是更新后的世界摘要。');
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
test('workspace enables quick fill after at least two turns and submits quick fill request', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'update_draft_card',
|
||||
cardId: 'world-foundation',
|
||||
sections: [
|
||||
{
|
||||
sectionId: 'title',
|
||||
value: '潮雾列岛',
|
||||
},
|
||||
{
|
||||
sectionId: 'summary',
|
||||
value: '这是更新后的世界摘要。',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={baseSession}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={onSubmitMessage}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /沈砺/u }));
|
||||
await waitFor(() => {
|
||||
expect(getCustomWorldAgentCardDetail).toHaveBeenLastCalledWith(
|
||||
baseSession.sessionId,
|
||||
'character-1',
|
||||
);
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '补全剩余设定' }));
|
||||
|
||||
const [generateCharacterButton] = screen.getAllByRole('button', {
|
||||
name: '新增角色',
|
||||
});
|
||||
await user.click(generateCharacterButton!);
|
||||
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '生成角色' }));
|
||||
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '请补全剩余设定。',
|
||||
quickFillRequested: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_characters',
|
||||
count: 2,
|
||||
promptText: null,
|
||||
anchorCardIds: ['character-1'],
|
||||
});
|
||||
|
||||
const [generateLandmarkButton] = screen.getAllByRole('button', {
|
||||
name: '新增场景',
|
||||
});
|
||||
await user.click(generateLandmarkButton!);
|
||||
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '生成场景' }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_landmarks',
|
||||
count: 2,
|
||||
promptText: null,
|
||||
anchorCardIds: ['character-1'],
|
||||
});
|
||||
|
||||
const [openRoleAssetsButton] = screen.getAllByRole('button', {
|
||||
name: '角色资产',
|
||||
});
|
||||
await user.click(openRoleAssetsButton!);
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_role_assets',
|
||||
roleIds: ['character-1'],
|
||||
});
|
||||
|
||||
rerender(
|
||||
test('workspace hides quick fill before two turns', () => {
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
...baseSession,
|
||||
stage: 'visual_refining',
|
||||
draftCards: [
|
||||
...baseSession.draftCards,
|
||||
{
|
||||
id: 'character-2',
|
||||
kind: 'character',
|
||||
title: '顾潮音',
|
||||
subtitle: '回潮记录员',
|
||||
summary: '她会把每一次海雾异常都记到连自己都不愿复看的本子里。',
|
||||
status: 'suggested',
|
||||
linkedIds: ['thread-1'],
|
||||
warningCount: 0,
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-04-14T10:05:00.000Z',
|
||||
}}
|
||||
activeOperation={{
|
||||
operationId: 'operation-role-assets',
|
||||
type: 'generate_role_assets',
|
||||
status: 'completed',
|
||||
phaseLabel: '角色资产工坊已就绪',
|
||||
phaseDetail: '可以开始生成角色主图与动作。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
currentTurn: 1,
|
||||
}}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onRefresh={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('顾潮音')).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText('角色资产工坊:沈砺')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '补全剩余设定' })).toBeNull();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '模拟同步角色资产' }));
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'sync_role_assets',
|
||||
roleId: 'character-1',
|
||||
portraitPath: '/generated/character-1.png',
|
||||
generatedVisualAssetId: 'visual-character-1',
|
||||
generatedAnimationSetId: 'animation-set-character-1',
|
||||
animationMap: {
|
||||
idle: { basePath: '/generated/character-1/idle' },
|
||||
run: { basePath: '/generated/character-1/run' },
|
||||
attack: { basePath: '/generated/character-1/attack' },
|
||||
hurt: { basePath: '/generated/character-1/hurt' },
|
||||
die: { basePath: '/generated/character-1/die' },
|
||||
},
|
||||
});
|
||||
test('workspace exposes draft action when progress reaches 100', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
rerender(
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
...baseSession,
|
||||
stage: 'visual_refining',
|
||||
draftCards: [
|
||||
{
|
||||
...baseSession.draftCards[0]!,
|
||||
},
|
||||
{
|
||||
...baseSession.draftCards[1]!,
|
||||
subtitle: '守灯会旧友 / 动作已就绪',
|
||||
assetStatus: 'complete',
|
||||
assetStatusLabel: '动作已就绪',
|
||||
},
|
||||
],
|
||||
assetCoverage: {
|
||||
roleAssets: [
|
||||
{
|
||||
roleId: 'character-1',
|
||||
roleName: '沈砺',
|
||||
roleKind: 'story',
|
||||
priorityTier: 'featured',
|
||||
portraitPath: '/generated/character-1.png',
|
||||
generatedVisualAssetId: 'visual-character-1',
|
||||
generatedAnimationSetId: 'animation-set-character-1',
|
||||
status: 'complete',
|
||||
missingAnimations: [],
|
||||
nextPointCost: 0,
|
||||
},
|
||||
],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: true,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
draftProfile: {
|
||||
...baseSession.draftProfile,
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'character-1',
|
||||
name: '沈砺',
|
||||
title: '守灯会旧友',
|
||||
role: '航道向导',
|
||||
publicMask: '守灯会里最熟悉旧航道的人。',
|
||||
hiddenHook: '暗地里正在为沉船商盟引路。',
|
||||
relationToPlayer: '旧友兼宿敌',
|
||||
threadIds: ['thread-1'],
|
||||
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
imageSrc: '/generated/character-1.png',
|
||||
generatedVisualAssetId: 'visual-character-1',
|
||||
generatedAnimationSetId: 'animation-set-character-1',
|
||||
animationMap: {
|
||||
idle: { basePath: '/generated/character-1/idle' },
|
||||
run: { basePath: '/generated/character-1/run' },
|
||||
attack: { basePath: '/generated/character-1/attack' },
|
||||
hurt: { basePath: '/generated/character-1/hurt' },
|
||||
die: { basePath: '/generated/character-1/die' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
activeOperation={{
|
||||
operationId: 'operation-sync-role-assets',
|
||||
type: 'sync_role_assets',
|
||||
status: 'completed',
|
||||
phaseLabel: '角色资产已同步',
|
||||
phaseDetail: '角色资产已经写回草稿。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
progressPercent: 100,
|
||||
stage: 'foundation_review',
|
||||
}}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onRefresh={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('动作已就绪').length).toBeGreaterThan(0);
|
||||
await user.click(screen.getByRole('button', { name: '生成游戏设定草稿' }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,73 +3,59 @@ import { expect, test } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
|
||||
|
||||
test('custom world agent workspace renders draft workspace instead of chat after draft cards appear', () => {
|
||||
test('custom world agent workspace renders minimum loop chat layout', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
stage: 'object_refining',
|
||||
focusCardId: 'world-foundation',
|
||||
currentTurn: 3,
|
||||
anchorContent: {
|
||||
worldPromise: {
|
||||
hook: '一个被潮雾改写航线秩序的群岛世界。',
|
||||
differentiator: '所有人都要为每一次借路付出代价。',
|
||||
desiredExperience: '压迫、悬疑、带一点海上传奇感',
|
||||
},
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
progressPercent: 42,
|
||||
lastAssistantReply: '我先把世界底色收住了,接下来想确认玩家会怎么被卷进来。',
|
||||
stage: 'collecting_intent',
|
||||
focusCardId: null,
|
||||
creatorIntent: {},
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [
|
||||
'world_hook',
|
||||
isReady: false,
|
||||
completedKeys: ['world_hook'],
|
||||
missingKeys: [
|
||||
'player_premise',
|
||||
'theme_and_tone',
|
||||
'core_conflict',
|
||||
'relationship_seed',
|
||||
'iconic_element',
|
||||
],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: {},
|
||||
lockState: {},
|
||||
draftProfile: {
|
||||
name: '潮雾列岛',
|
||||
},
|
||||
draftProfile: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'summary',
|
||||
text: '欢迎。当前底稿已经可以继续精修。',
|
||||
kind: 'chat',
|
||||
text: '先告诉我你想做一个怎样的世界。',
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: null,
|
||||
},
|
||||
],
|
||||
draftCards: [
|
||||
{
|
||||
id: 'world-foundation',
|
||||
kind: 'world',
|
||||
title: '潮雾列岛',
|
||||
subtitle: '旧灯塔与航道争夺',
|
||||
summary: '世界总卡已经生成。',
|
||||
status: 'warning',
|
||||
linkedIds: ['thread-1', 'character-1'],
|
||||
warningCount: 1,
|
||||
},
|
||||
{
|
||||
id: 'character-1',
|
||||
kind: 'character',
|
||||
title: '沈砺',
|
||||
subtitle: '守灯会旧友',
|
||||
summary: '他最了解旧航道,也最可能先背叛。',
|
||||
status: 'suggested',
|
||||
linkedIds: ['thread-1'],
|
||||
warningCount: 0,
|
||||
},
|
||||
],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [
|
||||
{
|
||||
id: 'request-summary',
|
||||
type: 'request_summary',
|
||||
label: '总结当前世界底稿',
|
||||
targetId: null,
|
||||
},
|
||||
],
|
||||
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定'],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
@@ -81,17 +67,20 @@ test('custom world agent workspace renders draft workspace instead of chat after
|
||||
}}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onRefresh={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('卡片详情');
|
||||
expect(html).toContain('快捷动作');
|
||||
expect(html).toContain('草稿抽屉');
|
||||
expect(html).not.toContain('首轮草稿会先确认这 6 项信息');
|
||||
expect(html).not.toContain('现在开始生成草稿');
|
||||
expect(html).not.toContain('欢迎。当前底稿已经可以继续精修。');
|
||||
expect(html).not.toContain('输入消息');
|
||||
expect(html).toContain('创作进度');
|
||||
expect(html).toContain('42%');
|
||||
expect(html).toContain('输入消息');
|
||||
expect(html).toContain('总结当前设定');
|
||||
expect(html).toContain('补全剩余设定');
|
||||
expect(html).not.toContain('Agent');
|
||||
expect(html).not.toContain('刷新');
|
||||
expect(html).not.toContain('当前轮次');
|
||||
expect(html).not.toContain('当前状态');
|
||||
expect(html).not.toContain('草稿抽屉');
|
||||
expect(html).not.toContain('快捷动作');
|
||||
});
|
||||
|
||||
@@ -1,60 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldAgentActionRequest,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldDraftCardDetail,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
|
||||
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
|
||||
import { CustomWorldAgentComposer } from './CustomWorldAgentComposer';
|
||||
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
|
||||
import { CustomWorldAgentDraftDrawer } from './CustomWorldAgentDraftDrawer';
|
||||
import { CustomWorldAgentHeader } from './CustomWorldAgentHeader';
|
||||
import { CustomWorldAgentOperationBanner } from './CustomWorldAgentOperationBanner';
|
||||
import { CustomWorldAgentQuickActions } from './CustomWorldAgentQuickActions';
|
||||
import { CustomWorldAgentThread } from './CustomWorldAgentThread';
|
||||
import { CustomWorldDraftCardDetailModal } from './CustomWorldDraftCardDetailModal';
|
||||
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
|
||||
|
||||
type WorkspaceRoleAssetTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, unknown>;
|
||||
};
|
||||
import { EightAnchorProgressBar } from './EightAnchorProgressBar';
|
||||
|
||||
type CustomWorldAgentWorkspaceProps = {
|
||||
session: CustomWorldAgentSessionSnapshot | null;
|
||||
activeOperation: CustomWorldAgentOperationRecord | null;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
onBack: () => void;
|
||||
onRefresh: () => void;
|
||||
onSubmitMessage: (payload: SendCustomWorldAgentMessageRequest) => void;
|
||||
onExecuteAction: (payload: CustomWorldAgentActionRequest) => void;
|
||||
};
|
||||
|
||||
const TOTAL_READINESS_STEPS = 6;
|
||||
const READINESS_ITEMS = [
|
||||
{ key: 'world_hook', label: '世界核心' },
|
||||
{ key: 'player_premise', label: '玩家开局' },
|
||||
{ key: 'theme_and_tone', label: '主题气质' },
|
||||
{ key: 'core_conflict', label: '核心冲突' },
|
||||
{ key: 'relationship_seed', label: '关键关系' },
|
||||
{ key: 'iconic_element', label: '标志元素' },
|
||||
] as const;
|
||||
|
||||
function createClientMessageId() {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
@@ -66,284 +31,15 @@ function createClientMessageId() {
|
||||
return `client-message-${Date.now()}`;
|
||||
}
|
||||
|
||||
function resolveInitialCardId(session: CustomWorldAgentSessionSnapshot | null) {
|
||||
if (!session || session.draftCards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
session.focusCardId ||
|
||||
session.draftCards.find((card) => card.kind === 'world')?.id ||
|
||||
session.draftCards[0]?.id ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function buildRecommendedReplies(session: CustomWorldAgentSessionSnapshot) {
|
||||
return session.recommendedReplies;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function resolveRoleAssetTarget(
|
||||
session: CustomWorldAgentSessionSnapshot | null,
|
||||
roleId: string | null,
|
||||
) {
|
||||
if (!session || !roleId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const draftProfile = toRecord(session.draftProfile);
|
||||
if (!draftProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const playableRole = toRecordArray(draftProfile.playableNpcs).find(
|
||||
(item) => toText(item.id) === roleId,
|
||||
);
|
||||
const storyRole = toRecordArray(draftProfile.storyNpcs).find(
|
||||
(item) => toText(item.id) === roleId,
|
||||
);
|
||||
const role = playableRole ?? storyRole;
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assetSummary =
|
||||
session.assetCoverage.roleAssets.find((entry) => entry.roleId === roleId) ??
|
||||
null;
|
||||
|
||||
return {
|
||||
role: {
|
||||
id: roleId,
|
||||
name: toText(role.name) || '未命名角色',
|
||||
title: toText(role.title) || toText(role.role) || '关键角色',
|
||||
role: toText(role.role) || toText(role.title) || '关键角色',
|
||||
description: toText(role.summary),
|
||||
backstory: toText(role.hiddenHook) || undefined,
|
||||
personality: toText(role.publicMask) || undefined,
|
||||
motivation: toText(role.relationToPlayer) || undefined,
|
||||
combatStyle: toText(role.role) || undefined,
|
||||
tags: Array.isArray(role.threadIds)
|
||||
? role.threadIds
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
: [],
|
||||
imageSrc: toText(role.imageSrc) || undefined,
|
||||
generatedVisualAssetId: toText(role.generatedVisualAssetId) || undefined,
|
||||
generatedAnimationSetId:
|
||||
toText(role.generatedAnimationSetId) || undefined,
|
||||
animationMap: toRecord(role.animationMap) ?? undefined,
|
||||
} satisfies WorkspaceRoleAssetTarget,
|
||||
roleKind: playableRole ? ('playable' as const) : ('story' as const),
|
||||
assetSummary,
|
||||
};
|
||||
}
|
||||
|
||||
function CustomWorldAgentReadinessBar(props: {
|
||||
completedKeys: string[];
|
||||
isReady: boolean;
|
||||
busy: boolean;
|
||||
onStartDraft: () => void;
|
||||
}) {
|
||||
const { completedKeys, isReady, busy, onStartDraft } = props;
|
||||
const completedKeySet = new Set(completedKeys);
|
||||
const completedCount = READINESS_ITEMS.filter((item) =>
|
||||
completedKeySet.has(item.key),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-[0.12em] text-zinc-300">
|
||||
首轮草稿会先确认这 6 项信息
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{Math.min(completedCount, TOTAL_READINESS_STEPS)}/
|
||||
{TOTAL_READINESS_STEPS}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2 sm:grid-cols-6">
|
||||
{READINESS_ITEMS.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`rounded-2xl border px-2.5 py-2 text-center text-[11px] ${
|
||||
completedKeySet.has(item.key)
|
||||
? 'border-emerald-300/25 bg-emerald-500/10 text-emerald-100'
|
||||
: 'border-white/10 bg-black/18 text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-end">
|
||||
{isReady ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartDraft}
|
||||
disabled={busy}
|
||||
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{busy ? '生成中' : '开始生成草稿'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomWorldAgentWorkspace({
|
||||
session,
|
||||
activeOperation,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
onBack,
|
||||
onSubmitMessage,
|
||||
onExecuteAction,
|
||||
}: CustomWorldAgentWorkspaceProps) {
|
||||
const [selectedCardId, setSelectedCardId] = useState<string | null>(() =>
|
||||
resolveInitialCardId(session),
|
||||
);
|
||||
const [detail, setDetail] = useState<CustomWorldDraftCardDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [autoCompleteConfirmOpen, setAutoCompleteConfirmOpen] = useState(false);
|
||||
const [generateEntityMode, setGenerateEntityMode] = useState<
|
||||
'character' | 'landmark' | null
|
||||
>(null);
|
||||
const [requestedRoleAssetTargetId, setRequestedRoleAssetTargetId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [activeRoleAssetTargetId, setActiveRoleAssetTargetId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [showRoleAssetStudio, setShowRoleAssetStudio] = useState(false);
|
||||
const [closeRoleAssetStudioAfterSync, setCloseRoleAssetStudioAfterSync] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
setSelectedCardId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const availableCardIds = new Set(session.draftCards.map((card) => card.id));
|
||||
if (session.focusCardId && availableCardIds.has(session.focusCardId)) {
|
||||
setSelectedCardId(session.focusCardId);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCardId((current) => {
|
||||
if (current && availableCardIds.has(current)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return resolveInitialCardId(session);
|
||||
});
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditMode(false);
|
||||
}, [detail?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedRoleAssetTargetId || !activeOperation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeOperation.type !== 'generate_role_assets') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeOperation.status === 'completed') {
|
||||
setActiveRoleAssetTargetId(requestedRoleAssetTargetId);
|
||||
setShowRoleAssetStudio(true);
|
||||
setRequestedRoleAssetTargetId(null);
|
||||
setDetailModalOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeOperation.status === 'failed') {
|
||||
setRequestedRoleAssetTargetId(null);
|
||||
}
|
||||
}, [activeOperation, requestedRoleAssetTargetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeOperation || activeOperation.type !== 'sync_role_assets') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeOperation.status === 'completed') {
|
||||
if (closeRoleAssetStudioAfterSync) {
|
||||
setShowRoleAssetStudio(false);
|
||||
}
|
||||
setCloseRoleAssetStudioAfterSync(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeOperation.status === 'failed') {
|
||||
setCloseRoleAssetStudioAfterSync(false);
|
||||
}
|
||||
}, [activeOperation, closeRoleAssetStudioAfterSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.sessionId || !selectedCardId) {
|
||||
setDetail(null);
|
||||
setDetailLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setDetailLoading(true);
|
||||
|
||||
void getCustomWorldAgentCardDetail(session.sessionId, selectedCardId)
|
||||
.then((nextDetail) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDetail(nextDetail);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDetail(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedCardId, session?.sessionId, session?.updatedAt]);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
|
||||
@@ -354,339 +50,58 @@ export function CustomWorldAgentWorkspace({
|
||||
|
||||
const isBusy =
|
||||
activeOperation?.status === 'queued' ||
|
||||
activeOperation?.status === 'running';
|
||||
const canStartDraft =
|
||||
session.creatorIntentReadiness.isReady &&
|
||||
session.stage === 'foundation_review';
|
||||
const showAutoCompleteButton =
|
||||
!session.creatorIntentReadiness.isReady &&
|
||||
session.creatorIntentReadiness.completedKeys.includes('world_hook');
|
||||
const showDraftWorkspace =
|
||||
session.stage !== 'foundation_review' && session.draftCards.length > 0;
|
||||
const showAgentConversation = !showDraftWorkspace;
|
||||
const selectedCard =
|
||||
session.draftCards.find((card) => card.id === selectedCardId) ?? null;
|
||||
const recommendedReplies = buildRecommendedReplies(session);
|
||||
const selectedRoleAssetContext = resolveRoleAssetTarget(
|
||||
session,
|
||||
activeRoleAssetTargetId,
|
||||
);
|
||||
activeOperation?.status === 'running' ||
|
||||
isStreamingReply;
|
||||
|
||||
const openRoleAssetStudio = (roleId: string | null) => {
|
||||
if (!roleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRequestedRoleAssetTargetId(roleId);
|
||||
onExecuteAction({
|
||||
action: 'generate_role_assets',
|
||||
roleIds: [roleId],
|
||||
});
|
||||
};
|
||||
|
||||
const submitTextMessage = (text: string) => {
|
||||
const submitMessage = (text: string, quickFillRequested = false) => {
|
||||
onSubmitMessage({
|
||||
clientMessageId: createClientMessageId(),
|
||||
text,
|
||||
focusCardId: selectedCardId,
|
||||
selectedCardIds: selectedCardId ? [selectedCardId] : [],
|
||||
quickFillRequested,
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
});
|
||||
};
|
||||
|
||||
const submitSummaryRequest = () => {
|
||||
submitTextMessage(
|
||||
showDraftWorkspace
|
||||
? '帮我总结当前世界底稿,并指出下一步最值得精修的卡片。'
|
||||
: '帮我总结当前设定,并指出下一步最值得补的世界锚点。',
|
||||
);
|
||||
};
|
||||
|
||||
const submitAutoCompleteRequest = () => {
|
||||
submitTextMessage(
|
||||
session.creatorIntentReadiness.isReady
|
||||
? '基于当前设定,帮我自动补强还可以更清晰的细节。'
|
||||
: '请根据当前信息自动补全还缺的设定,并给我一版默认方案。',
|
||||
);
|
||||
setAutoCompleteConfirmOpen(false);
|
||||
};
|
||||
|
||||
const handleRecommendedReply = (reply: string) => {
|
||||
if (canStartDraft && reply.includes('生成草稿')) {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
submitTextMessage(reply);
|
||||
};
|
||||
|
||||
const openGenerateModal = (mode: 'character' | 'landmark') => {
|
||||
setGenerateEntityMode(mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1500px] flex-col gap-3">
|
||||
{!showDraftWorkspace ? <CustomWorldAgentHeader onBack={onBack} /> : null}
|
||||
{!showDraftWorkspace ? (
|
||||
<CustomWorldAgentReadinessBar
|
||||
completedKeys={session.creatorIntentReadiness.completedKeys}
|
||||
isReady={canStartDraft}
|
||||
busy={isBusy}
|
||||
onStartDraft={() => {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<CustomWorldAgentOperationBanner operation={activeOperation} />
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<CustomWorldAgentHeader onBack={onBack} />
|
||||
|
||||
{showDraftWorkspace ? (
|
||||
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[20rem_minmax(0,1fr)]">
|
||||
<div className="flex min-h-0 flex-col gap-3 xl:overflow-hidden">
|
||||
<CustomWorldAgentQuickActions
|
||||
suggestedActions={session.suggestedActions}
|
||||
disabled={isBusy}
|
||||
canDraftFoundation={canStartDraft}
|
||||
showEntityActions
|
||||
showSummaryAction={false}
|
||||
showRoleAssetAction={selectedCard?.kind === 'character'}
|
||||
onRequestSummary={submitSummaryRequest}
|
||||
onDraftFoundation={() => {
|
||||
onExecuteAction({
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
}}
|
||||
onGenerateCharacter={() => {
|
||||
openGenerateModal('character');
|
||||
}}
|
||||
onGenerateLandmark={() => {
|
||||
openGenerateModal('landmark');
|
||||
}}
|
||||
onGenerateRoleAssets={() => {
|
||||
openRoleAssetStudio(selectedCardId);
|
||||
}}
|
||||
onFocusSuggestedAction={(action) => {
|
||||
if (action?.targetId) {
|
||||
setSelectedCardId(action.targetId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.draftCards[0]) {
|
||||
setSelectedCardId(session.draftCards[0].id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="xl:min-h-0 xl:overflow-y-auto">
|
||||
<CustomWorldAgentDraftDrawer
|
||||
draftCards={session.draftCards}
|
||||
activeCardId={selectedCardId}
|
||||
onSelectCard={(cardId) => {
|
||||
setSelectedCardId(cardId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 xl:overflow-y-auto">
|
||||
<CustomWorldAgentDraftDetailPanel
|
||||
detail={detail}
|
||||
loading={detailLoading}
|
||||
busy={isBusy}
|
||||
editMode={editMode}
|
||||
onClose={() => {
|
||||
setSelectedCardId(null);
|
||||
setDetailModalOpen(false);
|
||||
setEditMode(false);
|
||||
}}
|
||||
onStartEdit={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
onCancelEdit={() => {
|
||||
setEditMode(false);
|
||||
}}
|
||||
onSave={(sections) => {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditMode(false);
|
||||
onExecuteAction({
|
||||
action: 'update_draft_card',
|
||||
cardId: detail.id,
|
||||
sections,
|
||||
});
|
||||
}}
|
||||
onGenerateCharacter={() => {
|
||||
openGenerateModal('character');
|
||||
}}
|
||||
onGenerateLandmark={() => {
|
||||
openGenerateModal('landmark');
|
||||
}}
|
||||
onOpenRoleAssetStudio={() => {
|
||||
openRoleAssetStudio(detail?.id ?? selectedCardId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showAgentConversation ? (
|
||||
<>
|
||||
<CustomWorldAgentThread
|
||||
messages={session.messages}
|
||||
recommendedReplies={recommendedReplies}
|
||||
onRecommendedReply={handleRecommendedReply}
|
||||
/>
|
||||
<CustomWorldAgentComposer
|
||||
disabled={isBusy}
|
||||
onSubmit={onSubmitMessage}
|
||||
onSummaryClick={submitSummaryRequest}
|
||||
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
|
||||
showAutoComplete={showAutoCompleteButton}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{autoCompleteConfirmOpen ? (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-[1.5rem] border border-white/10 bg-[#111318] px-5 py-5 shadow-[0_20px_60px_rgba(0,0,0,0.45)]">
|
||||
<div className="text-base font-semibold text-white">
|
||||
自动补全剩余设定
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-7 text-zinc-300">
|
||||
自动补全会直接给缺失设定填入默认方案,可能降低作品质量。
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoCompleteConfirmOpen(false)}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitAutoCompleteRequest}
|
||||
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 transition hover:text-white"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CustomWorldDraftCardDetailModal
|
||||
open={detailModalOpen}
|
||||
detail={detail}
|
||||
loading={detailLoading}
|
||||
busy={isBusy}
|
||||
editMode={editMode}
|
||||
onClose={() => {
|
||||
setDetailModalOpen(false);
|
||||
setEditMode(false);
|
||||
}}
|
||||
onStartEdit={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
onCancelEdit={() => {
|
||||
setEditMode(false);
|
||||
}}
|
||||
onSave={(sections) => {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditMode(false);
|
||||
setDetailModalOpen(false);
|
||||
onExecuteAction({
|
||||
action: 'update_draft_card',
|
||||
cardId: detail.id,
|
||||
sections,
|
||||
});
|
||||
}}
|
||||
onGenerateCharacter={() => {
|
||||
setDetailModalOpen(false);
|
||||
openGenerateModal('character');
|
||||
}}
|
||||
onGenerateLandmark={() => {
|
||||
setDetailModalOpen(false);
|
||||
openGenerateModal('landmark');
|
||||
}}
|
||||
onOpenRoleAssetStudio={() => {
|
||||
setDetailModalOpen(false);
|
||||
openRoleAssetStudio(detail?.id ?? selectedCardId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CustomWorldGenerateEntityModal
|
||||
open={generateEntityMode !== null}
|
||||
mode={generateEntityMode ?? 'character'}
|
||||
anchorCardTitle={selectedCard?.title ?? detail?.title ?? null}
|
||||
<EightAnchorProgressBar
|
||||
currentTurn={session.currentTurn}
|
||||
progressPercent={session.progressPercent}
|
||||
disabled={isBusy}
|
||||
onClose={() => {
|
||||
setGenerateEntityMode(null);
|
||||
onSummaryClick={() => {
|
||||
submitMessage('请总结一下当前已经成形的世界设定。');
|
||||
}}
|
||||
onSubmit={({ count, promptText }) => {
|
||||
if (!generateEntityMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
onQuickFill={() => {
|
||||
submitMessage('请补全剩余设定。', true);
|
||||
}}
|
||||
onGenerateDraft={() => {
|
||||
onExecuteAction({
|
||||
action:
|
||||
generateEntityMode === 'character'
|
||||
? 'generate_characters'
|
||||
: 'generate_landmarks',
|
||||
count,
|
||||
promptText: promptText || null,
|
||||
anchorCardIds: selectedCardId ? [selectedCardId] : [],
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
setGenerateEntityMode(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{showRoleAssetStudio && selectedRoleAssetContext ? (
|
||||
<CustomWorldRoleAssetStudioModal
|
||||
role={selectedRoleAssetContext.role}
|
||||
roleKind={selectedRoleAssetContext.roleKind}
|
||||
priorityTier={
|
||||
selectedRoleAssetContext.assetSummary?.priorityTier ??
|
||||
(selectedRoleAssetContext.roleKind === 'playable'
|
||||
? 'hero'
|
||||
: 'featured')
|
||||
}
|
||||
visualPointCost={
|
||||
selectedRoleAssetContext.assetSummary?.status === 'missing'
|
||||
? (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20)
|
||||
: 20
|
||||
}
|
||||
animationPointCost={
|
||||
selectedRoleAssetContext.assetSummary?.status === 'missing'
|
||||
? 60
|
||||
: (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60)
|
||||
}
|
||||
syncBusy={
|
||||
activeOperation?.type === 'sync_role_assets' &&
|
||||
(activeOperation.status === 'queued' ||
|
||||
activeOperation.status === 'running')
|
||||
}
|
||||
onPublishSuccess={(payload, options) => {
|
||||
setCloseRoleAssetStudioAfterSync(Boolean(options?.closeAfterSync));
|
||||
onExecuteAction({
|
||||
action: 'sync_role_assets',
|
||||
...payload,
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowRoleAssetStudio(false);
|
||||
setCloseRoleAssetStudioAfterSync(false);
|
||||
}}
|
||||
/>
|
||||
{activeOperation?.type !== 'process_message' ? (
|
||||
<CustomWorldAgentOperationBanner operation={activeOperation} />
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="h-full min-h-[18rem] lg:min-h-0">
|
||||
<CustomWorldAgentThread
|
||||
messages={session.messages}
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={isStreamingReply}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomWorldAgentComposer
|
||||
disabled={isBusy}
|
||||
onSubmit={onSubmitMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
105
src/components/custom-world-agent/EightAnchorProgressBar.tsx
Normal file
105
src/components/custom-world-agent/EightAnchorProgressBar.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
type EightAnchorProgressBarProps = {
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
disabled: boolean;
|
||||
onSummaryClick: () => void;
|
||||
onQuickFill: () => void;
|
||||
onGenerateDraft: () => void;
|
||||
};
|
||||
|
||||
function clampProgress(progressPercent: number) {
|
||||
if (!Number.isFinite(progressPercent)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progressPercent)));
|
||||
}
|
||||
|
||||
function resolveProgressHint(progressPercent: number) {
|
||||
if (progressPercent >= 100) {
|
||||
return '当前设定已经收束完成,可以进入草稿生成';
|
||||
}
|
||||
|
||||
if (progressPercent >= 75) {
|
||||
return '正在收束成一版可进入草稿的世界底子';
|
||||
}
|
||||
|
||||
if (progressPercent >= 45) {
|
||||
return '世界方向已经成形,继续补关键骨架';
|
||||
}
|
||||
|
||||
if (progressPercent >= 15) {
|
||||
return '先把玩家视角、开局和冲突线钉稳';
|
||||
}
|
||||
|
||||
return '先抓住这个世界最关键的方向';
|
||||
}
|
||||
|
||||
export function EightAnchorProgressBar({
|
||||
currentTurn,
|
||||
progressPercent,
|
||||
disabled,
|
||||
onSummaryClick,
|
||||
onQuickFill,
|
||||
onGenerateDraft,
|
||||
}: EightAnchorProgressBarProps) {
|
||||
const normalizedProgress = clampProgress(progressPercent);
|
||||
const isCompleted = normalizedProgress >= 100;
|
||||
const canQuickFill = currentTurn >= 2;
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-[0.14em] text-zinc-300">
|
||||
创作进度
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
{resolveProgressHint(normalizedProgress)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-white">
|
||||
{normalizedProgress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-3 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,#d8ffd9_0%,#6ee7b7_45%,#34d399_100%)] transition-[width] duration-500"
|
||||
style={{ width: `${Math.max(6, normalizedProgress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSummaryClick}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
总结当前设定
|
||||
</button>
|
||||
{isCompleted ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGenerateDraft}
|
||||
disabled={disabled}
|
||||
className="flex min-h-[3rem] items-center justify-center rounded-[1.1rem] border border-emerald-300/25 bg-emerald-500/12 px-4 py-3 text-sm font-semibold text-emerald-50 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
生成游戏设定草稿
|
||||
</button>
|
||||
) : canQuickFill ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onQuickFill}
|
||||
disabled={disabled}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
补全剩余设定
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user