Integrate role asset studio into custom world agent flow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user