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