@@ -1,8 +1,8 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { AdventurePanel } from './AdventurePanel';
|
||||
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../types';
|
||||
import { AdventurePanel } from './AdventurePanel';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
@@ -43,17 +43,29 @@ function createOption(functionId: string, actionText: string): StoryOption {
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(currentStory: StoryMoment, displayedOptions: StoryOption[]) {
|
||||
function renderPanel(
|
||||
currentStory: StoryMoment,
|
||||
displayedOptions: StoryOption[],
|
||||
overrides: {
|
||||
canRefreshOptions?: boolean;
|
||||
hideOptions?: boolean;
|
||||
isLoading?: boolean;
|
||||
onSubmitNpcChatInput?: (input: string) => boolean;
|
||||
onExitNpcChat?: () => boolean;
|
||||
} = {},
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<AdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={false}
|
||||
isLoading={overrides.isLoading ?? false}
|
||||
displayedOptions={displayedOptions}
|
||||
hideOptions={false}
|
||||
canRefreshOptions={false}
|
||||
hideOptions={overrides.hideOptions ?? false}
|
||||
canRefreshOptions={overrides.canRefreshOptions ?? false}
|
||||
onRefreshOptions={() => undefined}
|
||||
onChoice={() => undefined}
|
||||
onSubmitNpcChatInput={overrides.onSubmitNpcChatInput}
|
||||
onExitNpcChat={overrides.onExitNpcChat}
|
||||
onOpenCharacter={() => undefined}
|
||||
onOpenInventory={() => undefined}
|
||||
playerCharacter={createCharacter()}
|
||||
@@ -129,3 +141,36 @@ test('adventure panel does not show deferred hint for non-continue options with
|
||||
|
||||
expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项');
|
||||
});
|
||||
|
||||
test('adventure panel shows npc chat custom input and exit button in chat mode', () => {
|
||||
const optionA = createOption('npc_chat', '先听对方把话说完');
|
||||
const optionB = createOption('npc_chat', '顺着这个问题继续追问');
|
||||
const optionC = createOption('npc_chat', '换个更轻松的语气回应');
|
||||
const currentStory: StoryMoment = {
|
||||
text: '你们的对话正在继续。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你刚才那句话是什么意思?' },
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' },
|
||||
{ speaker: 'system', text: '关系升温 好感 +3', affinityDelta: 3 },
|
||||
],
|
||||
options: [optionA, optionB, optionC],
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [optionA, optionB, optionC], {
|
||||
canRefreshOptions: true,
|
||||
onSubmitNpcChatInput: () => true,
|
||||
onExitNpcChat: () => true,
|
||||
});
|
||||
|
||||
expect(html).toContain('退出聊天');
|
||||
expect(html).toContain('输入你想对 TA 说的话');
|
||||
expect(html).toContain('发送');
|
||||
expect(html).not.toContain('换一换');
|
||||
});
|
||||
|
||||
@@ -1051,7 +1051,16 @@ export function AdventurePanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canRefreshOptions && !shouldHideChoiceUi && (
|
||||
{isNpcChatMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExitNpcChat?.()}
|
||||
aria-label="退出聊天"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
|
||||
>
|
||||
<span className="text-xs leading-none">退出聊天</span>
|
||||
</button>
|
||||
) : canRefreshOptions && !shouldHideChoiceUi ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefreshOptions}
|
||||
@@ -1064,7 +1073,7 @@ export function AdventurePanel({
|
||||
/>
|
||||
<span className="text-xs leading-none">换一换</span>
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -1082,7 +1091,8 @@ export function AdventurePanel({
|
||||
) : shouldHideChoiceUi ? (
|
||||
<div className="p-4" aria-hidden="true" />
|
||||
) : (
|
||||
displayedOptions.map((option, index) => {
|
||||
<>
|
||||
{displayedOptions.map((option, index) => {
|
||||
const optionImpactSummary = getOptionImpactSummary(
|
||||
option,
|
||||
playerCharacter,
|
||||
@@ -1146,24 +1156,59 @@ export function AdventurePanel({
|
||||
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
{getCompactOptionDetailText(option) && (
|
||||
{!isNpcChatMode && getCompactOptionDetailText(option) && (
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||||
{getCompactOptionDetailText(option)}
|
||||
</div>
|
||||
)}
|
||||
{option.goalAffordance?.label && (
|
||||
{!isNpcChatMode && option.goalAffordance?.label && (
|
||||
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
|
||||
{option.goalAffordance.label}
|
||||
</div>
|
||||
)}
|
||||
{optionImpactSummary && (
|
||||
{!isNpcChatMode && optionImpactSummary && (
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
{optionImpactSummary}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
})}
|
||||
{isNpcChatMode ? (
|
||||
<div className="pixel-nine-slice pixel-panel mt-1 border border-white/10 bg-black/25 p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={npcChatDraft}
|
||||
onChange={(event) => setNpcChatDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
submitNpcChatDraft();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
npcChatState?.customInputPlaceholder ??
|
||||
'输入你想说的话'
|
||||
}
|
||||
className="h-9 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
|
||||
maxLength={80}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitNpcChatDraft}
|
||||
disabled={isLoading || !npcChatDraft.trim()}
|
||||
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-3 text-xs text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function CustomWorldAgentComposer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="shrink-0">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -55,16 +55,16 @@ export function CustomWorldAgentComposer({
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
placeholder="输入消息"
|
||||
className="w-full resize-none rounded-[1.35rem] border border-white/10 bg-black/30 px-4 pb-12 pr-20 pt-3 text-sm leading-6 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="min-h-[5.5rem] w-full resize-none rounded-[1.35rem] border border-white/10 bg-[#111318]/92 px-4 pb-11 pr-18 pt-2.5 text-sm leading-5.5 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={disabled || !text.trim()}
|
||||
className="absolute bottom-3 right-3 rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
className="absolute bottom-2.5 right-2.5 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"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function CustomWorldAgentThread({
|
||||
}, [messages, streamingReplyText, isStreamingReply]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-y-auto px-1 py-2 sm:px-2">
|
||||
{messages.length === 0 ? (
|
||||
<div className="m-auto text-sm text-zinc-400">
|
||||
暂无消息
|
||||
@@ -68,13 +68,13 @@ export function CustomWorldAgentThread({
|
||||
{!isUser &&
|
||||
index === lastAssistantMessageIndex &&
|
||||
visibleRecommendedReplies.length > 0 ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<div className="mt-2.5 flex flex-col gap-1.5">
|
||||
{visibleRecommendedReplies.map((reply, replyIndex) => (
|
||||
<button
|
||||
key={`recommended-reply-${replyIndex}-${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"
|
||||
className="rounded-[0.95rem] border border-white/10 bg-white/5 px-2.5 py-1.5 text-left text-[11px] leading-4.5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"
|
||||
>
|
||||
{reply}
|
||||
</button>
|
||||
|
||||
@@ -159,3 +159,34 @@ test('workspace exposes draft action when progress reaches 100', async () => {
|
||||
action: 'draft_foundation',
|
||||
});
|
||||
});
|
||||
|
||||
test('workspace submits recommended reply from thread', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmitMessage = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldAgentWorkspace
|
||||
session={{
|
||||
...baseSession,
|
||||
recommendedReplies: ['继续补充这个世界的核心冲突'],
|
||||
}}
|
||||
activeOperation={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={onSubmitMessage}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '继续补充这个世界的核心冲突' }),
|
||||
);
|
||||
|
||||
expect(onSubmitMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '继续补充这个世界的核心冲突',
|
||||
quickFillRequested: false,
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -92,6 +92,10 @@ export function CustomWorldAgentWorkspace({
|
||||
<div className="h-full min-h-[18rem] lg:min-h-0">
|
||||
<CustomWorldAgentThread
|
||||
messages={session.messages}
|
||||
recommendedReplies={session.recommendedReplies}
|
||||
onRecommendedReply={(text) => {
|
||||
submitMessage(text);
|
||||
}}
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={isStreamingReply}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user