146 lines
6.2 KiB
TypeScript
146 lines
6.2 KiB
TypeScript
import { Inject, Injectable } from "@nestjs/common";
|
|
import { PrismaService } from "../prisma/prisma.service.js";
|
|
import { WorkingMemoryService } from "../memory/working-memory.service.js";
|
|
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
|
import { RelationshipService } from "../relationship/relationship.service.js";
|
|
import { ScheduleService } from "../schedule/schedule.service.js";
|
|
import { ScheduleExceptionService } from "../schedule/schedule-exception.service.js";
|
|
import { ScheduleEmotionBaselineService } from "../schedule/schedule-emotion-baseline.service.js";
|
|
import { OfflineEventService } from "../schedule/offline-event.service.js";
|
|
import { availabilityForStatus, type AvailabilityMode, type ExceptionType } from "../schedule/constants.js";
|
|
import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js";
|
|
import { ContextAssemblerService } from "./context-assembler.service.js";
|
|
import { OutputFilterService } from "./output-filter.service.js";
|
|
import { FastChannelDetector, DEFAULT_FAST_REPLIES } from "./fast-channel.service.js";
|
|
import { extractActions } from "./action-markup.js";
|
|
import { getForbiddenWords, applySpeechQuirks } from "../personality/language-style.js";
|
|
import type { GenerationContext } from "./types.js";
|
|
|
|
export interface HandleMessageOptions {
|
|
seed?: number;
|
|
now?: Date;
|
|
}
|
|
|
|
export interface HandleMessageResult {
|
|
text: string;
|
|
actions: string[];
|
|
isFastChannel: boolean;
|
|
availability: AvailabilityMode;
|
|
exceptionType: ExceptionType | null;
|
|
context: GenerationContext | null;
|
|
}
|
|
|
|
function fastChannelSituation(reason: string): string {
|
|
return `fast-channel:${reason}`;
|
|
}
|
|
|
|
const DELAYED_ACKNOWLEDGEMENT = "抱歉剛剛在忙,現在才看到——";
|
|
|
|
// F 群組整合入口:串起雙速通道判斷、上下文組裝、Provider 生成、輸出過濾與工作記憶寫入。
|
|
// I 群組擴充:作息可用度/破例規則/作息情緒基線/離線事件生成(見 todo.md 實作記錄)。
|
|
@Injectable()
|
|
export class DialogueService {
|
|
constructor(
|
|
@Inject(LLM_PROVIDER) private readonly llmProvider: LLMProvider,
|
|
private readonly fastChannel: FastChannelDetector,
|
|
private readonly contextAssembler: ContextAssemblerService,
|
|
private readonly outputFilter: OutputFilterService,
|
|
private readonly workingMemory: WorkingMemoryService,
|
|
private readonly emotion: EmotionService,
|
|
private readonly relationship: RelationshipService,
|
|
private readonly schedule: ScheduleService,
|
|
private readonly scheduleException: ScheduleExceptionService,
|
|
private readonly scheduleBaseline: ScheduleEmotionBaselineService,
|
|
private readonly offlineEvent: OfflineEventService,
|
|
private readonly prisma: PrismaService,
|
|
) {}
|
|
|
|
async handleMessage(
|
|
characterId: string,
|
|
userId: string,
|
|
sessionId: string,
|
|
userInput: string,
|
|
options: HandleMessageOptions = {},
|
|
): Promise<HandleMessageResult> {
|
|
const now = options.now ?? new Date();
|
|
await this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
|
|
|
|
// I-6:按需生成離線事件(今天只生成一次),成為話題來源。
|
|
await this.offlineEvent.maybeGenerate(characterId, now);
|
|
|
|
// I-5:作息驅動的情緒基線,只在本輪沒有明確情緒訊號時才介入。
|
|
const baselineSignal = await this.scheduleBaseline.computeBaseline(characterId, now);
|
|
const emotionResult = await this.emotion.processInput(characterId, userInput, {
|
|
now,
|
|
baselineSignal: baselineSignal ?? undefined,
|
|
});
|
|
await this.relationship.recordInteraction(characterId, userId, now);
|
|
|
|
// I-2:目前時段狀態 → 回應可用度;I-4:條件符合且未在冷卻期時才破例。
|
|
const scheduleInfo = await this.schedule.getInfo(characterId, now);
|
|
const availability = availabilityForStatus(scheduleInfo.slot?.status ?? null);
|
|
|
|
let exceptionType: ExceptionType | null = null;
|
|
let effectiveAvailability = availability;
|
|
if (availability !== "NORMAL") {
|
|
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
|
exceptionType = await this.scheduleException.tryApply(
|
|
characterId,
|
|
userId,
|
|
availability,
|
|
{ intimacy: relationship.intimacy, dominantEmotion: dominantState(emotionResult.state), userInput },
|
|
now,
|
|
);
|
|
if (exceptionType) {
|
|
effectiveAvailability = "NORMAL";
|
|
}
|
|
}
|
|
|
|
if (effectiveAvailability === "NO_REPLY") {
|
|
return { text: "", actions: [], isFastChannel: false, availability: effectiveAvailability, exceptionType, context: null };
|
|
}
|
|
|
|
const { isFastChannel, reason } = this.fastChannel.detect(userInput);
|
|
const skipMemoryRetrieval = isFastChannel || effectiveAvailability === "BRIEF";
|
|
|
|
const context = await this.contextAssembler.assemble(characterId, userId, sessionId, userInput, {
|
|
skipMemoryRetrieval,
|
|
seed: options.seed,
|
|
now,
|
|
});
|
|
|
|
let text: string;
|
|
let actions: string[];
|
|
|
|
if (effectiveAvailability === "DELAYED" && !exceptionType) {
|
|
text = DELAYED_ACKNOWLEDGEMENT;
|
|
actions = [];
|
|
} else if (isFastChannel && reason) {
|
|
const rule = await this.prisma.client.proceduralRule.findFirst({
|
|
where: { characterId, situation: fastChannelSituation(reason) },
|
|
orderBy: { weight: "desc" },
|
|
});
|
|
text = rule?.responsePattern ?? DEFAULT_FAST_REPLIES[reason];
|
|
actions = extractActions(text);
|
|
} else {
|
|
const generated = await this.llmProvider.generate(context);
|
|
text = generated.text;
|
|
actions = generated.actions;
|
|
}
|
|
|
|
// I-2 剛醒/睡前:以拖長語尾的簡單標記呈現迷糊演出(完整口吻交由模板庫日後擴充)。
|
|
if (scheduleInfo.isWaking || scheduleInfo.isPreSleep) {
|
|
text = `……${text}`;
|
|
}
|
|
|
|
const forbiddenWords = getForbiddenWords(context.character.personalityArchetype);
|
|
const filteredText = applySpeechQuirks(
|
|
this.outputFilter.filter(text, forbiddenWords),
|
|
context.character.personalityArchetype,
|
|
);
|
|
await this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
|
|
|
|
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context };
|
|
}
|
|
}
|