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
+127
View File
@@ -0,0 +1,127 @@
import { Injectable } from "@nestjs/common";
import { RoomService, USER_SPEAKER_ID, type Room } from "./room.service.js";
import { SpeakingRightService } from "./speaking-right.service.js";
import { RoomGenerationService } from "./room-generation.service.js";
import { PrismaService } from "../prisma/prisma.service.js";
import { SELF_CHAT_MAX_TURNS, STALL_CHECK_WINDOW, TOPIC_TWIST_LINES, SELF_CHAT_WRAP_UP_LINES } from "./constants.js";
function normalize(text: string): string {
return text.trim().toLowerCase();
}
// 偵測最近幾輪是否開始空轉:內容高度重複,或字數持續下降(話越講越少,快聊不下去了)。
function isStalling(recentContents: string[]): boolean {
if (recentContents.length < STALL_CHECK_WINDOW) {
return false;
}
const window = recentContents.slice(-STALL_CHECK_WINDOW);
const normalized = window.map(normalize);
const uniqueCount = new Set(normalized).size;
if (uniqueCount === 1) {
return true; // 完全重複
}
const lengths = window.map((text) => text.length);
const isMonotonicDecline = lengths.every((length, index) => index === 0 || length <= lengths[index - 1]);
return isMonotonicDecline && lengths[0] > lengths[lengths.length - 1];
}
export interface SelfChatAdvanceResult {
ended: boolean;
turn?: { characterId: string; content: string };
reason?: "topic-exhausted" | "turn-cap" | "stalled-out";
}
// K-6 角色自聊:話題種子驅動、發言權機制沿用 K-4、空轉偵測注入轉折或收尾、輪數硬上限。
@Injectable()
export class SelfChatService {
private readonly twistCounts = new Map<string, number>();
constructor(
private readonly prisma: PrismaService,
private readonly room: RoomService,
private readonly speakingRight: SpeakingRightService,
private readonly generation: RoomGenerationService,
) {}
async start(userId: string, characterIds: string[], topicSeed: string, now: Date = new Date()) {
const room = this.room.create(userId, characterIds, "SELF_CHAT", topicSeed);
this.twistCounts.set(room.id, 0);
return room;
}
async advance(roomId: string, now: Date = new Date()): Promise<SelfChatAdvanceResult> {
const room = this.room.get(roomId);
if (room.mode !== "SELF_CHAT" || room.status !== "ACTIVE") {
return { ended: true, reason: "topic-exhausted" };
}
if (room.turnCounter >= SELF_CHAT_MAX_TURNS) {
return this.wrapUp(room, now);
}
const lastTurn = room.turns.at(-1);
const triggerSpeakerId = lastTurn?.speakerId ?? "__SEED__";
const triggerContent = lastTurn?.content ?? room.topicSeed ?? "今天過得怎麼樣?";
const triggerLabel = lastTurn?.speakerLabel ?? "話題";
const recentContents = room.turns.slice(-STALL_CHECK_WINDOW).map((turn) => turn.content);
const twistCount = this.twistCounts.get(room.id) ?? 0;
if (isStalling(recentContents)) {
if (twistCount >= 2) {
return this.wrapUp(room, now);
}
return this.injectTwist(room, twistCount, now);
}
const candidates = await this.speakingRight.decideSpeakers(room, triggerSpeakerId, triggerContent, now);
const speakerId = candidates[0]?.characterId ?? [...room.participants.keys()][0];
if (!speakerId) {
return this.wrapUp(room, now);
}
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } });
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } });
const speakerLabel = alias?.nickname || alias?.formalName || character.id;
const reply = await this.generation.generateReply(room, speakerId, triggerSpeakerId, triggerLabel, triggerContent, {
now,
});
this.room.addTurn(roomId, speakerId, speakerLabel, reply.text, now);
return { ended: false, turn: { characterId: speakerId, content: reply.text } };
}
// 使用者插話:切換為群聊模式,插話內容本身視為使用者的第一則群聊訊息。
async interject(roomId: string): Promise<Room> {
return this.room.switchToGroup(roomId);
}
private async injectTwist(room: Room, twistCount: number, now: Date): Promise<SelfChatAdvanceResult> {
const speakerId = [...room.participants.keys()][twistCount % room.participants.size];
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } });
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } });
const speakerLabel = alias?.nickname || alias?.formalName || character.id;
const line = TOPIC_TWIST_LINES[twistCount % TOPIC_TWIST_LINES.length];
this.room.addTurn(room.id, speakerId, speakerLabel, line, now);
this.twistCounts.set(room.id, twistCount + 1);
return { ended: false, turn: { characterId: speakerId, content: line } };
}
private async wrapUp(room: Room, now: Date): Promise<SelfChatAdvanceResult> {
const speakerId = [...room.participants.keys()][0];
if (speakerId) {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: speakerId } });
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId: speakerId } });
const speakerLabel = alias?.nickname || alias?.formalName || character.id;
const line = SELF_CHAT_WRAP_UP_LINES[room.turnCounter % SELF_CHAT_WRAP_UP_LINES.length];
this.room.addTurn(room.id, speakerId, speakerLabel, line, now);
}
this.twistCounts.delete(room.id);
this.room.end(room.id);
return {
ended: true,
reason: room.turnCounter >= SELF_CHAT_MAX_TURNS ? "turn-cap" : "stalled-out",
};
}
}