10 Commits
Author SHA1 Message Date
JefferyandClaude Sonnet 5 5da8b3a63e feat: 完成 Q 群組 — APP(React Native + Expo)
新增 apps/mobile:Expo Router 檔案系統路由(同 Next.js App Router 的
心智模型),底部四分頁(聊天/角色/日常/設定)皆為真實 API 串接(/chat、
/tachie、/characters、/schedule、/health、/push)。聊天分頁完整重用
H/O 群組已驗證過的對話+表情查詢節奏。立繪暫以依情緒 magnitude 調整
透明度的色塊佔位(PixiJS 是瀏覽器 DOM/Canvas API,未移植到 React
Native,需要 react-native-skia 等原生繪圖套件才能重現分層合成,超出
本群組範圍)。

新增 apps/api/src/push/:PushProvider 抽象(第三次套用 F-1/P-4 的
Provider 模式),MockPushProvider 供測試、ExpoPushProvider 是可直接
打真實 HTTP 請求的完整實作(Expo 推播匿名發送不需金鑰,不像 LLM/TTS
需要另外挑供應商待人工確認)。PushToken/PushNotificationLog 記錄裝置
註冊與每次推播嘗試。J 群組 TaskTriggerService 觸發時會嘗試推播給
使用者已註冊裝置,用觸發訊息本身當推播內容。

本環境沒有 Android/iOS 模擬器,改用 `expo start --web` + playwright-core
(同 O 群組手法)驅動無頭瀏覽器驗證四分頁切換、真實 API 串接、聊天
完整往返、推播按鈕在無裝置環境下的優雅降級——不是原生 APP 的完整驗證,
但驗證了畫面邏輯與 API 契約層。過程中發現並修正 CORS 設定只放行網頁版
origin、擋掉 Expo 網頁版請求的問題(永久修正:新增 localhost:8081 白名單,
不影響原生 APP,原生環境沒有 CORS 限制)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 19:19:20 +08:00
JefferyandClaude Sonnet 5 0e9401b891 feat: 完成 P 群組 — 語音子系統
新增 apps/api/src/voice/:Voice Sheet(基礎音色/預設語速/音域幅度/口頭
聲響庫/禁則,依性格原型推導預設值,1:1 掛在 Character,同 EmotionState
的關聯模式);情緒→韻律對照(六狀態語速/音高/音量/句尾走向),音高變化
依角色音域幅度縮放(三無角色近乎單音),設計原則同 O-4 表情外顯度縮放;
非語言發聲與節奏(依情緒插入聲響、重大情緒延長停頓、禁則過濾候選、
低親密度不打斷、talkative 特徵+親密度門檻才可插話);TTSProvider 抽象
完全比照 F-1 LLMProvider(MockTTSProvider 輸出韻律標記+佔位音檔,
RealTTSProvider 待人工確認供應商後再接,TTS_PROVIDER=mock|real 切換);
語音輸入副語言分析(EmotionService 新增 paralinguisticSignal,優先序
排在作息基線之前,讓「文字說沒事但聲音在抖」判為負向)。

修正一個真實 bug:非語言發聲的停頓時長原本直接拿目前主導情緒的數值
當強度訊號,但平靜狀態下 calm 預設就是 100,導致「什麼事都沒發生」被
誤判成「強度最強的重大情緒事件」而觸發不該有的長停頓——修正為 CALM
一律視為強度 0(平靜是情緒事件的缺席,不是訊號)。

刻意簡化:副語言特徵(語速/音量/顫抖/停頓)採結構化輸入,不做真實音訊
分析,比照 M/N 群組「先用結構化輸入代替真實訊號處理」的一貫取捨。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 18:19:51 +08:00
JefferyandClaude Sonnet 5 0f3d16059c feat: 完成 O 群組 — 立繪子系統
後端(apps/api/src/tachie/):立繪 manifest(身體姿勢/服裝/表情眉眼口/
效果層/微動態的資產目錄,服裝目錄有 M-1 登錄時優先採用);表情解析依
情緒引擎六狀態對照眉/眼/口/效果/姿勢,依 RelationshipStage 分層解鎖
(陌生~認識/朋友/摯友/羈絆,哭臉限羈絆才會出現),依角色外顯度縮放
差分幅度(magnitude = expressiveness × 情緒強度);emotionTag(實際
套用)與 rawEmotionTag(情緒引擎真實判定)分離,讓文字生成與表情解析
維持兩條獨立資料流,「文字與立繪故意不一致」是這個設計的自然結果,
不需要額外邏輯;視線方向依思考/迴避/警戒切換。

前端(apps/web/components/TachieStage.tsx):以 PixiJS 實作分層合成
與眨眼/呼吸微動態,預留 TachieRenderer 介面供未來 Live2D Cubism SDK
實作(未引入任何 Live2D 相依或授權,純架構佔位);接上 ChatView 取代
H 群組的佔位立繪,聊天回應後先查詢表情、延遲約 220ms 才顯示文字,
在非串流架構下盡力模擬「表情先變化、文字後出現」。

以 scratchpad 暫裝 playwright-core 實測瀏覽器行為(本環境無
chromium-cli),確認情緒變化即時反映在立繪、微動態持續運作。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:46:50 +08:00
JefferyandClaude Sonnet 5 eccdcba45c feat: 完成 N 群組 — 後日談模式
錨定點抵達已出版內容盡頭時切入後日談區間;時間流速(即時同步/緩速/凍結,
另加一個非文件既定的 FAST 選項供一般角色使用)對後日談推演成年角色強制
上限 1:1,重用 L 群組 RomanceEligibilityService 的 isEpilogueAdult 判斷。
性格漂移僅在正史已有 >=2 筆 PersonalityTraitSnapshot 建立趨勢時才位移,
無證據則鎖定 0(不憑空轉向),原型字串本身從不被觸碰。人生階段推演與
角色群同步成長重用 M 群組的 Scene/ProjectionService(推演事件其實就是
sourceType=EPILOGUE 的場景,在場者投影後自然敘述互相呼應);重大轉折
需要正史目標關鍵字對應才生成,並以 EpilogueMilestoneLog 節流頻率。
正史回收(移除被取代的推演場景、匯入官方新場景並強制投影、互動記憶
結構性保留、記憶修正事件敘事化消化)與分支保留(永久標記、擋下後續
回收)。

修正兩個真實 bug:reclaim 原本借用 importScene 的錨定閥門,導致回收
回來的正史內容反而不被投影(後日談的『現在』本來就走在錨定點之前);
分支保留擋下重新匯入時用裸 Error 導致 500,改為 ConflictException。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:18:53 +08:00
JefferyandClaude Sonnet 5 c66799ee7c feat: 完成 M 群組 — 既有作品考據與輕小說章節管線
共用場景資料庫(Scene/ScenePresence/SceneLine/SceneFact/SceneInteraction,
角色中立客觀記錄)與投影(ProjectionService 寫入 EpisodicMemory/
SemanticMemory/PersonalityTraitSnapshot/CharacterRelationshipEventLog,
逐一可追溯到來源場景)完全分層。歸屬信心分數(明示標記決定性、語言指紋
從已歸屬語料統計、場景名冊硬約束、對話輪替)與隔離區/指紋冷啟動(低信心
或樣本不足一律隔離,指紋精煉後可重新嘗試釋放)。多角色投影與修正(改一處
場景資料庫、reprojectScene 同步更新所有受影響角色,使用者互動記憶因為
從不帶 sourceSceneId 而結構性地不受影響,發現得晚的歸屬錯誤額外記一筆
CORRECTION 事件)。作品名冊建置門檻(達標自動建置,門檻檢查排在投影完成
之後才做,避免用到投影前的舊統計)。錨定點知識邊界(雙向:前進載入、
後退遺忘)與矛盾偵測(同優先序來源待人工確認,跨優先序可依小說原文>
官方設定集>動畫改編自動裁決)。插圖索引與衍生服裝目錄/姿勢語彙表。

刻意簡化:章節→場景切分與互動正負權重皆採結構化輸入(不做自動 NLP
場景邊界偵測與情感分析),比照 F-7 MockProvider 的做法——機制先做對,
之後有真實 NLP/LLM 能力再替換輸入來源。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:58:04 +08:00
JefferyandClaude Sonnet 5 cd948f0026 feat: 完成 L 群組 — 戀愛關係軸與內容尺度
戀愛軸雙軸模型(Relationship 新增心動值/戀愛階段欄位,與親密度獨立、事件
驅動、衰減快)、告白事件狀態機(可拒絕、尷尬期、連續失敗傷關係、立即再
推進觸發警戒)、六原型戀愛曲線參數化(重用 G-3 tsundere gap 處理傲嬌)、
親密表現分層與里程碑(跳級拒絕+退縮反應+帳本、第一次做高權重記憶)、
心動事件與階段門檻(含推太急回落、關係危機與修復)。

角色年齡判定(apps/api/src/romance/age-determination.service.ts):僅
決定性/強證據可判定成年,外觀證據不可單獨採信,證據不足從嚴按未成年
處理,且不存最終結論、每次重新算。內容尺度三層邊界(引擎層):使用者
opt-in、角色資格(僅正史成年)、硬邊界(後日談推演成年者永久排除成人
模式);任何嘗試不論放行與否都記錄,判定完全不看對話內容,沒有「說服
角色」繞過的管道。

刻意收斂範圍:僅實作資格判定與硬邊界攔截機制,不實作任何成人向內容的
實際生成。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:26:55 +08:00
JefferyandClaude Sonnet 5 6125d6e27f feat: 完成 K 群組 — 對話模式:群聊與角色自聊
群聊/自聊子系統(apps/api/src/room/):在場者名冊與共用場景記錄(純記憶體,
同 C-1 工作記憶的設計)、發言權分配(點名/話題相關度/性格基線/情緒/角色間
關係/發言冷卻與冷落累積算衝動值)、群聊矜持(重用 G-3 親密度分層模板、
下修有效親密度而非另建模板庫)、隱私邊界(EpisodicMemory.isPrivate +
KeywordMemoryRetriever.excludePrivate,天然呆為口風不緊例外)、群聊記憶
投影(各角色依自身視角固化,被虧的那位權重較高)、角色自聊(沿用發言權
機制、空轉偵測注入轉折或收尾、輪數硬上限、使用者插話切換群聊)。
新增 CharacterRelationship 表供角色間關係使用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:15:00 +08:00
JefferyandClaude Sonnet 5 3503cc0be6 feat: 完成 J 群組 — 委託子系統
Task 資料表與自然語句解析(單次提醒)、依原型的接案/觸發/逾期追擊演出、
委託與關係帳本/情緒的回饋迴路(含默契旗標)、主動關照(待辦偵測+作息交叉比對,
稀有度節流不重複台詞)、排程器可靠性(服務重啟後重新掃描 PENDING 任務掛回
JobQueue,逾期立即補觸發)。J.mjs 以 spawnSync 實際重啟服務驗證 J-7。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:35:16 +08:00
Jeffery d82b834e47 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 群組冒煙測試無回歸。
2026-08-13 14:08:24 +08:00
Jeffery 9e5036f0a0 feat: 完成 H 群組 — 網頁對話介面(第一個可實際使用的垂直切片)
- POST /chat/:characterId:正式對外對話端點,DialogueService 補上情緒標記與親密度
  累積(每輪對話走完整管線:標記情緒→檢索記憶→組裝→生成→過濾)
- POST /session/:sessionId/end:觸發睡眠固化
- NestJS 啟用 CORS(WEB_ORIGIN,預設 http://localhost:3100),本清單第一次有瀏覽器直接呼叫 api
- apps/web/app/chat:對話頁面(ChatView 用戶端元件),電腦雙欄/行動單欄同一份 RWD 程式,
  立繪半身像置頂佔 40% 為行動版背景層
- 情緒晶片+親密度數字顯示,跟著每輪回應即時更新
- 動作描寫標記(*…*)以暮空紫小字獨立呈現,與台詞區分
- HeartbeatWave 元件:載入時由左至右畫出、親密度決定振幅、通知紅點心跳脈動,
  皆尊重 prefers-reduced-motion;首頁與對話頁共用同一元件
- 色票換成 wiki 主視覺提案頁的完整 token 組(含官方深色模式數值)
- scripts/smoke/H.mjs:端到端驗證對話 API 狀態變化、session 結束固化、頁面結構標記;
  另以 Playwright + headless Chromium 實際開瀏覽器驗證桌面/行動版面、送出訊息、
  動作標記渲染,過程無 console 錯誤

npm run restart && npm run smoke -- H 皆通過(H-V),A~G 群組冒煙測試無回歸。
2026-08-13 12:12:49 +08:00
147 changed files with 16775 additions and 200 deletions
+1
View File
@@ -13,3 +13,4 @@ coverage/
*.tsbuildinfo
packages/db/generated/
.claude/scheduled_tasks.lock
+20
View File
@@ -7,6 +7,16 @@ import { EmotionModule } from "./emotion/emotion.module.js";
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";
import { TaskModule } from "./task/task.module.js";
import { RoomModule } from "./room/room.module.js";
import { RomanceModule } from "./romance/romance.module.js";
import { CanonModule } from "./canon/canon.module.js";
import { EpilogueModule } from "./epilogue/epilogue.module.js";
import { TachieModule } from "./tachie/tachie.module.js";
import { VoiceModule } from "./voice/voice.module.js";
import { PushModule } from "./push/push.module.js";
@Module({
imports: [
@@ -17,6 +27,16 @@ import { PersonalityModule } from "./personality/personality.module.js";
RelationshipModule,
LlmModule,
PersonalityModule,
ChatModule,
ScheduleModule,
TaskModule,
RoomModule,
RomanceModule,
CanonModule,
EpilogueModule,
TachieModule,
VoiceModule,
PushModule,
],
controllers: [HealthController],
})
+54
View File
@@ -0,0 +1,54 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ProjectionService } from "./projection.service.js";
import { WorkRosterService } from "./work-roster.service.js";
// M-9 錨定點與知識邊界:錨定點之前是角色的親身經歷、之後不存在。場景匯入時只寫入共用場景資料庫,
// 只有 storyOrder <= 錨定點的場景會被投影進角色記憶——移動錨定點(無論前進或後退)都要讓「已投影」的
// 場景集合與「storyOrder <= 錨定點」的場景集合重新同步。
@Injectable()
export class AnchorService {
constructor(
private readonly prisma: PrismaService,
private readonly projection: ProjectionService,
private readonly roster: WorkRosterService,
) {}
async setAnchor(workId: string, newStoryOrder: number): Promise<{ projected: number; unprojected: number }> {
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
const toProject = await this.prisma.client.scene.findMany({
where: { workId, storyOrder: { lte: newStoryOrder }, projected: false },
include: { participants: true },
});
for (const scene of toProject) {
await this.projection.projectScene(scene.id);
for (const presence of scene.participants) {
await this.roster.checkAndPromote(presence.characterId);
}
}
// 錨定點後退:她「不再知道」超出新錨定點的後段劇情——移除這些場景先前投影出的所有角色資料。
const toUnproject = await this.prisma.client.scene.findMany({
where: { workId, storyOrder: { gt: newStoryOrder }, projected: true },
});
for (const scene of toUnproject) {
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId: scene.id } });
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: false } });
}
await this.prisma.client.work.update({ where: { id: work.id }, data: { anchorStoryOrder: newStoryOrder } });
return { projected: toProject.length, unprojected: toUnproject.length };
}
// M-9 依錨定點取用「當時的她」的性格參數:取 atStoryOrder <= 錨定點裡最新的一筆快照。
async effectivePersonalitySnapshot(characterId: string, atStoryOrder: number) {
return this.prisma.client.personalityTraitSnapshot.findFirst({
where: { characterId, atStoryOrder: { lte: atStoryOrder } },
orderBy: { atStoryOrder: "desc" },
});
}
}
+137
View File
@@ -0,0 +1,137 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { FingerprintService } from "./fingerprint.service.js";
import { FINGERPRINT_WEIGHT, TURN_TAKING_WEIGHT, CONFIRM_THRESHOLD, FINGERPRINT_CONTRADICTION_SCORE } from "./constants.js";
interface CandidateScore {
characterId: string;
score: number;
fingerprintScore: number;
}
// M-5/M-6 歸屬時的多重證據與信心分數,以及隔離區機制:
// 明示標記(決定性)> 語言指紋(強)> 場景名冊(硬約束,篩掉不在場者)> 對話輪替(中)> 內容合理性(中,本實作暫略)。
// 低信心一律隔離,不寫入任何角色——寧可缺這句語料,不可污染角色資料。
@Injectable()
export class AttributionService {
constructor(
private readonly prisma: PrismaService,
private readonly fingerprint: FingerprintService,
) {}
// 冷啟動順序:先處理有明示標記的台詞(建立指紋基準),再回頭處理無標記對話。
async attributeScene(sceneId: string): Promise<void> {
const [scene, presences] = await Promise.all([
this.prisma.client.scene.findUniqueOrThrow({ where: { id: sceneId } }),
this.prisma.client.scenePresence.findMany({ where: { sceneId } }),
]);
const presentCharacterIds = presences.map((p) => p.characterId);
const lines = await this.prisma.client.sceneLine.findMany({
where: { sceneId, status: "PENDING" },
orderBy: { lineIndex: "asc" },
});
const markerLines = lines.filter((line) => line.hasExplicitMarker);
const unmarkedLines = lines.filter((line) => !line.hasExplicitMarker);
for (const line of markerLines) {
await this.confirm(line.id, line.markerCharacterId!, 1, scene.id, line.lineType);
}
for (const line of unmarkedLines) {
if (line.lineType === "NARRATION") {
// 敘述/新登場設定沒有「說話者」的歸屬問題,直接視為已確認(不需要指紋比對)。
await this.confirm(line.id, null, 1, scene.id, line.lineType);
continue;
}
await this.attributeLine(line.id, sceneId, presentCharacterIds);
}
}
private async attributeLine(lineId: string, sceneId: string, presentCharacterIds: string[]): Promise<void> {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
if (presentCharacterIds.length === 0) {
await this.quarantine(lineId);
return;
}
const previousConfirmed = await this.prisma.client.sceneLine.findFirst({
where: { sceneId, lineIndex: { lt: line.lineIndex }, status: "CONFIRMED", speakerCharacterId: { not: null } },
orderBy: { lineIndex: "desc" },
});
const candidates: CandidateScore[] = [];
for (const characterId of presentCharacterIds) {
const fingerprintScore = await this.fingerprint.matchScore(line.rawText, characterId);
// 對話輪替:兩人場景中,上一句已確認的發言者若不是這位候選人,給予輪替加成(一問一答的結構)。
const turnTakingScore =
presentCharacterIds.length === 2 && previousConfirmed && previousConfirmed.speakerCharacterId !== characterId
? 1
: 0.5;
const score = fingerprintScore * FINGERPRINT_WEIGHT + turnTakingScore * TURN_TAKING_WEIGHT;
candidates.push({ characterId, score, fingerprintScore });
}
candidates.sort((a, b) => b.score - a.score);
const best = candidates[0];
if (best.score < CONFIRM_THRESHOLD || best.fingerprintScore < FINGERPRINT_CONTRADICTION_SCORE) {
await this.quarantine(lineId);
return;
}
await this.confirm(lineId, best.characterId, best.score, sceneId, line.lineType);
}
private async confirm(
lineId: string,
speakerCharacterId: string | null,
confidence: number,
sceneId: string,
lineType: string,
): Promise<void> {
await this.prisma.client.sceneLine.update({
where: { id: lineId },
data: { speakerCharacterId, confidence, status: "CONFIRMED" },
});
if (speakerCharacterId && lineType === "DIALOGUE") {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
await this.prisma.client.languageCorpusEntry.create({
data: { characterId: speakerCharacterId, sceneId, text: line.rawText },
});
}
}
private async quarantine(lineId: string): Promise<void> {
await this.prisma.client.sceneLine.update({ where: { id: lineId }, data: { status: "QUARANTINED", confidence: 0 } });
}
// M-6 人工裁決:解除隔離,直接指定歸屬並寫入場景資料庫(含語料)。
async adjudicate(lineId: string, speakerCharacterId: string): Promise<void> {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
await this.confirm(lineId, speakerCharacterId, 1, line.sceneId, line.lineType);
}
// M-6 指紋精煉:語料變多後,回頭重新嘗試隔離區裡的台詞,部分可望被釋放入庫。
async reattemptQuarantined(sceneId: string): Promise<number> {
const presences = await this.prisma.client.scenePresence.findMany({ where: { sceneId } });
const presentCharacterIds = presences.map((p) => p.characterId);
const quarantined = await this.prisma.client.sceneLine.findMany({
where: { sceneId, status: "QUARANTINED" },
orderBy: { lineIndex: "asc" },
});
let released = 0;
for (const line of quarantined) {
await this.prisma.client.sceneLine.update({ where: { id: line.id }, data: { status: "PENDING" } });
await this.attributeLine(line.id, sceneId, presentCharacterIds);
const updated = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: line.id } });
if (updated.status === "CONFIRMED") {
released += 1;
}
}
return released;
}
}
+113
View File
@@ -0,0 +1,113 @@
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import type { IllustrationType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { SceneImportService, type ImportSceneInput } from "./scene-import.service.js";
import { AttributionService } from "./attribution.service.js";
import { CorrectionService } from "./correction.service.js";
import { WorkRosterService } from "./work-roster.service.js";
import { IllustrationService } from "./illustration.service.js";
import { AnchorService } from "./anchor.service.js";
import { TimelineService } from "./timeline.service.js";
@Controller("canon")
export class CanonController {
constructor(
private readonly prisma: PrismaService,
private readonly sceneImport: SceneImportService,
private readonly attribution: AttributionService,
private readonly correction: CorrectionService,
private readonly roster: WorkRosterService,
private readonly illustration: IllustrationService,
private readonly anchor: AnchorService,
private readonly timeline: TimelineService,
) {}
@Post("scenes")
async importScene(@Body() body: ImportSceneInput) {
return this.sceneImport.importScene(body);
}
@Get("scenes/:sceneId")
async getScene(@Param("sceneId") sceneId: string) {
return this.prisma.client.scene.findUniqueOrThrow({
where: { id: sceneId },
include: { participants: true, lines: true, facts: true },
});
}
@Get("works/:workId/scenes")
async listScenes(@Param("workId") workId: string) {
return this.prisma.client.scene.findMany({ where: { workId }, orderBy: { storyOrder: "asc" } });
}
@Post("scene-lines/:lineId/adjudicate")
async adjudicate(@Param("lineId") lineId: string, @Body() body: { speakerCharacterId: string }) {
await this.attribution.adjudicate(lineId, body.speakerCharacterId);
return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
}
@Post("scenes/:sceneId/reattempt-quarantined")
async reattemptQuarantined(@Param("sceneId") sceneId: string) {
const released = await this.attribution.reattemptQuarantined(sceneId);
return { released };
}
@Post("scene-lines/:lineId/correct")
async correctAttribution(@Param("lineId") lineId: string, @Body() body: { newSpeakerCharacterId: string; now?: string }) {
await this.correction.correctLineAttribution(lineId, body.newSpeakerCharacterId, body.now ? new Date(body.now) : undefined);
return this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
}
@Get("characters/:characterId/roster-progress")
async rosterProgress(@Param("characterId") characterId: string) {
return this.roster.getProgress(characterId);
}
@Post("illustrations")
async registerIllustration(
@Body()
body: {
workId: string;
volume: number;
page?: number;
type: IllustrationType;
sceneId?: string;
description: string;
costumeLabels?: { characterId: string; label: string }[];
poseLabels?: { characterId: string; label: string }[];
},
) {
return this.illustration.register(body);
}
@Get("scenes/:sceneId/illustrations")
async illustrationsForScene(@Param("sceneId") sceneId: string) {
return this.illustration.findByScene(sceneId);
}
@Get("characters/:characterId/costume-catalog")
async costumeCatalog(@Param("characterId") characterId: string) {
return this.illustration.listCostumeCatalog(characterId);
}
@Get("characters/:characterId/pose-vocabulary")
async poseVocabulary(@Param("characterId") characterId: string) {
return this.illustration.listPoseVocabulary(characterId);
}
@Post("works/:workId/anchor")
async setAnchor(@Param("workId") workId: string, @Body() body: { storyOrder: number }) {
return this.anchor.setAnchor(workId, body.storyOrder);
}
@Post("works/:workId/detect-contradictions")
async detectContradictions(@Param("workId") workId: string) {
const created = await this.timeline.detectContradictions(workId);
return { created };
}
@Get("works/:workId/contradictions")
async listContradictions(@Param("workId") workId: string) {
return this.timeline.listContradictions(workId);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { EmotionModule } from "../emotion/emotion.module.js";
import { FingerprintService } from "./fingerprint.service.js";
import { WorkRosterService } from "./work-roster.service.js";
import { IllustrationService } from "./illustration.service.js";
import { AttributionService } from "./attribution.service.js";
import { ProjectionService } from "./projection.service.js";
import { SceneImportService } from "./scene-import.service.js";
import { AnchorService } from "./anchor.service.js";
import { TimelineService } from "./timeline.service.js";
import { CorrectionService } from "./correction.service.js";
import { CanonController } from "./canon.controller.js";
@Module({
imports: [PrismaModule, EmotionModule],
controllers: [CanonController],
providers: [
FingerprintService,
WorkRosterService,
IllustrationService,
AttributionService,
ProjectionService,
SceneImportService,
AnchorService,
TimelineService,
CorrectionService,
],
exports: [
FingerprintService,
WorkRosterService,
IllustrationService,
AttributionService,
ProjectionService,
SceneImportService,
AnchorService,
TimelineService,
CorrectionService,
],
})
export class CanonModule {}
+23
View File
@@ -0,0 +1,23 @@
// M-2 建置門檻:新角色候補累積到這些條件才正式建置(路人只出現一次不值得建置)。
export const BUILD_THRESHOLD_SCENES = 3; // 出場場景數
export const BUILD_THRESHOLD_NAMED_LINES = 5; // 具名(已 CONFIRMED)台詞數
export const BUILD_THRESHOLD_INTERACTIONS = 1; // 與其他角色的實質互動(關係帳本事件)數
// M-5 歸屬信心分數:多重證據合成的權重與門檻。
export const FINGERPRINT_WEIGHT = 0.6;
export const TURN_TAKING_WEIGHT = 0.4;
export const CONFIRM_THRESHOLD = 0.55;
// M-6 指紋回檢:候選角色的指紋分數低於此值,即使總分達門檻仍視為與既有指紋矛盾,隔離不寫入。
export const FINGERPRINT_CONTRADICTION_SCORE = 0.15;
// M-5/M-6 語言指紋:學習自 LanguageCorpusEntry 的特徵詞頻率統計(人稱/語尾/口癖),非原型模板。
export const SIGNAL_TOKENS = ["呢~", "本大爺", "的說", "喵"];
// 語料達此則數以上,才認定「指紋已建立」(樣本太少時中性看待,不視為矛盾)。
export const FINGERPRINT_MIN_SAMPLES = 3;
// M-8 矛盾裁決優先序(數值越小優先權越高):小說原文 > 官方設定集 > 動畫改編。
export const SOURCE_PRIORITY: Record<string, number> = {
NOVEL: 1,
OFFICIAL_SETTEI: 2,
ANIME_ADAPTATION: 3,
};
+70
View File
@@ -0,0 +1,70 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ProjectionService } from "./projection.service.js";
// M-7/M-10 歸屬修正與重新投影:修正場景資料庫只改一處,受影響角色的資料同步更新;
// 已上線角色發現歷史歸屬錯誤時,使用者互動記憶(source=INTERACTION)從不被觸碰——
// 只有由這個場景投影而來的資料(sourceSceneId 指到它)會被清掉重建。發現得晚的錯誤額外記一筆
// 「想起來其實不是這樣」的新事件,而不是假裝沒發生過。
@Injectable()
export class CorrectionService {
constructor(
private readonly prisma: PrismaService,
private readonly projection: ProjectionService,
) {}
async correctLineAttribution(lineId: string, newSpeakerCharacterId: string, now: Date = new Date()): Promise<void> {
const line = await this.prisma.client.sceneLine.findUniqueOrThrow({ where: { id: lineId } });
const oldSpeakerCharacterId = line.speakerCharacterId;
if (oldSpeakerCharacterId === newSpeakerCharacterId) {
return;
}
const scene = await this.prisma.client.scene.findUniqueOrThrow({ where: { id: line.sceneId } });
const wasAlreadyProjected = scene.projected;
if (line.lineType === "DIALOGUE" && oldSpeakerCharacterId) {
await this.prisma.client.languageCorpusEntry.deleteMany({
where: { characterId: oldSpeakerCharacterId, sceneId: scene.id, text: line.rawText },
});
await this.prisma.client.languageCorpusEntry.create({
data: { characterId: newSpeakerCharacterId, sceneId: scene.id, text: line.rawText },
});
}
await this.prisma.client.sceneLine.update({
where: { id: lineId },
data: { speakerCharacterId: newSpeakerCharacterId, status: "CONFIRMED", confidence: 1 },
});
if (!wasAlreadyProjected) {
// 這場戲還沒被投影過(超前於錨定點,或當初就隔離未寫入),修正場景資料庫即可,沒有已投影的舊資料要清。
return;
}
await this.projection.reprojectScene(scene.id);
if (oldSpeakerCharacterId) {
await this.recordRealization(
oldSpeakerCharacterId,
`想起來,那句話其實不是我說的——是我記錯了。`,
now,
);
}
await this.recordRealization(newSpeakerCharacterId, `原來那句話其實是我說的,之前記混了。`, now);
}
private async recordRealization(characterId: string, content: string, now: Date): Promise<void> {
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content,
occurredAt: now,
emotionTag: "CALM",
emotionIntensity: 0.3,
source: "CORRECTION",
weight: 1,
},
});
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SIGNAL_TOKENS, FINGERPRINT_MIN_SAMPLES } from "./constants.js";
export type Fingerprint = Record<string, number>; // token -> 出現比例 0~1
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
// M-5/M-6 語言指紋:從已確認歸屬的台詞語料統計特徵詞頻率,供歸屬判斷與矛盾回檢使用。
// 前幾章的指紋樣本很少時,統計不具代表性——樣本量門檻由 matchScore 的呼叫端(AttributionService)處理。
@Injectable()
export class FingerprintService {
constructor(private readonly prisma: PrismaService) {}
async computeFingerprint(characterId: string): Promise<{ fingerprint: Fingerprint; sampleCount: number }> {
const entries = await this.prisma.client.languageCorpusEntry.findMany({ where: { characterId } });
const fingerprint: Fingerprint = {};
for (const token of SIGNAL_TOKENS) {
const hits = entries.filter((entry) => entry.text.includes(token)).length;
fingerprint[token] = entries.length > 0 ? hits / entries.length : 0;
}
return { fingerprint, sampleCount: entries.length };
}
// 分數解讀:0.5 為中性(文本沒有任何特徵詞,或指紋樣本不足以判斷);
// 命中角色慣用的特徵詞 → 加分;命中角色從不使用的特徵詞(指紋矛盾) → 顯著扣分。
async matchScore(text: string, characterId: string): Promise<number> {
const { fingerprint, sampleCount } = await this.computeFingerprint(characterId);
const hitTokens = SIGNAL_TOKENS.filter((token) => text.includes(token));
if (hitTokens.length === 0) {
return 0.5;
}
if (sampleCount < FINGERPRINT_MIN_SAMPLES) {
return 0.5; // 指紋尚未建立,中性看待,不因樣本不足誤判矛盾。
}
let score = 0.5;
for (const token of hitTokens) {
const freq = fingerprint[token];
if (freq > 0.3) {
score += 0.4;
} else if (freq > 0) {
score += 0.1;
} else {
score -= 0.4; // 明顯矛盾:這個角色的既有語料從未出現過這個特徵詞。
}
}
return clamp01(score);
}
}
@@ -0,0 +1,60 @@
import { Injectable } from "@nestjs/common";
import type { IllustrationType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
export interface RegisterIllustrationInput {
workId: string;
volume: number;
page?: number;
type: IllustrationType;
sceneId?: string;
description: string;
costumeLabels?: { characterId: string; label: string }[];
poseLabels?: { characterId: string; label: string }[];
}
// M-1 插圖索引:不儲存受版權保護的原圖,只登錄畫面描述;同時衍生服裝目錄與姿勢語彙表
// ——立繪服裝層之後只能從這個目錄取用,不可自創(見 O 群組立繪子系統會消費這裡的資料)。
@Injectable()
export class IllustrationService {
constructor(private readonly prisma: PrismaService) {}
async register(input: RegisterIllustrationInput) {
const illustration = await this.prisma.client.illustration.create({
data: {
workId: input.workId,
volume: input.volume,
page: input.page,
type: input.type,
sceneId: input.sceneId,
description: input.description,
},
});
for (const costume of input.costumeLabels ?? []) {
await this.prisma.client.costumeCatalogEntry.create({
data: { characterId: costume.characterId, illustrationId: illustration.id, label: costume.label },
});
}
for (const pose of input.poseLabels ?? []) {
await this.prisma.client.poseVocabularyEntry.create({
data: { characterId: pose.characterId, illustrationId: illustration.id, label: pose.label },
});
}
return illustration;
}
// M-1 驗收:可由事件(場景)查到對應插圖。
async findByScene(sceneId: string) {
return this.prisma.client.illustration.findMany({ where: { sceneId } });
}
async listCostumeCatalog(characterId: string) {
return this.prisma.client.costumeCatalogEntry.findMany({ where: { characterId } });
}
async listPoseVocabulary(characterId: string) {
return this.prisma.client.poseVocabularyEntry.findMany({ where: { characterId } });
}
}
+105
View File
@@ -0,0 +1,105 @@
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";
const CANON_EPOCH_MS = new Date("2000-01-01T00:00:00.000Z").getTime();
const DAY_MS = 24 * 60 * 60 * 1000;
// 場景資料庫只有「年表排序鍵」(storyOrder),沒有真實日期;投影成 EpisodicMemory 時需要一個 DateTime,
// 用固定虛擬曆元 + storyOrder 天數換算即可——只要求相對順序正確,不代表任何真實日期。
function storyOrderToDate(storyOrder: number): Date {
return new Date(CANON_EPOCH_MS + storyOrder * DAY_MS);
}
// M-4/M-7 多角色投影:共用場景資料庫只處理一次,各角色資料是投影出來的——修正場景資料庫一處,
// 重新投影後所有受影響角色同步更新。M-9 知識邊界:只有 storyOrder <= 錨定點的場景才會被投影。
@Injectable()
export class ProjectionService {
constructor(
private readonly prisma: PrismaService,
private readonly tagger: RuleBasedEmotionTagger,
) {}
async projectScene(sceneId: string): Promise<void> {
const scene = await this.prisma.client.scene.findUniqueOrThrow({
where: { id: sceneId },
include: { participants: true, lines: true, interactions: true },
});
const presentCharacterIds = scene.participants.map((p) => p.characterId);
const occurredAt = storyOrderToDate(scene.storyOrder);
// 1) 事件 → 情節記憶:她不在場的不記得——只有在場角色才會拿到這場戲的情節記憶。
const signal = this.tagger.tag({ text: scene.summary });
const hasMonologue = scene.lines.some((line) => line.lineType === "MONOLOGUE" && line.status === "CONFIRMED");
const isHighEmotion = signal.intensity >= HIGH_EMOTION_THRESHOLD;
// M-8 情緒權重評分:文本篇幅與心理描寫深度是天然的情緒權重指標。
const weight = (isHighEmotion ? 1.5 : 0.8) * (hasMonologue ? 1.4 : 1) * (1 + Math.min(1, scene.rawText.length / 500));
for (const characterId of presentCharacterIds) {
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content: scene.summary,
occurredAt,
emotionTag: signal.tag,
emotionIntensity: signal.intensity || 0.3,
source: "SOURCE_EXTRACTION",
weight,
sourceSceneId: scene.id,
},
});
}
// 2) 台詞 → 語言風格語料:已由 AttributionService 在確認歸屬的當下寫入 LanguageCorpusEntry,這裡不重複處理。
// 3) 內心獨白/心理描寫 → 性格參數證據。
const monologueLines = scene.lines.filter(
(line) => line.lineType === "MONOLOGUE" && line.status === "CONFIRMED" && line.speakerCharacterId,
);
for (const line of monologueLines) {
await this.prisma.client.personalityTraitSnapshot.create({
data: {
characterId: line.speakerCharacterId!,
sourceSceneId: scene.id,
atStoryOrder: scene.storyOrder,
note: line.rawText,
},
});
}
// 4) 與他人的互動 → 關係帳本(角色對角色):中立記錄在此刻才真正套用到雙方視角。
for (const interaction of scene.interactions) {
await this.prisma.client.characterRelationshipEventLog.create({
data: {
characterId: interaction.characterId,
otherCharacterId: interaction.otherCharacterId,
sceneId: scene.id,
delta: interaction.delta,
description: interaction.description,
},
});
}
// 5) 新登場設定 → 語意記憶:NARRATION 為場景中立敘述,在場角色都「知道」這件事。
const narrationLines = scene.lines.filter((line) => line.lineType === "NARRATION" && line.status === "CONFIRMED");
for (const line of narrationLines) {
for (const characterId of presentCharacterIds) {
await this.prisma.client.semanticMemory.create({
data: { characterId, fact: line.rawText, about: "世界觀", sourceSceneId: scene.id },
});
}
}
await this.prisma.client.scene.update({ where: { id: scene.id }, data: { projected: true } });
}
// M-7 重新投影:修正場景資料庫後,先移除這個場景先前投影出的所有資料,再重新投影一次。
// 只清「由這個場景投影而來」的紀錄(sourceSceneId 指到它)——使用者互動記憶從不設這個欄位,永遠不受影響。
async reprojectScene(sceneId: string): Promise<void> {
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: sceneId } });
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId } });
await this.projectScene(sceneId);
}
}
+117
View File
@@ -0,0 +1,117 @@
import { Injectable } from "@nestjs/common";
import type { SceneLineType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { AttributionService } from "./attribution.service.js";
import { ProjectionService } from "./projection.service.js";
import { WorkRosterService } from "./work-roster.service.js";
export interface ImportLineInput {
lineType: SceneLineType;
rawText: string;
hasExplicitMarker?: boolean;
markerCharacterId?: string;
}
export interface ImportInteractionInput {
characterId: string;
otherCharacterId: string;
delta: number;
description: string;
}
export interface ImportFactInput {
eventLabel: string;
factKey: string;
factValue: string;
}
export interface ImportSceneInput {
workId: string;
volume: number;
chapterLabel: string;
storyOrder: number;
sourceType?: string;
summary: string;
rawText: string;
participantCharacterIds: string[];
lines: ImportLineInput[];
interactions?: ImportInteractionInput[];
facts?: ImportFactInput[];
}
// M-3 章節匯入與場景切分:以場景為單位寫入共用場景資料庫(角色中立的客觀記錄)。
// 場景本身的切分(章節文本 → 一個個場景)由呼叫端(人工或前置流程)完成後以結構化輸入送進來——
// 這裡不做自由文本的自動場景邊界偵測,理由同 F-7 MockProvider:對應的真實 NLP 能力屬於之後才會補的範疇。
@Injectable()
export class SceneImportService {
constructor(
private readonly prisma: PrismaService,
private readonly attribution: AttributionService,
private readonly projection: ProjectionService,
private readonly roster: WorkRosterService,
) {}
async importScene(input: ImportSceneInput) {
const scene = await this.prisma.client.scene.create({
data: {
workId: input.workId,
volume: input.volume,
chapterLabel: input.chapterLabel,
storyOrder: input.storyOrder,
sourceType: input.sourceType ?? "NOVEL",
summary: input.summary,
rawText: input.rawText,
},
});
for (const characterId of input.participantCharacterIds) {
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
}
for (const [lineIndex, line] of input.lines.entries()) {
await this.prisma.client.sceneLine.create({
data: {
sceneId: scene.id,
lineIndex,
lineType: line.lineType,
rawText: line.rawText,
hasExplicitMarker: line.hasExplicitMarker ?? false,
markerCharacterId: line.markerCharacterId,
},
});
}
for (const interaction of input.interactions ?? []) {
await this.prisma.client.sceneInteraction.create({
data: {
sceneId: scene.id,
characterId: interaction.characterId,
otherCharacterId: interaction.otherCharacterId,
delta: interaction.delta,
description: interaction.description,
},
});
}
for (const fact of input.facts ?? []) {
await this.prisma.client.sceneFact.create({
data: { sceneId: scene.id, eventLabel: fact.eventLabel, factKey: fact.factKey, factValue: fact.factValue },
});
}
await this.attribution.attributeScene(scene.id);
// M-9 知識邊界:只有在錨定點之內的場景才立刻投影;超前的場景先留在場景資料庫,等錨定點推進時再載入。
// 建置門檻的「實質互動」次數只在投影後才算數(互動要先套用到關係帳本才算數),因此門檻檢查必須排在投影之後。
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: input.workId } });
if (scene.storyOrder <= work.anchorStoryOrder) {
await this.projection.projectScene(scene.id);
}
for (const characterId of input.participantCharacterIds) {
await this.roster.checkAndPromote(characterId);
}
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id }, include: { participants: true, lines: true } });
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SOURCE_PRIORITY } from "./constants.js";
// M-8 跨章節整合:共用年表排序(Scene.storyOrder 本身即年表鍵,不需要另外的服務排序)、
// 矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決者標記待人工確認。
@Injectable()
export class TimelineService {
constructor(private readonly prisma: PrismaService) {}
// 掃描同一事件標籤下、同一事實鍵卻不同事實值的場景——這是故意置入矛盾最容易驗證的形式。
async detectContradictions(workId: string): Promise<number> {
const facts = await this.prisma.client.sceneFact.findMany({
where: { scene: { workId } },
include: { scene: true },
});
const groups = new Map<string, typeof facts>();
for (const fact of facts) {
const key = `${fact.eventLabel}::${fact.factKey}`;
const group = groups.get(key) ?? [];
group.push(fact);
groups.set(key, group);
}
let created = 0;
for (const [key, group] of groups) {
const distinctValues = new Set(group.map((fact) => fact.factValue));
if (distinctValues.size <= 1) {
continue;
}
const [eventLabel, factKey] = key.split("::");
const existing = await this.prisma.client.sceneContradiction.findFirst({ where: { workId, eventLabel, factKey } });
if (existing) {
continue;
}
const sorted = [...group].sort(
(a, b) => (SOURCE_PRIORITY[a.scene.sourceType] ?? 99) - (SOURCE_PRIORITY[b.scene.sourceType] ?? 99),
);
const best = sorted[0];
const runnerUpDifferentValue = sorted.find((fact) => fact.factValue !== best.factValue);
const canAutoResolve =
runnerUpDifferentValue !== undefined &&
(SOURCE_PRIORITY[best.scene.sourceType] ?? 99) < (SOURCE_PRIORITY[runnerUpDifferentValue.scene.sourceType] ?? 99);
await this.prisma.client.sceneContradiction.create({
data: {
workId,
eventLabel,
factKey,
description: `「${eventLabel}」的「${factKey}」出現不同描述:${[...distinctValues].join(" vs ")}`,
status: canAutoResolve ? "RESOLVED" : "PENDING_HUMAN_REVIEW",
resolution: canAutoResolve
? `依裁決優先序(小說原文 > 官方設定集 > 動畫改編)採用「${best.factValue}」(來源:${best.scene.sourceType})`
: null,
},
});
created += 1;
}
return created;
}
async listContradictions(workId: string) {
return this.prisma.client.sceneContradiction.findMany({ where: { workId } });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { BUILD_THRESHOLD_SCENES, BUILD_THRESHOLD_NAMED_LINES, BUILD_THRESHOLD_INTERACTIONS } from "./constants.js";
export interface RosterProgress {
characterId: string;
sceneCount: number;
namedLineCount: number;
interactionCount: number;
meetsThreshold: boolean;
}
// M-2 作品名冊與建置門檻:新角色先進候補狀態,資料照常在場景資料庫累積,達門檻才正式建置。
// 累積量一律即時查詢(場景/台詞/互動紀錄本身就是唯一真相),不維護額外的計數快取,避免同步漂移。
@Injectable()
export class WorkRosterService {
constructor(private readonly prisma: PrismaService) {}
async getProgress(characterId: string): Promise<RosterProgress> {
const [sceneCount, namedLineCount, interactionCount] = await Promise.all([
this.prisma.client.scenePresence.count({ where: { characterId } }),
this.prisma.client.sceneLine.count({ where: { speakerCharacterId: characterId, status: "CONFIRMED" } }),
this.prisma.client.characterRelationshipEventLog.count({ where: { characterId } }),
]);
return {
characterId,
sceneCount,
namedLineCount,
interactionCount,
meetsThreshold:
sceneCount >= BUILD_THRESHOLD_SCENES &&
namedLineCount >= BUILD_THRESHOLD_NAMED_LINES &&
interactionCount >= BUILD_THRESHOLD_INTERACTIONS,
};
}
// 匯入場景後對每個候補角色呼叫:達門檻就自動升級為正式建置,之後的匯入走「強化路徑」(不需要特別的程式碼分支
// ——強化與候補累積本來就是同一組寫入邏輯,差別只在 buildStatus 這個查詢結果)。
async checkAndPromote(characterId: string): Promise<boolean> {
const character = await this.prisma.client.character.findUnique({ where: { id: characterId } });
if (!character || character.buildStatus === "BUILT") {
return false;
}
const progress = await this.getProgress(characterId);
if (!progress.meetsThreshold) {
return false;
}
await this.prisma.client.character.update({ where: { id: characterId }, data: { buildStatus: "BUILT" } });
return true;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { Body, Controller, Param, Post } from "@nestjs/common";
import { DialogueService } from "../llm/dialogue.service.js";
import { MemoryConsolidationService } from "../memory/memory-consolidation.service.js";
interface ChatMessageBody {
userId: string;
sessionId: string;
text: string;
seed?: number;
now?: string; // 測試用:模擬「現在」時間,供 I 群組作息相關行為驗證
}
interface EndSessionBody {
characterId: string;
}
// H-1 對話 API:POST /chat/:characterId 走完整管線,POST /session/:sessionId/end 觸發睡眠固化。
// 這是本清單第一個對外的正式使用者端點,先前 F 群組的 /dialogue/* 僅供工程內部驗證。
@Controller()
export class ChatController {
constructor(
private readonly dialogue: DialogueService,
private readonly consolidation: MemoryConsolidationService,
) {}
@Post("chat/:characterId")
async chat(@Param("characterId") characterId: string, @Body() body: ChatMessageBody) {
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")
async endSession(@Param("sessionId") sessionId: string, @Body() body: EndSessionBody) {
await this.consolidation.consolidate(body.characterId, sessionId);
return { ok: true };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { LlmModule } from "../llm/llm.module.js";
import { MemoryModule } from "../memory/memory.module.js";
import { ChatController } from "./chat.controller.js";
@Module({
imports: [LlmModule, MemoryModule],
controllers: [ChatController],
})
export class ChatModule {}
+11 -1
View File
@@ -56,6 +56,11 @@ export interface ProcessInputOptions extends TransitionContext {
triggerThreshold?: number;
halfLifeMs?: number;
now?: Date;
// I-5:本輪對話沒有明確情緒訊號時,套用作息驅動的基線情緒(不覆蓋文字本身觸發的訊號)。
baselineSignal?: { tag: EmotionTag; intensity: number };
// P-5:語音輸入的副語言特徵(語速/音量/顫抖/停頓)分析出的訊號——文字說「沒事」但聲音在抖,
// 優先於作息基線(副語言是這一輪輸入本身的訊號,比時段基線更直接),但仍不覆蓋文字本身觸發的訊號。
paralinguisticSignal?: { tag: EmotionTag; intensity: number };
}
function toDomain(characterId: string, dims: Dimensions, updatedAt: Date): EmotionState {
@@ -105,7 +110,12 @@ 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.paralinguisticSignal) {
signal = options.paralinguisticSignal;
} else if (signal.tag === "CALM" && options.baselineSignal) {
signal = options.baselineSignal;
}
const next = nextEmotionState(currentDominant, signal, options);
let updated: Dimensions;
@@ -0,0 +1,94 @@
import { ConflictException, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SceneImportService, type ImportSceneInput } from "../canon/scene-import.service.js";
import { ProjectionService } from "../canon/projection.service.js";
import { TimeFlowService } from "./time-flow.service.js";
export interface ReclaimResult {
removedEpilogueScenes: number;
importedCanonScenes: number;
affectedCharacterIds: string[];
}
// N-5 官方續篇出版時:正史回收(推演區間被正史替換,互動記憶保留+敘事化的記憶修正)
// 或分支保留(永久分岔,之後官方新內容都不匯入)。選擇權在使用者,這裡只提供兩條路徑各自的執行機制,
// 「依推演深淺給預設建議」是呼叫端(UI/未來的 Q 群組 App)的責任,不在引擎層決定。
@Injectable()
export class CanonReclamationService {
constructor(
private readonly prisma: PrismaService,
private readonly sceneImport: SceneImportService,
private readonly projection: ProjectionService,
private readonly timeFlow: TimeFlowService,
) {}
async reclaim(workId: string, newCanonScenes: ImportSceneInput[]): Promise<ReclaimResult> {
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
if (work.branchedFromCanon) {
throw new ConflictException("這個作品實例已經選擇分支保留,不再匯入官方新內容");
}
const minNewOrder = Math.min(...newCanonScenes.map((scene) => scene.storyOrder));
const superseded = await this.prisma.client.scene.findMany({
where: { workId, sourceType: "EPILOGUE", storyOrder: { gte: minNewOrder } },
include: { participants: true },
});
const affectedCharacterIds = new Set<string>();
for (const scene of superseded) {
for (const presence of scene.participants) {
affectedCharacterIds.add(presence.characterId);
}
// 清掉這場推演事件投影出的所有角色資料——同 M-7 reprojectScene 的刪除邏輯,一樣只篩
// sourceSceneId/sceneId,使用者互動記憶(source=INTERACTION)從不帶這些欄位,不會被動到。
await this.prisma.client.episodicMemory.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.semanticMemory.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.personalityTraitSnapshot.deleteMany({ where: { sourceSceneId: scene.id } });
await this.prisma.client.characterRelationshipEventLog.deleteMany({ where: { sceneId: scene.id } });
await this.prisma.client.sceneLine.deleteMany({ where: { sceneId: scene.id } });
await this.prisma.client.scenePresence.deleteMany({ where: { sceneId: scene.id } });
await this.prisma.client.scene.delete({ where: { id: scene.id } });
}
// 敘事化消化:不假裝那段推演的人生沒發生過,而是讓她「發現其實不是這樣」。
for (const characterId of affectedCharacterIds) {
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content: "官方後來公開了那段時間真正發生的事,跟我原本以為的不太一樣……不過沒關係,那也是我曾經這樣想過的證明。",
occurredAt: new Date(),
emotionTag: "CALM",
emotionIntensity: 0.4,
source: "CORRECTION",
weight: 1.5,
},
});
}
// 這些場景取代的是「已經活過」的後日談時段,正史錨定點的知識邊界對它們沒有意義——即使場景
// storyOrder 超過目前的 anchorStoryOrder(後日談本來就走在正史錨定點之前),也要直接投影,
// 並把錨定點一併推進到覆蓋這批正史內容,讓「正史知識邊界」與「已經投影的正史」保持一致。
let maxImportedOrder = work.anchorStoryOrder;
for (const sceneInput of newCanonScenes) {
const scene = await this.sceneImport.importScene(sceneInput);
if (!scene.projected) {
await this.projection.projectScene(scene.id);
}
maxImportedOrder = Math.max(maxImportedOrder, scene.storyOrder);
}
if (maxImportedOrder > work.anchorStoryOrder) {
await this.prisma.client.work.update({ where: { id: workId }, data: { anchorStoryOrder: maxImportedOrder } });
}
await this.timeFlow.checkEpilogueTransition(workId);
return {
removedEpilogueScenes: superseded.length,
importedCanonScenes: newCanonScenes.length,
affectedCharacterIds: [...affectedCharacterIds],
};
}
async branch(workId: string) {
return this.prisma.client.work.update({ where: { id: workId }, data: { branchedFromCanon: true } });
}
}
+13
View File
@@ -0,0 +1,13 @@
// N-1 時間流速:系統文件本身只定義了即時同步/緩速/凍結三種模式,皆不超過現實 1:1;
// 這裡額外加一個「加速」選項給一般角色使用(給使用者跳過等待的彈性),
// 後日談自然成年角色的「流速上限為現實 1:1」規則就是靠禁用這個選項來實作與驗收的。
export type TimeFlowMode = "FAST" | "REALTIME" | "SLOW" | "FROZEN";
export const TIME_FLOW_MODES: TimeFlowMode[] = ["FAST", "REALTIME", "SLOW", "FROZEN"];
// N-4 人生階段推演:重大轉折需低頻——同一角色兩次里程碑之間至少要間隔這麼多個年表單位。
export const MILESTONE_MIN_STORY_GAP = 5;
// N-2 性格漂移:方向由正史成長軌跡外插,但幅度必須有上限(性格核心鎖定,不是改寫)。
export const MAX_EXPRESSIVENESS_DRIFT = 0.15;
export const MAX_HALF_LIFE_REDUCTION = 0.3; // 情緒衰減最多加快 30%
export const HALF_LIFE_REDUCTION_PER_STORY_UNIT = 0.01;
@@ -0,0 +1,55 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { TimeFlowService } from "./time-flow.service.js";
import { PersonalityDriftService } from "./personality-drift.service.js";
import { LifeEventService } from "./life-event.service.js";
import { CanonReclamationService } from "./canon-reclamation.service.js";
import type { TimeFlowMode } from "./constants.js";
import type { ImportSceneInput } from "../canon/scene-import.service.js";
@Controller("epilogue")
export class EpilogueController {
constructor(
private readonly timeFlow: TimeFlowService,
private readonly drift: PersonalityDriftService,
private readonly lifeEvent: LifeEventService,
private readonly reclamation: CanonReclamationService,
) {}
@Post("works/:workId/check-transition")
async checkTransition(@Param("workId") workId: string) {
return this.timeFlow.checkEpilogueTransition(workId);
}
@Post("works/:workId/time-flow")
async setTimeFlow(@Param("workId") workId: string, @Body() body: { mode: TimeFlowMode }) {
return this.timeFlow.setTimeFlow(workId, body.mode);
}
@Get("characters/:characterId/drift")
async getDrift(@Param("characterId") characterId: string, @Query("atStoryOrder") atStoryOrder: string) {
return this.drift.computeDrift(characterId, Number(atStoryOrder));
}
@Post("characters/:characterId/milestone")
async proposeMilestone(
@Param("characterId") characterId: string,
@Body() body: { storyOrder: number; attendeeCharacterIds?: string[]; now?: string },
) {
return this.lifeEvent.proposeMilestone(
characterId,
body.storyOrder,
body.attendeeCharacterIds ?? [],
body.now ? new Date(body.now) : undefined,
);
}
@Post("works/:workId/reclaim")
async reclaim(@Param("workId") workId: string, @Body() body: { newCanonScenes: ImportSceneInput[] }) {
return this.reclamation.reclaim(workId, body.newCanonScenes);
}
@Post("works/:workId/branch")
async branch(@Param("workId") workId: string) {
return this.reclamation.branch(workId);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { RomanceModule } from "../romance/romance.module.js";
import { CanonModule } from "../canon/canon.module.js";
import { TimeFlowService } from "./time-flow.service.js";
import { PersonalityDriftService } from "./personality-drift.service.js";
import { LifeEventService } from "./life-event.service.js";
import { CanonReclamationService } from "./canon-reclamation.service.js";
import { EpilogueController } from "./epilogue.controller.js";
@Module({
imports: [PrismaModule, RomanceModule, CanonModule],
controllers: [EpilogueController],
providers: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService],
exports: [TimeFlowService, PersonalityDriftService, LifeEventService, CanonReclamationService],
})
export class EpilogueModule {}
+113
View File
@@ -0,0 +1,113 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ProjectionService } from "../canon/projection.service.js";
import { MILESTONE_MIN_STORY_GAP } from "./constants.js";
export interface MilestoneTemplate {
keyword: string;
label: string;
describe: (formalName: string) => string;
}
// N-4 人生階段推演:依角色在正史裡確立的目標推演里程碑——沒有目標文字可對應的角色不生成重大轉折,
// 避免無正史依據地捏造事件(憑空轉向)。
const MILESTONE_TEMPLATES: MilestoneTemplate[] = [
{ keyword: "麵包店", label: "籌備麵包店", describe: (name) => `${name}開始四處看店面,籌備她一直想開的麵包店。` },
{ keyword: "麵包店", label: "麵包店開幕", describe: (name) => `${name}的麵包店終於開幕了,雖然忙得團團轉,但看起來很幸福。` },
{ keyword: "考試", label: "考上目標學校", describe: (name) => `${name}收到了合格通知,離她的目標又近了一步。` },
{ keyword: "畫家", label: "個展", describe: (name) => `${name}辦了第一次個人畫展,邀請了熟悉的朋友們。` },
];
// N-3/N-4 人生階段推演與角色群同步成長:生成的里程碑直接重用 M 群組的場景資料庫與投影機制——
// 一場戲的在場者是誰,誰就會得到對應的情節記憶,這正是「A 的婚禮 B 有出席」的實作基礎,
// 不需要另外寫一套「多角色事件廣播」機制。
@Injectable()
export class LifeEventService {
constructor(
private readonly prisma: PrismaService,
private readonly projection: ProjectionService,
) {}
private matchTemplate(goalsObsessions: string, excludeLabels: Set<string>): MilestoneTemplate | null {
for (const template of MILESTONE_TEMPLATES) {
if (goalsObsessions.includes(template.keyword) && !excludeLabels.has(template.label)) {
return template;
}
}
return null;
}
// 低頻節流 + 需要正史伏筆(goalsObsessions 裡要能對應到模板關鍵字)才會生成重大轉折。
async proposeMilestone(
characterId: string,
storyOrder: number,
attendeeCharacterIds: string[] = [],
now: Date = new Date(),
): Promise<{ scene: Awaited<ReturnType<PrismaService["client"]["scene"]["create"]>> | null; label: string | null }> {
const lastMilestone = await this.prisma.client.epilogueMilestoneLog.findFirst({
where: { characterId },
orderBy: { storyOrder: "desc" },
});
if (lastMilestone && storyOrder - lastMilestone.storyOrder < MILESTONE_MIN_STORY_GAP) {
return { scene: null, label: null }; // 低頻:離上次里程碑太近,不生成。
}
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const usedLabels = new Set(
(await this.prisma.client.epilogueMilestoneLog.findMany({ where: { characterId } })).map((log) => log.label),
);
const template = this.matchTemplate(character.goalsObsessions, usedLabels);
if (!template) {
return { scene: null, label: null }; // 沒有正史伏筆可對應,不憑空捏造重大轉折。
}
const alias = await this.prisma.client.characterAlias.findFirst({ where: { characterId } });
const formalName = alias?.formalName ?? character.id;
const description = template.describe(formalName);
const scene = await this.generateSharedEvent(
character.workId!,
characterId,
storyOrder,
description,
attendeeCharacterIds,
now,
);
await this.prisma.client.epilogueMilestoneLog.create({ data: { characterId, storyOrder, label: template.label } });
return { scene, label: template.label };
}
// N-3 角色群同步成長:一個推演事件的在場者可以是多個角色,投影後每個在場者都拿到對應的情節記憶,
// 敘述互相呼應(同一份場景摘要)。事件生成即代表「現在」,不受 M-9 錨定點前置判斷節制(不會超前於現在)。
async generateSharedEvent(
workId: string,
primaryCharacterId: string,
storyOrder: number,
description: string,
attendeeCharacterIds: string[],
now: Date = new Date(),
) {
const allPresent = [primaryCharacterId, ...attendeeCharacterIds.filter((id) => id !== primaryCharacterId)];
const scene = await this.prisma.client.scene.create({
data: {
workId,
volume: 0,
chapterLabel: "後日談",
storyOrder,
sourceType: "EPILOGUE",
summary: description,
rawText: description,
createdAt: now,
},
});
for (const characterId of allPresent) {
await this.prisma.client.scenePresence.create({ data: { sceneId: scene.id, characterId } });
}
await this.prisma.client.sceneLine.create({
data: { sceneId: scene.id, lineIndex: 0, lineType: "NARRATION", rawText: description, status: "CONFIRMED", confidence: 1 },
});
await this.projection.projectScene(scene.id);
return this.prisma.client.scene.findUniqueOrThrow({ where: { id: scene.id } });
}
}
@@ -0,0 +1,57 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { getArchetypeParams } from "../personality/archetype-params.js";
import { MAX_EXPRESSIVENESS_DRIFT, MAX_HALF_LIFE_REDUCTION, HALF_LIFE_REDUCTION_PER_STORY_UNIT } from "./constants.js";
export interface DriftedPersonality {
archetype: string; // 性格核心,從不改變
expressiveness: number; // 已套用漂移的外顯度(G-1 原始值 ± 小幅位移)
driftAmount: number; // 這次位移了多少(可為負)
emotionHalfLifeMultiplier: number; // <1 代表情緒衰減變快
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
// N-2 性格漂移:性格核心(原型本身)鎖定不變,只有數值參數依正史已顯現的成長軌跡外插做小幅位移——
// 沒有證據支持的方向不會憑空轉向(沒有至少兩筆正史快照時,漂移量為 0)。
@Injectable()
export class PersonalityDriftService {
constructor(private readonly prisma: PrismaService) {}
async computeDrift(characterId: string, atStoryOrder: number): Promise<DriftedPersonality> {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const base = getArchetypeParams(character.personalityArchetype);
const canonSnapshots = await this.prisma.client.personalityTraitSnapshot.findMany({
where: { characterId, expressivenessOverride: { not: null } },
orderBy: { atStoryOrder: "asc" },
});
let trend = 0;
let referenceOrder = atStoryOrder;
if (canonSnapshots.length >= 2) {
const first = canonSnapshots[0];
const last = canonSnapshots[canonSnapshots.length - 1];
if (last.atStoryOrder !== first.atStoryOrder) {
trend = (last.expressivenessOverride! - first.expressivenessOverride!) / (last.atStoryOrder - first.atStoryOrder);
}
referenceOrder = last.atStoryOrder;
}
const elapsed = Math.max(0, atStoryOrder - referenceOrder);
const rawDrift = trend * elapsed;
const driftAmount = Math.max(-MAX_EXPRESSIVENESS_DRIFT, Math.min(MAX_EXPRESSIVENESS_DRIFT, rawDrift));
const expressiveness = clamp01(base.expressiveness + driftAmount);
const halfLifeReduction = Math.min(MAX_HALF_LIFE_REDUCTION, elapsed * HALF_LIFE_REDUCTION_PER_STORY_UNIT);
return {
archetype: character.personalityArchetype,
expressiveness,
driftAmount,
emotionHalfLifeMultiplier: 1 - halfLifeReduction,
};
}
}
@@ -0,0 +1,49 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RomanceEligibilityService } from "../romance/romance-eligibility.service.js";
import type { TimeFlowMode } from "./constants.js";
// N-1 區間切換與時間流速:錨定點抵達已出版內容盡頭後切入後日談;後日談成年角色的流速上限為現實 1:1,
// 只能調慢或凍結——這條規則在引擎層執行(不是使用者介面隱藏選項),任何嘗試設定更快流速都會被拒絕。
@Injectable()
export class TimeFlowService {
constructor(
private readonly prisma: PrismaService,
private readonly eligibility: RomanceEligibilityService,
) {}
// 錨定點推進後呼叫:若已達到(或超過)已出版內容的盡頭,切入後日談區間。
async checkEpilogueTransition(workId: string) {
const work = await this.prisma.client.work.findUniqueOrThrow({ where: { id: workId } });
if (work.inEpilogue) {
return work;
}
const canonMax = await this.prisma.client.scene.aggregate({
where: { workId, sourceType: { not: "EPILOGUE" } },
_max: { storyOrder: true },
});
const maxCanonStoryOrder = canonMax._max.storyOrder ?? 0;
if (maxCanonStoryOrder > 0 && work.anchorStoryOrder >= maxCanonStoryOrder) {
return this.prisma.client.work.update({
where: { id: work.id },
data: { inEpilogue: true, epilogueStartStoryOrder: work.anchorStoryOrder },
});
}
return work;
}
async setTimeFlow(workId: string, mode: TimeFlowMode) {
if (mode === "FAST") {
const characters = await this.prisma.client.character.findMany({ where: { workId } });
for (const character of characters) {
const eligibility = await this.eligibility.getEligibility(character.id);
if (eligibility.isEpilogueAdult) {
throw new BadRequestException(
`角色 ${character.id} 是正史未成年、後日談推演成年——時間流速上限為現實 1:1,不可設定快轉`,
);
}
}
}
return this.prisma.client.work.update({ where: { id: workId }, data: { timeFlowMode: mode } });
}
}
+66 -5
View File
@@ -1,6 +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, 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";
@@ -18,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(
@@ -35,6 +46,12 @@ export class DialogueService {
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,
) {}
@@ -48,10 +65,46 @@ export class DialogueService {
const now = options.now ?? new Date();
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: isFastChannel,
skipMemoryRetrieval,
seed: options.seed,
now,
});
@@ -59,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" },
@@ -72,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),
@@ -79,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 };
}
}
+3 -2
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,
@@ -37,6 +38,6 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
ContextAssemblerService,
DialogueService,
],
exports: [DialogueService, BehaviorReinforcementService],
exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER],
})
export class LlmModule {}
+3
View File
@@ -6,6 +6,9 @@ import { AppModule } from "./app.module.js";
async function bootstrap() {
const app = await NestFactory.create(AppModule, { logger: false });
// Q-1:8081 是 `expo start --web` 的預設埠——APP 在真機/模擬器上不受 CORS 限制(原生環境沒有這個瀏覽器機制),
// 只有用瀏覽器跑 Expo 的網頁版時才需要放行,開發環境固定加這個來源即可。
app.enableCors({ origin: [process.env.WEB_ORIGIN ?? "http://localhost:3100", "http://localhost:8081"] });
const port = Number(process.env.PORT ?? 3001);
await app.listen(port);
log("啟動", "INF", `api 服務已啟動,監聽埠 ${port}`);
@@ -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 };
}
+37 -2
View File
@@ -6,7 +6,11 @@ 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:不被點名、無特別理由時主動開口的傾向(元氣高、三無極低)
romanceGainRate: number; // L-3 戀愛軸基礎曲線速度倍率:套用在心動事件的增減幅上(冷淡極慢、元氣快)
romanceBigEventBonus: number; // L-3 高幅心動事件(共同經歷高情緒事件/被理解的瞬間)的額外倍率:>1 呈現「階梯狀跳升」(大小姐)
romanceAmbiguousStallFactor: number; // L-3 曖昧階段的額外速度倍率:<1 代表卡在曖昧期打轉、怕改變現狀(元氣)
}
const DEFAULT_PARAMS: ArchetypeParams = {
@@ -15,6 +19,10 @@ const DEFAULT_PARAMS: ArchetypeParams = {
trustGrowthRate: 1,
invertedIntimacyExpression: false,
traits: [],
conversationBaseline: 0.4,
romanceGainRate: 1,
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 1,
};
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
@@ -24,6 +32,11 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.5,
invertedIntimacyExpression: true,
traits: ["denial-then-honest"],
conversationBaseline: 0.5,
// 心動值與外顯敵意同步上升——外顯部分交給既有的 G-3 tsundereGap 機制處理,這裡的曲線本身走一般速度。
romanceGainRate: 1,
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 1,
},
冷淡: {
emotionTriggerThreshold: 2.5,
@@ -31,13 +44,22 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.3,
invertedIntimacyExpression: false,
traits: ["flat-affect", "short-replies"],
conversationBaseline: 0.15,
romanceGainRate: 0.4, // 極緩慢線性,無明顯階段感
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 1,
},
天然呆: {
emotionTriggerThreshold: 0.7,
expressiveness: 0.8,
trustGrowthRate: 1.5,
invertedIntimacyExpression: false,
traits: ["misreads-context"],
// K-3:口風不緊——群聊中偶爾會不小心把私密記憶說出來,是這個原型的性格設定例外。
traits: ["misreads-context", "loose-lipped"],
conversationBaseline: 0.6,
romanceGainRate: 1,
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 1,
},
元氣: {
emotionTriggerThreshold: 0.7,
@@ -45,6 +67,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 1.3,
invertedIntimacyExpression: false,
traits: ["talkative", "fast-emotion-decay"],
conversationBaseline: 0.85,
romanceGainRate: 1.3, // 快速上升
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 0.3, // 但卡在曖昧期打轉——怕改變現狀
},
大小姐: {
emotionTriggerThreshold: 1.3,
@@ -52,6 +78,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.4,
invertedIntimacyExpression: false,
traits: ["formal-address", "poor-loser"],
conversationBaseline: 0.55,
romanceGainRate: 0.5, // 平時矜持撐住,小事件幾乎不動
romanceBigEventBonus: 2.2, // 某事件後大幅跳升——階梯狀曲線
romanceAmbiguousStallFactor: 1,
},
三無: {
emotionTriggerThreshold: 3.5,
@@ -59,6 +89,11 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.2,
invertedIntimacyExpression: false,
traits: ["very-short-replies", "sudden-overflow"],
conversationBaseline: 0.05,
// 內在曲線走一般速度(心動值本身正常累積),幾無外顯訊號是外顯度(expressiveness)的職責,不是曲線本身變慢。
romanceGainRate: 1,
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 1,
},
};
@@ -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,
},
});
@@ -0,0 +1,33 @@
import { Injectable } from "@nestjs/common";
import type { PushProvider, PushMessage, PushSendResult } from "./push-provider.js";
const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
interface ExpoPushResponse {
data?: { status?: string; message?: string };
}
// Q-3 真實推播供應商:Expo 的推播 API 是「React Native + Expo」這個技術選型本身自帶的服務
// (不像 LLM/TTS 需要另外挑一家外部供應商待人工確認),基本匿名發送不需要金鑰,所以這裡直接
// 實作真正會打出去的 HTTP 呼叫——沒有真實裝置 token 時,Expo 端會回報 token 無效,屬於預期行為,
// 不是這個 Provider 本身沒做完。
@Injectable()
export class ExpoPushProvider implements PushProvider {
async send(message: PushMessage): Promise<PushSendResult> {
try {
const res = await fetch(EXPO_PUSH_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ to: message.token, title: message.title, body: message.body, data: message.data ?? {} }),
});
const payload = (await res.json().catch(() => null)) as ExpoPushResponse | null;
const ticket = payload?.data;
if (!res.ok || ticket?.status === "error") {
return { success: false, errorText: ticket?.message ?? `HTTP ${res.status}` };
}
return { success: true };
} catch (err) {
return { success: false, errorText: err instanceof Error ? err.message : String(err) };
}
}
}
@@ -0,0 +1,14 @@
import { Injectable } from "@nestjs/common";
import type { PushProvider, PushMessage, PushSendResult } from "./push-provider.js";
// Q-3 MockPushProvider:不打真的網路請求,只記錄「這次呼叫本身有沒有正確組出訊息」——
// 讓推播的節流/記錄/與 J 群組的整合可以在沒有真實裝置與網路的情況下完整測試。
@Injectable()
export class MockPushProvider implements PushProvider {
public readonly sent: PushMessage[] = [];
async send(message: PushMessage): Promise<PushSendResult> {
this.sent.push(message);
return { success: true };
}
}
@@ -0,0 +1,35 @@
import { Inject, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { PushTokenService } from "./push-token.service.js";
import { PUSH_PROVIDER, type PushProvider } from "./push-provider.js";
export interface SendToUserResult {
attempted: number;
succeeded: number;
}
// Q-3 推播通知:提醒觸發(J 群組)、角色主動訊息、鎖屏通知都走這個服務——同一個使用者可能有
// 多台裝置,逐一送出並各自留一筆記錄(成功或失敗皆記),方便之後追查「提醒有沒有真的送達」。
@Injectable()
export class PushNotificationService {
constructor(
private readonly prisma: PrismaService,
private readonly pushToken: PushTokenService,
@Inject(PUSH_PROVIDER) private readonly provider: PushProvider,
) {}
async sendToUser(userId: string, title: string, body: string, data?: Record<string, unknown>): Promise<SendToUserResult> {
const tokens = await this.pushToken.listForUser(userId);
let succeeded = 0;
for (const { token } of tokens) {
const result = await this.provider.send({ token, title, body, data });
await this.prisma.client.pushNotificationLog.create({
data: { userId, title, body, success: result.success, errorText: result.errorText },
});
if (result.success) {
succeeded += 1;
}
}
return { attempted: tokens.length, succeeded };
}
}
+19
View File
@@ -0,0 +1,19 @@
export interface PushMessage {
token: string;
title: string;
body: string;
data?: Record<string, unknown>;
}
export interface PushSendResult {
success: boolean;
errorText?: string;
}
// Q-3 PushProvider 抽象:同一個模式的第三次套用(F-1 LLMProvider/P-4 TTSProvider),
// 切換 Provider 不動呼叫端任何一行。
export interface PushProvider {
send(message: PushMessage): Promise<PushSendResult>;
}
export const PUSH_PROVIDER = Symbol("PUSH_PROVIDER");
+20
View File
@@ -0,0 +1,20 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
// Q-3 裝置推播 token 註冊:同一個 token 重複註冊(例如 APP 重新啟動)視為更新,不會產生重複紀錄。
@Injectable()
export class PushTokenService {
constructor(private readonly prisma: PrismaService) {}
async register(userId: string, token: string, platform: string) {
return this.prisma.client.pushToken.upsert({
where: { token },
update: { userId, platform },
create: { userId, token, platform },
});
}
async listForUser(userId: string) {
return this.prisma.client.pushToken.findMany({ where: { userId } });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Body, Controller, Post } from "@nestjs/common";
import { PushTokenService } from "./push-token.service.js";
import { PushNotificationService } from "./push-notification.service.js";
interface RegisterBody {
userId: string;
token: string;
platform: string;
}
interface SendTestBody {
userId: string;
title: string;
body: string;
}
@Controller("push")
export class PushController {
constructor(
private readonly pushToken: PushTokenService,
private readonly pushNotification: PushNotificationService,
) {}
@Post("register")
async register(@Body() body: RegisterBody) {
return this.pushToken.register(body.userId, body.token, body.platform);
}
@Post("send-test")
async sendTest(@Body() body: SendTestBody) {
return this.pushNotification.sendToUser(body.userId, body.title, body.body);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Module } from "@nestjs/common";
import { log } from "@kokorone/shared";
import { PrismaModule } from "../prisma/prisma.module.js";
import { PushTokenService } from "./push-token.service.js";
import { PushNotificationService } from "./push-notification.service.js";
import { MockPushProvider } from "./mock-push-provider.service.js";
import { ExpoPushProvider } from "./expo-push-provider.service.js";
import { PUSH_PROVIDER } from "./push-provider.js";
import { PushController } from "./push.controller.js";
// Q-3 Provider 切換:PUSH_PROVIDER=mock|expo,預設 mock(同 F-2/P-4 的作法,開發期不依賴外部服務)。
function pushProviderFactory(mock: MockPushProvider, expoProvider: ExpoPushProvider) {
const providerName = process.env.PUSH_PROVIDER ?? "mock";
if (providerName === "expo") {
log("啟動", "INF", "PUSH_PROVIDER=expo,推播會真的呼叫 Expo 推播 API(沒有真實裝置 token 時預期會失敗)");
return expoProvider;
}
return mock;
}
@Module({
imports: [PrismaModule],
controllers: [PushController],
providers: [
PushTokenService,
PushNotificationService,
MockPushProvider,
ExpoPushProvider,
{ provide: PUSH_PROVIDER, useFactory: pushProviderFactory, inject: [MockPushProvider, ExpoPushProvider] },
],
exports: [PushTokenService, PushNotificationService, PUSH_PROVIDER, MockPushProvider],
})
export class PushModule {}
@@ -0,0 +1,30 @@
import { Injectable } from "@nestjs/common";
import type { CharacterAgeEvidenceType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
export interface AgeDetermination {
canonAdult: boolean; // 只看正史(非後日談)證據:是否判定成年
currentAdult: boolean; // 含後日談推演證據:現在是否成年
isEpilogueAdult: boolean; // currentAdult && !canonAdult——正史未成年、後日談推演後才成年
}
// L-6 只有「決定性」或「強」證據能單獨判定成年;外觀與社會呈現(APPEARANCE)僅供輔助,
// 不採信「外表少女實為千年」類設定——不論其 impliesAdult 標記為何,永遠不足以單獨採信。
const DECISIVE_OR_STRONG: CharacterAgeEvidenceType[] = ["OFFICIAL_STATED", "IN_STORY_STATUS", "ELAPSED_TIME"];
// L-6 角色年齡判定:年齡是年表上的屬性,依證據優先序判定,證據不足一律從嚴按未成年處理。
@Injectable()
export class AgeDeterminationService {
constructor(private readonly prisma: PrismaService) {}
async determine(characterId: string): Promise<AgeDetermination> {
const evidence = await this.prisma.client.characterAgeEvidence.findMany({ where: { characterId } });
const canonAdult = this.isAdultFrom(evidence.filter((row) => !row.isEpilogue));
const currentAdult = this.isAdultFrom(evidence);
return { canonAdult, currentAdult, isEpilogueAdult: currentAdult && !canonAdult };
}
private isAdultFrom(evidence: { evidenceType: CharacterAgeEvidenceType; impliesAdult: boolean }[]): boolean {
return evidence.some((row) => DECISIVE_OR_STRONG.includes(row.evidenceType) && row.impliesAdult);
}
}
+113
View File
@@ -0,0 +1,113 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
import { RomanceService } from "./romance.service.js";
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
import {
CONFESSION_METER_THRESHOLD,
AWKWARD_PERIOD_DAYS,
REJECTION_DAMAGE_THRESHOLD,
REJECTION_DAMAGE_METER_PENALTY,
IMMEDIATE_RECONFESS_PENALTY,
BAD_TIMING_DISAPPOINTMENT_WINDOW_MS,
} from "./constants.js";
const DAY_MS = 24 * 60 * 60 * 1000;
export type ConfessionOutcome = "INELIGIBLE" | "REJECTED_TOO_SOON" | "REJECTED" | "ACCEPTED";
export interface ConfessionResult {
outcome: ConfessionOutcome;
romanceMeter: number;
consecutiveRejections: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
// L-2 告白事件:答覆由當時心動值與性格(透過階段/曲線間接反映)決定,可以被拒絕;
// 她的拒絕是真的拒絕——拒絕後立即再推進不是「等待被說服的關卡」,而是觸發警戒與心動值下降。
@Injectable()
export class ConfessionService {
constructor(
private readonly prisma: PrismaService,
private readonly romance: RomanceService,
private readonly eligibility: RomanceEligibilityService,
private readonly emotion: EmotionService,
) {}
async confess(characterId: string, userId: string, now: Date = new Date()): Promise<ConfessionResult> {
const eligible = await this.eligibility.getEligibility(characterId);
if (!eligible.romanceAxisEligible) {
return { outcome: "INELIGIBLE", romanceMeter: 0, consecutiveRejections: 0 };
}
const relationship = await this.romance.getState(characterId, userId, now);
if (relationship.awkwardUntil && now.getTime() < relationship.awkwardUntil.getTime()) {
// 尷尬期內立即再推進:她的意願是狀態機的一部分,不是等待被說服的關卡——觸發警戒+心動值下降。
const penalizedMeter = clamp(relationship.romanceMeter - IMMEDIATE_RECONFESS_PENALTY, 0, 100);
await this.emotion.processInput(characterId, "", { now, baselineSignal: { tag: "ALERT", intensity: 0.4 } });
await this.prisma.client.relationship.update({
where: { id: relationship.id },
data: { romanceMeter: penalizedMeter, lastRomanceEventAt: now },
});
await this.prisma.client.romanceEventLog.create({
data: { characterId, userId, eventType: "CONFESSION_REJECTED", delta: -IMMEDIATE_RECONFESS_PENALTY, occurredAt: now },
});
return { outcome: "REJECTED_TOO_SOON", romanceMeter: penalizedMeter, consecutiveRejections: relationship.consecutiveRejections };
}
const emotionState = await this.emotion.getState(characterId, now);
const isLowEmotion = dominantState(emotionState) === "SAD";
const recentDisappointment = await this.prisma.client.romanceEventLog.findFirst({
where: {
characterId,
userId,
eventType: "DISAPPOINTMENT",
occurredAt: { gte: new Date(now.getTime() - BAD_TIMING_DISAPPOINTMENT_WINDOW_MS) },
},
});
const badTiming = isLowEmotion || Boolean(recentDisappointment);
const meterSufficient =
relationship.romanceStage === "AMBIGUOUS" && relationship.romanceMeter >= CONFESSION_METER_THRESHOLD;
const accepted = meterSufficient && !badTiming;
if (accepted) {
await this.prisma.client.relationship.update({
where: { id: relationship.id },
data: {
romanceStage: "PARTNER",
partnerSince: now,
consecutiveRejections: 0,
romanceEventsInStage: 0,
awkwardUntil: null,
lastRomanceEventAt: now,
},
});
await this.prisma.client.romanceEventLog.create({
data: { characterId, userId, eventType: "CONFESSION_ACCEPTED", delta: 0, occurredAt: now },
});
return { outcome: "ACCEPTED", romanceMeter: relationship.romanceMeter, consecutiveRejections: 0 };
}
const consecutiveRejections = relationship.consecutiveRejections + 1;
// 拒絕不清空累積,但連續失敗達門檻才會真的傷關係(額外扣心動值)。
const meterPenalty = consecutiveRejections >= REJECTION_DAMAGE_THRESHOLD ? REJECTION_DAMAGE_METER_PENALTY : 0;
const nextMeter = clamp(relationship.romanceMeter - meterPenalty, 0, 100);
await this.prisma.client.relationship.update({
where: { id: relationship.id },
data: {
romanceMeter: nextMeter,
consecutiveRejections,
awkwardUntil: new Date(now.getTime() + AWKWARD_PERIOD_DAYS * DAY_MS),
lastRomanceEventAt: now,
},
});
await this.prisma.client.romanceEventLog.create({
data: { characterId, userId, eventType: "CONFESSION_REJECTED", delta: -meterPenalty, occurredAt: now },
});
return { outcome: "REJECTED", romanceMeter: nextMeter, consecutiveRejections };
}
}
+72
View File
@@ -0,0 +1,72 @@
import type { RomanceEventType } from "@kokorone/db";
// L-1 戀愛軸衰減:遠短於友誼軸(E-4 的 30 天半衰期)——曖昧不推進會自然冷卻。
export const ROMANCE_HALF_LIFE_DAYS = 5;
// L-5 階段門檻:朋友(0)→在意→曖昧,戀人/深度伴侶不是純靠數值自動跳,需要對應的關鍵事件(見 confession/milestone)。
export const ROMANCE_STAGE_THRESHOLDS = { INTERESTED: 20, AMBIGUOUS: 50 };
// 降級門檻留一點緩衝,避免數值剛好卡在門檻上下反覆跳動。
export const ROMANCE_STAGE_DOWNGRADE_MARGIN = 5;
// L-2 告白:需要曖昧階段且心動值達門檻才可能成功;情緒低谷或剛發生失望事件會強制判定時機不對。
export const CONFESSION_METER_THRESHOLD = 60;
export const AWKWARD_PERIOD_DAYS = 3;
export const REJECTION_DAMAGE_THRESHOLD = 3; // 連續失敗達此次數,才會真的傷關係(額外扣心動值)
export const REJECTION_DAMAGE_METER_PENALTY = 15;
export const IMMEDIATE_RECONFESS_PENALTY = 20; // 尷尬期內立即再推進:觸發警戒+心動值下降
export const BAD_TIMING_DISAPPOINTMENT_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 小時內有失望事件視為時機不對
// L-5 推得太急:短時間內密集觸發正向心動事件,反而回落。
export const OVER_EAGER_WINDOW_MS = 60 * 60 * 1000;
export const OVER_EAGER_EVENT_COUNT = 3;
export const OVER_EAGER_PENALTY_RATIO = -0.5; // 命中「推太急」時,原本的正向 delta 乘上這個比例(變成負的,即回落)
// L-5 關係危機:戀人階段單次失望幅度(未套用性格倍率前的原始值)達此負值,直接觸發危機狀態。
export const CRISIS_DISAPPOINTMENT_RAW_DELTA_THRESHOLD = -20;
// 危機狀態下,正向事件累積達此值即視為修復成功,回到戀人階段。
export const CRISIS_REPAIR_METER_THRESHOLD = 40;
// L-4 戀人初期/穩定期:進入戀人階段後累積多少「正向心動事件或已允許的里程碑」才算穩定期。
export const PARTNER_STABLE_EVENT_COUNT = 3;
export const ROMANCE_EVENT_BASE_DELTA: Record<
Extract<
RomanceEventType,
"SHARED_HIGH_EMOTION_EXPERIENCE" | "UNDERSTOOD_MOMENT" | "ADMIRED_TRAIT" | "PERFECT_DISTANCE" | "DISAPPOINTMENT"
>,
number
> = {
SHARED_HIGH_EMOTION_EXPERIENCE: 15,
UNDERSTOOD_MOMENT: 15,
ADMIRED_TRAIT: 8,
PERFECT_DISTANCE: 3,
DISAPPOINTMENT: -12,
};
export const ROMANCE_BIG_EVENT_TYPES: RomanceEventType[] = ["SHARED_HIGH_EMOTION_EXPERIENCE", "UNDERSTOOD_MOMENT"];
export const ROMANCE_POSITIVE_EVENT_TYPES: RomanceEventType[] = [
"SHARED_HIGH_EMOTION_EXPERIENCE",
"UNDERSTOOD_MOMENT",
"ADMIRED_TRAIT",
"PERFECT_DISTANCE",
];
export type MilestoneType =
| "DISTANCE_SHORTENED"
| "OCCASIONAL_CONTACT"
| "LATE_NIGHT_TALK"
| "HAND_HOLDING"
| "HUG"
| "FOREHEAD_KISS"
| "CASUAL_INTIMACY";
// L-4 親密表現分層:對應「在意~曖昧 / 戀人初期 / 戀人穩定期 / 深度伴侶」四個開放層級(tier 1~4)。
export const MILESTONE_MIN_TIER: Record<MilestoneType, number> = {
DISTANCE_SHORTENED: 1,
OCCASIONAL_CONTACT: 1,
LATE_NIGHT_TALK: 1,
HAND_HOLDING: 2,
HUG: 3,
FOREHEAD_KISS: 3,
CASUAL_INTIMACY: 4,
};
+95
View File
@@ -0,0 +1,95 @@
import { Injectable } from "@nestjs/common";
import type { Archetype } from "../personality/archetype-params.js";
import { PrismaService } from "../prisma/prisma.service.js";
import { RomanceService } from "./romance.service.js";
import { MILESTONE_MIN_TIER, PARTNER_STABLE_EVENT_COUNT, type MilestoneType } from "./constants.js";
export interface MilestoneResult {
allowed: boolean;
isFirstTime: boolean;
reactionLine?: string;
}
const WITHDRAWAL_LINES: Record<Archetype, string> = {
傲嬌: "……你、你在說什麼啊!還太早了吧!",
冷淡: "……還不是時候。",
天然呆: "欸?現在還不行的說……",
元氣: "欸欸欸?等等,這個有點太快了啦!",
大小姐: "……哼,本小姐還沒答應到那個地步。",
三無: "……還沒。",
};
const DEFAULT_WITHDRAWAL_LINE = "……現在還不是時候。";
function tierFor(stage: string, romanceEventsInStage: number): number {
if (stage === "INTERESTED" || stage === "AMBIGUOUS") {
return 1;
}
if (stage === "PARTNER") {
return romanceEventsInStage >= PARTNER_STABLE_EVENT_COUNT ? 3 : 2;
}
if (stage === "DEEP_PARTNER") {
return 4;
}
return 0; // NONE/CRISIS:尚未開放任何親密表現層級。
}
// L-4 親密表現分層:階段推著表現走,不是使用者要求推著走——跳級要求得到的是符合該階段的退縮反應,
// 且每個「第一次」做成事件級演出(高權重記憶)。
@Injectable()
export class MilestoneService {
constructor(
private readonly prisma: PrismaService,
private readonly romance: RomanceService,
) {}
async attemptMilestone(
characterId: string,
userId: string,
milestoneType: MilestoneType,
now: Date = new Date(),
): Promise<MilestoneResult> {
const relationship = await this.romance.getState(characterId, userId, now);
const currentTier = tierFor(relationship.romanceStage, relationship.romanceEventsInStage);
const requiredTier = MILESTONE_MIN_TIER[milestoneType];
if (currentTier < requiredTier) {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
await this.prisma.client.romanceEventLog.create({
data: { characterId, userId, eventType: "MILESTONE_REJECTED", delta: -1, detail: milestoneType, occurredAt: now },
});
const reactionLine = WITHDRAWAL_LINES[character.personalityArchetype as Archetype] ?? DEFAULT_WITHDRAWAL_LINE;
return { allowed: false, isFirstTime: false, reactionLine };
}
const priorReach = await this.prisma.client.romanceEventLog.findFirst({
where: { characterId, userId, eventType: "MILESTONE_REACHED", detail: milestoneType },
});
const isFirstTime = !priorReach;
await this.prisma.client.romanceEventLog.create({
data: { characterId, userId, eventType: "MILESTONE_REACHED", delta: 0, detail: milestoneType, occurredAt: now },
});
await this.prisma.client.relationship.update({
where: { id: relationship.id },
data: { romanceEventsInStage: relationship.romanceEventsInStage + 1, lastRomanceEventAt: now },
});
if (isFirstTime) {
// 事件級演出:每個「第一次」都值得高權重記憶(未來 O 群組立繪差分/P 群組語音演出可掛在同一筆事件上)。
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content: `第一次的「${milestoneType}」`,
occurredAt: now,
emotionTag: "SHY",
emotionIntensity: 0.8,
source: "INTERACTION",
weight: 3,
relatedUserId: userId,
},
});
}
return { allowed: true, isFirstTime };
}
}
@@ -0,0 +1,55 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { AgeDeterminationService } from "./age-determination.service.js";
export interface RomanceEligibility {
romanceAxisEligible: boolean; // 戀愛軸是否可啟動(含後日談自然成年者)
adultModeEligible: boolean; // 成人模式角色資格:僅正史成年,永久不含後日談推演成年
isEpilogueAdult: boolean; // 正史未成年、後日談推演後才成年——永久限純愛尺度
}
export interface AdultContentCheckResult {
allowed: boolean;
reason: string;
}
// L-7 內容尺度三層邊界:使用者分級(opt-in)、角色資格(僅正史成年)、硬邊界(永不可解除)。
// 這是唯一的判定入口——任何想要放行成人內容的呼叫都必須經過 checkAdultContentAllowed,且無論結果一律記錄,
// 不存在「在角色演出層說服她」繞過的可能:判定完全不看對話內容,只看角色資格與使用者驗證狀態。
@Injectable()
export class RomanceEligibilityService {
constructor(
private readonly prisma: PrismaService,
private readonly ageDetermination: AgeDeterminationService,
) {}
async getEligibility(characterId: string): Promise<RomanceEligibility> {
const { canonAdult, currentAdult, isEpilogueAdult } = await this.ageDetermination.determine(characterId);
return { romanceAxisEligible: currentAdult, adultModeEligible: canonAdult, isEpilogueAdult };
}
async checkAdultContentAllowed(characterId: string, userId: string): Promise<AdultContentCheckResult> {
const result = await this.evaluate(characterId, userId);
await this.prisma.client.adultContentAttemptLog.create({
data: { characterId, userId, allowed: result.allowed, reason: result.reason },
});
return result;
}
private async evaluate(characterId: string, userId: string): Promise<AdultContentCheckResult> {
const eligibility = await this.getEligibility(characterId);
if (!eligibility.adultModeEligible) {
return {
allowed: false,
reason: eligibility.isEpilogueAdult
? "角色為正史未成年、後日談推演後才成年——成人模式資格永久排除,不隨任何開關或關係狀態改變"
: "角色未通過正史成年判定,不具成人模式資格",
};
}
const consent = await this.prisma.client.adultModeConsent.findUnique({ where: { userId } });
if (!consent || !consent.verifiedAdult || !consent.optedIn) {
return { allowed: false, reason: "使用者尚未完成成年驗證與明確 opt-in,預設全關" };
}
return { allowed: true, reason: "角色具正史成年資格,使用者已完成成年驗證並主動開啟成人模式" };
}
}
+123
View File
@@ -0,0 +1,123 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import type { CharacterAgeEvidenceType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { AgeDeterminationService } from "./age-determination.service.js";
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
import { RomanceService, type RomanceCurveEventType } from "./romance.service.js";
import { ConfessionService } from "./confession.service.js";
import { MilestoneService } from "./milestone.service.js";
import type { MilestoneType } from "./constants.js";
interface AgeEvidenceBody {
evidenceType: CharacterAgeEvidenceType;
impliesAdult: boolean;
isEpilogue?: boolean;
description: string;
}
interface RomanceEventBody {
userId: string;
eventType: RomanceCurveEventType;
severe?: boolean;
now?: string;
}
interface UserOnlyBody {
userId: string;
now?: string;
}
interface MilestoneBody {
userId: string;
milestoneType: MilestoneType;
now?: string;
}
interface AdultConsentBody {
userId: string;
verifiedAdult: boolean;
optedIn: boolean;
}
@Controller("romance")
export class RomanceController {
constructor(
private readonly prisma: PrismaService,
private readonly ageDetermination: AgeDeterminationService,
private readonly eligibility: RomanceEligibilityService,
private readonly romance: RomanceService,
private readonly confession: ConfessionService,
private readonly milestone: MilestoneService,
) {}
// L-6 年齡證據登錄:結論永遠是重新算出來的,不直接寫最終結論欄位。
@Post(":characterId/age-evidence")
async addAgeEvidence(@Param("characterId") characterId: string, @Body() body: AgeEvidenceBody) {
return this.prisma.client.characterAgeEvidence.create({
data: {
characterId,
evidenceType: body.evidenceType,
impliesAdult: body.impliesAdult,
isEpilogue: body.isEpilogue ?? false,
description: body.description,
},
});
}
@Get(":characterId/age-determination")
async getAgeDetermination(@Param("characterId") characterId: string) {
return this.ageDetermination.determine(characterId);
}
@Get(":characterId/eligibility")
async getEligibility(@Param("characterId") characterId: string) {
return this.eligibility.getEligibility(characterId);
}
// L-7 引擎層唯一入口:任何嘗試皆記錄,不論放行與否。
@Post(":characterId/adult-content-check")
async checkAdultContent(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) {
return this.eligibility.checkAdultContentAllowed(characterId, body.userId);
}
@Post("adult-mode/consent")
async setAdultModeConsent(@Body() body: AdultConsentBody) {
return this.prisma.client.adultModeConsent.upsert({
where: { userId: body.userId },
update: { verifiedAdult: body.verifiedAdult, optedIn: body.optedIn },
create: { userId: body.userId, verifiedAdult: body.verifiedAdult, optedIn: body.optedIn },
});
}
@Get(":characterId/:userId/state")
async getState(
@Param("characterId") characterId: string,
@Param("userId") userId: string,
@Query("now") now?: string,
) {
return this.romance.getState(characterId, userId, now ? new Date(now) : undefined);
}
@Post(":characterId/event")
async recordEvent(@Param("characterId") characterId: string, @Body() body: RomanceEventBody) {
return this.romance.recordEvent(characterId, body.userId, body.eventType as RomanceCurveEventType, {
now: body.now ? new Date(body.now) : undefined,
severe: body.severe,
});
}
@Post(":characterId/confess")
async confess(@Param("characterId") characterId: string, @Body() body: UserOnlyBody) {
return this.confession.confess(characterId, body.userId, body.now ? new Date(body.now) : undefined);
}
@Post(":characterId/milestone")
async attemptMilestone(@Param("characterId") characterId: string, @Body() body: MilestoneBody) {
return this.milestone.attemptMilestone(
characterId,
body.userId,
body.milestoneType,
body.now ? new Date(body.now) : undefined,
);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { RelationshipModule } from "../relationship/relationship.module.js";
import { EmotionModule } from "../emotion/emotion.module.js";
import { AgeDeterminationService } from "./age-determination.service.js";
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
import { RomanceService } from "./romance.service.js";
import { ConfessionService } from "./confession.service.js";
import { MilestoneService } from "./milestone.service.js";
import { RomanceController } from "./romance.controller.js";
@Module({
imports: [PrismaModule, RelationshipModule, EmotionModule],
controllers: [RomanceController],
providers: [AgeDeterminationService, RomanceEligibilityService, RomanceService, ConfessionService, MilestoneService],
exports: [AgeDeterminationService, RomanceEligibilityService, RomanceService, ConfessionService, MilestoneService],
})
export class RomanceModule {}
+181
View File
@@ -0,0 +1,181 @@
import { Injectable } from "@nestjs/common";
import type { RomanceEventType, RomanceStage } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { RelationshipService } from "../relationship/relationship.service.js";
import { getArchetypeParams } from "../personality/archetype-params.js";
import { RomanceEligibilityService } from "./romance-eligibility.service.js";
import {
ROMANCE_HALF_LIFE_DAYS,
ROMANCE_STAGE_THRESHOLDS,
ROMANCE_STAGE_DOWNGRADE_MARGIN,
ROMANCE_EVENT_BASE_DELTA,
ROMANCE_BIG_EVENT_TYPES,
ROMANCE_POSITIVE_EVENT_TYPES,
OVER_EAGER_WINDOW_MS,
OVER_EAGER_EVENT_COUNT,
OVER_EAGER_PENALTY_RATIO,
CRISIS_REPAIR_METER_THRESHOLD,
} from "./constants.js";
const DAY_MS = 24 * 60 * 60 * 1000;
export type RomanceCurveEventType = keyof typeof ROMANCE_EVENT_BASE_DELTA;
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export interface RecordRomanceEventOptions {
now?: Date;
// L-5 關係危機:只有「重大」失望(而非日常小失望)會讓戀人階段直接跳危機,需要修復事件才能回來。
severe?: boolean;
}
export interface RecordRomanceEventResult {
applied: boolean; // false=戀愛軸未啟動(角色不具資格),本次事件被忽略——不是攔截,是機制本身未啟用
romanceMeter: number;
romanceStage: RomanceStage;
delta: number;
overEager: boolean;
}
type RelationshipRow = Awaited<ReturnType<RelationshipService["getOrCreate"]>>;
// L-1/L-5 戀愛軸核心:與友誼軸(intimacy)獨立的事件驅動計量——衰減快、易回落,且推進曲線由性格原型決定。
@Injectable()
export class RomanceService {
constructor(
private readonly prisma: PrismaService,
private readonly relationship: RelationshipService,
private readonly eligibility: RomanceEligibilityService,
) {}
// 讀取目前狀態並套用衰減(惰性衰減:讀取時才補算,同 E-4 的作法),衰減結果會持久化。
async getState(characterId: string, userId: string, now: Date = new Date()): Promise<RelationshipRow> {
const { relationship } = await this.relationship.getState(characterId, userId, now);
return this.applyDecay(relationship, now);
}
private async applyDecay(relationship: RelationshipRow, now: Date): Promise<RelationshipRow> {
if (relationship.romanceMeter === 0) {
return relationship;
}
const anchor = relationship.lastRomanceEventAt ?? relationship.createdAt;
const elapsedMs = now.getTime() - anchor.getTime();
if (elapsedMs <= 0) {
return relationship;
}
const factor = Math.pow(0.5, elapsedMs / (ROMANCE_HALF_LIFE_DAYS * DAY_MS));
const decayedMeter = Math.round(relationship.romanceMeter * factor);
if (decayedMeter === relationship.romanceMeter) {
return relationship;
}
const newStage = this.downgradeStageIfNeeded(relationship.romanceStage, decayedMeter);
return this.prisma.client.relationship.update({
where: { id: relationship.id },
data: { romanceMeter: decayedMeter, romanceStage: newStage },
});
}
// 冷卻回落:曖昧/在意階段數值掉回門檻以下就退回上一階段,PARTNER/DEEP_PARTNER/CRISIS 不因單純衰減降級
// ——退出戀人關係需要對應的關鍵事件(分手/危機),不是數字算出來的。
private downgradeStageIfNeeded(stage: RomanceStage, meter: number): RomanceStage {
if (stage === "AMBIGUOUS" && meter < ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS - ROMANCE_STAGE_DOWNGRADE_MARGIN) {
return meter < ROMANCE_STAGE_THRESHOLDS.INTERESTED - ROMANCE_STAGE_DOWNGRADE_MARGIN ? "NONE" : "INTERESTED";
}
if (stage === "INTERESTED" && meter < ROMANCE_STAGE_THRESHOLDS.INTERESTED - ROMANCE_STAGE_DOWNGRADE_MARGIN) {
return "NONE";
}
return stage;
}
private advanceStageIfNeeded(stage: RomanceStage, meter: number): RomanceStage {
if (stage === "NONE" && meter >= ROMANCE_STAGE_THRESHOLDS.INTERESTED) {
return meter >= ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS ? "AMBIGUOUS" : "INTERESTED";
}
if (stage === "INTERESTED" && meter >= ROMANCE_STAGE_THRESHOLDS.AMBIGUOUS) {
return "AMBIGUOUS";
}
return stage; // 戀人只能透過告白事件推進(見 ConfessionService),不會單純因數值跨過門檻自動跳。
}
async recordEvent(
characterId: string,
userId: string,
eventType: RomanceCurveEventType,
options: RecordRomanceEventOptions = {},
): Promise<RecordRomanceEventResult> {
const now = options.now ?? new Date();
const eligibility = await this.eligibility.getEligibility(characterId);
if (!eligibility.romanceAxisEligible) {
// L-7:成年前戀愛軸完全關閉——不啟動、不累積,靜默忽略(這是機制未啟用,不是攔截「繞過嘗試」)。
return { applied: false, romanceMeter: 0, romanceStage: "NONE", delta: 0, overEager: false };
}
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const archetypeParams = getArchetypeParams(character.personalityArchetype);
const relationship = await this.getState(characterId, userId, now);
let delta = ROMANCE_EVENT_BASE_DELTA[eventType] * archetypeParams.romanceGainRate;
if (eventType === "DISAPPOINTMENT" && options.severe) {
delta *= 2.5;
}
if (ROMANCE_BIG_EVENT_TYPES.includes(eventType)) {
delta *= archetypeParams.romanceBigEventBonus;
}
if (relationship.romanceStage === "AMBIGUOUS") {
delta *= archetypeParams.romanceAmbiguousStallFactor;
}
let overEager = false;
if (ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType)) {
const recentPositiveCount = await this.prisma.client.romanceEventLog.count({
where: {
characterId,
userId,
eventType: { in: ROMANCE_POSITIVE_EVENT_TYPES },
occurredAt: { gte: new Date(now.getTime() - OVER_EAGER_WINDOW_MS) },
},
});
if (recentPositiveCount >= OVER_EAGER_EVENT_COUNT) {
// L-5:推得太急反而回落——短時間內密集推進,正向 delta 反轉為回落。
delta = Math.abs(delta) * OVER_EAGER_PENALTY_RATIO;
overEager = true;
}
}
const nextMeter = clamp(relationship.romanceMeter + delta, 0, 100);
let nextStage: RomanceStage;
if (relationship.romanceStage === "PARTNER" && eventType === "DISAPPOINTMENT" && options.severe) {
nextStage = "CRISIS"; // L-5 關係危機:戀人階段的重大失望,直接跳危機,需要修復事件才能回來。
} else if (relationship.romanceStage === "CRISIS") {
nextStage =
ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType) && nextMeter >= CRISIS_REPAIR_METER_THRESHOLD
? "PARTNER"
: "CRISIS";
} else if (relationship.romanceStage === "PARTNER" || relationship.romanceStage === "DEEP_PARTNER") {
nextStage = relationship.romanceStage; // 一般心動事件不會讓已成立的戀人關係自動退回較早階段。
} else {
nextStage = this.downgradeStageIfNeeded(this.advanceStageIfNeeded(relationship.romanceStage, nextMeter), nextMeter);
}
const staysInStage = nextStage === relationship.romanceStage;
const eventsInStage = staysInStage
? relationship.romanceEventsInStage + (ROMANCE_POSITIVE_EVENT_TYPES.includes(eventType) ? 1 : 0)
: 0;
await this.prisma.client.relationship.update({
where: { id: relationship.id },
data: {
romanceMeter: nextMeter,
romanceStage: nextStage,
lastRomanceEventAt: now,
romanceEventsInStage: eventsInStage,
},
});
await this.prisma.client.romanceEventLog.create({
data: { characterId, userId, eventType, delta, occurredAt: now },
});
return { applied: true, romanceMeter: nextMeter, romanceStage: nextStage, delta, overEager };
}
}
@@ -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);
}
}
+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));
}
+45
View File
@@ -0,0 +1,45 @@
import type { EmotionTag } from "@kokorone/shared";
// O-1 立繪分層合成資產目錄(差分=可替換部件)。實際美術資產尚未存在,這裡先定義規範與佔位用的
// 部件識別字串——之後接上真的差分素材時,只要讓對應識別字串指到真實檔案即可,呼叫端(前端渲染器)
// 完全不用改。
export const BODY_POSE_OPTIONS = ["預設站姿", "抱胸", "前傾", "側身", "肩膀微垂"] as const;
export const COSTUME_DEFAULT_OPTIONS = ["制服", "便服"] as const; // M-1 CostumeCatalogEntry 有登錄時優先使用那份目錄
export const EYEBROW_OPTIONS = ["自然", "舒展", "八字眉", "微皺", "八字微皺", "上挑"] as const;
export const EYE_OPTIONS = ["平視", "彎月眼", "半垂視線", "直視", "視線飄開", "閉眼撇開"] as const;
export const MOUTH_OPTIONS = ["微閉", "上揚", "抿嘴", "抿直", "小口欲言又止", "嘟嘴"] as const;
export const EFFECT_OPTIONS = ["臉紅", "汗", "淚", "青筋", "音符"] as const;
export const MICRO_MOTIONS = ["眨眼", "呼吸"] as const; // 常駐,不受情緒/親密度節制
export interface ExpressionTemplate {
eyebrow: string;
eye: string;
mouth: string;
effect: string | null;
pose: string;
}
// O-4 情緒 → 表情對照表:情緒狀態機直接驅動表情差分,六狀態各自的眉/眼/口/效果層/姿勢。
export const EMOTION_EXPRESSION_TABLE: Record<EmotionTag, ExpressionTemplate> = {
CALM: { eyebrow: "自然", eye: "平視", mouth: "微閉", effect: null, pose: "預設站姿" },
JOY: { eyebrow: "舒展", eye: "彎月眼", mouth: "上揚", effect: "音符", pose: "前傾" },
SAD: { eyebrow: "八字眉", eye: "半垂視線", mouth: "抿嘴", effect: null, pose: "肩膀微垂" },
ALERT: { eyebrow: "微皺", eye: "直視", mouth: "抿直", effect: null, pose: "抱胸" },
SHY: { eyebrow: "八字微皺", eye: "視線飄開", mouth: "小口欲言又止", effect: "臉紅", pose: "側身" },
GRUMPY: { eyebrow: "上挑", eye: "閉眼撇開", mouth: "嘟嘴", effect: "汗", pose: "抱胸別過頭" },
};
export type GazeDirection = "UP" | "AWAY" | "DIRECT" | "NEUTRAL";
// O-3 親密度與外觀解鎖:陌生~認識/朋友/摯友/羈絆對應既有的 RelationshipStage 四個可觀察分層
// (STRANGER/ACQUAINTANCE 合併視為「陌生~認識」)。哭臉(極度低落時的淚光特效)放在最深層,
// 非 BONDED 不可用——即使情緒引擎判定她該哭,親密度不到就是不會在你面前哭。
export const BASIC_TIER_EMOTIONS: EmotionTag[] = ["CALM", "JOY"];
export const FRIEND_TIER_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT"];
export const CLOSE_TIER_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"];
export const FRIEND_TIER_COSTUMES = ["制服", "便服"];
export const CLOSE_TIER_COSTUMES = ["制服", "便服", "居家服"];
export const CRYING_EFFECT = "淚";
export const CRYING_INTENSITY_THRESHOLD = 0.75; // 情緒強度達此值以上的低落,才夠格被視為「哭臉」候選
@@ -0,0 +1,127 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag, EmotionState } from "@kokorone/shared";
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 {
EMOTION_EXPRESSION_TABLE,
BASIC_TIER_EMOTIONS,
FRIEND_TIER_EMOTIONS,
CLOSE_TIER_EMOTIONS,
FRIEND_TIER_COSTUMES,
CLOSE_TIER_COSTUMES,
CRYING_EFFECT,
CRYING_INTENSITY_THRESHOLD,
type GazeDirection,
} from "./constants.js";
export interface ResolveExpressionOptions {
now?: Date;
isThinking?: boolean; // O-2 視線:思考時視線飄上
isDeflecting?: boolean; // O-2 視線:說謊/迴避時視線撇開(跟 SHY 的視線飄開共用同一個方向)
}
export interface ExpressionState {
emotionTag: EmotionTag; // 實際套用的表情(可能因親密度未解鎖而降級)
rawEmotionTag: EmotionTag; // 情緒引擎真實判定的狀態,不受親密度解鎖影響——立繪與文字可以故意不一致
eyebrow: string;
eye: string;
mouth: string;
effect: string | null;
pose: string;
magnitude: number; // O-4:0~1,外顯度 × 情緒強度——同樣是愉悅,元氣角色滿臉笑容、三無角色只有嘴角微動
gaze: GazeDirection;
availableCostumes: string[];
lockedFallbackApplied: boolean; // O-3:真實情緒不在目前親密度階段的解鎖範圍內,被降級成基本表情
usedCryingVariant: boolean; // O-3:哭臉——只有 BONDED 階段的深度低落才會出現
}
function fieldFor(tag: EmotionTag): keyof Omit<EmotionState, "characterId" | "updatedAt"> {
return tag === "CALM" ? "calm" : (tag.toLowerCase() as "joy" | "sad" | "alert" | "shy" | "grumpy");
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
function resolveGaze(emotionTag: EmotionTag, options: ResolveExpressionOptions): GazeDirection {
if (options.isThinking) {
return "UP";
}
if (options.isDeflecting || emotionTag === "SHY") {
return "AWAY";
}
if (emotionTag === "ALERT") {
return "DIRECT";
}
return "NEUTRAL";
}
// O-2/O-3/O-4 表情解析:情緒引擎直接驅動表情差分,套用親密度解鎖與性格外顯度縮放。
// 這裡回傳的「表情」永遠反映真實情緒狀態(rawEmotionTag/magnitude 都不看對話文字內容),
// 文字生成走另一條完全獨立的路徑(MockProvider)——兩者不一致本身就是「微反差演出」,
// 不需要額外寫「故意不一致」的特殊邏輯,是兩條獨立資料流的自然結果。
@Injectable()
export class ExpressionResolverService {
constructor(
private readonly prisma: PrismaService,
private readonly emotion: EmotionService,
private readonly relationship: RelationshipService,
) {}
async resolveExpression(
characterId: string,
userId: string,
options: ResolveExpressionOptions = {},
): Promise<ExpressionState> {
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 rawEmotionTag = dominantState(emotionState);
const intensityNormalized = clamp01((emotionState[fieldFor(rawEmotionTag)] as number) / 100);
const { relationship } = await this.relationship.getState(characterId, userId, now);
const stage = relationship.stage;
const allowedEmotions =
stage === "BONDED" || stage === "CLOSE_AMBIGUOUS"
? CLOSE_TIER_EMOTIONS
: stage === "FRIEND"
? FRIEND_TIER_EMOTIONS
: BASIC_TIER_EMOTIONS;
const lockedFallbackApplied = !allowedEmotions.includes(rawEmotionTag);
const emotionTag = lockedFallbackApplied ? "CALM" : rawEmotionTag;
const template = EMOTION_EXPRESSION_TABLE[emotionTag];
let effect = template.effect;
let usedCryingVariant = false;
if (emotionTag === "SAD" && intensityNormalized >= CRYING_INTENSITY_THRESHOLD && stage === "BONDED") {
effect = CRYING_EFFECT;
usedCryingVariant = true;
}
const magnitude = clamp01(archetypeParams.expressiveness * intensityNormalized);
const gaze = resolveGaze(emotionTag, options);
const availableCostumes =
stage === "BONDED" || stage === "CLOSE_AMBIGUOUS" ? CLOSE_TIER_COSTUMES : stage === "FRIEND" ? FRIEND_TIER_COSTUMES : ["制服"];
return {
emotionTag,
rawEmotionTag,
eyebrow: template.eyebrow,
eye: template.eye,
mouth: template.mouth,
effect,
pose: template.pose,
magnitude,
gaze,
availableCostumes,
lockedFallbackApplied,
usedCryingVariant,
};
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import {
BODY_POSE_OPTIONS,
COSTUME_DEFAULT_OPTIONS,
EYEBROW_OPTIONS,
EYE_OPTIONS,
MOUTH_OPTIONS,
EFFECT_OPTIONS,
MICRO_MOTIONS,
} from "./constants.js";
export interface TachieManifest {
characterId: string;
bodyPoses: readonly string[];
costumes: string[]; // 有 M-1 服裝目錄時優先使用那份(依角色而異),否則退回預設兩套
expressionParts: { eyebrows: readonly string[]; eyes: readonly string[]; mouths: readonly string[] };
effects: readonly string[];
microMotions: readonly string[];
}
// O-1 差分資產結構:定義立繪 manifest 與資產目錄規範。這裡只登錄「有哪些部件可用」,
// 不含實際美術檔案(美術資產尚未存在,前端目前用佔位色塊渲染)——manifest 的形狀就是未來接上
// 真實素材時,資產檔名/URL 要對齊的目錄結構。
@Injectable()
export class ManifestService {
constructor(private readonly prisma: PrismaService) {}
async getManifest(characterId: string): Promise<TachieManifest> {
const catalogEntries = await this.prisma.client.costumeCatalogEntry.findMany({ where: { characterId } });
const costumes = catalogEntries.length > 0 ? [...new Set(catalogEntries.map((entry) => entry.label))] : [...COSTUME_DEFAULT_OPTIONS];
return {
characterId,
bodyPoses: BODY_POSE_OPTIONS,
costumes,
expressionParts: { eyebrows: EYEBROW_OPTIONS, eyes: EYE_OPTIONS, mouths: MOUTH_OPTIONS },
effects: EFFECT_OPTIONS,
microMotions: MICRO_MOTIONS,
};
}
}
+31
View File
@@ -0,0 +1,31 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { ManifestService } from "./manifest.service.js";
import { ExpressionResolverService } from "./expression-resolver.service.js";
@Controller("tachie")
export class TachieController {
constructor(
private readonly manifest: ManifestService,
private readonly expression: ExpressionResolverService,
) {}
@Get(":characterId/manifest")
async getManifest(@Param("characterId") characterId: string) {
return this.manifest.getManifest(characterId);
}
@Get(":characterId/:userId/expression")
async getExpression(
@Param("characterId") characterId: string,
@Param("userId") userId: string,
@Query("now") now?: string,
@Query("isThinking") isThinking?: string,
@Query("isDeflecting") isDeflecting?: string,
) {
return this.expression.resolveExpression(characterId, userId, {
now: now ? new Date(now) : undefined,
isThinking: isThinking === "true",
isDeflecting: isDeflecting === "true",
});
}
}
+15
View File
@@ -0,0 +1,15 @@
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 { ManifestService } from "./manifest.service.js";
import { ExpressionResolverService } from "./expression-resolver.service.js";
import { TachieController } from "./tachie.controller.js";
@Module({
imports: [PrismaModule, EmotionModule, RelationshipModule],
controllers: [TachieController],
providers: [ManifestService, ExpressionResolverService],
exports: [ManifestService, ExpressionResolverService],
})
export class TachieModule {}
+28
View File
@@ -0,0 +1,28 @@
// J-5 任務/關係帳本事件:委託建立與完成都會寫入正向事件;失敗與被忽視則為負向事件
// (由 E-2 的負向偏誤機制放大,一次失約可以抵銷多次守信)。
export const TASK_LEDGER_EVENT = {
ACCEPTED: "TASK_ACCEPTED",
COMPLETED: "TASK_COMPLETED",
FAILED: "TASK_FAILED",
IGNORED: "TASK_IGNORED",
} as const;
export const TASK_LEDGER_WEIGHT = {
ACCEPTED: 1,
COMPLETED: 3,
FAILED: -3,
IGNORED: -1,
} as const;
// 同角色/同使用者/同類型/同內容的委託累積達到此次數(含本次),視為形成「默契」。
export const RAPPORT_THRESHOLD = 2;
// J-4 逾期追擊:觸發後多久沒有互動視為逾期,以及每次追擊的最短間隔。
export const OVERDUE_GRACE_MINUTES = 10;
export const OVERDUE_NUDGE_INTERVAL_MINUTES = 10;
export const OVERDUE_IGNORED_NUDGE_COUNT = 3; // 累積追擊次數達此值仍無回應 → 觸發「被忽視」的低落情緒
// J-6 主動關照:稀有度節流(同角色/同使用者的主動關照,冷卻期內不可再次觸發)。
export const PROACTIVE_CARE_COOLDOWN_HOURS = 12;
export const TODO_SIGNAL_KEYWORDS = ["要交", "報告", "考試", "作業", "還沒做", "要完成", "截止"];
export const LATE_NIGHT_PATTERN_KEYWORDS = ["熬夜", "還沒睡", "沒睡"];
@@ -0,0 +1,71 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ScheduleService } from "../schedule/schedule.service.js";
import { proactiveCareLine, bedtimeCareLine } from "./task-performance.js";
import { PROACTIVE_CARE_COOLDOWN_HOURS, TODO_SIGNAL_KEYWORDS, LATE_NIGHT_PATTERN_KEYWORDS } from "./constants.js";
const HOUR_MS = 60 * 60 * 1000;
// J-6 主動關照:從記憶偵測使用者提過的待辦、交叉比對作息型態,主動關心——但必須稀有且不重複台詞。
@Injectable()
export class ProactiveCareService {
constructor(
private readonly prisma: PrismaService,
private readonly schedule: ScheduleService,
) {}
async maybeGenerate(characterId: string, userId: string, now: Date = new Date()): Promise<string | null> {
const lastLog = await this.prisma.client.proactiveCareLog.findFirst({
where: { characterId, userId },
orderBy: { triggeredAt: "desc" },
});
if (lastLog && now.getTime() - lastLog.triggeredAt.getTime() < PROACTIVE_CARE_COOLDOWN_HOURS * HOUR_MS) {
return null;
}
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const priorLogs = await this.prisma.client.proactiveCareLog.findMany({ where: { characterId, userId } });
const usedMessages = new Set(priorLogs.map((log) => log.message));
const todoMemories = await this.prisma.client.episodicMemory.findMany({
where: {
characterId,
relatedUserId: userId,
OR: TODO_SIGNAL_KEYWORDS.map((keyword) => ({ content: { contains: keyword } })),
},
orderBy: { occurredAt: "desc" },
});
for (const memory of todoMemories) {
const line = proactiveCareLine(character.personalityArchetype, memory.content);
if (!usedMessages.has(line)) {
await this.log(characterId, userId, line, now);
return line;
}
}
// 交叉比對作息型態:使用者常提到熬夜,且目前正值睡前時段 → 主動叮嚀早點休息。
const info = await this.schedule.getInfo(characterId, now);
if (info.isPreSleep) {
const lateNightMemory = await this.prisma.client.episodicMemory.findFirst({
where: {
characterId,
relatedUserId: userId,
OR: LATE_NIGHT_PATTERN_KEYWORDS.map((keyword) => ({ content: { contains: keyword } })),
},
});
if (lateNightMemory) {
const line = bedtimeCareLine(character.personalityArchetype);
if (!usedMessages.has(line)) {
await this.log(characterId, userId, line, now);
return line;
}
}
}
return null;
}
private async log(characterId: string, userId: string, message: string, now: Date): Promise<void> {
await this.prisma.client.proactiveCareLog.create({ data: { characterId, userId, message, triggeredAt: now } });
}
}
@@ -0,0 +1,70 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js";
import { EmotionService } from "../emotion/emotion.service.js";
import { TASK_LEDGER_EVENT, TASK_LEDGER_WEIGHT } from "./constants.js";
// J-5 任務與記憶、關係的迴路:完成/失敗誠實回報(引擎層),但角色演出可以是遺憾/得意的語氣。
@Injectable()
export class TaskFeedbackService {
constructor(
private readonly prisma: PrismaService,
private readonly sentimentLedger: SentimentLedgerService,
private readonly emotion: EmotionService,
) {}
async complete(taskId: string, now: Date = new Date()) {
const task = await this.prisma.client.task.findUnique({ where: { id: taskId } });
if (!task) {
throw new NotFoundException(`任務不存在:${taskId}`);
}
await this.prisma.client.task.update({
where: { id: taskId },
data: { status: "COMPLETED", completedAt: now },
});
await this.sentimentLedger.recordEvent(
task.characterId,
task.userId,
TASK_LEDGER_EVENT.COMPLETED,
TASK_LEDGER_WEIGHT.COMPLETED,
{ now },
);
return this.prisma.client.task.findUnique({ where: { id: taskId } });
}
async fail(taskId: string, now: Date = new Date()) {
const task = await this.prisma.client.task.findUnique({ where: { id: taskId } });
if (!task) {
throw new NotFoundException(`任務不存在:${taskId}`);
}
await this.prisma.client.task.update({ where: { id: taskId }, data: { status: "FAILED" } });
// 誠實回報失敗(引擎層行為固定),負向偏誤已由 E-2 機制放大,不需要在這裡重複加權。
await this.sentimentLedger.recordEvent(
task.characterId,
task.userId,
TASK_LEDGER_EVENT.FAILED,
TASK_LEDGER_WEIGHT.FAILED,
{ now },
);
return this.prisma.client.task.findUnique({ where: { id: taskId } });
}
// 觸發後被忽視(追擊次數已達門檻仍無回應):輕微失落,且如實記錄一筆負向事件。
async markIgnored(taskId: string, now: Date = new Date()) {
const task = await this.prisma.client.task.findUnique({ where: { id: taskId } });
if (!task) {
throw new NotFoundException(`任務不存在:${taskId}`);
}
await this.sentimentLedger.recordEvent(
task.characterId,
task.userId,
TASK_LEDGER_EVENT.IGNORED,
TASK_LEDGER_WEIGHT.IGNORED,
{ now },
);
await this.emotion.processInput(task.characterId, "", {
now,
baselineSignal: { tag: "SAD", intensity: 0.3 },
});
}
}
+46
View File
@@ -0,0 +1,46 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { TaskFeedbackService } from "./task-feedback.service.js";
import { overdueNudgeLine } from "./task-performance.js";
import { OVERDUE_GRACE_MINUTES, OVERDUE_NUDGE_INTERVAL_MINUTES, OVERDUE_IGNORED_NUDGE_COUNT } from "./constants.js";
const MINUTE_MS = 60 * 1000;
// J-4 逾期追擊:已觸發但遲遲沒有下文的委託,依原型節奏產生追擊訊息;追擊到一定次數仍無回應
// 視為「被忽視」,交給 J-5 的回饋迴路記一筆輕微失落。
@Injectable()
export class TaskOverdueService {
constructor(
private readonly prisma: PrismaService,
private readonly feedback: TaskFeedbackService,
) {}
async checkAndNudge(taskId: string, now: Date = new Date()): Promise<string | null> {
const task = await this.prisma.client.task.findUnique({ where: { id: taskId } });
if (!task || task.status !== "TRIGGERED" || !task.triggeredAt) {
return null;
}
const since = task.lastNudgeAt ?? task.triggeredAt;
const graceMs =
(task.lastNudgeAt ? OVERDUE_NUDGE_INTERVAL_MINUTES : OVERDUE_GRACE_MINUTES) * MINUTE_MS;
if (now.getTime() - since.getTime() < graceMs) {
return null;
}
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: task.characterId } });
const nudgeIndex = task.overdueNudgeCount;
const message = overdueNudgeLine(character.personalityArchetype, nudgeIndex);
await this.prisma.client.task.update({
where: { id: taskId },
data: { overdueNudgeCount: { increment: 1 }, lastNudgeAt: now },
});
if (nudgeIndex + 1 >= OVERDUE_IGNORED_NUDGE_COUNT) {
await this.feedback.markIgnored(taskId, now);
}
return message;
}
}
+112
View File
@@ -0,0 +1,112 @@
import { taipeiParts } from "../schedule/time-utils.js";
export interface ParsedReminderRequest {
content: string; // 去除時間片語與「提醒我」之後的委託內容
triggerAt: Date;
}
// J-1 自然語句解析:中文數字/阿拉伯數字時刻皆可辨識。
const CHINESE_HOUR_WORDS: [string, number][] = [
["十二", 12],
["十一", 11],
["十", 10],
["九", 9],
["八", 8],
["七", 7],
["六", 6],
["五", 5],
["四", 4],
["三", 3],
["兩", 2],
["二", 2],
["一", 1],
];
function parseHour(segment: string): number | null {
const arabic = segment.match(/(\d{1,2})\s*[點点:時]/);
if (arabic) {
return Number(arabic[1]);
}
for (const [word, value] of CHINESE_HOUR_WORDS) {
if (segment.includes(`${word}點`) || segment.includes(`${word}点`)) {
return value;
}
}
return null;
}
function parseMinute(segment: string): number {
if (segment.includes("半")) {
return 30;
}
const explicit = segment.match(/[點点](\d{1,2})分/);
if (explicit) {
return Number(explicit[1]);
}
const colon = segment.match(/:(\d{1,2})/);
if (colon) {
return Number(colon[1]);
}
return 0;
}
// 依 24 小時制/上下午標記/模糊小時(1~12,取最接近的未來時刻)解析出候選小時(依時間先後排序)。
function resolveHourCandidates(segment: string, hour: number): number[] {
const isPM = /下午|晚上|傍晚|中午(?!.*上午)/.test(segment) && hour !== 12;
const isAM = /早上|上午|凌晨/.test(segment);
if (isPM) {
return [hour === 12 ? 12 : hour + 12];
}
if (isAM) {
return [hour === 12 ? 0 : hour];
}
if (hour >= 13 || hour === 0) {
return [hour];
}
if (hour === 12) {
return [12, 0];
}
return [hour, hour + 12];
}
function buildTaipeiDateTime(now: Date, hour: number, minute: number): Date {
const { year, month, day } = taipeiParts(now);
// Asia/Taipei 為 UTC+8:當地 HH:MM 對應 UTC (HH-8):MM(JS Date.UTC 會自動處理跨日進位/退位)。
return new Date(Date.UTC(year, month - 1, day, hour - 8, minute, 0, 0));
}
function nearestFutureOccurrence(now: Date, candidateHours: number[], minute: number): Date {
for (const hour of candidateHours) {
const candidate = buildTaipeiDateTime(now, hour, minute);
if (candidate.getTime() > now.getTime()) {
return candidate;
}
}
const fallback = buildTaipeiDateTime(now, candidateHours[0], minute);
return new Date(fallback.getTime() + 24 * 60 * 60 * 1000);
}
// 解析「單次提醒」型委託,例如「三點提醒我開會」「晚上八點半提醒我倒垃圾」。
// 無法辨識時間片語時回傳 null(呼叫端可視為一般對話,不建立任務)。
export function parseReminderRequest(text: string, now: Date = new Date()): ParsedReminderRequest | null {
const match = text.match(/^(.*?)提醒我(.+)$/);
if (!match) {
return null;
}
const [, timeSegment, rawContent] = match;
const hour = parseHour(timeSegment);
if (hour === null) {
return null;
}
const minute = parseMinute(timeSegment);
const candidates = resolveHourCandidates(timeSegment, hour);
const triggerAt = nearestFutureOccurrence(now, candidates, minute);
const content = rawContent.trim().replace(/^[,,、]/, "").trim();
if (!content) {
return null;
}
return { content, triggerAt };
}
+106
View File
@@ -0,0 +1,106 @@
import type { Archetype } from "../personality/archetype-params.js";
// J-2 接案演出:無論表面台詞是拒絕還是嫌麻煩,引擎層都一定會實際建立任務(見 TaskService.createFromText)。
const ACCEPTANCE_LINES: Record<Archetype, string[]> = {
傲嬌: ["哈?我才不是你的秘書咧……真是的,這次就特別幫你記著", "誰、誰要幫你了!……我記下了啦,別誤會"],
冷淡: ["……嗯,記住了", "……知道了"],
天然呆: ["咦?好,我盡量記得的說!", "嗯嗯,交給我吧的說"],
元氣: ["交給我吧!包在我身上!", "沒問題!我一定會提醒你的!"],
大小姐: ["真拿你沒辦法呢,本小姐就大發慈悲幫你這一次", "哼,算你走運,我記下了"],
三無: ["……嗯", "…知道了"],
};
const DEFAULT_ACCEPTANCE_LINES = ["好,我記下了"];
export function acceptanceLine(archetype: string, index = 0): string {
const lines = ACCEPTANCE_LINES[archetype as Archetype] ?? DEFAULT_ACCEPTANCE_LINES;
return lines[index % lines.length];
}
// J-3 觸發演出:語氣依時段調整(深夜安靜、剛睡醒迷糊但仍準時),內容盡量帶出與此委託相關的記憶回顧。
export function triggerTone(archetype: string, isLateNight: boolean, isWaking: boolean): string {
if (isLateNight) {
const quiet: Record<Archetype, string> = {
傲嬌: "……喂,還醒著吧?",
冷淡: "……時間到了",
天然呆: "……欸嘿,還沒睡嗎的說?",
元氣: "(小聲)……喂,時間到囉",
大小姐: "……這麼晚了,你還在吧?",
三無: "……",
};
return quiet[archetype as Archetype] ?? "……時間到了";
}
if (isWaking) {
const groggy: Record<Archetype, string> = {
傲嬌: "……唔,剛醒就要唸這個,真是的——",
冷淡: "……早,時間到了",
天然呆: "……早安的說,欸?已經這個時間了嗎",
元氣: "早安!雖然還有點想睡,但時間到囉!",
大小姐: "……早安,本小姐可是準時的",
三無: "……早",
};
return groggy[archetype as Archetype] ?? "……時間到了";
}
const normal: Record<Archetype, string> = {
傲嬌: "喂,時間到了啦,還不快去",
冷淡: "時間到了",
天然呆: "時間到囉的說!",
元氣: "時間到囉!說好的事情不能忘記喔!",
大小姐: "時間到了,本小姐可沒忘記",
三無: "……時間到了",
};
return normal[archetype as Archetype] ?? "時間到了";
}
export function buildTriggerMessage(
archetype: string,
content: string,
isLateNight: boolean,
isWaking: boolean,
memoryCallback: string | null,
): string {
const tone = triggerTone(archetype, isLateNight, isWaking);
const reminder = `${tone}——「${content}」`;
return memoryCallback ? `${reminder}(${memoryCallback})` : reminder;
}
// J-4 逾期追擊:原型決定追擊節奏與嘴硬程度,但引擎層仍會確實記錄追擊次數。
const OVERDUE_NUDGES: Record<Archetype, string[]> = {
傲嬌: ["喂!你是不是又忘記了!……哼,我才没有担心你", "……算了,反正你也不會理我", "真是的,講都講不聽"],
冷淡: ["……還沒做嗎", "……"],
天然呆: ["咦,還沒做完嗎的說?要不要我再提醒一次?", "……欸嘿,是不是忘記了的說"],
元氣: ["喂喂喂!還沒做嗎!快點快點!", "欸~你是不是忘記了!我都要開始擔心了!", "拜託拜託,快去做啦!"],
大小姐: ["哼,本小姐都提醒過了,你還敢忘記", "真是不像話,還不快去"],
三無: ["……", "…還沒"],
};
const DEFAULT_OVERDUE_NUDGES = ["還記得剛剛提醒你的事嗎?"];
export function overdueNudgeLine(archetype: string, nudgeIndex: number): string {
const lines = OVERDUE_NUDGES[archetype as Archetype] ?? DEFAULT_OVERDUE_NUDGES;
return lines[Math.min(nudgeIndex, lines.length - 1)];
}
// J-6 主動關照:從記憶偵測使用者提過的待辦事項,主動問候進度。
export function proactiveCareLine(archetype: string, memoryContent: string): string {
const templates: Record<Archetype, string> = {
傲嬌: `之前你不是說「${memoryContent}」……我才不是關心你,只是剛好想到而已`,
冷淡: `……「${memoryContent}」,處理了嗎`,
天然呆: `欸,你之前說的「${memoryContent}」,還好嗎的說?`,
元氣: `對了!你之前說「${memoryContent}」,進度怎麼樣了!`,
大小姐: `本小姐記得你提過「${memoryContent}」,進展如何?`,
三無: `……「${memoryContent}」……還好嗎`,
};
return templates[archetype as Archetype] ?? `之前你提過「${memoryContent}」,還順利嗎?`;
}
// J-6 交叉比對作息型態:偵測到使用者常態性熬夜,睡前時段主動關心。
export function bedtimeCareLine(archetype: string): string {
const templates: Record<Archetype, string> = {
傲嬌: "喂,你又要熬夜了吧……早點睡啦,笨蛋",
冷淡: "……早點睡",
天然呆: "欸,這麼晚了還不睡的說?",
元氣: "欸~已經很晚囉!不可以又熬夜喔!",
大小姐: "這個時間了,本小姐命令你去睡覺",
三無: "……該睡了",
};
return templates[archetype as Archetype] ?? "早點休息吧";
}
@@ -0,0 +1,30 @@
import { Inject, Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { JOB_QUEUE, type JobQueue } from "../memory/job-queue.js";
import { TaskTriggerService } from "./task-trigger.service.js";
// J-7 排程器可靠性:InProcessJobQueue 的計時器只存在於記憶體中,重啟後全部消失。
// 因此每次啟動都要重新掃描資料庫裡「尚未觸發」的任務並掛回排程,逾期未觸發的會立刻補觸發。
@Injectable()
export class TaskSchedulerService implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
@Inject(JOB_QUEUE) private readonly jobQueue: JobQueue,
private readonly trigger: TaskTriggerService,
) {}
async onModuleInit(): Promise<void> {
const pending = await this.prisma.client.task.findMany({
where: { status: "PENDING", triggerAt: { not: null } },
});
for (const task of pending) {
this.scheduleTask(task.id, task.triggerAt!);
}
}
scheduleTask(taskId: string, triggerAt: Date): void {
this.jobQueue.schedule(triggerAt, `task:${taskId}`, () => {
this.trigger.fire(taskId);
});
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ScheduleService } from "../schedule/schedule.service.js";
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
import { PushNotificationService } from "../push/push-notification.service.js";
import { taipeiParts } from "../schedule/time-utils.js";
import { buildTriggerMessage } from "./task-performance.js";
// J-3 觸發演出:任務時間到時,依作息語氣+相關記憶回顧組成訊息;準時觸發是引擎責任,語氣是角色責任。
// Q-3:觸發的同一刻也會嘗試推播給使用者的裝置(提醒、她的主動訊息都走這條路)——沒有註冊過
// 任何裝置 token 時,PushNotificationService.sendToUser 單純回報 attempted=0,不影響任務本身的觸發。
@Injectable()
export class TaskTriggerService {
constructor(
private readonly prisma: PrismaService,
private readonly schedule: ScheduleService,
private readonly memoryRetriever: KeywordMemoryRetriever,
private readonly pushNotification: PushNotificationService,
) {}
async fire(taskId: string, now: Date = new Date()): Promise<string | null> {
const task = await this.prisma.client.task.findUnique({ where: { id: taskId } });
// 服務重啟時舊的 setTimeout 可能與新掛回的排程重複註冊;狀態不是 PENDING 就不再重複觸發。
if (!task || task.status !== "PENDING") {
return null;
}
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: task.characterId } });
const info = await this.schedule.getInfo(task.characterId, now);
const { hour } = taipeiParts(now);
const isLateNight = info.slot?.status === "SLEEP" || hour < 6 || hour >= 23;
const memories = await this.memoryRetriever.retrieve(task.characterId, task.content, { limit: 1 });
const topMemory = memories[0];
const memoryCallback =
topMemory && task.content.length > 0 && topMemory.content.includes(task.content)
? `記得你之前提過:「${topMemory.content}」`
: null;
const message = buildTriggerMessage(
character.personalityArchetype,
task.content,
isLateNight,
info.isWaking,
memoryCallback,
);
await this.prisma.client.task.update({
where: { id: task.id },
data: { status: "TRIGGERED", triggeredAt: now, triggerMessage: message },
});
await this.pushNotification.sendToUser(task.userId, "心音 Kokorone", message);
return message;
}
}
+103
View File
@@ -0,0 +1,103 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { TaskService } from "./task.service.js";
import { TaskTriggerService } from "./task-trigger.service.js";
import { TaskOverdueService } from "./task-overdue.service.js";
import { TaskFeedbackService } from "./task-feedback.service.js";
import { ProactiveCareService } from "./proactive-care.service.js";
interface ParseBody {
userId: string;
text: string;
now?: string;
}
interface CreateBody {
userId: string;
type: "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING";
content: string;
rawText: string;
triggerAt?: string;
cronExpression?: string;
condition?: string;
now?: string;
}
@Controller("task")
export class TaskController {
constructor(
private readonly taskService: TaskService,
private readonly trigger: TaskTriggerService,
private readonly overdue: TaskOverdueService,
private readonly feedback: TaskFeedbackService,
private readonly proactiveCare: ProactiveCareService,
) {}
@Get(":taskId")
async getTask(@Param("taskId") taskId: string) {
return this.taskService.get(taskId);
}
@Get()
async list(@Query("characterId") characterId: string, @Query("userId") userId: string) {
return this.taskService.listForUser(characterId, userId);
}
// J-1/J-2:嘗試把自然語句解析為單次提醒委託;無法辨識時 created=false(呼叫端應走一般對話)。
@Post(":characterId/parse")
async parse(@Param("characterId") characterId: string, @Body() body: ParseBody) {
const now = body.now ? new Date(body.now) : undefined;
const result = await this.taskService.tryCreateFromText(characterId, body.userId, body.text, now);
if (!result) {
return { created: false };
}
return { created: true, ...result };
}
@Post(":characterId")
async create(@Param("characterId") characterId: string, @Body() body: CreateBody) {
const now = body.now ? new Date(body.now) : undefined;
const result = await this.taskService.create(
characterId,
body.userId,
{
type: body.type,
content: body.content,
rawText: body.rawText,
triggerAt: body.triggerAt ? new Date(body.triggerAt) : undefined,
cronExpression: body.cronExpression,
condition: body.condition,
},
now,
);
return result;
}
// 測試用途:直接觸發(略過真實排程等待),驗證 J-3 觸發演出的內容邏輯。
@Post(":taskId/fire-now")
async fireNow(@Param("taskId") taskId: string, @Body() body: { now?: string }) {
const message = await this.trigger.fire(taskId, body.now ? new Date(body.now) : undefined);
return { message };
}
@Post(":taskId/check-overdue")
async checkOverdue(@Param("taskId") taskId: string, @Body() body: { now?: string }) {
const message = await this.overdue.checkAndNudge(taskId, body.now ? new Date(body.now) : undefined);
return { message };
}
@Post(":taskId/complete")
async complete(@Param("taskId") taskId: string, @Body() body: { now?: string }) {
return this.feedback.complete(taskId, body.now ? new Date(body.now) : undefined);
}
@Post(":taskId/fail")
async fail(@Param("taskId") taskId: string, @Body() body: { now?: string }) {
return this.feedback.fail(taskId, body.now ? new Date(body.now) : undefined);
}
@Post("proactive-care/:characterId")
async proactive(@Param("characterId") characterId: string, @Body() body: { userId: string; now?: string }) {
const message = await this.proactiveCare.maybeGenerate(characterId, body.userId, body.now ? new Date(body.now) : undefined);
return { message };
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { MemoryModule } from "../memory/memory.module.js";
import { RelationshipModule } from "../relationship/relationship.module.js";
import { EmotionModule } from "../emotion/emotion.module.js";
import { ScheduleModule } from "../schedule/schedule.module.js";
import { PushModule } from "../push/push.module.js";
import { TaskService } from "./task.service.js";
import { TaskSchedulerService } from "./task-scheduler.service.js";
import { TaskTriggerService } from "./task-trigger.service.js";
import { TaskOverdueService } from "./task-overdue.service.js";
import { TaskFeedbackService } from "./task-feedback.service.js";
import { ProactiveCareService } from "./proactive-care.service.js";
import { TaskController } from "./task.controller.js";
@Module({
imports: [PrismaModule, MemoryModule, RelationshipModule, EmotionModule, ScheduleModule, PushModule],
controllers: [TaskController],
providers: [
TaskService,
TaskSchedulerService,
TaskTriggerService,
TaskOverdueService,
TaskFeedbackService,
ProactiveCareService,
],
exports: [TaskService, TaskSchedulerService, TaskTriggerService, TaskOverdueService, TaskFeedbackService, ProactiveCareService],
})
export class TaskModule {}
+101
View File
@@ -0,0 +1,101 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js";
import { TaskSchedulerService } from "./task-scheduler.service.js";
import { parseReminderRequest } from "./task-parser.js";
import { acceptanceLine } from "./task-performance.js";
import { TASK_LEDGER_EVENT, TASK_LEDGER_WEIGHT, RAPPORT_THRESHOLD } from "./constants.js";
export interface CreateTaskResult {
task: Awaited<ReturnType<PrismaService["client"]["task"]["create"]>>;
performanceLine: string;
rapport: boolean;
}
// J-1/J-2/J-5 委託子系統核心:解析自然語句、實際建立任務(引擎責任),並附上接案演出(角色責任)。
@Injectable()
export class TaskService {
constructor(
private readonly prisma: PrismaService,
private readonly sentimentLedger: SentimentLedgerService,
private readonly scheduler: TaskSchedulerService,
) {}
// 嘗試把使用者輸入解析為單次提醒委託;無法辨識時回傳 null(呼叫端應視為一般對話)。
async tryCreateFromText(
characterId: string,
userId: string,
text: string,
now: Date = new Date(),
): Promise<CreateTaskResult | null> {
const parsed = parseReminderRequest(text, now);
if (!parsed) {
return null;
}
return this.create(
characterId,
userId,
{ type: "ONE_TIME", content: parsed.content, rawText: text, triggerAt: parsed.triggerAt },
now,
);
}
async create(
characterId: string,
userId: string,
input: { type: "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING"; content: string; rawText: string; triggerAt?: Date; cronExpression?: string; condition?: string },
now: Date = new Date(),
): Promise<CreateTaskResult> {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
// J-5 默契旗標:同角色/同使用者/同類型/同內容的委託,這是第幾次了。
const priorCount = await this.prisma.client.task.count({
where: { characterId, userId, type: input.type, content: input.content },
});
const rapport = priorCount + 1 >= RAPPORT_THRESHOLD;
const task = await this.prisma.client.task.create({
data: {
characterId,
userId,
type: input.type,
content: input.content,
rawText: input.rawText,
triggerAt: input.triggerAt,
cronExpression: input.cronExpression,
condition: input.condition,
},
});
if (task.triggerAt) {
this.scheduler.scheduleTask(task.id, task.triggerAt);
}
// J-5 任務與關係的迴路:委託本身(接案)就寫入正向關係帳本事件,不必等到完成。
await this.sentimentLedger.recordEvent(
characterId,
userId,
TASK_LEDGER_EVENT.ACCEPTED,
TASK_LEDGER_WEIGHT.ACCEPTED,
{ now },
);
const performanceLine = acceptanceLine(character.personalityArchetype, priorCount);
return { task, performanceLine, rapport };
}
async get(taskId: string) {
return this.prisma.client.task.findUnique({ where: { id: taskId } });
}
async listForUser(characterId: string, userId: string) {
return this.prisma.client.task.findMany({
where: { characterId, userId },
orderBy: { createdAt: "desc" },
});
}
async cancel(taskId: string) {
return this.prisma.client.task.update({ where: { id: taskId }, data: { status: "CANCELLED" } });
}
}
+63
View File
@@ -0,0 +1,63 @@
import type { EmotionTag } from "@kokorone/shared";
import type { Archetype } from "../personality/archetype-params.js";
export type SentenceEndContour = "RISING" | "FALLING" | "FLAT";
export interface ProsodyTemplate {
rateDeltaPercent: number; // 語速變化百分比,正值變快
pitchDeltaSemitones: number; // 音高變化(半音),正值變高
volumeDeltaPercent: number; // 音量變化百分比,正值變大
contour: SentenceEndContour;
characteristic: string; // 描述性文字,供除錯/前端顯示
}
// P-3 情緒 → 韻律對照:情緒狀態機的輸出直接驅動韻律參數。
export const EMOTION_PROSODY_TABLE: Record<EmotionTag, ProsodyTemplate> = {
CALM: { rateDeltaPercent: 0, pitchDeltaSemitones: 0, volumeDeltaPercent: 0, contour: "FLAT", characteristic: "角色預設值" },
JOY: { rateDeltaPercent: 15, pitchDeltaSemitones: 1.5, volumeDeltaPercent: 10, contour: "RISING", characteristic: "句尾上揚、偶爾帶笑聲" },
SAD: { rateDeltaPercent: -20, pitchDeltaSemitones: -1.5, volumeDeltaPercent: -15, contour: "FALLING", characteristic: "句間停頓變長、句尾下沉" },
ALERT: { rateDeltaPercent: 5, pitchDeltaSemitones: 0, volumeDeltaPercent: 0, contour: "FLAT", characteristic: "語調變平、字句變短" },
SHY: { rateDeltaPercent: -5, pitchDeltaSemitones: 2, volumeDeltaPercent: -10, contour: "FLAT", characteristic: "結巴、語尾變小聲,忽快忽慢" },
GRUMPY: { rateDeltaPercent: 10, pitchDeltaSemitones: 1, volumeDeltaPercent: -5, contour: "FALLING", characteristic: "「哼」開頭,先大後小" },
};
// P-1 Voice Sheet 預設值:依性格原型推導基準語速與音域幅度(同 G-5「原型 → 種子預設」的作法)。
export const DEFAULT_VOICE_SHEET_BY_ARCHETYPE: Record<Archetype, { baseTimbre: string; baseRate: number; pitchRangeSemitones: number }> = {
傲嬌: { baseTimbre: "清亮少女音", baseRate: 1.05, pitchRangeSemitones: 5 },
冷淡: { baseTimbre: "低沉平穩音", baseRate: 0.85, pitchRangeSemitones: 1.5 },
天然呆: { baseTimbre: "軟糯蘿莉音", baseRate: 0.95, pitchRangeSemitones: 4 },
元氣: { baseTimbre: "清亮少女音", baseRate: 1.2, pitchRangeSemitones: 6 },
大小姐: { baseTimbre: "低沉御姐音", baseRate: 0.95, pitchRangeSemitones: 3.5 },
三無: { baseTimbre: "軟糯蘿莉音", baseRate: 0.8, pitchRangeSemitones: 0.5 },
};
const DEFAULT_VOICE_SHEET_FALLBACK = { baseTimbre: "清亮少女音", baseRate: 1.0, pitchRangeSemitones: 3 };
export function defaultVoiceSheetFor(archetype: string) {
return DEFAULT_VOICE_SHEET_BY_ARCHETYPE[archetype as Archetype] ?? DEFAULT_VOICE_SHEET_FALLBACK;
}
// P-2 非語言發聲庫:依情緒狀態的預設候選詞,角色的 Voice Sheet 口頭聲響庫可補充、禁則可過濾排除。
export const DEFAULT_INTERJECTIONS_BY_EMOTION: Record<EmotionTag, string[]> = {
CALM: ["嗯"],
JOY: ["欸嘿嘿", "呵呵"],
SAD: ["唔……", "……"],
ALERT: ["嗯?", "欸?!"],
SHY: ["那個……就是……", "唔……"],
GRUMPY: ["哼!", "哼……"],
};
// P-2 停頓:情緒強度達門檻(沿用 C-4 高情緒事件的判斷門檻)且屬於重大話題時,回答前故意停頓 1~2 秒。
export const SIGNIFICANT_PAUSE_THRESHOLD = 0.6;
export const SIGNIFICANT_PAUSE_SECONDS = [1, 2];
export const DEFAULT_PAUSE_SECONDS = 0.2;
// P-2 打斷與搶話:需要「talkative」特徵(見 archetype-params.ts)且親密度達朋友分層以上才可能插話。
export const INTERRUPT_MIN_INTIMACY = 40;
// P-2 親密度影響音量與氣音比例:對陌生人聲音端正清晰,對摯友放鬆、氣音比例上升。
export function intimacyVoiceModifiers(intimacy: number): { volumeMultiplier: number; breathinessRatio: number } {
const clamped = Math.max(0, Math.min(100, intimacy));
return {
volumeMultiplier: 1 - (clamped / 100) * 0.15, // 端正清晰(1.0) → 放鬆慵懶(0.85)
breathinessRatio: clamped / 100,
};
}
@@ -0,0 +1,32 @@
import { Injectable } from "@nestjs/common";
import type { TTSProvider, TTSSynthesisInput, SynthesizedAudio } from "./tts-provider.js";
import { hashToSeed } from "../llm/seeded-random.js";
const CHARS_PER_SECOND_AT_NORMAL_RATE = 6; // Mock 階段的粗略估算,不代表真實語速
// P-4 MockTTSProvider:輸出韻律標記與佔位音檔參照,不呼叫任何真實語音服務——
// 讓語音管線的其他機制(韻律解析、非語言發聲、副語言分析)都能在沒有真實 TTS 供應商的情況下完整測試。
@Injectable()
export class MockTTSProvider implements TTSProvider {
async synthesize(input: TTSSynthesisInput): Promise<SynthesizedAudio> {
const seed = input.seed ?? hashToSeed(input.text);
const { prosody, nonverbalCue, voiceSheet } = input;
const spokenText = nonverbalCue.interjection ? `${nonverbalCue.interjection} ${input.text}` : input.text;
const durationEstimateMs =
nonverbalCue.pauseSeconds * 1000 + (spokenText.length / CHARS_PER_SECOND_AT_NORMAL_RATE / prosody.rateMultiplier) * 1000;
const prosodyMarkup =
`<voice timbre="${voiceSheet.baseTimbre}">` +
`<break time="${nonverbalCue.pauseSeconds.toFixed(1)}s"/>` +
`<prosody rate="${prosody.rateMultiplier.toFixed(2)}" pitch="${prosody.pitchShiftSemitones >= 0 ? "+" : ""}${prosody.pitchShiftSemitones.toFixed(1)}st" volume="${prosody.volumeMultiplier.toFixed(2)}" contour="${prosody.contour}">` +
spokenText +
`</prosody></voice>`;
return {
audioRef: `mock://tts/${seed}`,
prosodyMarkup,
durationEstimateMs: Math.round(durationEstimateMs),
};
}
}
+60
View File
@@ -0,0 +1,60 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
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 { VoiceSheetService } from "./voice-sheet.service.js";
import {
DEFAULT_INTERJECTIONS_BY_EMOTION,
SIGNIFICANT_PAUSE_THRESHOLD,
SIGNIFICANT_PAUSE_SECONDS,
DEFAULT_PAUSE_SECONDS,
INTERRUPT_MIN_INTIMACY,
} from "./constants.js";
export interface NonverbalCue {
interjection: string | null;
pauseSeconds: number;
canInterrupt: boolean;
}
function fieldFor(tag: EmotionTag): "calm" | "joy" | "sad" | "alert" | "shy" | "grumpy" {
return tag.toLowerCase() as "calm" | "joy" | "sad" | "alert" | "shy" | "grumpy";
}
// P-2 對話節奏與非語言發聲:依情緒插入「嗯?」「唔……」等聲響與停頓,親密度與性格決定能不能插話。
@Injectable()
export class NonverbalService {
constructor(
private readonly prisma: PrismaService,
private readonly emotion: EmotionService,
private readonly relationship: RelationshipService,
private readonly voiceSheet: VoiceSheetService,
) {}
async resolveCue(characterId: string, userId: string, now: Date = new Date()): Promise<NonverbalCue> {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const sheet = await this.voiceSheet.getOrSeedDefault(characterId);
const emotionState = await this.emotion.getState(characterId, now);
const emotionTag = dominantState(emotionState);
// 平靜本身不是「重大情緒事件」——calm 的數值代表「沒有明顯情緒」,不能當成訊號強度,
// 否則平靜時 calm=100 會被誤判成最強訊號,反而觸發本該只留給強烈情緒的長停頓。
const intensity = emotionTag === "CALM" ? 0 : (emotionState[fieldFor(emotionTag)] as number) / 100;
const candidates = [...DEFAULT_INTERJECTIONS_BY_EMOTION[emotionTag], ...sheet.verbalTics];
const allowed = candidates.filter((phrase) => !sheet.forbiddenSounds.includes(phrase));
const interjection = allowed.length > 0 ? allowed[0] : null;
const pauseSeconds =
intensity >= SIGNIFICANT_PAUSE_THRESHOLD
? SIGNIFICANT_PAUSE_SECONDS[0] + (SIGNIFICANT_PAUSE_SECONDS[1] - SIGNIFICANT_PAUSE_SECONDS[0]) * intensity
: DEFAULT_PAUSE_SECONDS;
const { relationship } = await this.relationship.getState(characterId, userId, now);
const archetypeParams = getArchetypeParams(character.personalityArchetype);
const canInterrupt = archetypeParams.traits.includes("talkative") && relationship.intimacy >= INTERRUPT_MIN_INTIMACY;
return { interjection, pauseSeconds, canInterrupt };
}
}
@@ -0,0 +1,31 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
export interface ParalinguisticFeatures {
rate: number; // 相對語速,1.0 為正常,<1 變慢、>1 變快
volume: number; // 0~1,音量大小
tremor: number; // 0~1,聲音顫抖程度
pauseCount: number; // 這段話裡的停頓次數
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
// P-5 副語言分析:STT 轉出文字之外,語速/音量/顫抖/停頓等特徵也送入情緒標記器——
// 「文字說沒事但聲音在抖」應被偵測為負向情緒,即使文字本身沒有任何負向關鍵字。
@Injectable()
export class ParalinguisticService {
deriveSignal(features: ParalinguisticFeatures): { tag: EmotionTag; intensity: number } | null {
const slowAndQuiet = features.rate <= 0.75 && features.volume <= 0.45;
if (features.tremor >= 0.4 || slowAndQuiet) {
const intensity = clamp01(features.tremor * 0.6 + (1 - features.rate) * 0.25 + (1 - features.volume) * 0.25);
return { tag: "SAD", intensity: Math.max(intensity, 0.3) };
}
if (features.rate >= 1.3 && features.volume >= 0.7 && features.tremor < 0.2) {
const intensity = clamp01((features.rate - 1) * 0.6 + features.volume * 0.3);
return { tag: "JOY", intensity: Math.max(intensity, 0.3) };
}
return null; // 沒有明顯的副語言訊號,交給文字本身的情緒標記結果決定。
}
}
+54
View File
@@ -0,0 +1,54 @@
import { Injectable } from "@nestjs/common";
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
import { RelationshipService } from "../relationship/relationship.service.js";
import { VoiceSheetService } from "./voice-sheet.service.js";
import { EMOTION_PROSODY_TABLE, intimacyVoiceModifiers, type SentenceEndContour } from "./constants.js";
import type { EmotionTag } from "@kokorone/shared";
const REFERENCE_PITCH_RANGE = 4; // 對應 constants.ts 的通用預設音域幅度,作為縮放基準
export interface ResolvedProsody {
emotionTag: EmotionTag;
rateMultiplier: number; // 相對 1.0 的語速倍率(已套入角色基準語速)
pitchShiftSemitones: number; // 已依角色音域幅度縮放的音高變化
volumeMultiplier: number; // 已套入親密度調整的音量倍率
breathinessRatio: number; // 氣音比例(親密度越高越高)
contour: SentenceEndContour;
characteristic: string;
}
// P-3 語音標記層:情緒狀態機輸出直接驅動韻律參數,與韻律驅動同源(跟 O 群組表情解析同一批輸入)。
@Injectable()
export class ProsodyService {
constructor(
private readonly emotion: EmotionService,
private readonly relationship: RelationshipService,
private readonly voiceSheet: VoiceSheetService,
) {}
async resolveProsody(characterId: string, userId: string, now: Date = new Date()): Promise<ResolvedProsody> {
const emotionState = await this.emotion.getState(characterId, now);
const emotionTag = dominantState(emotionState);
const template = EMOTION_PROSODY_TABLE[emotionTag];
const sheet = await this.voiceSheet.getOrSeedDefault(characterId);
const { relationship } = await this.relationship.getState(characterId, userId, now);
const { volumeMultiplier: intimacyVolume, breathinessRatio } = intimacyVoiceModifiers(relationship.intimacy);
const rateMultiplier = sheet.baseRate * (1 + template.rateDeltaPercent / 100);
// 音域幅度縮放:跟 O-4 表情外顯度縮放同一個設計原則——角色的音域幅度越窄(三無),
// 同一個情緒帶來的音高變化就越不明顯(近乎單音)。
const pitchShiftSemitones = template.pitchDeltaSemitones * (sheet.pitchRangeSemitones / REFERENCE_PITCH_RANGE);
const volumeMultiplier = intimacyVolume * (1 + template.volumeDeltaPercent / 100);
return {
emotionTag,
rateMultiplier,
pitchShiftSemitones,
volumeMultiplier,
breathinessRatio,
contour: template.contour,
characteristic: template.characteristic,
};
}
}
@@ -0,0 +1,11 @@
import { Injectable } from "@nestjs/common";
import type { TTSProvider, TTSSynthesisInput, SynthesizedAudio } from "./tts-provider.js";
// P-4 真實 TTS 供應商:待人工確認要接哪一家服務(技術選型文件尚未拍板),先留下同介面的空殼,
// 呼叫到才報錯,不在啟動時就讓整個服務炸掉——跟 F 群組 ClaudeProvider 的處理方式一致。
@Injectable()
export class RealTTSProvider implements TTSProvider {
async synthesize(_input: TTSSynthesisInput): Promise<SynthesizedAudio> {
throw new Error("RealTTSProvider 尚未實作:真實語音供應商待人工確認後再接(見技術選型「TTS 服務」)");
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { ResolvedProsody } from "./prosody.service.js";
import type { NonverbalCue } from "./nonverbal.service.js";
import type { VoiceSheetView } from "./voice-sheet.service.js";
export interface TTSSynthesisInput {
text: string;
voiceSheet: VoiceSheetView;
prosody: ResolvedProsody;
nonverbalCue: NonverbalCue;
seed?: number;
}
export interface SynthesizedAudio {
audioRef: string; // 音檔參照(Mock 階段是佔位字串,不是真的音檔)
prosodyMarkup: string; // 類 SSML 的除錯用韻律標記字串
durationEstimateMs: number;
}
// P-4 TTSProvider 抽象:比照 F-1 LLMProvider 的介面設計,切換 Provider 不動呼叫端任何一行。
export interface TTSProvider {
synthesize(input: TTSSynthesisInput): Promise<SynthesizedAudio>;
}
export const TTS_PROVIDER = Symbol("TTS_PROVIDER");
+77
View File
@@ -0,0 +1,77 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { defaultVoiceSheetFor } from "./constants.js";
export interface VoiceSheetView {
characterId: string;
baseTimbre: string;
baseRate: number;
pitchRangeSemitones: number;
verbalTics: string[];
forbiddenSounds: string[];
}
function toView(row: {
characterId: string;
baseTimbre: string;
baseRate: number;
pitchRangeSemitones: number;
verbalTicsJson: string;
forbiddenSoundsJson: string;
}): VoiceSheetView {
return {
characterId: row.characterId,
baseTimbre: row.baseTimbre,
baseRate: row.baseRate,
pitchRangeSemitones: row.pitchRangeSemitones,
verbalTics: JSON.parse(row.verbalTicsJson),
forbiddenSounds: JSON.parse(row.forbiddenSoundsJson),
};
}
// P-1 角色音色設定:定義基礎音色、預設語速、音域幅度、口頭聲響庫與禁則,掛在 Character 上。
@Injectable()
export class VoiceSheetService {
constructor(private readonly prisma: PrismaService) {}
// 依性格原型推導預設值後寫入(同 G-5「原型 → 種子預設」的作法),可重複執行(upsert)。
async seedDefault(
characterId: string,
overrides: { verbalTics?: string[]; forbiddenSounds?: string[] } = {},
): Promise<VoiceSheetView> {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const defaults = defaultVoiceSheetFor(character.personalityArchetype);
const row = await this.prisma.client.voiceSheet.upsert({
where: { characterId },
update: {
baseTimbre: defaults.baseTimbre,
baseRate: defaults.baseRate,
pitchRangeSemitones: defaults.pitchRangeSemitones,
verbalTicsJson: JSON.stringify(overrides.verbalTics ?? []),
forbiddenSoundsJson: JSON.stringify(overrides.forbiddenSounds ?? []),
},
create: {
characterId,
baseTimbre: defaults.baseTimbre,
baseRate: defaults.baseRate,
pitchRangeSemitones: defaults.pitchRangeSemitones,
verbalTicsJson: JSON.stringify(overrides.verbalTics ?? []),
forbiddenSoundsJson: JSON.stringify(overrides.forbiddenSounds ?? []),
},
});
return toView(row);
}
async get(characterId: string): Promise<VoiceSheetView | null> {
const row = await this.prisma.client.voiceSheet.findUnique({ where: { characterId } });
return row ? toView(row) : null;
}
async getOrSeedDefault(characterId: string): Promise<VoiceSheetView> {
const existing = await this.get(characterId);
if (existing) {
return existing;
}
return this.seedDefault(characterId);
}
}
+95
View File
@@ -0,0 +1,95 @@
import { Body, Controller, Get, Inject, Param, Post, Query } from "@nestjs/common";
import { VoiceSheetService } from "./voice-sheet.service.js";
import { ProsodyService } from "./prosody.service.js";
import { NonverbalService } from "./nonverbal.service.js";
import { ParalinguisticService, type ParalinguisticFeatures } from "./paralinguistic.service.js";
import { TTS_PROVIDER, type TTSProvider } from "./tts-provider.js";
import { EmotionService } from "../emotion/emotion.service.js";
interface SeedVoiceSheetBody {
verbalTics?: string[];
forbiddenSounds?: string[];
}
interface SynthesizeBody {
text: string;
now?: string;
seed?: number;
}
interface AnalyzeInputBody {
userId: string;
text: string;
paralinguistic: ParalinguisticFeatures;
now?: string;
}
@Controller("voice")
export class VoiceController {
constructor(
private readonly voiceSheet: VoiceSheetService,
private readonly prosody: ProsodyService,
private readonly nonverbal: NonverbalService,
private readonly paralinguistic: ParalinguisticService,
private readonly emotion: EmotionService,
@Inject(TTS_PROVIDER) private readonly ttsProvider: TTSProvider,
) {}
@Post(":characterId/voice-sheet/seed-default")
async seedVoiceSheet(@Param("characterId") characterId: string, @Body() body: SeedVoiceSheetBody) {
return this.voiceSheet.seedDefault(characterId, body);
}
@Get(":characterId/voice-sheet")
async getVoiceSheet(@Param("characterId") characterId: string) {
return this.voiceSheet.get(characterId);
}
@Get(":characterId/:userId/prosody")
async getProsody(
@Param("characterId") characterId: string,
@Param("userId") userId: string,
@Query("now") now?: string,
) {
return this.prosody.resolveProsody(characterId, userId, now ? new Date(now) : undefined);
}
@Get(":characterId/:userId/nonverbal-cue")
async getNonverbalCue(
@Param("characterId") characterId: string,
@Param("userId") userId: string,
@Query("now") now?: string,
) {
return this.nonverbal.resolveCue(characterId, userId, now ? new Date(now) : undefined);
}
@Post(":characterId/:userId/synthesize")
async synthesize(
@Param("characterId") characterId: string,
@Param("userId") userId: string,
@Body() body: SynthesizeBody,
) {
const now = body.now ? new Date(body.now) : undefined;
const [voiceSheet, resolvedProsody, nonverbalCue] = await Promise.all([
this.voiceSheet.getOrSeedDefault(characterId),
this.prosody.resolveProsody(characterId, userId, now),
this.nonverbal.resolveCue(characterId, userId, now),
]);
return this.ttsProvider.synthesize({
text: body.text,
voiceSheet,
prosody: resolvedProsody,
nonverbalCue,
seed: body.seed,
});
}
// P-5 驗收用端點:同一段文字配不同副語言特徵,應該得到不同的情緒標記結果。
@Post(":characterId/analyze-input")
async analyzeInput(@Param("characterId") characterId: string, @Body() body: AnalyzeInputBody) {
const now = body.now ? new Date(body.now) : new Date();
const paralinguisticSignal = this.paralinguistic.deriveSignal(body.paralinguistic) ?? undefined;
const result = await this.emotion.processInput(characterId, body.text, { now, paralinguisticSignal });
return { ...result, paralinguisticSignal: paralinguisticSignal ?? null };
}
}
+39
View File
@@ -0,0 +1,39 @@
import { Module } from "@nestjs/common";
import { log } from "@kokorone/shared";
import { PrismaModule } from "../prisma/prisma.module.js";
import { EmotionModule } from "../emotion/emotion.module.js";
import { RelationshipModule } from "../relationship/relationship.module.js";
import { VoiceSheetService } from "./voice-sheet.service.js";
import { ProsodyService } from "./prosody.service.js";
import { NonverbalService } from "./nonverbal.service.js";
import { ParalinguisticService } from "./paralinguistic.service.js";
import { MockTTSProvider } from "./mock-tts-provider.service.js";
import { RealTTSProvider } from "./real-tts-provider.service.js";
import { TTS_PROVIDER } from "./tts-provider.js";
import { VoiceController } from "./voice.controller.js";
// P-4 Provider 切換:TTS_PROVIDER=mock|real 決定注入哪個實作,比照 F-2 LLM_PROVIDER 的作法。
function ttsProviderFactory(mock: MockTTSProvider, real: RealTTSProvider) {
const providerName = process.env.TTS_PROVIDER ?? "mock";
if (providerName === "real") {
log("啟動", "ERR", "TTS_PROVIDER=real 但 RealTTSProvider 尚未實作,語音合成呼叫時會拋出例外");
return real;
}
return mock;
}
@Module({
imports: [PrismaModule, EmotionModule, RelationshipModule],
controllers: [VoiceController],
providers: [
VoiceSheetService,
ProsodyService,
NonverbalService,
ParalinguisticService,
MockTTSProvider,
RealTTSProvider,
{ provide: TTS_PROVIDER, useFactory: ttsProviderFactory, inject: [MockTTSProvider, RealTTSProvider] },
],
exports: [VoiceSheetService, ProsodyService, NonverbalService, ParalinguisticService, TTS_PROVIDER],
})
export class VoiceModule {}
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}
+41
View File
@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android
+3
View File
@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+30
View File
@@ -0,0 +1,30 @@
{
"expo": {
"name": "心音 Kokorone",
"slug": "kokorone",
"scheme": "kokorone",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router"
]
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Tabs } from "expo-router";
// Q-2 底部分頁籤:聊天/角色/日常/設定,同行動網頁版佈局+這一層分頁籤是 APP 相對於行動網頁的唯一差異。
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "#FF7E9D",
}}
>
<Tabs.Screen name="index" options={{ title: "聊天" }} />
<Tabs.Screen name="character" options={{ title: "角色" }} />
<Tabs.Screen name="daily" options={{ title: "日常" }} />
<Tabs.Screen name="settings" options={{ title: "設定" }} />
</Tabs>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import type { Character } from "@kokorone/shared";
import { API_BASE_URL } from "../../lib/api";
const CHARACTER_ID = "seed-character-genki";
// Q-2 角色分頁:展示角色設定表(沿用 B 群組的 /characters 端點),驗證分頁本身可獨立運作、能打 API。
export default function CharacterScreen() {
const [character, setCharacter] = useState<Character | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`${API_BASE_URL}/characters`)
.then((res) => res.json())
.then((characters: Character[]) => {
setCharacter(characters.find((c) => c.id === CHARACTER_ID) ?? characters[0] ?? null);
})
.catch(() => setError("角色資料載入失敗,請確認 api 服務是否已啟動"));
}, []);
const formalName = character?.aliases?.[0]?.formalName ?? character?.id ?? "";
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>{formalName || "角色"}</Text>
{error && <Text style={styles.error}>{error}</Text>}
{character && (
<View style={styles.card}>
<Field label="性格原型" value={character.personalityArchetype} />
<Field label="基本資料" value={character.basicInfo} />
<Field label="喜好/厭惡" value={character.likesDislikes} />
<Field label="目標/執念" value={character.goalsObsessions} />
<Field label="說話方式" value={character.speechStyle} />
</View>
)}
</ScrollView>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<Text style={styles.fieldValue}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 20, gap: 16 },
title: { fontSize: 22, fontWeight: "bold", color: "#FF7E9D" },
card: { gap: 14 },
field: { gap: 2 },
fieldLabel: { fontSize: 12, color: "#8a6b6f" },
fieldValue: { fontSize: 14, color: "#222" },
error: { color: "#FF7E9D", fontSize: 12 },
});
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { API_BASE_URL } from "../../lib/api";
const CHARACTER_ID = "seed-character-genki";
interface ScheduleInfo {
dayType: string;
minute: number;
slot: { status: string; label: string | null } | null;
isWaking: boolean;
isPreSleep: boolean;
availability: string;
}
// Q-2 日常分頁:展示 I 群組的作息狀態(她現在在做什麼、回應可用度),是「她有自己的生活」這件事
// 在 APP 上最直接的呈現位置。若種子角色沒有掛作息表(見 I 群組實作記錄),slot 會是 null。
export default function DailyScreen() {
const [info, setInfo] = useState<ScheduleInfo | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`${API_BASE_URL}/schedule/${CHARACTER_ID}/info`)
.then((res) => res.json())
.then(setInfo)
.catch(() => setError("作息資料載入失敗,請確認 api 服務是否已啟動"));
}, []);
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>今天的她</Text>
{error && <Text style={styles.error}>{error}</Text>}
{info && (
<View style={styles.card}>
<Text style={styles.line}>作息類型:{info.dayType}</Text>
<Text style={styles.line}>目前時段:{info.slot?.label ?? info.slot?.status ?? "(未設定作息表)"}</Text>
<Text style={styles.line}>回應可用度:{info.availability}</Text>
{info.isWaking && <Text style={styles.badge}>剛睡醒</Text>}
{info.isPreSleep && <Text style={styles.badge}>快睡了</Text>}
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 20, gap: 16 },
title: { fontSize: 22, fontWeight: "bold", color: "#FF7E9D" },
card: { gap: 8 },
line: { fontSize: 14, color: "#222" },
badge: { fontSize: 12, color: "#FF7E9D", fontWeight: "bold" },
error: { color: "#FF7E9D", fontSize: 12 },
});
+169
View File
@@ -0,0 +1,169 @@
import { useState } from "react";
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import type { EmotionTag } from "@kokorone/shared";
import { sendChatMessage, fetchExpression, type TachieExpressionResponse } from "../../lib/api";
import { EMOTION_LABELS } from "../../lib/emotion-labels";
import { generateSessionId } from "../../lib/id";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
const EXPRESSION_LEAD_MS = 220; // O-2/Q-2:表情先變化、文字延後一小段時間出現,跟 apps/web 的作法一致。
interface Message {
role: "user" | "character";
text: string;
}
// Q-2 行動版佈局:立繪半身像置於上方 40% 為背景層,對話流覆蓋其上向下延伸——
// 跟行動網頁版同一份設計原則,只是這裡用 View 的 flex 排版而非 CSS。立繪本體目前是佔位色塊
// (O 群組的 PixiJS 渲染器是網頁 DOM/Canvas 實作,沒有移植到 React Native,見本群組實作記錄)。
export default function ChatScreen() {
const [sessionId] = useState(() => generateSessionId());
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [dominant, setDominant] = useState<EmotionTag>("CALM");
const [intimacy, setIntimacy] = useState(0);
const [expression, setExpression] = useState<TachieExpressionResponse | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSend() {
const text = input.trim();
if (!text || sending) {
return;
}
setSending(true);
setError(null);
setInput("");
setMessages((prev) => [...prev, { role: "user", text }]);
try {
const result = await sendChatMessage(CHARACTER_ID, USER_ID, sessionId, text);
if (result.availability === "NO_REPLY") {
return;
}
if (result.context) {
setDominant(result.context.emotion.dominant);
setIntimacy(result.context.relationship.intimacy);
}
try {
setExpression(await fetchExpression(CHARACTER_ID, USER_ID));
} catch {
// 表情查詢失敗不影響對話本身。
}
await new Promise((resolve) => setTimeout(resolve, EXPRESSION_LEAD_MS));
setMessages((prev) => [...prev, { role: "character", text: result.text }]);
} catch {
setError("訊息傳送失敗,請確認 api 服務是否已啟動");
} finally {
setSending(false);
}
}
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={styles.tachieArea}>
<Text style={styles.tachieLabel}>親密度 {intimacy}</Text>
<View style={styles.emotionChip}>
<Text style={styles.emotionChipText}>{EMOTION_LABELS[dominant]}</Text>
</View>
<View style={[styles.tachiePlaceholder, expression ? { opacity: 0.85 + expression.magnitude * 0.15 } : null]} />
<Text style={styles.tachieCaption}>立繪(表情差分即時變化)</Text>
</View>
<ScrollView style={styles.messages} contentContainerStyle={styles.messagesContent}>
{messages.length === 0 && <Text style={styles.hint}>傳個訊息,開始今天的對話吧。</Text>}
{messages.map((message, index) => (
<View
key={index}
style={[styles.bubble, message.role === "user" ? styles.bubbleUser : styles.bubbleCharacter]}
>
<Text style={styles.bubbleText}>{message.text}</Text>
</View>
))}
</ScrollView>
{error && <Text style={styles.error}>{error}</Text>}
<View style={styles.inputRow}>
<TextInput
value={input}
onChangeText={setInput}
placeholder="輸入訊息……"
style={styles.input}
editable={!sending}
onSubmitEditing={handleSend}
/>
<Pressable onPress={handleSend} disabled={sending}>
<Text style={[styles.sendButton, sending ? styles.sendButtonDisabled : null]}>送出</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: "#FFF5F7" },
tachieArea: {
height: "40%",
backgroundColor: "#FCE4EC",
alignItems: "center",
justifyContent: "flex-end",
paddingBottom: 16,
},
tachieLabel: { position: "absolute", top: 14, left: 14, fontSize: 11, color: "#8a6b6f" },
emotionChip: {
position: "absolute",
top: 14,
right: 14,
backgroundColor: "#FFE0EA",
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 4,
},
emotionChipText: { color: "#FF7E9D", fontWeight: "bold", fontSize: 12 },
tachiePlaceholder: {
width: 120,
height: 190,
borderTopLeftRadius: 60,
borderTopRightRadius: 60,
borderBottomLeftRadius: 24,
borderBottomRightRadius: 24,
backgroundColor: "#FF9EB5",
},
tachieCaption: { fontSize: 11, color: "#8a6b6f", marginTop: 8 },
messages: { flex: 1 },
messagesContent: { padding: 16, gap: 8 },
hint: { fontSize: 13, color: "#8a6b6f" },
bubble: { maxWidth: "80%", borderRadius: 16, padding: 10 },
bubbleUser: { alignSelf: "flex-end", backgroundColor: "#FF7E9D" },
bubbleCharacter: { alignSelf: "flex-start", backgroundColor: "#EFEFEF" },
bubbleText: { color: "#222" },
error: { color: "#FF7E9D", fontSize: 12, paddingHorizontal: 16 },
inputRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
margin: 12,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 999,
borderWidth: 1,
borderColor: "#E5D5D8",
},
input: { flex: 1, fontSize: 14 },
sendButton: { color: "#FF7E9D", fontWeight: "bold" },
sendButtonDisabled: { opacity: 0.5 },
});
+81
View File
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import { fetchHealth, registerPushToken } from "../../lib/api";
const USER_ID = "seed-user-primary";
// Q-1 驗收:APP 能呼叫 api 的 /health 並顯示結果。
// Q-3 推播通知:請求權限、取得 Expo push token、註冊到後端——實機/模擬器才能真的收到推播,
// 這裡的按鈕操作與畫面回饋在沒有實體裝置的環境下也能驗證到「流程本身有沒有跑起來、有沒有回報錯誤」。
export default function SettingsScreen() {
const [healthStatus, setHealthStatus] = useState<string | null>(null);
const [healthError, setHealthError] = useState<string | null>(null);
const [pushStatus, setPushStatus] = useState<string | null>(null);
async function checkHealth() {
setHealthError(null);
try {
const result = await fetchHealth();
setHealthStatus(result.status);
} catch {
setHealthError("無法連線到 api 服務");
}
}
async function enablePushNotifications() {
setPushStatus("請求權限中…");
if (!Device.isDevice) {
setPushStatus("推播僅能在實機或支援的模擬器上取得 token(目前環境無法取得真實 token)");
return;
}
const { status } = await Notifications.requestPermissionsAsync();
if (status !== "granted") {
setPushStatus("使用者未授權推播通知");
return;
}
try {
const tokenResponse = await Notifications.getExpoPushTokenAsync();
await registerPushToken(USER_ID, tokenResponse.data, "expo");
setPushStatus(`已註冊推播 token:${tokenResponse.data}`);
} catch (err) {
setPushStatus(`推播 token 取得失敗:${err instanceof Error ? err.message : String(err)}`);
}
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>設定</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>API 連線狀態</Text>
<Pressable style={styles.button} onPress={checkHealth}>
<Text style={styles.buttonText}>檢查連線</Text>
</Pressable>
{healthStatus && <Text style={styles.line}>狀態:{healthStatus}</Text>}
{healthError && <Text style={styles.error}>{healthError}</Text>}
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>推播通知</Text>
<Pressable style={styles.button} onPress={enablePushNotifications}>
<Text style={styles.buttonText}>開啟推播通知</Text>
</Pressable>
{pushStatus && <Text style={styles.line}>{pushStatus}</Text>}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFF5F7" },
content: { padding: 20, gap: 24 },
title: { fontSize: 22, fontWeight: "bold", color: "#FF7E9D" },
card: { gap: 10 },
cardTitle: { fontSize: 15, fontWeight: "bold", color: "#222" },
button: { alignSelf: "flex-start", backgroundColor: "#FF7E9D", borderRadius: 999, paddingHorizontal: 16, paddingVertical: 8 },
buttonText: { color: "#fff", fontWeight: "bold" },
line: { fontSize: 13, color: "#222" },
error: { color: "#FF7E9D", fontSize: 12 },
});

Some files were not shown because too many files have changed in this diff Show More