feat: 完成 I 群組 — 生活作息與離線生活

- ScheduleService:I-1 平日/週末/假期時段表(可為角色寫入高中生預設作息)、
  I-3 時間感知(Asia/Taipei 當地時間,不受伺服器時區影響)、剛醒/睡前偵測
- constants.ts:I-2 時段狀態 → 回應可用度對照(FREE/SEMI_BUSY/BUSY/SLEEP →
  NORMAL/BRIEF/DELAYED/NO_REPLY),沒有作息表的角色一律視為 NORMAL(相容既有角色)
- ScheduleExceptionService:I-4 三種破例(深夜還陪你聊/上課偷回訊息/為你調整行程),
  各自獨立冷卻期,觸發時記入關係帳本
- ScheduleEmotionBaselineService:I-5 作息驅動情緒基線(社團剛結束的疲憊、假日偏高心情),
  只在本輪沒有明確情緒訊號時介入,串接進 EmotionService.processInput 的新增選項
- OfflineEventService:I-6 離線事件生成,依「今天已發生的固定行程」挑選對應模板,
  一天只生成一次,寫入低權重情節記憶
- DialogueService 整合以上機制:離線事件生成 → 情緒基線標記 → 累積親密度 →
  作息可用度判定 → 不符合則嘗試破例 → 依可用度決定回覆內容
- 修正真實 bug:G-5 角色建立流程誤用 source=OFFLINE_GENERATED 標記背景故事,
  與 I-6「今天是否已生成離線事件」的判斷共用同一標記,已改為 SOURCE_EXTRACTION
- ChatView.tsx 補上 NO_REPLY/context 為 null 的防呆(作息可用度整合後的必要調整)
- scripts/smoke/I.mjs:以獨立測試角色(避免污染種子角色的排程狀態)驗證全部六項

npm run restart && npm run smoke -- I 皆通過(I-V),A~H 群組冒煙測試無回歸。
This commit is contained in:
Jeffery
2026-08-13 14:08:24 +08:00
parent 9e5036f0a0
commit d82b834e47
20 changed files with 840 additions and 22 deletions
+2
View File
@@ -8,6 +8,7 @@ import { RelationshipModule } from "./relationship/relationship.module.js";
import { LlmModule } from "./llm/llm.module.js";
import { PersonalityModule } from "./personality/personality.module.js";
import { ChatModule } from "./chat/chat.module.js";
import { ScheduleModule } from "./schedule/schedule.module.js";
@Module({
imports: [
@@ -19,6 +20,7 @@ import { ChatModule } from "./chat/chat.module.js";
LlmModule,
PersonalityModule,
ChatModule,
ScheduleModule,
],
controllers: [HealthController],
})
+6 -1
View File
@@ -6,6 +6,8 @@ interface ChatMessageBody {
userId: string;
sessionId: string;
text: string;
seed?: number;
now?: string; // 測試用:模擬「現在」時間,供 I 群組作息相關行為驗證
}
interface EndSessionBody {
@@ -23,7 +25,10 @@ export class ChatController {
@Post("chat/:characterId")
async chat(@Param("characterId") characterId: string, @Body() body: ChatMessageBody) {
return this.dialogue.handleMessage(characterId, body.userId, body.sessionId, body.text);
return this.dialogue.handleMessage(characterId, body.userId, body.sessionId, body.text, {
seed: body.seed,
now: body.now ? new Date(body.now) : undefined,
});
}
@Post("session/:sessionId/end")
+6 -1
View File
@@ -56,6 +56,8 @@ export interface ProcessInputOptions extends TransitionContext {
triggerThreshold?: number;
halfLifeMs?: number;
now?: Date;
// I-5:本輪對話沒有明確情緒訊號時,套用作息驅動的基線情緒(不覆蓋文字本身觸發的訊號)。
baselineSignal?: { tag: EmotionTag; intensity: number };
}
function toDomain(characterId: string, dims: Dimensions, updatedAt: Date): EmotionState {
@@ -105,7 +107,10 @@ export class EmotionService {
const rawSignal = this.tagger.tag({ text, triggerThreshold });
// 情緒觸發閾值除了縮放強度,也要能讓「難觸發」原型對弱刺激完全不觸發(而不只是觸發得比較小力)。
const signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
let signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
if (signal.tag === "CALM" && options.baselineSignal) {
signal = options.baselineSignal;
}
const next = nextEmotionState(currentDominant, signal, options);
let updated: Dimensions;
+61 -8
View File
@@ -1,8 +1,13 @@
import { Inject, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { WorkingMemoryService } from "../memory/working-memory.service.js";
import { EmotionService } from "../emotion/emotion.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";
@@ -20,15 +25,19 @@ export interface HandleMessageResult {
text: string;
actions: string[];
isFastChannel: boolean;
context: GenerationContext;
availability: AvailabilityMode;
exceptionType: ExceptionType | null;
context: GenerationContext | null;
}
function fastChannelSituation(reason: string): string {
return `fast-channel:${reason}`;
}
const DELAYED_ACKNOWLEDGEMENT = "抱歉剛剛在忙,現在才看到——";
// F 群組整合入口:串起雙速通道判斷、上下文組裝、Provider 生成、輸出過濾與工作記憶寫入。
// H-1 的 POST /chat/:characterId 會以此為基礎擴充(見 todo.md 實作記錄)。
// I 群組擴充:作息可用度/破例規則/作息情緒基線/離線事件生成(見 todo.md 實作記錄)。
@Injectable()
export class DialogueService {
constructor(
@@ -39,6 +48,10 @@ export class DialogueService {
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,
) {}
@@ -52,14 +65,46 @@ export class DialogueService {
const now = options.now ?? new Date();
this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
// H-1:每輪對話先標記情緒、累積日常互動的親密度,讓後續組裝的上下文反映本輪的變化。
await this.emotion.processInput(characterId, userInput, { 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: isFastChannel,
skipMemoryRetrieval,
seed: options.seed,
now,
});
@@ -67,7 +112,10 @@ export class DialogueService {
let text: string;
let actions: string[];
if (isFastChannel && reason) {
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" },
@@ -80,6 +128,11 @@ export class DialogueService {
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),
@@ -87,6 +140,6 @@ export class DialogueService {
);
this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
return { text: filteredText, actions, isFastChannel, context };
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context };
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ 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 { ScheduleModule } from "../schedule/schedule.module.js";
import { MockProvider } from "./mock-provider.service.js";
import { ClaudeProvider } from "./claude-provider.service.js";
import { LLM_PROVIDER } from "./llm-provider.js";
@@ -25,7 +26,7 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
}
@Module({
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule],
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, ScheduleModule],
controllers: [DialogueController],
providers: [
MockProvider,
@@ -68,7 +68,9 @@ export class CharacterCreationService {
occurredAt: now,
emotionTag: signal.tag,
emotionIntensity: signal.intensity || 0.3,
source: "OFFLINE_GENERATED",
// 來自角色設定表本身,不是 I-6 的「離線生成日常事件」,兩者不可共用同一 source 值
// (I-6 用 source=OFFLINE_GENERATED 判斷「今天是否已生成過離線事件」,混用會誤判)。
source: "SOURCE_EXTRACTION",
weight: 2,
},
});
+74
View File
@@ -0,0 +1,74 @@
import type { ScheduleDayType, ScheduleSlotStatus } from "@kokorone/db";
export interface DefaultSlotSeed {
dayType: ScheduleDayType;
startMinute: number;
endMinute: number;
status: ScheduleSlotStatus;
label?: string;
}
// I-1 高中生角色的預設平日/週末作息表(依 wiki §作息表設計的甘特圖範例)。
// 跨午夜的睡眠時段拆成兩列(前一天 23:00~24:00 與當天 00:00~06:30)。
export const DEFAULT_HIGH_SCHOOLER_SCHEDULE: DefaultSlotSeed[] = [
// 平日
{ dayType: "WEEKDAY", startMinute: 0, endMinute: 390, status: "SLEEP", label: "睡眠" },
{ dayType: "WEEKDAY", startMinute: 390, endMinute: 510, status: "SEMI_BUSY", label: "晨間準備/通學" },
{ dayType: "WEEKDAY", startMinute: 510, endMinute: 960, status: "BUSY", label: "上課" },
{ dayType: "WEEKDAY", startMinute: 960, endMinute: 1080, status: "BUSY", label: "社團活動" },
{ dayType: "WEEKDAY", startMinute: 1080, endMinute: 1140, status: "SEMI_BUSY", label: "回家路上" },
{ dayType: "WEEKDAY", startMinute: 1140, endMinute: 1260, status: "SEMI_BUSY", label: "晚餐/家庭時間" },
{ dayType: "WEEKDAY", startMinute: 1260, endMinute: 1380, status: "FREE", label: "自由時間" },
{ dayType: "WEEKDAY", startMinute: 1380, endMinute: 1440, status: "SLEEP", label: "睡眠" },
// 週末(睡得晚一點、多數時間空閒)
{ dayType: "WEEKEND", startMinute: 0, endMinute: 480, status: "SLEEP", label: "睡眠" },
{ dayType: "WEEKEND", startMinute: 480, endMinute: 720, status: "FREE", label: "悠閒早晨" },
{ dayType: "WEEKEND", startMinute: 720, endMinute: 780, status: "SEMI_BUSY", label: "午餐/家庭時間" },
{ dayType: "WEEKEND", startMinute: 780, endMinute: 1320, status: "FREE", label: "自由時間" },
{ dayType: "WEEKEND", startMinute: 1320, endMinute: 1440, status: "SLEEP", label: "睡眠" },
];
export const WAKE_WINDOW_MINUTES = 30; // 剛醒的迷糊演出窗口
export const PRE_SLEEP_WINDOW_MINUTES = 30; // 睡前的慵懶演出窗口
export type AvailabilityMode = "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
// I-2 時段狀態 → 回應可用度
export function availabilityForStatus(status: ScheduleSlotStatus | null): AvailabilityMode {
switch (status) {
case "FREE":
return "NORMAL";
case "SEMI_BUSY":
return "BRIEF";
case "BUSY":
return "DELAYED";
case "SLEEP":
return "NO_REPLY";
default:
return "NORMAL"; // 沒有作息資料時預設正常回應,不因缺資料而封鎖對話
}
}
export type ExceptionType = "LATE_NIGHT_CHAT" | "SNEAK_REPLY_IN_CLASS" | "ADJUST_SCHEDULE";
// I-4 破例規則:必須稀有,因此每種破例都有各自的冷卻天數。
export const EXCEPTION_COOLDOWN_DAYS: Record<ExceptionType, number> = {
LATE_NIGHT_CHAT: 7,
SNEAK_REPLY_IN_CLASS: 10,
ADJUST_SCHEDULE: 30,
};
export const EXCEPTION_INTIMACY_THRESHOLD: Record<ExceptionType, number> = {
LATE_NIGHT_CHAT: 60, // 摯友-曖昧以上
SNEAK_REPLY_IN_CLASS: 80, // 羈絆級
ADJUST_SCHEDULE: 80, // 羈絆級
};
export const EXCEPTION_LEDGER_EVENT: Record<ExceptionType, string> = {
LATE_NIGHT_CHAT: "深夜還是抽空陪你聊天",
SNEAK_REPLY_IN_CLASS: "上課中還偷偷回你訊息",
ADJUST_SCHEDULE: "為了你調整了自己的行程",
};
export const URGENT_KEYWORDS = ["緊急", "快點", "立刻", "馬上", "救命"];
export const SCHEDULE_ADJUST_REQUEST_KEYWORDS = ["可以陪我", "要不要一起", "今天可以出來", "有空嗎", "陪我一下"];
@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ScheduleService } from "./schedule.service.js";
// I-6 離線事件生成的模板庫,鍵為固定行程的 label;一致性約束:只依「今天已經發生過」的
// 固定行程挑選對應模板,不會生成角色作息表裡沒有的活動。
const OFFLINE_EVENT_TEMPLATES: Record<string, string[]> = {
社團活動: ["今天社團練習時出了點小糗,大家都笑翻了", "社團今天氣氛超好,練得特別起勁"],
上課: ["今天上課差點打瞌睡,還好沒被抓到", "今天課堂上聽到一個超有趣的小知識"],
自由時間: ["剛剛看到夕陽超美,忍不住多看了好幾眼", "吃到一款新口味的點心,好吃到想再買"],
悠閒早晨: ["今天早上賴床賴了好久,超舒服"],
};
const DEFAULT_OFFLINE_EVENT_TEMPLATES = ["今天過得普普通通,沒什麼特別的事"];
// I-6 離線生活:不在線時她在活著,於下次對話開始時按需生成合理的日常小事。
@Injectable()
export class OfflineEventService {
constructor(
private readonly prisma: PrismaService,
private readonly schedule: ScheduleService,
) {}
async maybeGenerate(characterId: string, now: Date = new Date()): Promise<void> {
const todayStart = this.schedule.startOfToday(now);
const existing = await this.prisma.client.episodicMemory.findFirst({
where: { characterId, source: "OFFLINE_GENERATED", occurredAt: { gte: todayStart } },
});
if (existing) {
return;
}
const pastSlots = await this.schedule.getPastSlotsToday(characterId, now);
const labelledSlots = pastSlots.filter((slot) => slot.label && slot.status !== "SLEEP");
const label = labelledSlots.at(-1)?.label ?? null;
const templates = (label && OFFLINE_EVENT_TEMPLATES[label]) || DEFAULT_OFFLINE_EVENT_TEMPLATES;
const content = templates[Math.floor(Math.random() * templates.length)];
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content,
occurredAt: now,
emotionTag: "CALM",
emotionIntensity: 0.3,
source: "OFFLINE_GENERATED",
weight: 0.5, // 低權重日常事件,供 C-5 自然遺忘
},
});
}
}
@@ -0,0 +1,31 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { ScheduleService } from "./schedule.service.js";
export interface BaselineSignal {
tag: EmotionTag;
intensity: number;
}
const CLUB_TIRED_WINDOW_MINUTES = 30;
// I-5 作息驅動情緒基線:社團剛結束的疲憊、假日心情基線偏高等日內波動。
// 只在本輪對話「沒有明確情緒訊號」時介入,不覆蓋使用者輸入本身觸發的情緒。
@Injectable()
export class ScheduleEmotionBaselineService {
constructor(private readonly schedule: ScheduleService) {}
async computeBaseline(characterId: string, now: Date = new Date()): Promise<BaselineSignal | null> {
const justEnded = await this.schedule.slotJustEnded(characterId, now, CLUB_TIRED_WINDOW_MINUTES);
if (justEnded?.label?.includes("社團")) {
return { tag: "SAD", intensity: 0.3 }; // 累
}
const dayType = await this.schedule.getDayType(characterId, now);
if (dayType === "HOLIDAY" || dayType === "WEEKEND") {
return { tag: "JOY", intensity: 0.3 }; // 放假日心情基線偏高
}
return null;
}
}
@@ -0,0 +1,89 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js";
import {
type AvailabilityMode,
type ExceptionType,
EXCEPTION_COOLDOWN_DAYS,
EXCEPTION_INTIMACY_THRESHOLD,
EXCEPTION_LEDGER_EVENT,
URGENT_KEYWORDS,
SCHEDULE_ADJUST_REQUEST_KEYWORDS,
} from "./constants.js";
export interface ExceptionContext {
intimacy: number;
dominantEmotion: EmotionTag;
userInput: string;
}
// I-4 破例規則:作息規則平常確實執行,破例必須稀有才有重量——條件不足時完全不破例。
@Injectable()
export class ScheduleExceptionService {
constructor(
private readonly prisma: PrismaService,
private readonly sentimentLedger: SentimentLedgerService,
) {}
// 依目前的作息限制模式與情境,判斷是否符合任一破例的「資格條件」(尚未套用冷卻判定)。
private pickCandidate(mode: AvailabilityMode, context: ExceptionContext): ExceptionType | null {
if (mode === "NO_REPLY" && context.dominantEmotion === "SAD" && context.intimacy >= EXCEPTION_INTIMACY_THRESHOLD.LATE_NIGHT_CHAT) {
return "LATE_NIGHT_CHAT";
}
const isUrgent = URGENT_KEYWORDS.some((keyword) => context.userInput.includes(keyword));
if (mode === "DELAYED" && isUrgent && context.intimacy >= EXCEPTION_INTIMACY_THRESHOLD.SNEAK_REPLY_IN_CLASS) {
return "SNEAK_REPLY_IN_CLASS";
}
const isScheduleAdjustRequest = SCHEDULE_ADJUST_REQUEST_KEYWORDS.some((keyword) =>
context.userInput.includes(keyword),
);
if (
(mode === "DELAYED" || mode === "NO_REPLY") &&
isScheduleAdjustRequest &&
context.intimacy >= EXCEPTION_INTIMACY_THRESHOLD.ADJUST_SCHEDULE
) {
return "ADJUST_SCHEDULE";
}
return null;
}
// 回傳實際觸發的破例類型;資格不符或冷卻中一律回傳 null(維持原本的作息限制)。
async tryApply(
characterId: string,
userId: string,
mode: AvailabilityMode,
context: ExceptionContext,
now: Date = new Date(),
): Promise<ExceptionType | null> {
if (mode === "NORMAL") {
return null;
}
const candidate = this.pickCandidate(mode, context);
if (!candidate) {
return null;
}
const last = await this.prisma.client.scheduleExceptionLog.findFirst({
where: { characterId, exceptionType: candidate },
orderBy: { triggeredAt: "desc" },
});
if (last) {
const cooldownMs = EXCEPTION_COOLDOWN_DAYS[candidate] * 24 * 60 * 60 * 1000;
if (now.getTime() - last.triggeredAt.getTime() < cooldownMs) {
return null;
}
}
await this.prisma.client.scheduleExceptionLog.create({
data: { characterId, exceptionType: candidate, triggeredAt: now },
});
await this.sentimentLedger.recordEvent(characterId, userId, EXCEPTION_LEDGER_EVENT[candidate], 5, { now });
return candidate;
}
}
@@ -0,0 +1,76 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { ScheduleService } from "./schedule.service.js";
import { ScheduleExceptionService } from "./schedule-exception.service.js";
import { ScheduleEmotionBaselineService } from "./schedule-emotion-baseline.service.js";
import { OfflineEventService } from "./offline-event.service.js";
import { PrismaService } from "../prisma/prisma.service.js";
import { availabilityForStatus } from "./constants.js";
interface SpecialDayBody {
date: string;
label: string;
}
interface ExceptionBody {
userId: string;
mode: "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
intimacy: number;
dominantEmotion: EmotionTag;
userInput: string;
now?: string;
}
@Controller("schedule")
export class ScheduleController {
constructor(
private readonly schedule: ScheduleService,
private readonly exception: ScheduleExceptionService,
private readonly baseline: ScheduleEmotionBaselineService,
private readonly offlineEvent: OfflineEventService,
private readonly prisma: PrismaService,
) {}
@Post(":characterId/seed-default")
async seedDefault(@Param("characterId") characterId: string) {
await this.schedule.seedDefaultSchedule(characterId);
return { ok: true };
}
@Post(":characterId/special-days")
async addSpecialDay(@Param("characterId") characterId: string, @Body() body: SpecialDayBody) {
return this.prisma.client.specialDay.create({
data: { characterId, date: new Date(body.date), label: body.label },
});
}
@Get(":characterId/info")
async getInfo(@Param("characterId") characterId: string, @Query("now") now?: string) {
const info = await this.schedule.getInfo(characterId, now ? new Date(now) : undefined);
return { ...info, availability: availabilityForStatus(info.slot?.status ?? null) };
}
@Post(":characterId/exception")
async tryException(@Param("characterId") characterId: string, @Body() body: ExceptionBody) {
const exceptionType = await this.exception.tryApply(
characterId,
body.userId,
body.mode,
{ intimacy: body.intimacy, dominantEmotion: body.dominantEmotion, userInput: body.userInput },
body.now ? new Date(body.now) : undefined,
);
return { exceptionType };
}
@Get(":characterId/baseline")
async getBaseline(@Param("characterId") characterId: string, @Query("now") now?: string) {
const baseline = await this.baseline.computeBaseline(characterId, now ? new Date(now) : undefined);
return { baseline };
}
@Post(":characterId/offline-event")
async generateOfflineEvent(@Param("characterId") characterId: string, @Body() body: { now?: string }) {
await this.offlineEvent.maybeGenerate(characterId, body.now ? new Date(body.now) : undefined);
return { ok: true };
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { RelationshipModule } from "../relationship/relationship.module.js";
import { ScheduleService } from "./schedule.service.js";
import { ScheduleExceptionService } from "./schedule-exception.service.js";
import { ScheduleEmotionBaselineService } from "./schedule-emotion-baseline.service.js";
import { OfflineEventService } from "./offline-event.service.js";
import { ScheduleController } from "./schedule.controller.js";
@Module({
imports: [PrismaModule, RelationshipModule],
controllers: [ScheduleController],
providers: [ScheduleService, ScheduleExceptionService, ScheduleEmotionBaselineService, OfflineEventService],
exports: [ScheduleService, ScheduleExceptionService, ScheduleEmotionBaselineService, OfflineEventService],
})
export class ScheduleModule {}
+89
View File
@@ -0,0 +1,89 @@
import { Injectable } from "@nestjs/common";
import type { ScheduleDayType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { DEFAULT_HIGH_SCHOOLER_SCHEDULE, WAKE_WINDOW_MINUTES, PRE_SLEEP_WINDOW_MINUTES } from "./constants.js";
import { minutesOfDay, taipeiParts, isSameTaipeiDate, startOfTaipeiDay } from "./time-utils.js";
export interface CurrentSlotInfo {
dayType: ScheduleDayType;
minute: number;
slot: Awaited<ReturnType<PrismaService["client"]["scheduleSlot"]["findFirst"]>> | null;
isWaking: boolean;
isPreSleep: boolean;
}
// I-1/I-3 作息表與時間感知:週期表判定、目前時段查詢、剛醒/睡前偵測。
@Injectable()
export class ScheduleService {
constructor(private readonly prisma: PrismaService) {}
// I-1 驗收:可為種子角色寫入一份高中生作息表(可重複執行)。
async seedDefaultSchedule(characterId: string): Promise<void> {
await this.prisma.client.scheduleSlot.deleteMany({ where: { characterId } });
await this.prisma.client.scheduleSlot.createMany({
data: DEFAULT_HIGH_SCHOOLER_SCHEDULE.map((slot) => ({ characterId, ...slot })),
});
}
async getDayType(characterId: string, now: Date = new Date()): Promise<ScheduleDayType> {
const specialDays = await this.prisma.client.specialDay.findMany({ where: { characterId } });
if (specialDays.some((day) => isSameTaipeiDate(day.date, now))) {
return "HOLIDAY";
}
const { weekday } = taipeiParts(now);
return weekday === 0 || weekday === 6 ? "WEEKEND" : "WEEKDAY";
}
async getCurrentSlot(characterId: string, now: Date = new Date()) {
const dayType = await this.getDayType(characterId, now);
const minute = minutesOfDay(now);
return this.prisma.client.scheduleSlot.findFirst({
where: { characterId, dayType, startMinute: { lte: minute }, endMinute: { gt: minute } },
});
}
async getInfo(characterId: string, now: Date = new Date()): Promise<CurrentSlotInfo> {
const dayType = await this.getDayType(characterId, now);
const minute = minutesOfDay(now);
const slot = await this.getCurrentSlot(characterId, now);
const allSlots = await this.prisma.client.scheduleSlot.findMany({ where: { characterId, dayType } });
const isWaking = allSlots.some(
(s) => s.status === "SLEEP" && minute >= s.endMinute && minute < s.endMinute + WAKE_WINDOW_MINUTES,
);
const isPreSleep = allSlots.some(
(s) => s.status === "SLEEP" && minute <= s.startMinute && minute > s.startMinute - PRE_SLEEP_WINDOW_MINUTES,
);
return { dayType, minute, slot, isWaking, isPreSleep };
}
// 供 I-5/I-6 使用:今天到目前為止已經「結束」的固定行程時段(依開始時間排序)。
async getPastSlotsToday(characterId: string, now: Date = new Date()) {
const dayType = await this.getDayType(characterId, now);
const minute = minutesOfDay(now);
const slots = await this.prisma.client.scheduleSlot.findMany({
where: { characterId, dayType, endMinute: { lte: minute } },
orderBy: { startMinute: "asc" },
});
return slots;
}
// 供 I-5 使用:某個固定行程是否在最近 windowMinutes 分鐘內剛結束。
async slotJustEnded(characterId: string, now: Date = new Date(), windowMinutes = 30) {
const dayType = await this.getDayType(characterId, now);
const minute = minutesOfDay(now);
return this.prisma.client.scheduleSlot.findFirst({
where: {
characterId,
dayType,
endMinute: { lte: minute, gt: minute - windowMinutes },
},
orderBy: { endMinute: "desc" },
});
}
startOfToday(now: Date = new Date()): Date {
return startOfTaipeiDay(now);
}
}
+61
View File
@@ -0,0 +1,61 @@
// I-3 時間感知:所有作息判斷一律以 Asia/Taipei 當地時間為準,不受伺服器所在時區影響。
const TAIPEI_PARTS_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Taipei",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const WEEKDAY_INDEX: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
export interface TaipeiTimeParts {
weekday: number; // 0=週日 ... 6=週六
hour: number;
minute: number;
year: number;
month: number;
day: number;
}
export function taipeiParts(date: Date): TaipeiTimeParts {
const parts = TAIPEI_PARTS_FORMATTER.formatToParts(date);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
return {
weekday: WEEKDAY_INDEX[get("weekday")] ?? 0,
hour: Number(get("hour")),
minute: Number(get("minute")),
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
};
}
export function minutesOfDay(date: Date): number {
const { hour, minute } = taipeiParts(date);
return hour * 60 + minute;
}
export function isSameTaipeiDate(a: Date, b: Date): boolean {
const pa = taipeiParts(a);
const pb = taipeiParts(b);
return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day;
}
// 回傳「當地日期」的午夜時間點(以 UTC Date 物件表示,僅用於資料庫時間比較的下界)。
export function startOfTaipeiDay(date: Date): Date {
const { year, month, day } = taipeiParts(date);
// Asia/Taipei 為 UTC+8,當地 00:00 對應 UTC 前一天 16:00。
return new Date(Date.UTC(year, month - 1, day, -8, 0, 0, 0));
}
+8 -2
View File
@@ -36,9 +36,15 @@ export function ChatView() {
try {
const result = await sendChatMessage(CHARACTER_ID, USER_ID, sessionId, text);
if (result.availability === "NO_REPLY") {
// I-2:作息狀態為睡眠且未破例時,這輪不會有真的回覆。
return;
}
setMessages((prev) => [...prev, { role: "character", text: result.text }]);
setDominant(result.context.emotion.dominant);
setIntimacy(result.context.relationship.intimacy);
if (result.context) {
setDominant(result.context.emotion.dominant);
setIntimacy(result.context.relationship.intimacy);
}
} catch {
setError("訊息傳送失敗,請確認 api 服務是否已啟動");
} finally {
+6 -1
View File
@@ -13,11 +13,16 @@ export interface ChatContext {
relationship: { intimacy: number; trust: number; stage: RelationshipStage };
}
export type ChatAvailability = "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
export interface ChatResult {
text: string;
actions: string[];
isFastChannel: boolean;
context: ChatContext;
availability: ChatAvailability;
exceptionType: string | null;
// I-2:作息為睡眠且未破例時不會組裝上下文,此時為 null。
context: ChatContext | null;
}
export async function sendChatMessage(