1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-18 19:40:33 +08:00
parent 54b3d3c490
commit 8c3fbd9bcf
15 changed files with 904 additions and 65 deletions

View File

@@ -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('换一换');
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: [],
}),
);
});

View File

@@ -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}
/>