diff --git a/AGENTS.md b/AGENTS.md index b4380165..d2e357ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## 项目约束 - +- 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中 - 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。 - 看到中文乱码时,不要直接沿用乱码文本,也不要用英文替换;先确认文件真实编码,再决定是否修改。 - 在 PowerShell 5.1 中读取或写入文本时,必须显式使用 UTF-8;如果终端输出疑似乱码,要用 `Get-Content -Encoding UTF8`、Python 或 Node 再次核对原文。 @@ -16,6 +16,7 @@ - 禁止将功能说明描述类的文本默认写入UI界面中。 - prd文档中每个模块的描述要落地设计到可以精准编码到位,不能出现需求落地漂移。 + ## 文档图谱 ```text diff --git a/docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md b/docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md new file mode 100644 index 00000000..f2e820ea --- /dev/null +++ b/docs/prd/AI_NATIVE_NPC_CHAT_SINGLE_TURN_SESSION_PRD_2026-04-18.md @@ -0,0 +1,471 @@ +# AI 原生 NPC 单轮聊天会话迭代 PRD + +更新时间:`2026-04-18` + +## 0. 文档目的 + +本 PRD 只定义 `npc_chat` 在冒险主面板中的这一轮迭代落地方式。 + +目标不是新建一套聊天系统,而是在保持现有游戏 UI 外壳、剧情面板结构、消息流位置不变的前提下,把 `npc_chat` 从“触发一次后回到普通剧情流”升级为“进入可持续续写的单轮聊天会话”。 + +本次文档必须直接指导编码,避免需求落地漂移。 + +--- + +## 1. 一句话定义 + +玩家点击 `npc_chat` 后,进入 NPC 聊天模式;每次只完成一轮“玩家输入 -> NPC 回复 -> 关系变化消息 -> 下一轮 3 个建议选项 + 1 个自定义输入”,直到玩家主动退出聊天。 + +--- + +## 2. 背景与问题 + +当前 `npc_chat` 更接近“触发一段剧情性对话”,而不是“持续聊天”: + +1. 玩家点击聊天后,常常是一次性生成较长结果,再回到普通冒险选项。 +2. 缺少稳定的续聊状态,无法把多轮聊天作为一个连续会话维持。 +3. 好感度变化更多停留在逻辑层,玩家在消息流中感知不明显。 +4. 没有单独的退出聊天控制,用户只能被动等系统切回普通状态。 +5. 选项形态仍偏剧情动作,不够像聊天接话。 + +这会导致 `npc_chat` 的体验不像“和角色对话”,而像“触发一个剧情功能”。 + +--- + +## 3. 本次目标 + +本次迭代必须同时满足以下目标: + +1. `npc_chat` 触发后进入聊天交互态。 +2. 聊天态沿用当前主冒险面板,不新增页面、不弹新系统。 +3. 每次用户只推进一轮对话。 +4. 每轮结束后稳定出现 `3` 个建议续聊选项。 +5. 每轮结束后稳定出现 `1` 个自定义输入框。 +6. 玩家选择建议项或提交自定义输入后,继续在同一消息队列中续写。 +7. 好感度增减必须作为“系统消息”插入到对话消息队列中。 +8. NPC 回复必须支持流式传输,并在前端边接收边解析显示。 +9. 背包按钮所在行的最右侧必须新增“退出聊天”按钮。 +10. 退出聊天后恢复普通冒险态,不保留当前聊天输入框与聊天建议项。 + +--- + +## 4. 明确不做 + +本次不做: + +1. 不新建独立聊天页面。 +2. 不重做现有主面板 UI 结构。 +3. 不引入多 NPC 并行聊天。 +4. 不做聊天记录存档面板。 +5. 不做复杂关系公式配置后台。 +6. 不做语音输入、表情、附件等扩展输入能力。 +7. 不把前端改成承担关系计算或剧情判定。 + +--- + +## 5. 核心体验 + +## 5.1 进入聊天 + +当玩家点击 `npc_chat` 选项时: + +1. 保持当前冒险面板布局不变。 +2. 中部内容区切换为聊天消息流展示模式。 +3. 底部选项区切换为聊天建议区。 +4. 底部附加一个自定义输入框与发送按钮。 +5. 顶层不跳转、不弹窗、不覆盖成新页面。 + +## 5.2 单轮推进 + +单轮定义固定为: + +1. 玩家通过“建议选项”或“自定义输入”提交一句话。 +2. 玩家消息立即进入消息队列。 +3. NPC 回复开始流式显示。 +4. 流式结束后,如果有关系变化,插入一条系统消息。 +5. 系统刷新下一轮 `3` 个建议选项。 +6. 系统保留自定义输入入口,等待下一轮。 + +## 5.3 退出聊天 + +玩家点击“退出聊天”后: + +1. 当前聊天态结束。 +2. 面板底部恢复普通冒险选项区域。 +3. 本轮聊天输入框、聊天建议项消失。 +4. 当前故事内容回到普通故事展示逻辑。 + +退出聊天不触发额外确认弹窗。 + +--- + +## 6. UI 设计要求 + +## 6.1 保持不变的部分 + +以下 UI 外壳保持当前实现: + +1. 主冒险面板整体框架。 +2. 对话消息区所在位置。 +3. 底部操作区的整体层级。 +4. 队伍按钮、背包按钮的视觉风格。 + +## 6.2 必须变化的部分 + +### 消息区 + +1. 聊天态下,消息区按时间顺序展示: + - 玩家消息 + - NPC 消息 + - 系统关系变化消息 +2. 系统关系变化消息必须和普通消息共用同一消息流容器。 +3. 系统关系变化消息视觉上应与玩家/NPC 气泡有明确区分。 + +### 底部按钮区 + +1. 左侧仍保留队伍、背包按钮。 +2. 右侧在聊天态下展示“退出聊天”按钮。 +3. “退出聊天”按钮必须位于该行最右侧。 +4. 非聊天态下,该位置仍保持原有刷新选项按钮逻辑。 + +### 选项区 + +1. 聊天态下只展示 `3` 个续聊选项。 +2. 聊天态下不展示普通剧情选项附带的说明文案、目标提示、影响摘要。 +3. 聊天态下选项文案本身就是“下一句怎么接”。 + +### 输入区 + +1. 聊天态下在 3 个建议项下方展示输入框。 +2. 输入框右侧固定展示发送按钮。 +3. 输入框在请求进行中禁用。 +4. 点击发送或回车提交时,进入下一轮。 + +## 6.3 移动端要求 + +1. 移动端优先保证输入框与发送按钮可点击。 +2. 三个建议选项必须保持纵向堆叠,不做横向卡片排布。 +3. “退出聊天”按钮在小屏下仍需完整可见,不允许被背包按钮挤出。 +4. 输入框与发送按钮在窄屏下优先保证输入框宽度,其次压缩按钮内边距。 + +--- + +## 7. 前后端职责边界 + +遵循“前端只负责表现,逻辑和数据放后端”原则。 + +## 7.1 前端职责 + +前端只负责: + +1. 进入/退出聊天态的 UI 切换。 +2. 渲染当前消息队列。 +3. 发送玩家本轮输入。 +4. 接收流式事件并实时更新 NPC 当前回复文本。 +5. 渲染系统关系变化消息。 +6. 渲染下一轮 `3` 个建议项与自定义输入框。 + +前端不负责: + +1. 生成 NPC 回复文本。 +2. 生成建议续聊选项。 +3. 计算关系增减。 +4. 解析剧情逻辑分支。 + +## 7.2 后端职责 + +后端负责: + +1. 接收 NPC 单轮聊天请求。 +2. 结合当前世界、角色、NPC 状态、历史消息构建 prompt。 +3. 流式生成 NPC 回复。 +4. 解析回复内容。 +5. 生成下一轮 3 个建议续聊选项。 +6. 计算并返回本轮关系增减。 +7. 通过 SSE 向前端发送流式事件与最终结果。 + +--- + +## 8. 数据结构要求 + +## 8.1 前端故事态扩展 + +`StoryMoment` 需要具备聊天态附加状态: + +```ts +type StoryNpcChatState = { + npcId: string; + npcName: string; + turnCount: number; + customInputPlaceholder?: string; +}; +``` + +要求: + +1. 仅当当前故事处于 NPC 聊天模式时写入。 +2. `turnCount` 表示已完成的轮数。 +3. `npcId` 用于保证只续写当前聊天对象。 + +## 8.2 聊天消息结构 + +消息队列需要支持系统消息: + +```ts +type StoryDialogueTurn = { + speaker: 'player' | 'npc' | 'companion' | 'system'; + speakerName?: string; + text: string; + affinityDelta?: number; +}; +``` + +要求: + +1. `system` 只用于关系变化、系统反馈类消息。 +2. `affinityDelta` 仅在关系变化消息中写入。 + +## 8.3 单轮接口契约 + +请求: + +```ts +type NpcChatTurnRequest = { + worldType: WorldType; + player: Character; + encounter: Encounter; + history: StoryMoment[]; + dialogue: StoryDialogueTurn[]; + playerMessage: string; + npcState: { + affinity: number; + chattedCount: number; + recruited: boolean; + }; + context: StoryGenerationContext; +}; +``` + +返回完成事件载荷: + +```ts +type NpcChatTurnResult = { + npcReply: string; + affinityDelta: number; + affinityText: string; + suggestions: string[]; +}; +``` + +--- + +## 9. 流式协议 + +本次统一使用 SSE。 + +## 9.1 事件类型 + +后端至少输出以下事件: + +1. `reply_delta` +2. `complete` +3. `error` + +## 9.2 事件含义 + +### `reply_delta` + +用途: + +1. 逐步推送 NPC 当前回复文本增量。 +2. 前端收到后立即更新消息流中“当前 NPC 气泡”的文本。 + +### `complete` + +用途: + +1. 标记本轮流式输出结束。 +2. 一次性返回最终结果对象: + - `npcReply` + - `affinityDelta` + - `affinityText` + - `suggestions` + +### `error` + +用途: + +1. 标记本轮失败。 +2. 前端停止流式态并回退到可继续输入的状态。 + +## 9.3 前端解析规则 + +1. 当收到第一个 `reply_delta` 时,若消息流末尾还没有 NPC 临时消息,前端先插入一条空的 NPC 消息。 +2. 每次收到 `reply_delta`,替换该临时 NPC 消息文本。 +3. 收到 `complete` 后,把临时 NPC 消息固化为最终文本。 +4. 如果 `affinityDelta !== 0`,在 NPC 消息后追加一条系统关系消息。 +5. 之后再刷新下一轮建议选项。 + +--- + +## 10. 关系变化显示规则 + +## 10.1 显示位置 + +关系变化必须作为一条独立消息插入消息队列,位置在本轮 NPC 回复之后、下一轮建议项之前。 + +## 10.2 文案规则 + +1. 有增长时显示正向文案,例如:`关系升温 好感 +3` +2. 有下降时显示负向文案,例如:`关系转冷 好感 -2` +3. 无变化时不强制插入系统消息。 + +## 10.3 视觉规则 + +1. 关系变化消息居中显示。 +2. 关系变化消息使用不同于普通对话气泡的视觉样式。 +3. 正向变化可使用更暖色的边框/底色。 +4. 负向变化与中性反馈使用系统消息样式。 + +--- + +## 11. 交互流程 + +## 11.1 进入流程 + +```text +玩家点击 npc_chat +-> 系统进入聊天态 +-> 当前故事切换为聊天消息模式 +-> 展示本轮可选接话 +-> 展示自定义输入框 +``` + +## 11.2 单轮流程 + +```text +玩家点击建议项或提交输入 +-> 玩家消息立即入队 +-> 前端发起 /runtime/chat/npc/turn/stream +-> 后端流式返回 NPC 回复 +-> 前端边接收边渲染 NPC 当前回复 +-> complete 返回最终结果 +-> 前端插入关系变化系统消息 +-> 前端刷新下一轮 3 个建议项 +-> 等待下一轮输入 +``` + +## 11.3 退出流程 + +```text +玩家点击退出聊天 +-> 清理当前聊天态标记 +-> 当前故事回到普通冒险展示 +-> 恢复普通选项区 +``` + +--- + +## 12. 状态更新规则 + +每完成一轮聊天,系统必须更新: + +1. `npcState.affinity` +2. `npcState.chattedCount` +3. `npcState.relationState` +4. `npcState.stanceProfile` +5. `storyHistory` +6. `currentStory.dialogue` +7. `currentStory.npcChatState.turnCount` + +要求: + +1. 当前轮玩家输入和 NPC 回复都要进入故事历史。 +2. 关系变化消息属于展示型系统消息,可进入当前对话队列,但不要求作为独立剧情行动历史参与模型推理。 + +--- + +## 13. 异常与兜底 + +## 13.1 流式失败 + +如果流式失败: + +1. 当前轮玩家消息保留。 +2. 不保留半截乱码式 NPC 文本。 +3. 前端恢复可继续输入状态。 +4. 使用后端或前端兜底建议项,保证仍有 3 个建议续聊选项可选。 + +## 13.2 建议项不足 + +如果后端返回建议项不足 `3` 条: + +1. 由后端优先补齐兜底话术。 +2. 前端只展示最多 `3` 条。 + +## 13.3 空输入 + +空输入、纯空格输入不发请求。 + +--- + +## 14. 代码落点 + +本次迭代应优先落在现有链路: + +### 前端 + +1. `src/hooks/story/npcEncounterActions.ts` +2. `src/hooks/story/useStoryInteractionCoordinator.ts` +3. `src/hooks/story/useStoryFlowCoordinator.ts` +4. `src/hooks/useStoryGeneration.ts` +5. `src/services/aiService.ts` +6. `src/components/AdventurePanel.tsx` +7. `src/components/GameShell.tsx` + +### 共享契约 + +1. `packages/shared/src/contracts/story.ts` + +### 后端 + +1. `server-node/src/routes/runtimeRoutes.ts` +2. `server-node/src/services/chatService.ts` +3. `server-node/src/modules/ai/chatPromptBuilders.ts` +4. `server-node/src/modules/ai/chatOrchestrator.ts` + +要求: + +1. 复用现有故事流、运行时接口和主面板。 +2. 不另起新页面、新 store、新聊天系统。 + +--- + +## 15. 验收标准 + +以下全部满足才算通过: + +1. 点击 `npc_chat` 后,面板进入聊天态而不是跳去新页面。 +2. 当前消息区能连续展示玩家消息、NPC 消息、系统关系消息。 +3. 每轮结束后稳定出现 `3` 个建议项。 +4. 每轮结束后稳定出现 `1` 个自定义输入框。 +5. 点击建议项可继续下一轮。 +6. 输入自定义文本后点击发送或回车可继续下一轮。 +7. NPC 回复支持流式逐字/逐段显示。 +8. 好感度变化会以消息形式插入聊天队列。 +9. 背包按钮所在行最右侧可见“退出聊天”按钮。 +10. 点击“退出聊天”后恢复普通冒险态。 +11. 聊天态下不显示普通剧情选项的说明、目标提示、影响摘要。 +12. 移动端下输入框、发送按钮、退出按钮均可正常操作。 + +--- + +## 16. 后续可扩展方向 + +本次上线后,再考虑后续迭代: + +1. 更精细的关系变化公式。 +2. 基于 NPC 性格的建议续聊风格差异。 +3. 聊天摘要沉淀到长期记忆。 +4. 聊天中触发支线、任务、物品、邀约等事件。 +5. 退出聊天后保留“最近一次聊天摘要”。 diff --git a/server-node/src/modules/ai/chatOrchestrator.ts b/server-node/src/modules/ai/chatOrchestrator.ts index 705e749d..834910c9 100644 --- a/server-node/src/modules/ai/chatOrchestrator.ts +++ b/server-node/src/modules/ai/chatOrchestrator.ts @@ -47,6 +47,75 @@ function readNumber(value: unknown, fallback = 0) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } +function countKeywordMatches(text: string, keywords: string[]) { + return keywords.reduce( + (count, keyword) => (text.includes(keyword) ? count + 1 : count), + 0, + ); +} + +function clampAffinityDelta(value: number) { + return Math.max(-3, Math.min(3, value)); +} + +function computeNpcChatAffinityDelta(params: { + playerMessage: string; + npcReply: string; + chattedCount: number; +}) { + const playerMessage = params.playerMessage.trim(); + const npcReply = params.npcReply.trim(); + const positiveKeywords = [ + '谢谢', + '辛苦', + '抱歉', + '理解', + '相信', + '放心', + '一起', + '帮你', + '在意', + '关心', + ]; + const negativeKeywords = [ + '闭嘴', + '滚', + '少废话', + '威胁', + '骗', + '不信', + '别装', + '快说', + '审问', + '怀疑', + ]; + const warmReplyKeywords = ['可以', '愿意', '放心', '谢谢', '明白', '好']; + const coldReplyKeywords = ['没必要', '不想', '别问', '与你无关', '算了', '住口']; + + const positiveScore = + countKeywordMatches(playerMessage, positiveKeywords) + + countKeywordMatches(npcReply, warmReplyKeywords); + const negativeScore = + countKeywordMatches(playerMessage, negativeKeywords) + + countKeywordMatches(npcReply, coldReplyKeywords); + + if (positiveScore === 0 && negativeScore === 0) { + return params.chattedCount === 0 ? 1 : 0; + } + + if (positiveScore > negativeScore) { + const baseDelta = + positiveScore - negativeScore + (params.chattedCount <= 1 ? 1 : 0); + return clampAffinityDelta(baseDelta); + } + + if (negativeScore > positiveScore) { + return clampAffinityDelta(positiveScore - negativeScore); + } + + return 0; +} + function describeAffinityShift(affinityDelta: number) { if (affinityDelta >= 8) return '态度明显软化了下来。'; if (affinityDelta >= 5) return '态度比刚才亲近了一些。'; @@ -153,7 +222,11 @@ export async function streamNpcChatTurnFromOrchestrator( const suggestions = parseLineListContent(suggestionText, 3); const npcState = readRecord(params.payload.npcState); const chattedCount = readNumber(npcState?.chattedCount, 0); - const affinityDelta = Math.max(2, 6 - chattedCount); + const affinityDelta = computeNpcChatAffinityDelta({ + playerMessage: params.payload.playerMessage, + npcReply: npcReply || streamedReply, + chattedCount, + }); writeSseEvent(params.response, 'complete', { npcReply: npcReply || streamedReply, diff --git a/server-node/src/modules/ai/chatPromptBuilders.ts b/server-node/src/modules/ai/chatPromptBuilders.ts index 645d43f4..e1698545 100644 --- a/server-node/src/modules/ai/chatPromptBuilders.ts +++ b/server-node/src/modules/ai/chatPromptBuilders.ts @@ -353,7 +353,7 @@ export function buildCharacterPanelChatSummaryPrompt( } function buildNpcDialoguePromptBase( - payload: NpcChatDialogueRequest | NpcRecruitDialogueRequest, + payload: NpcChatDialogueRequest | NpcChatTurnRequest | NpcRecruitDialogueRequest, ) { const encounter = describeEncounter(payload.encounter); diff --git a/src/components/AdventurePanel.test.tsx b/src/components/AdventurePanel.test.tsx index ea9ee746..76a9cee5 100644 --- a/src/components/AdventurePanel.test.tsx +++ b/src/components/AdventurePanel.test.tsx @@ -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( 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('换一换'); +}); diff --git a/src/components/AdventurePanel.tsx b/src/components/AdventurePanel.tsx index 88ae151b..1d05fa10 100644 --- a/src/components/AdventurePanel.tsx +++ b/src/components/AdventurePanel.tsx @@ -1051,7 +1051,16 @@ export function AdventurePanel({ - {canRefreshOptions && !shouldHideChoiceUi && ( + {isNpcChatMode ? ( + + ) : canRefreshOptions && !shouldHideChoiceUi ? ( - )} + ) : null}
@@ -1082,7 +1091,8 @@ export function AdventurePanel({ ) : shouldHideChoiceUi ? ( - {getCompactOptionDetailText(option) && ( + {!isNpcChatMode && getCompactOptionDetailText(option) && (
{getCompactOptionDetailText(option)}
)} - {option.goalAffordance?.label && ( + {!isNpcChatMode && option.goalAffordance?.label && (
{option.goalAffordance.label}
)} - {optionImpactSummary && ( + {!isNpcChatMode && optionImpactSummary && (
{optionImpactSummary}
)} ); - }) + })} + {isNpcChatMode ? ( +
+
+ 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} + /> + +
+
+ ) : null} + )}
diff --git a/src/components/custom-world-agent/CustomWorldAgentComposer.tsx b/src/components/custom-world-agent/CustomWorldAgentComposer.tsx index 1f82e20a..5edd52c4 100644 --- a/src/components/custom-world-agent/CustomWorldAgentComposer.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentComposer.tsx @@ -43,7 +43,7 @@ export function CustomWorldAgentComposer({ }; return ( -
+