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(); 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 { 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 { return this.room.switchToGroup(roomId); } private async injectTwist(room: Room, twistCount: number, now: Date): Promise { 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 { 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", }; } }