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
+2
View File
@@ -10,6 +10,7 @@ import { PersonalityModule } from "./personality/personality.module.js";
import { ChatModule } from "./chat/chat.module.js";
import { ScheduleModule } from "./schedule/schedule.module.js";
import { TaskModule } from "./task/task.module.js";
import { RoomModule } from "./room/room.module.js";
@Module({
imports: [
@@ -23,6 +24,7 @@ import { TaskModule } from "./task/task.module.js";
ChatModule,
ScheduleModule,
TaskModule,
RoomModule,
],
controllers: [HealthController],
})
+1 -1
View File
@@ -38,6 +38,6 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
ContextAssemblerService,
DialogueService,
],
exports: [DialogueService, BehaviorReinforcementService],
exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER],
})
export class LlmModule {}
@@ -6,6 +6,8 @@ export interface RetrieveOptions {
limit?: number;
// E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。
relatedUserId?: string;
// K-3 隱私邊界:群聊等有旁人在場的情境傳 true,標記為私密的記憶不會被檢索到。
excludePrivate?: boolean;
}
export interface MemoryRetriever {
@@ -37,7 +39,9 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> {
const limit = options.limit ?? DEFAULT_LIMIT;
const rows = await this.prisma.client.episodicMemory.findMany({ where: { characterId } });
const rows = await this.prisma.client.episodicMemory.findMany({
where: { characterId, ...(options.excludePrivate ? { isPrivate: false } : {}) },
});
const now = Date.now();
const keywords = query.split(/\s+/).filter(Boolean);
@@ -76,6 +80,7 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
lastRetrievedAt: new Date(now).toISOString(),
source: row.source,
weight: row.weight,
isPrivate: row.isPrivate,
}));
}
}
+2
View File
@@ -55,10 +55,12 @@ export class MemoryController {
@Query("query") query: string,
@Query("limit") limit?: string,
@Query("relatedUserId") relatedUserId?: string,
@Query("excludePrivate") excludePrivate?: string,
) {
const results = await this.retriever.retrieve(characterId, query ?? "", {
limit: limit ? Number(limit) : undefined,
relatedUserId,
excludePrivate: excludePrivate === "true",
});
return { results };
}
+11 -2
View File
@@ -6,7 +6,8 @@ export interface ArchetypeParams {
expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣)
trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上
invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3)
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6)參考
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6/K-3)參考
conversationBaseline: number; // K-4 群聊發言基線 0~1:不被點名、無特別理由時主動開口的傾向(元氣高、三無極低)
}
const DEFAULT_PARAMS: ArchetypeParams = {
@@ -15,6 +16,7 @@ const DEFAULT_PARAMS: ArchetypeParams = {
trustGrowthRate: 1,
invertedIntimacyExpression: false,
traits: [],
conversationBaseline: 0.4,
};
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
@@ -24,6 +26,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.5,
invertedIntimacyExpression: true,
traits: ["denial-then-honest"],
conversationBaseline: 0.5,
},
冷淡: {
emotionTriggerThreshold: 2.5,
@@ -31,13 +34,16 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.3,
invertedIntimacyExpression: false,
traits: ["flat-affect", "short-replies"],
conversationBaseline: 0.15,
},
天然呆: {
emotionTriggerThreshold: 0.7,
expressiveness: 0.8,
trustGrowthRate: 1.5,
invertedIntimacyExpression: false,
traits: ["misreads-context"],
// K-3:口風不緊——群聊中偶爾會不小心把私密記憶說出來,是這個原型的性格設定例外。
traits: ["misreads-context", "loose-lipped"],
conversationBaseline: 0.6,
},
元氣: {
emotionTriggerThreshold: 0.7,
@@ -45,6 +51,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 1.3,
invertedIntimacyExpression: false,
traits: ["talkative", "fast-emotion-decay"],
conversationBaseline: 0.85,
},
大小姐: {
emotionTriggerThreshold: 1.3,
@@ -52,6 +59,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.4,
invertedIntimacyExpression: false,
traits: ["formal-address", "poor-loser"],
conversationBaseline: 0.55,
},
三無: {
emotionTriggerThreshold: 3.5,
@@ -59,6 +67,7 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.2,
invertedIntimacyExpression: false,
traits: ["very-short-replies", "sudden-overflow"],
conversationBaseline: 0.05,
},
};
@@ -0,0 +1,25 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
// K-2/K-4 角色間關係:同作品角色互損、拆台、護短、吃醋等化學反應的資料來源。
// 有向邊:characterId 對 otherCharacterId 的觀感,兩邊可以各自設定、不必對稱。
@Injectable()
export class CharacterRelationshipService {
constructor(private readonly prisma: PrismaService) {}
async set(characterId: string, otherCharacterId: string, affinity: number, dynamic?: string) {
return this.prisma.client.characterRelationship.upsert({
where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
update: { affinity, dynamic },
create: { characterId, otherCharacterId, affinity, dynamic },
});
}
// 未設定關係時,回傳中性值(50)——同作品角色預設不特別親近也不特別有心結。
async getAffinity(characterId: string, otherCharacterId: string): Promise<number> {
const row = await this.prisma.client.characterRelationship.findUnique({
where: { characterId_otherCharacterId: { characterId, otherCharacterId } },
});
return row?.affinity ?? 50;
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { EmotionTag } from "@kokorone/shared";
// K-4 發言權分配:各因素的權重與門檻。
export const SPEAKING_THRESHOLD = 0.25;
export const NAMED_BOOST = 0.6; // 被點名/被提問幾乎必回
export const BASELINE_WEIGHT = 0.3;
export const TOPIC_RELEVANCE_WEIGHT = 0.25;
export const EMOTION_WEIGHT = 0.15;
export const RELATIONSHIP_WEIGHT = 0.15;
// 剛發言者衝動下降;被冷落的角色衝動值隨連續沉默輪數累積(有上限)。
export const SPEAK_COOLDOWN_PENALTY = 0.35;
export const SILENT_STREAK_BONUS_PER_TURN = 0.05;
export const SILENT_STREAK_BONUS_CAP = 0.3;
export const EMOTION_SPEAKING_MODIFIER: Record<EmotionTag, number> = {
CALM: 0,
JOY: 0.3, // 愉悅多話
SAD: -0.3, // 低落沉默
ALERT: 0.1, // 警戒時會出聲反應
SHY: -0.1, // 害羞會退縮
GRUMPY: -0.2, // 彆扭故意不接話
};
// K-2 群聊矜持:在群聊中,實際傳入生成上下文的親密度會被下修,讓模板系統自然選到更收斂的回應。
export const GROUP_RESERVE_INTIMACY_PENALTY = 35;
// 傲嬌式反向表達在群聊中「人前更嘴硬」,額外多下修一些。
export const TSUNDERE_EXTRA_GROUP_PENALTY = 15;
// K-6 角色自聊
export const SELF_CHAT_MAX_TURNS = 12;
export const STALL_CHECK_WINDOW = 3; // 檢查最近幾輪是否開始空轉
export const TOPIC_TWIST_LINES = [
"對了,說到這個,我突然想到另一件事……",
"欸,先別說這個了,你們聽說了嗎——",
"話說回來,今天發生了一件蠻好玩的事。",
];
export const SELF_CHAT_WRAP_UP_LINES = ["好啦,先聊到這裡吧,晚點再聊!", "時間也差不多了,先這樣吧~"];
@@ -0,0 +1,87 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { HIGH_EMOTION_THRESHOLD } from "../memory/constants.js";
import { RoomService, USER_SPEAKER_ID, type Room, type RoomTurnRecord } from "./room.service.js";
async function isAboutCharacter(
prisma: PrismaService,
characterId: string,
content: string,
): Promise<boolean> {
const aliases = await prisma.client.characterAlias.findMany({ where: { characterId } });
return aliases.some(
(alias) =>
(alias.formalName && content.includes(alias.formalName)) ||
(alias.nickname && content.includes(alias.nickname)) ||
(alias.calledByOthers && content.includes(alias.calledByOthers)),
);
}
// K-5 群聊記憶投影:群聊記錄是一份共用場景記錄,session 結束時各角色以自身視角萃取記憶——
// 同一場對話,被提到/被虧的那位記得比較牢,情緒標記也可能不同(沿用 C-4 的固化風格,但改成逐角色視角)。
@Injectable()
export class RoomConsolidationService {
constructor(
private readonly prisma: PrismaService,
private readonly room: RoomService,
private readonly tagger: RuleBasedEmotionTagger,
) {}
async consolidate(roomId: string): Promise<void> {
const room = this.room.get(roomId);
for (const characterId of room.participants.keys()) {
await this.consolidateForCharacter(room, characterId);
}
this.room.end(roomId);
this.room.clear(roomId);
}
private async consolidateForCharacter(room: Room, characterId: string): Promise<void> {
for (const turn of room.turns) {
const isSelf = turn.speakerId === characterId;
const signal = this.tagger.tag({ text: turn.content });
const isHighEmotion = signal.intensity >= HIGH_EMOTION_THRESHOLD;
const aboutMe = !isSelf && (await isAboutCharacter(this.prisma, characterId, turn.content));
if (isSelf && !isHighEmotion) {
continue; // 自己說過的話,只有情緒強烈時才特別記得(同 C-4:平淡的自述不特別留存)。
}
if (!isSelf && !isHighEmotion && !aboutMe) {
continue; // 別人之間的閒聊,跟自己無關且情緒平淡,不特別留存。
}
const weight = this.weightFor(isSelf, aboutMe, signal.intensity);
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content: this.describe(turn, isSelf),
occurredAt: turn.timestamp,
emotionTag: signal.tag,
emotionIntensity: signal.intensity || 0.3,
source: "INTERACTION",
weight,
relatedUserId: turn.speakerId === USER_SPEAKER_ID ? room.userId : undefined,
},
});
}
}
// 被提到/被虧的那位記得比較牢:aboutMe 的權重高於單純旁觀到的高情緒對話。
private weightFor(isSelf: boolean, aboutMe: boolean, intensity: number): number {
if (aboutMe) {
return 1.5 + intensity;
}
if (isSelf) {
return 1 + intensity;
}
return 0.5 + intensity;
}
private describe(turn: RoomTurnRecord, isSelf: boolean): string {
if (isSelf) {
return `我說:「${turn.content}」`;
}
return `${turn.speakerLabel}說:「${turn.content}」`;
}
}
@@ -0,0 +1,112 @@
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 };
}
}
+113
View File
@@ -0,0 +1,113 @@
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RoomService, USER_SPEAKER_ID } from "./room.service.js";
import { SpeakingRightService } from "./speaking-right.service.js";
import { RoomGenerationService } from "./room-generation.service.js";
import { RoomConsolidationService } from "./room-consolidation.service.js";
import { SelfChatService } from "./self-chat.service.js";
import { CharacterRelationshipService } from "./character-relationship.service.js";
interface CreateRoomBody {
userId: string;
characterIds: string[];
mode?: "GROUP" | "SELF_CHAT";
topicSeed?: string;
}
interface MessageBody {
userId: string;
userLabel?: string;
text: string;
now?: string;
}
interface RelationshipBody {
otherCharacterId: string;
affinity: number;
dynamic?: string;
}
@Controller("room")
export class RoomController {
constructor(
private readonly prisma: PrismaService,
private readonly room: RoomService,
private readonly speakingRight: SpeakingRightService,
private readonly generation: RoomGenerationService,
private readonly consolidation: RoomConsolidationService,
private readonly selfChat: SelfChatService,
private readonly characterRelationship: CharacterRelationshipService,
) {}
@Post()
create(@Body() body: CreateRoomBody) {
const room = this.room.create(body.userId, body.characterIds, body.mode ?? "GROUP", body.topicSeed);
return { id: room.id, mode: room.mode, status: room.status, topicSeed: room.topicSeed };
}
// K-1 驗收:角色(或呼叫端)可取得完整在場名單。
@Get(":roomId")
get(@Param("roomId") roomId: string) {
const room = this.room.get(roomId);
return {
id: room.id,
mode: room.mode,
status: room.status,
topicSeed: room.topicSeed,
participants: this.room.roster(roomId),
turnCount: room.turnCounter,
turns: room.turns.map((turn) => ({ speakerId: turn.speakerId, speakerLabel: turn.speakerLabel, content: turn.content })),
};
}
// K-2/K-4 群聊訊息:使用者發言後,依發言權分配決定哪些角色本輪回應,依衝動值高到低依序生成。
@Post(":roomId/message")
async message(@Param("roomId") roomId: string, @Body() body: MessageBody) {
const now = body.now ? new Date(body.now) : new Date();
const room = this.room.get(roomId);
const userLabel = body.userLabel ?? body.userId;
this.room.addTurn(roomId, USER_SPEAKER_ID, userLabel, body.text, now);
const speakers = await this.speakingRight.decideSpeakers(room, USER_SPEAKER_ID, body.text, now);
const replies: { characterId: string; text: string; score: number }[] = [];
for (const candidate of speakers) {
const character = await this.prisma.client.character.findUniqueOrThrow({
where: { id: candidate.characterId },
});
const alias = await this.prisma.client.characterAlias.findFirst({
where: { characterId: candidate.characterId },
});
const speakerLabel = alias?.nickname || alias?.formalName || character.id;
const reply = await this.generation.generateReply(room, candidate.characterId, USER_SPEAKER_ID, userLabel, body.text, {
now,
});
this.room.addTurn(roomId, candidate.characterId, speakerLabel, reply.text, now);
replies.push({ characterId: candidate.characterId, text: reply.text, score: candidate.score });
}
return { speakers: replies };
}
@Post(":roomId/self-chat/advance")
async advanceSelfChat(@Param("roomId") roomId: string, @Body() body: { now?: string }) {
return this.selfChat.advance(roomId, body.now ? new Date(body.now) : undefined);
}
@Post(":roomId/interject")
async interject(@Param("roomId") roomId: string, @Body() body: MessageBody) {
await this.selfChat.interject(roomId);
return this.message(roomId, body);
}
@Post(":roomId/end")
async end(@Param("roomId") roomId: string) {
await this.consolidation.consolidate(roomId);
return { ok: true };
}
@Post("relationship/:characterId")
async setRelationship(@Param("characterId") characterId: string, @Body() body: RelationshipBody) {
return this.characterRelationship.set(characterId, body.otherCharacterId, body.affinity, body.dynamic);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { EmotionModule } from "../emotion/emotion.module.js";
import { RelationshipModule } from "../relationship/relationship.module.js";
import { MemoryModule } from "../memory/memory.module.js";
import { LlmModule } from "../llm/llm.module.js";
import { RoomService } from "./room.service.js";
import { CharacterRelationshipService } from "./character-relationship.service.js";
import { SpeakingRightService } from "./speaking-right.service.js";
import { RoomGenerationService } from "./room-generation.service.js";
import { RoomConsolidationService } from "./room-consolidation.service.js";
import { SelfChatService } from "./self-chat.service.js";
import { RoomController } from "./room.controller.js";
@Module({
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, LlmModule],
controllers: [RoomController],
providers: [
RoomService,
CharacterRelationshipService,
SpeakingRightService,
RoomGenerationService,
RoomConsolidationService,
SelfChatService,
],
exports: [RoomService, CharacterRelationshipService, SpeakingRightService, RoomGenerationService, RoomConsolidationService, SelfChatService],
})
export class RoomModule {}
+129
View File
@@ -0,0 +1,129 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
export type RoomMode = "GROUP" | "SELF_CHAT";
export type RoomStatus = "ACTIVE" | "ENDED";
export const USER_SPEAKER_ID = "USER";
export interface RoomParticipantState {
characterId: string;
silentStreak: number;
lastSpokeAtTurn: number; // -1 = 從未發言
}
export interface RoomTurnRecord {
turnIndex: number;
speakerId: string; // characterId,或使用者固定用 USER_SPEAKER_ID
speakerLabel: string;
content: string;
emotionTag?: EmotionTag;
emotionIntensity?: number;
timestamp: Date;
}
export interface Room {
id: string;
userId: string;
mode: RoomMode;
status: RoomStatus;
topicSeed?: string;
participants: Map<string, RoomParticipantState>;
turns: RoomTurnRecord[];
turnCounter: number;
}
let roomSeq = 0;
function nextRoomId(): string {
roomSeq += 1;
return `room-${roomSeq}-${roomSeq * 31 + 7}`;
}
// K-1 在場者模型:群聊/自聊 session 的參與者名冊與共用場景記錄。
// 刻意採用純記憶體儲存(同 C-1 WorkingMemoryService 的設計),因為這是 session 期間的暫存場景,
// session 結束後會由 K-5 的固化流程萃取成各角色的長期記憶,原始記錄本身不需要落地保存。
@Injectable()
export class RoomService {
private readonly rooms = new Map<string, Room>();
create(userId: string, characterIds: string[], mode: RoomMode, topicSeed?: string): Room {
const room: Room = {
id: nextRoomId(),
userId,
mode,
status: "ACTIVE",
topicSeed,
participants: new Map(
characterIds.map((characterId) => [characterId, { characterId, silentStreak: 0, lastSpokeAtTurn: -1 }]),
),
turns: [],
turnCounter: 0,
};
this.rooms.set(room.id, room);
return room;
}
get(roomId: string): Room {
const room = this.rooms.get(roomId);
if (!room) {
throw new NotFoundException(`聊天室不存在:${roomId}`);
}
return room;
}
// K-1 驗收:角色(或呼叫端)可取得完整在場名單。
roster(roomId: string): string[] {
return [...this.get(roomId).participants.keys()];
}
addTurn(
roomId: string,
speakerId: string,
speakerLabel: string,
content: string,
now: Date = new Date(),
emotion?: { tag: EmotionTag; intensity: number },
): RoomTurnRecord {
const room = this.get(roomId);
const turnIndex = room.turnCounter;
room.turnCounter += 1;
const turn: RoomTurnRecord = {
turnIndex,
speakerId,
speakerLabel,
content,
emotionTag: emotion?.tag,
emotionIntensity: emotion?.intensity,
timestamp: now,
};
room.turns.push(turn);
// 發言冷卻/被冷落累積:發言者本輪清空沉默計數,其他在場角色沉默計數 +1。
for (const participant of room.participants.values()) {
if (participant.characterId === speakerId) {
participant.lastSpokeAtTurn = turnIndex;
participant.silentStreak = 0;
} else {
participant.silentStreak += 1;
}
}
return turn;
}
switchToGroup(roomId: string): Room {
const room = this.get(roomId);
room.mode = "GROUP";
return room;
}
end(roomId: string): Room {
const room = this.get(roomId);
room.status = "ENDED";
return room;
}
clear(roomId: string): void {
this.rooms.delete(roomId);
}
}
+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",
};
}
}
+117
View File
@@ -0,0 +1,117 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
import { RelationshipService } from "../relationship/relationship.service.js";
import { getArchetypeParams } from "../personality/archetype-params.js";
import { CharacterRelationshipService } from "./character-relationship.service.js";
import { type Room, USER_SPEAKER_ID } from "./room.service.js";
import {
SPEAKING_THRESHOLD,
NAMED_BOOST,
BASELINE_WEIGHT,
TOPIC_RELEVANCE_WEIGHT,
EMOTION_WEIGHT,
RELATIONSHIP_WEIGHT,
SPEAK_COOLDOWN_PENALTY,
SILENT_STREAK_BONUS_PER_TURN,
SILENT_STREAK_BONUS_CAP,
EMOTION_SPEAKING_MODIFIER,
} from "./constants.js";
export interface SpeakingImpulse {
characterId: string;
score: number;
isNamed: boolean;
}
function keywordOverlap(text: string, corpus: string): number {
// 中文常見以單字成詞(貓/狗/書),門檻不能設 >=2,否則會漏掉大量真實關鍵字。
const keywords = corpus.split(/[、,,;::\s]+/).filter((word) => word.length >= 1);
if (keywords.length === 0) {
return 0;
}
const hits = keywords.filter((keyword) => text.includes(keyword)).length;
if (hits === 0) {
return 0;
}
// 只要命中,就是明確訊號(哪怕只命中角色喜好清單裡的一個詞):至少給予下限分數,
// 不讓「喜好清單條目多」稀釋掉單次命中的重要性。
return Math.max(0.5, hits / keywords.length);
}
// K-4 發言權分配:每輪為每位在場角色計算「發言衝動值」,超過門檻者本輪發言。
@Injectable()
export class SpeakingRightService {
constructor(
private readonly prisma: PrismaService,
private readonly emotion: EmotionService,
private readonly relationship: RelationshipService,
private readonly characterRelationship: CharacterRelationshipService,
) {}
async computeImpulse(
room: Room,
characterId: string,
speakerId: string,
content: string,
now: Date,
): Promise<SpeakingImpulse> {
const [character, aliases] = await Promise.all([
this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }),
this.prisma.client.characterAlias.findMany({ where: { characterId } }),
]);
const archetypeParams = getArchetypeParams(character.personalityArchetype);
const isNamed = aliases.some(
(alias) =>
(alias.formalName && content.includes(alias.formalName)) ||
(alias.nickname && content.includes(alias.nickname)) ||
(alias.calledByOthers && content.includes(alias.calledByOthers)),
);
const topicCorpus = [character.likesDislikes, character.goalsObsessions, character.basicInfo].join("、");
const topicRelevance = keywordOverlap(content, topicCorpus);
const emotionState = await this.emotion.getState(characterId, now);
const emotionModifier = EMOTION_SPEAKING_MODIFIER[dominantState(emotionState)];
let relationshipModifier = 0;
if (speakerId === USER_SPEAKER_ID) {
const { relationship } = await this.relationship.getState(characterId, room.userId, now);
relationshipModifier = (relationship.intimacy - 50) / 100;
} else if (speakerId !== characterId) {
const affinity = await this.characterRelationship.getAffinity(characterId, speakerId);
relationshipModifier = (affinity - 50) / 100;
}
const participant = room.participants.get(characterId);
const justSpoke = participant?.lastSpokeAtTurn === room.turnCounter - 1;
const cooldownPenalty = justSpoke ? SPEAK_COOLDOWN_PENALTY : 0;
const silentStreakBonus = Math.min(
SILENT_STREAK_BONUS_CAP,
(participant?.silentStreak ?? 0) * SILENT_STREAK_BONUS_PER_TURN,
);
let score =
archetypeParams.conversationBaseline * BASELINE_WEIGHT +
topicRelevance * TOPIC_RELEVANCE_WEIGHT +
emotionModifier * EMOTION_WEIGHT +
relationshipModifier * RELATIONSHIP_WEIGHT +
silentStreakBonus -
cooldownPenalty;
if (isNamed) {
score += NAMED_BOOST;
}
return { characterId, score: Math.max(0, Math.min(1.6, score)), isNamed };
}
// 回傳本輪應該發言的角色(依衝動值高到低排序),排除發言者本人。
async decideSpeakers(room: Room, speakerId: string, content: string, now: Date = new Date()): Promise<SpeakingImpulse[]> {
const candidates = [...room.participants.keys()].filter((characterId) => characterId !== speakerId);
const impulses = await Promise.all(
candidates.map((characterId) => this.computeImpulse(room, characterId, speakerId, content, now)),
);
return impulses.filter((impulse) => impulse.score >= SPEAKING_THRESHOLD).sort((a, b) => b.score - a.score);
}
}