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

This commit is contained in:
2026-04-18 17:28:23 +08:00
parent b3066c7bc1
commit 54b3d3c490
21 changed files with 731 additions and 156 deletions

View File

@@ -5,8 +5,10 @@ import type {
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js';
import {
buildCharacterPanelChatPrompt,
buildCharacterPanelChatSuggestionPrompt,
@@ -16,11 +18,52 @@ import {
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
buildNpcRecruitDialoguePrompt,
buildStrictNpcChatDialoguePrompt,
buildNpcChatTurnReplyPrompt,
buildNpcChatTurnSuggestionPrompt,
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT,
NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
} from './chatPromptBuilders.js';
import { prepareEventStreamResponse } from '../../http.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
function writeSseEvent(
response: Response,
event: string,
payload: Record<string, unknown>,
) {
response.write(`event: ${event}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);
}
function readRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function describeAffinityShift(affinityDelta: number) {
if (affinityDelta >= 8) return '态度明显软化了下来。';
if (affinityDelta >= 5) return '态度比刚才亲近了一些。';
if (affinityDelta > 0) return '对话气氛稍微松动了一点。';
if (affinityDelta < 0) return '这轮对话让气氛变得更紧了一些。';
return '这轮对话暂时没有带来明显关系变化。';
}
function buildFallbackNpcChatSuggestions(playerMessage: string) {
const topic = playerMessage.trim() || '刚才那句话';
return [
`顺着“${topic}”再追问一句`,
'先表明你的判断,再看对方反应',
'换个更轻一点的语气继续聊下去',
];
}
export async function generateCharacterChatSuggestionsFromOrchestrator(
llmClient: UpstreamLlmClient,
payload: CharacterChatSuggestionsRequest,
@@ -73,6 +116,64 @@ export async function streamNpcChatDialogueFromOrchestrator(
});
}
export async function streamNpcChatTurnFromOrchestrator(
llmClient: UpstreamLlmClient,
params: {
request: Request;
response: Response;
payload: NpcChatTurnRequest;
},
) {
prepareEventStreamResponse(params.request, params.response);
try {
let streamedReply = '';
const npcReply = (
await llmClient.streamMessageContent({
systemPrompt: NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT,
userPrompt: buildNpcChatTurnReplyPrompt(params.payload),
debugLabel: 'runtime.npc_chat.turn.reply',
onUpdate: (text) => {
streamedReply = text;
writeSseEvent(params.response, 'reply_delta', { text });
},
})
).trim();
const suggestionText = await llmClient.requestMessageContent({
systemPrompt: NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
userPrompt: buildNpcChatTurnSuggestionPrompt(
params.payload,
npcReply || streamedReply,
),
debugLabel: 'runtime.npc_chat.turn.suggestions',
});
const suggestions = parseLineListContent(suggestionText, 3);
const npcState = readRecord(params.payload.npcState);
const chattedCount = readNumber(npcState?.chattedCount, 0);
const affinityDelta = Math.max(2, 6 - chattedCount);
writeSseEvent(params.response, 'complete', {
npcReply: npcReply || streamedReply,
affinityDelta,
affinityText: describeAffinityShift(affinityDelta),
suggestions:
suggestions.length === 3
? suggestions
: buildFallbackNpcChatSuggestions(params.payload.playerMessage),
});
params.response.write('data: [DONE]\n\n');
params.response.end();
} catch (error) {
const message =
error instanceof Error ? error.message : 'NPC 单轮聊天流式生成失败';
writeSseEvent(params.response, 'error', { message });
params.response.end();
}
}
export async function streamNpcRecruitDialogueFromOrchestrator(
llmClient: UpstreamLlmClient,
params: {

View File

@@ -3,6 +3,7 @@ import type {
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
@@ -48,6 +49,16 @@ export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招
- 不允许出现“我不能答应”“我还没想好”“再让我考虑”“暂时不行”“以后再说”这类拒绝或拖延表述。
- 最后一行必须由对方明确答应加入队伍。`;
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
只输出纯文本,共 3 行,每行 1 条。
不要加编号、项目符号、Markdown、JSON 或额外说明。
三条候选必须明显不同,分别体现继续追问、表达态度、轻微拉近关系这三种不同方向。`;
function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as JsonRecord)
@@ -143,6 +154,41 @@ function describeConversationHistory(history: unknown) {
: '聊天记录:暂无。';
}
function describeNpcConversationHistory(history: unknown, npcName: string) {
if (!Array.isArray(history) || history.length === 0) {
return '当前聊天记录:暂无。';
}
const lines = history
.slice(-10)
.map((item) => {
const record = asRecord(item);
const speaker = readString(record?.speaker);
const speakerName = readString(record?.speakerName);
const text = readString(record?.text);
if (!text) return null;
if (speaker === 'player') {
return `- 玩家:${text}`;
}
if (speaker === 'npc') {
return `- ${speakerName ?? npcName}${text}`;
}
if (speaker === 'system') {
return `- 系统提示:${text}`;
}
return `- ${speakerName ?? '同伴'}${text}`;
})
.filter((item): item is string => Boolean(item));
return lines.length > 0
? ['当前聊天记录:', ...lines].join('\n')
: '当前聊天记录:暂无。';
}
function describeSceneContext(context: unknown) {
const record = asRecord(context);
const sceneName = readString(record?.sceneName) ?? '当前区域';
@@ -370,3 +416,40 @@ export function buildNpcRecruitDialoguePrompt(
.filter(Boolean)
.join('\n\n');
}
export function buildNpcChatTurnReplyPrompt(
payload: NpcChatTurnRequest,
) {
const encounter = describeEncounter(payload.encounter);
const npcState = asRecord(payload.npcState);
const affinity = readNumber(npcState?.affinity, 0);
const chattedCount = readNumber(npcState?.chattedCount, 0);
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(payload.conversationHistory, encounter.npcName),
`当前关系值:${affinity}`,
`已聊天轮次:${chattedCount}`,
`玩家刚刚说:${payload.playerMessage}`,
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
]
.filter(Boolean)
.join('\n\n');
}
export function buildNpcChatTurnSuggestionPrompt(
payload: NpcChatTurnRequest,
npcReply: string,
) {
const encounter = describeEncounter(payload.encounter);
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(payload.conversationHistory, encounter.npcName),
`玩家刚刚说:${payload.playerMessage}`,
`NPC 刚刚回复:${npcReply}`,
`请围绕刚刚这轮对话,为玩家生成 3 条可以继续和 ${encounter.npcName} 聊下去的中文短句候选。`,
]
.filter(Boolean)
.join('\n\n');
}

View File

@@ -28,6 +28,7 @@ import type {
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
import type { AppContext } from '../context.js';
@@ -45,6 +46,7 @@ import {
generateCharacterChatSummaryFromOrchestrator,
streamCharacterChatReplyFromOrchestrator,
streamNpcChatDialogueFromOrchestrator,
streamNpcChatTurnFromOrchestrator,
streamNpcRecruitDialogueFromOrchestrator,
} from '../modules/ai/chatOrchestrator.js';
import {
@@ -56,6 +58,7 @@ import {
characterChatSuggestionsRequestSchema,
characterChatSummaryRequestSchema,
npcChatDialogueRequestSchema,
npcChatTurnRequestSchema,
npcRecruitDialogueRequestSchema,
} from '../services/chatService.js';
import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js';
@@ -671,6 +674,21 @@ export function createRuntimeRoutes(context: AppContext) {
}),
);
router.post(
'/runtime/chat/npc/turn/stream',
routeMeta({ operation: 'runtime.chat.npc.turnStream' }),
asyncHandler(async (request, response) => {
const payload = npcChatTurnRequestSchema.parse(
request.body,
) as NpcChatTurnRequest;
await streamNpcChatTurnFromOrchestrator(context.llmClient, {
request,
response,
payload,
});
}),
);
router.post(
'/runtime/chat/npc/recruit/stream',
routeMeta({ operation: 'runtime.chat.npc.recruitStream' }),

View File

@@ -5,6 +5,7 @@ import type {
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../packages/shared/src/contracts/story.js';
@@ -50,6 +51,12 @@ export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({
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 npcRecruitDialogueRequestSchema = baseNpcChatSchema.extend({
invitationText: z.string().trim().min(1),
recruitSummary: z.string().optional().default(''),