@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
Reference in New Issue
Block a user