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