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:
co-authored by
Claude Sonnet 5
parent
3503cc0be6
commit
6125d6e27f
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user