1
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
# AGENTS.md
|
||||
|
||||
## 项目约束
|
||||
- 前端工程node版本使用22.22.2
|
||||
- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。
|
||||
- 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中
|
||||
- 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。
|
||||
- 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。
|
||||
|
||||
@@ -166,12 +166,14 @@ export type NpcChatTurnRequest<
|
||||
TNpcState = unknown,
|
||||
> = {
|
||||
worldType: string;
|
||||
character: TCharacter;
|
||||
character?: TCharacter;
|
||||
player?: TCharacter;
|
||||
encounter: TEncounter;
|
||||
monsters: TMonster[];
|
||||
history: TStoryMoment[];
|
||||
context: TContext;
|
||||
conversationHistory: TConversationTurn[];
|
||||
conversationHistory?: TConversationTurn[];
|
||||
dialogue?: TConversationTurn[];
|
||||
playerMessage: string;
|
||||
npcState: TNpcState;
|
||||
};
|
||||
|
||||
@@ -356,6 +356,12 @@ function buildNpcDialoguePromptBase(
|
||||
payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const character =
|
||||
(payload as NpcChatTurnRequest).character ??
|
||||
(payload as NpcChatTurnRequest).player;
|
||||
if (!(payload as NpcChatTurnRequest).character && character) {
|
||||
(payload as NpcChatTurnRequest).character = character;
|
||||
}
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(payload.worldType)}`,
|
||||
@@ -422,12 +428,16 @@ export function buildNpcChatTurnReplyPrompt(
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const npcState = asRecord(payload.npcState);
|
||||
const conversationHistory =
|
||||
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
|
||||
? payload.conversationHistory
|
||||
: payload.dialogue ?? payload.conversationHistory ?? [];
|
||||
const affinity = readNumber(npcState?.affinity, 0);
|
||||
const chattedCount = readNumber(npcState?.chattedCount, 0);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(payload.conversationHistory, encounter.npcName),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
`当前关系值:${affinity}`,
|
||||
`已聊天轮次:${chattedCount}`,
|
||||
`玩家刚刚说:${payload.playerMessage}`,
|
||||
@@ -442,10 +452,14 @@ export function buildNpcChatTurnSuggestionPrompt(
|
||||
npcReply: string,
|
||||
) {
|
||||
const encounter = describeEncounter(payload.encounter);
|
||||
const conversationHistory =
|
||||
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
|
||||
? payload.conversationHistory
|
||||
: payload.dialogue ?? payload.conversationHistory ?? [];
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(payload.conversationHistory, encounter.npcName),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
`玩家刚刚说:${payload.playerMessage}`,
|
||||
`NPC 刚刚回复:${npcReply}`,
|
||||
`请围绕刚刚这轮对话,为玩家生成 3 条可以继续和 ${encounter.npcName} 聊下去的中文短句候选。`,
|
||||
|
||||
43
server-node/src/services/chatService.test.ts
Normal file
43
server-node/src/services/chatService.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { npcChatTurnRequestSchema } from './chatService.js';
|
||||
|
||||
test('npc chat turn schema normalizes player and dialogue aliases', () => {
|
||||
const payload = npcChatTurnRequestSchema.parse({
|
||||
worldType: 'WUXIA',
|
||||
player: {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
},
|
||||
monsters: [],
|
||||
history: [],
|
||||
context: {
|
||||
sceneName: '客栈内室',
|
||||
},
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '你刚才那句话是什么意思?',
|
||||
},
|
||||
],
|
||||
playerMessage: '你能说得再明白一点吗?',
|
||||
npcState: {
|
||||
affinity: 4,
|
||||
chattedCount: 1,
|
||||
recruited: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(payload.character.name, '沈行');
|
||||
assert.deepEqual(payload.conversationHistory, [
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '你刚才那句话是什么意思?',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -23,7 +23,8 @@ const baseCharacterChatSchema = z.object({
|
||||
|
||||
const baseNpcChatSchema = z.object({
|
||||
worldType: z.string().trim().min(1),
|
||||
character: jsonObjectSchema,
|
||||
character: jsonObjectSchema.optional(),
|
||||
player: jsonObjectSchema.optional(),
|
||||
encounter: jsonObjectSchema,
|
||||
monsters: z.array(jsonObjectSchema).default([]),
|
||||
history: z.array(jsonObjectSchema).default([]),
|
||||
@@ -47,17 +48,35 @@ export const characterChatSummaryRequestSchema = baseCharacterChatSchema.extend(
|
||||
) satisfies z.ZodType<CharacterChatSummaryRequest>;
|
||||
|
||||
export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({
|
||||
character: jsonObjectSchema,
|
||||
topic: z.string().trim().min(1),
|
||||
resultSummary: z.string().optional().default(''),
|
||||
}) satisfies z.ZodType<NpcChatDialogueRequest>;
|
||||
|
||||
export const npcChatTurnRequestSchema = baseNpcChatSchema.extend({
|
||||
conversationHistory: z.array(jsonObjectSchema).default([]),
|
||||
playerMessage: z.string().trim().min(1),
|
||||
npcState: jsonObjectSchema,
|
||||
}) satisfies z.ZodType<NpcChatTurnRequest>;
|
||||
export const npcChatTurnRequestSchema = baseNpcChatSchema
|
||||
.extend({
|
||||
conversationHistory: z.array(jsonObjectSchema).optional(),
|
||||
dialogue: z.array(jsonObjectSchema).optional(),
|
||||
playerMessage: z.string().trim().min(1),
|
||||
npcState: jsonObjectSchema,
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.character && !value.player) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'npc chat turn request requires character or player',
|
||||
path: ['character'],
|
||||
});
|
||||
}
|
||||
})
|
||||
.transform((value) => ({
|
||||
...value,
|
||||
character: value.character ?? value.player ?? {},
|
||||
conversationHistory: value.conversationHistory ?? value.dialogue ?? [],
|
||||
})) satisfies z.ZodType<NpcChatTurnRequest>;
|
||||
|
||||
export const npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({
|
||||
character: jsonObjectSchema,
|
||||
invitationText: z.string().trim().min(1),
|
||||
recruitSummary: z.string().optional().default(''),
|
||||
}) satisfies z.ZodType<NpcRecruitDialogueRequest>;
|
||||
|
||||
102
src/components/AdventurePanel.npcChat.test.tsx
Normal file
102
src/components/AdventurePanel.npcChat.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { AdventurePanel } from './AdventurePanel';
|
||||
import { type Character, type StoryMoment, WorldType } from '../types';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
test('adventure panel treats negative affinity updates as relationship change system messages', () => {
|
||||
const currentStory: StoryMoment = {
|
||||
text: '你们的语气忽然冷了下来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' },
|
||||
{ speaker: 'system', text: '关系转冷 好感 -2', affinityDelta: -2 },
|
||||
],
|
||||
options: [],
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<AdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={false}
|
||||
displayedOptions={[]}
|
||||
hideOptions={false}
|
||||
canRefreshOptions={false}
|
||||
onRefreshOptions={() => undefined}
|
||||
onChoice={() => undefined}
|
||||
onOpenCharacter={() => undefined}
|
||||
onOpenInventory={() => undefined}
|
||||
playerCharacter={createCharacter()}
|
||||
worldType={WorldType.WUXIA}
|
||||
quests={[]}
|
||||
questUi={{
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
immediateStepGoal: null,
|
||||
supportGoals: [],
|
||||
}}
|
||||
goalPulse={null}
|
||||
onDismissGoalPulse={() => undefined}
|
||||
battleRewardUi={{
|
||||
reward: null,
|
||||
dismiss: () => undefined,
|
||||
}}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
playerMana={20}
|
||||
playerMaxMana={20}
|
||||
playerSkillCooldowns={{}}
|
||||
inBattle={false}
|
||||
currentNpcBattleMode={null}
|
||||
statistics={{
|
||||
playTimeMs: 0,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
questsCompleted: 0,
|
||||
questsTurnedIn: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
currentSceneName: '竹林古道',
|
||||
playerCurrency: 0,
|
||||
inventoryItemCount: 0,
|
||||
inventoryStackCount: 0,
|
||||
activeCompanionCount: 0,
|
||||
rosterCompanionCount: 0,
|
||||
}}
|
||||
musicVolume={0.6}
|
||||
onMusicVolumeChange={() => undefined}
|
||||
onSaveAndExit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('关系变化');
|
||||
expect(html).toContain('关系转冷 好感 -2');
|
||||
});
|
||||
@@ -200,7 +200,7 @@ function getDialogueTurnLabel(
|
||||
turn: NonNullable<StoryMoment['dialogue']>[number],
|
||||
) {
|
||||
if (turn.speaker === 'system') {
|
||||
return turn.affinityDelta && turn.affinityDelta > 0 ? '关系变化' : '系统';
|
||||
return typeof turn.affinityDelta === 'number' ? '关系变化' : '系统';
|
||||
}
|
||||
|
||||
if (turn.speaker === 'player') {
|
||||
@@ -1029,13 +1029,13 @@ export function AdventurePanel({
|
||||
</div>
|
||||
|
||||
<div className="mt-auto shrink-0 pb-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCharacter}
|
||||
aria-label="打开队伍"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={TAB_ICONS.character.active} className="h-4 w-4" />
|
||||
<span className="text-xs leading-none">队伍</span>
|
||||
@@ -1044,7 +1044,7 @@ export function AdventurePanel({
|
||||
type="button"
|
||||
onClick={onOpenInventory}
|
||||
aria-label="打开背包"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon src={TAB_ICONS.inventory.active} className="h-4 w-4" />
|
||||
<span className="text-xs leading-none">背包</span>
|
||||
@@ -1056,7 +1056,7 @@ export function AdventurePanel({
|
||||
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"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start 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>
|
||||
@@ -1065,7 +1065,7 @@ export function AdventurePanel({
|
||||
type="button"
|
||||
onClick={onRefreshOptions}
|
||||
aria-label="换一换选项"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
<PixelIcon
|
||||
src={CHROME_ICONS.refreshOptions}
|
||||
@@ -1176,7 +1176,7 @@ export function AdventurePanel({
|
||||
})}
|
||||
{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">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
value={npcChatDraft}
|
||||
onChange={(event) => setNpcChatDraft(event.target.value)}
|
||||
@@ -1193,7 +1193,7 @@ export function AdventurePanel({
|
||||
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"
|
||||
className="h-9 min-w-0 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}
|
||||
/>
|
||||
@@ -1201,7 +1201,7 @@ export function AdventurePanel({
|
||||
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"
|
||||
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
@@ -1264,3 +1264,4 @@ export function AdventurePanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -689,15 +689,13 @@ export function createStoryNpcEncounterActions({
|
||||
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
|
||||
currentStory.dialogue
|
||||
? [...currentStory.dialogue]
|
||||
: currentStory?.dialogue && currentStory.dialogue.length > 0
|
||||
? [...currentStory.dialogue]
|
||||
: [
|
||||
{
|
||||
speaker: 'npc' as const,
|
||||
speakerName: encounter.npcName,
|
||||
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
|
||||
},
|
||||
];
|
||||
: [
|
||||
{
|
||||
speaker: 'npc' as const,
|
||||
speakerName: encounter.npcName,
|
||||
text: `${encounter.npcName}\u770b\u7740\u4f60\uff0c\u50cf\u662f\u5728\u7b49\u4f60\u628a\u8bdd\u63a5\u4e0b\u53bb\u3002`,
|
||||
},
|
||||
];
|
||||
|
||||
setAiError(null);
|
||||
setCurrentStory(
|
||||
@@ -836,10 +834,9 @@ export function createStoryNpcEncounterActions({
|
||||
? [
|
||||
{
|
||||
speaker: 'system' as const,
|
||||
text:
|
||||
chatTurn.affinityDelta > 0
|
||||
? `${chatTurn.affinityText} 好感 +${chatTurn.affinityDelta}`
|
||||
: chatTurn.affinityText,
|
||||
text: `${chatTurn.affinityText} \u597d\u611f ${
|
||||
chatTurn.affinityDelta > 0 ? '+' : '-'
|
||||
}${Math.abs(chatTurn.affinityDelta)}`,
|
||||
affinityDelta: chatTurn.affinityDelta,
|
||||
},
|
||||
]
|
||||
@@ -1319,3 +1316,4 @@ export function createStoryNpcEncounterActions({
|
||||
exitNpcChat,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -943,11 +943,13 @@ export async function streamNpcChatTurn(
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
player: character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
conversationHistory: conversationHistory ?? [],
|
||||
dialogue: conversationHistory ?? [],
|
||||
playerMessage,
|
||||
npcState,
|
||||
} satisfies NpcChatTurnRequest;
|
||||
|
||||
@@ -76,14 +76,15 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
expect(prompt).toContain('大头身');
|
||||
});
|
||||
|
||||
it('builds a master prompt with square canvas and chibi ratio', () => {
|
||||
const prompt = buildMasterPrompt('Q版大头身少女冒险者。');
|
||||
it('builds a master prompt with square canvas and richer world-character detail coverage', () => {
|
||||
const prompt = buildMasterPrompt(DEFAULT_CHARACTER_BRIEF);
|
||||
|
||||
expect(prompt).toContain('1:1 正方形画布');
|
||||
expect(prompt).toContain('大头身');
|
||||
expect(prompt).toContain('2 到 3 头身');
|
||||
expect(prompt).toContain('不是完全 90 度纯右视图');
|
||||
expect(prompt).toContain('背景固定为纯绿色绿幕');
|
||||
expect(prompt).toContain('1:1');
|
||||
expect(prompt).toContain('sprite sheet');
|
||||
expect(prompt).toContain('90');
|
||||
expect(prompt).toContain(DEFAULT_CHARACTER_BRIEF);
|
||||
expect(prompt).toContain('????????????');
|
||||
expect(prompt).toContain('????????????????????????');
|
||||
});
|
||||
|
||||
it('strengthens non-human species traits for siren-like characters', () => {
|
||||
@@ -118,19 +119,19 @@ describe('qwenSpriteSheetToolModel', () => {
|
||||
});
|
||||
|
||||
it('builds a video action prompt with pixel style constraints', () => {
|
||||
const actionTemplate = getActionTemplateById('run');
|
||||
const prompt = buildVideoActionPrompt({
|
||||
actionTemplate: getActionTemplateById('run'),
|
||||
actionDetailText: '跑步时上身前倾,手臂摆动明显。',
|
||||
characterBrief: '海妖刺客,蓝绿色鳞片,鱼鳍耳。',
|
||||
actionTemplate,
|
||||
actionDetailText: '?????????????????????????????????????????????',
|
||||
characterBrief: '?????????????????????????????????????????????',
|
||||
useChromaKey: true,
|
||||
});
|
||||
|
||||
expect(prompt).toContain('动作视频');
|
||||
expect(prompt).toContain('右向斜侧身动作视角');
|
||||
expect(prompt).toContain('像素风');
|
||||
expect(prompt).toContain('绿幕');
|
||||
expect(prompt).toContain('默认优先生成人形拟人化角色');
|
||||
expect(prompt).toContain('Q版可爱的人形动作角色');
|
||||
expect(prompt).toContain(actionTemplate.label);
|
||||
expect(prompt).toContain(actionTemplate.stagingDirection ?? '');
|
||||
expect(prompt).toContain('90');
|
||||
expect(prompt).toContain('Q');
|
||||
expect(prompt).toContain('sprite');
|
||||
});
|
||||
|
||||
it('builds generic theme over-literalization negatives', () => {
|
||||
|
||||
Reference in New Issue
Block a user