import { Inject, Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service.js"; import { EmotionService, dominantState } from "../emotion/emotion.service.js"; import { toResponseStyle } from "../emotion/response-style.js"; import { RelationshipService } from "../relationship/relationship.service.js"; import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js"; import { LLM_PROVIDER, type LLMProvider } from "../llm/llm-provider.js"; import { hashToSeed } from "../llm/seeded-random.js"; import type { GenerationContext, GeneratedResponse } from "../llm/types.js"; import { getArchetypeParams } from "../personality/archetype-params.js"; import { resolveAddress } from "../personality/language-style.js"; import { stageForIntimacy } from "../relationship/constants.js"; import { CharacterRelationshipService } from "./character-relationship.service.js"; import { USER_SPEAKER_ID, type Room } from "./room.service.js"; import { GROUP_RESERVE_INTIMACY_PENALTY, TSUNDERE_EXTRA_GROUP_PENALTY } from "./constants.js"; export interface RoomReplyOptions { seed?: number; now?: Date; } export interface RoomReply extends GeneratedResponse { address: string; } // K-2/K-3 群聊生成:一律套用「人前矜持」的親密度下修+隱私過濾,一對一聊天不會走這條路徑。 @Injectable() export class RoomGenerationService { constructor( private readonly prisma: PrismaService, private readonly emotion: EmotionService, private readonly relationship: RelationshipService, private readonly retriever: KeywordMemoryRetriever, private readonly characterRelationship: CharacterRelationshipService, @Inject(LLM_PROVIDER) private readonly provider: LLMProvider, ) {} async generateReply( room: Room, characterId: string, speakerId: string, speakerLabel: string, userInput: string, options: RoomReplyOptions = {}, ): Promise { const now = options.now ?? new Date(); const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }); const archetypeParams = getArchetypeParams(character.personalityArchetype); const emotionState = await this.emotion.getState(characterId, now); const dominant = dominantState(emotionState); const style = toResponseStyle(dominant); // 對使用者發言沿用 G-2 的稱呼分層;對其他角色發言則直接以名字稱呼(NPC 之間不走使用者稱呼分層)。 let baseIntimacy: number; let trust: number; let address: string; if (speakerId === USER_SPEAKER_ID) { const { relationship } = await this.relationship.getState(characterId, room.userId, now); baseIntimacy = relationship.intimacy; trust = relationship.trust; address = resolveAddress(speakerLabel, relationship.intimacy); } else { baseIntimacy = await this.characterRelationship.getAffinity(characterId, speakerId); trust = baseIntimacy; address = speakerLabel; } // K-2 群聊矜持:實際餵給生成的親密度下修,讓既有的模板分層(intimacyTier)自然選到更收斂的回應; // 傲嬌式反向表達在人前更嘴硬,額外多下修一些。 let effectiveIntimacy = baseIntimacy - GROUP_RESERVE_INTIMACY_PENALTY; if (archetypeParams.invertedIntimacyExpression) { effectiveIntimacy -= TSUNDERE_EXTRA_GROUP_PENALTY; } effectiveIntimacy = Math.max(0, Math.min(100, effectiveIntimacy)); // K-3 隱私邊界:有旁人在場,私密記憶不主動洩漏(口風不緊原型除外)。 const excludePrivate = !archetypeParams.traits.includes("loose-lipped"); const retrievedMemories = await this.retriever.retrieve(characterId, userInput, { relatedUserId: room.userId, excludePrivate, }); const history = room.turns.slice(-10).map((turn) => ({ role: (turn.speakerId === USER_SPEAKER_ID ? "user" : "character") as "user" | "character", content: `${turn.speakerLabel}:${turn.content}`, timestamp: turn.timestamp.toISOString(), })); const seed = options.seed ?? hashToSeed(`${room.id}:${room.turnCounter}:${characterId}`); const context: GenerationContext = { character: { id: character.id, personalityArchetype: character.personalityArchetype, speechStyle: character.speechStyle, likesDislikes: character.likesDislikes, basicInfo: character.basicInfo, }, emotion: { dominant, style }, relationship: { intimacy: effectiveIntimacy, trust, stage: stageForIntimacy(effectiveIntimacy) }, retrievedMemories, history, userInput, injectedMemoryIds: retrievedMemories.map((memory) => memory.id), seed, }; const generated = await this.provider.generate(context); return { text: `${address},${generated.text}`, actions: generated.actions, address }; } }