feat: 完成 K 群組 — 對話模式:群聊與角色自聊

群聊/自聊子系統(apps/api/src/room/):在場者名冊與共用場景記錄(純記憶體,
同 C-1 工作記憶的設計)、發言權分配(點名/話題相關度/性格基線/情緒/角色間
關係/發言冷卻與冷落累積算衝動值)、群聊矜持(重用 G-3 親密度分層模板、
下修有效親密度而非另建模板庫)、隱私邊界(EpisodicMemory.isPrivate +
KeywordMemoryRetriever.excludePrivate,天然呆為口風不緊例外)、群聊記憶
投影(各角色依自身視角固化,被虧的那位權重較高)、角色自聊(沿用發言權
機制、空轉偵測注入轉折或收尾、輪數硬上限、使用者插話切換群聊)。
新增 CharacterRelationship 表供角色間關係使用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-08-13 15:15:00 +08:00
co-authored by Claude Sonnet 5
parent 3503cc0be6
commit 6125d6e27f
19 changed files with 1088 additions and 11 deletions
@@ -0,0 +1,112 @@
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<RoomReply> {
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 };
}
}