From c39dbc59ee0a260fb30b0bed71ee9c136dd17126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sat, 18 Apr 2026 20:29:33 +0800 Subject: [PATCH] 1 --- AGENTS.md | 2 + packages/shared/src/contracts/story.ts | 6 +- .../src/modules/ai/chatPromptBuilders.ts | 18 +++- server-node/src/services/chatService.test.ts | 43 ++++++++ server-node/src/services/chatService.ts | 31 ++++-- .../AdventurePanel.npcChat.test.tsx | 102 ++++++++++++++++++ src/components/AdventurePanel.tsx | 21 ++-- src/hooks/story/npcEncounterActions.ts | 24 ++--- src/services/aiService.ts | 2 + src/tools/qwenSpriteSheetToolModel.test.ts | 33 +++--- 10 files changed, 233 insertions(+), 49 deletions(-) create mode 100644 server-node/src/services/chatService.test.ts create mode 100644 src/components/AdventurePanel.npcChat.test.tsx diff --git a/AGENTS.md b/AGENTS.md index d2e357ef..c69fdd0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ # AGENTS.md ## 项目约束 +- 前端工程node版本使用22.22.2 +- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 - 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中 - 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。 - 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。 diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts index e5b58a4d..4b3f2d24 100644 --- a/packages/shared/src/contracts/story.ts +++ b/packages/shared/src/contracts/story.ts @@ -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; }; diff --git a/server-node/src/modules/ai/chatPromptBuilders.ts b/server-node/src/modules/ai/chatPromptBuilders.ts index e1698545..c233262f 100644 --- a/server-node/src/modules/ai/chatPromptBuilders.ts +++ b/server-node/src/modules/ai/chatPromptBuilders.ts @@ -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} 聊下去的中文短句候选。`, diff --git a/server-node/src/services/chatService.test.ts b/server-node/src/services/chatService.test.ts new file mode 100644 index 00000000..7f439ad3 --- /dev/null +++ b/server-node/src/services/chatService.test.ts @@ -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: '你刚才那句话是什么意思?', + }, + ]); +}); diff --git a/server-node/src/services/chatService.ts b/server-node/src/services/chatService.ts index c4682a18..83d4cf1e 100644 --- a/server-node/src/services/chatService.ts +++ b/server-node/src/services/chatService.ts @@ -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; export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({ + character: jsonObjectSchema, topic: z.string().trim().min(1), resultSummary: z.string().optional().default(''), }) satisfies z.ZodType; -export const npcChatTurnRequestSchema = baseNpcChatSchema.extend({ - conversationHistory: z.array(jsonObjectSchema).default([]), - playerMessage: z.string().trim().min(1), - npcState: jsonObjectSchema, -}) satisfies z.ZodType; +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; export const npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({ + character: jsonObjectSchema, invitationText: z.string().trim().min(1), recruitSummary: z.string().optional().default(''), }) satisfies z.ZodType; diff --git a/src/components/AdventurePanel.npcChat.test.tsx b/src/components/AdventurePanel.npcChat.test.tsx new file mode 100644 index 00000000..81b69c09 --- /dev/null +++ b/src/components/AdventurePanel.npcChat.test.tsx @@ -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( + 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'); +}); diff --git a/src/components/AdventurePanel.tsx b/src/components/AdventurePanel.tsx index 1d05fa10..3c7baa17 100644 --- a/src/components/AdventurePanel.tsx +++ b/src/components/AdventurePanel.tsx @@ -200,7 +200,7 @@ function getDialogueTurnLabel( turn: NonNullable[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({
-
-
+
+
@@ -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" > -
+
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" > 发送 @@ -1264,3 +1264,4 @@ export function AdventurePanel({
); } + diff --git a/src/hooks/story/npcEncounterActions.ts b/src/hooks/story/npcEncounterActions.ts index 354621bf..6b8a9d0b 100644 --- a/src/hooks/story/npcEncounterActions.ts +++ b/src/hooks/story/npcEncounterActions.ts @@ -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, }; } + diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 75c382d7..f6572811 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -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; diff --git a/src/tools/qwenSpriteSheetToolModel.test.ts b/src/tools/qwenSpriteSheetToolModel.test.ts index 1833e291..b8550f71 100644 --- a/src/tools/qwenSpriteSheetToolModel.test.ts +++ b/src/tools/qwenSpriteSheetToolModel.test.ts @@ -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', () => {