Author SHA1 Message Date
jiantw83 8cd371d985 chore(hooks): 新增 jsc-hooks 整合腳本(session timer、STE100 guard、skill usage logger) 2026-08-24 13:48:45 +08:00
jiantw83 127a59dafd docs(操作手冊): 新增心音操作手冊,涵蓋安裝、日常操作、API、冒煙測試與疑難排解 2026-08-18 11:15:29 +08:00
admin a45a5f1fc3 刪除 todo.md 2026-08-14 04:56:12 +00:00
Jeffery 5732925f64 docs(README): 補上 pnpm/turbo 開發流程、環境變數表與正式環境部署路徑說明 2026-08-14 12:44:59 +08:00
Jeffery 021284b852 test(冒煙測試): 更新 F/J 群組冒煙測試以涵蓋 llm/記憶快取新行為 2026-08-14 12:44:54 +08:00
Jeffery 30719a2960 feat(llm 對話): 新增 prompt builder 與 few-shot 範例組裝,強化 Claude 對話品質 2026-08-14 12:44:48 +08:00
Jeffery 6ce7b2e800 feat(記憶與情緒快取): 記憶與情緒狀態加入 Redis 快取層,連不到時自動退回原行為 2026-08-14 12:44:41 +08:00
Jeffery 080ac390d3 chore(monorepo 建置工具): 由 npm workspaces 遷移至 pnpm workspace + Turborepo 2026-08-14 12:44:36 +08:00
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
167 changed files with 22223 additions and 10672 deletions
+11
View File
@@ -1,3 +1,14 @@
# 複製為 .env 後依需要調整。 # 複製為 .env 後依需要調整。
# R-2 階段會改為 postgresql 連線字串(例如 postgresql://user:password@localhost:5432/kokorone)。 # R-2 階段會改為 postgresql 連線字串(例如 postgresql://user:password@localhost:5432/kokorone)。
DATABASE_URL="file:./prisma/dev.db" DATABASE_URL="file:./prisma/dev.db"
# R-2:情緒狀態即時讀寫/session 快取的加速層;連不到時自動退回原本行為(DB/行程記憶體),不是必要依賴。
REDIS_URL=redis://127.0.0.1:6379
# R-3:LLM_PROVIDER=mock(預設,冒煙測試固定用這個)|claude(真的呼叫 API)
LLM_PROVIDER=mock
# 這個部署走 CLIProxy(OpenAI 相容的分散式派工代理)而不是直連 api.anthropic.com,
# 底層仍是 Claude;若改直連官方 API,把 CLAUDE_BASE_URL 換成 https://api.anthropic.com/v1 即可。
CLAUDE_BASE_URL=http://localhost:3000/api/v1
CLAUDE_API_KEY=
CLAUDE_MODEL=claude-sonnet-4-5
+1
View File
@@ -13,3 +13,4 @@ coverage/
*.tsbuildinfo *.tsbuildinfo
packages/db/generated/ packages/db/generated/
.claude/scheduled_tasks.lock
+20
View File
@@ -0,0 +1,20 @@
{
"_comment": "kiro-cli has no verified native hook system (no 'hooks' field in kiro-cli agent config; no hook subcommand). This file documents the INTENDED wiring for when kiro-cli gains hook support, and doubles as a manual reference for the wrapper scripts in this directory. JSC_CLI=kiro is exported by each wrapper.",
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "sh \"$PWD/.kiro/hooks/session-timer.sh\" start" } ] }
],
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "sh \"$PWD/.kiro/hooks/ste100-guard.sh\"" } ] }
],
"Stop": [
{ "hooks": [ { "type": "command", "command": "sh \"$PWD/.kiro/hooks/session-timer.sh\" mark" } ] }
],
"SessionEnd": [
{ "hooks": [ { "type": "command", "command": "sh \"$PWD/.kiro/hooks/session-timer.sh\" mark" } ] }
],
"PostToolUse": [
{ "matcher": "Skill", "hooks": [ { "type": "command", "command": "sh \"$PWD/.kiro/hooks/skill-usage.sh\"" } ] }
]
}
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
# session-timer.sh — kiro-cli wrapper for jsc-hooks' session timer.
# See ste100-guard.sh in this directory for the kiro-cli hook-support assumption.
# Usage: session-timer.sh start|mark|report [sid]
export JSC_CLI=kiro
exec sh "/root/.claude/plugins/cache/jsc/jsc-hooks/0.0.2/hooks/session-timer.sh" "$@"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env sh
# skill-usage.sh — kiro-cli wrapper for jsc-hooks' skill usage logger.
# See ste100-guard.sh in this directory for the kiro-cli hook-support assumption.
# Usage: JSC_SKILL=<skill-name> skill-usage.sh
export JSC_CLI=kiro
exec sh "/root/.claude/plugins/cache/jsc/jsc-hooks/0.0.2/hooks/skill-usage.sh" "$@"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env sh
# ste100-guard.sh — kiro-cli wrapper for jsc-hooks' STE100 guard.
#
# ASSUMPTION: kiro-cli (2.18.1) exposes no native "agent hook" / lifecycle-hook
# concept (checked `kiro-cli agent --help`, `kiro-cli agent create --help`, and
# the agent_config.json.example schema — no hooks field). This directory and
# script are a pragmatic manual fallback, not something kiro-cli invokes on
# its own. If kiro-cli later adds a hooks.json-style mechanism, point it at
# the scripts in this directory (they already set JSC_CLI=kiro).
export JSC_CLI=kiro
exec sh "/root/.claude/plugins/cache/jsc/jsc-hooks/0.0.2/hooks/ste100-guard.sh" "$@"
+65 -1
View File
@@ -2,4 +2,68 @@
以人腦「記憶/情緒/關係」架構為引擎基礎的戀愛陪伴系統。 以人腦「記憶/情緒/關係」架構為引擎基礎的戀愛陪伴系統。
> 目前使用 npm workspaces 開發(本機無 pnpm),待 R-1 階段會遷移至 pnpm + Turborepo,對外指令介面(`npm run restart` / `npm run smoke`)保持不變。 ## 開發環境
Monorepo 採 pnpm workspace + Turborepo(`pnpm-workspace.yaml` / `turbo.json`)。
```bash
pnpm install # 安裝所有 workspace 套件
pnpm run build # turbo run build(依相依順序建置 shared/db/api,並建置 web 的 production bundle)
pnpm run restart # 停止舊的 api/web 行程、重建 TypeScript(tsc -b)、重新啟動並等待健康檢查
pnpm run smoke A # 執行 A 群組冒煙測試;all 表示依序執行 A~R 全部群組
```
對外指令介面(`restart` / `smoke`)維持與遷移前相同的腳本名稱;唯一差異是
**pnpm 不會像 npm 一樣吃掉 `--` 分隔符**,`pnpm run smoke -- A` 會把字面上的
`--` 一起傳給腳本而找不到群組,要執行子指令請直接接在腳本名稱後面
(`pnpm run smoke A`),不要加 `--`。
## 環境變數
複製 `.env.example` 為 `.env` 後依需要調整;`apps/web/.env.local` 另外可設定
`NEXT_PUBLIC_API_URL`(瀏覽器端呼叫 api 用,預設 `http://localhost:3001`)。
| 變數 | 預設值 | 說明 |
| --- | --- | --- |
| `DATABASE_URL` | `file:./prisma/dev.db` | Prisma 連線字串。R-5 若遷移到 PostgreSQL,改為 `postgresql://user:password@host:5432/db` 並將 `prisma/schema.prisma` 的 `provider` 由 `sqlite` 換成 `postgresql`。 |
| `PORT` | `3001` | api(NestJS)監聽埠。 |
| `PORT_API` / `PORT_WEB` | `3001` / `3100` | `pnpm run restart`(`scripts/dev-restart.mjs`)啟動 api/web 時使用的埠號。 |
| `WEB_ORIGIN` | `http://localhost:3100` | api 的 CORS 允許來源之一;`http://localhost:8081`(Expo web 開發伺服器)已固定加入,不受此變數影響。 |
| `NEXT_PUBLIC_API_URL` | `http://localhost:3001` | web(Next.js)呼叫 api 的位址,build time 注入,正式環境需在建置時設定。 |
| `LLM_PROVIDER` | `mock` | `mock`(模板庫,冒煙測試固定用這個)/`claude`(呼叫真實 API,見下)。 |
| `CLAUDE_BASE_URL` | `http://localhost:3000/api/v1` | OpenAI 相容的 chat completions 端點基底路徑。本專案的部署方式是透過 [CLIProxy](https://gitea.jsc.idv.tw/jiantw83/CLIProxy)(分散式派工代理,本機 3000 埠)轉發到真實 Claude,而非直連 `api.anthropic.com`;若要直連官方 API,把這個值換成 `https://api.anthropic.com/v1` 並確認金鑰/請求格式相容即可,`ClaudeProvider` 的呼叫端程式碼不需要改動。 |
| `CLAUDE_API_KEY` | (無) | 呼叫 `CLAUDE_BASE_URL` 用的金鑰,`LLM_PROVIDER=claude` 時必填。 |
| `CLAUDE_MODEL` | `claude-sonnet-4-5` | 要求 Provider 使用的模型名稱;CLIProxy 端需要有對應的 Worker 已註冊、能接這個模型的派工,否則會回報「沒有任何可用的 {模型}-{執行者}-{工具} 組合」。 |
| `TTS_PROVIDER` | `mock` | `mock`/`real`(P-4 空殼,尚未接上真實供應商,人工確認後才實作)。 |
| `PUSH_PROVIDER` | `mock` | `mock`/`expo`(Q-3,`expo` 直接可用,不需金鑰)。 |
## 部署路徑
開發環境:以上服務直接用 `node`/`next dev`/`expo start` 跑在同一台 Linux 主機上
(`pnpm run restart` 一鍵重啟 api/web),**不使用 Docker**——這是本專案的實際
部署選擇,取代原先技術選型草案中「Docker Compose(開發)」的規劃。
正式環境建議路徑:
1. **建置**:`pnpm install --frozen-lockfile && pnpm run build`(turbo 依序建置
`packages/shared`/`packages/db`/`apps/api`,並產出 `apps/web` 的 production
bundle)。
2. **資料庫遷移**:`npx prisma migrate deploy`(依 `DATABASE_URL` 指向的資料庫套用
遷移;R-5 遷移到 PostgreSQL 後流程相同,只是連線字串換成 postgresql)。
3. **啟動**:
- api:`node apps/api/dist/main.js`(讀取上表環境變數)。
- web:`apps/web` 下 `npx next start -p <PORT_WEB>`(讀取建置時注入的
`NEXT_PUBLIC_API_URL`)。
- mobile:`apps/mobile` 透過 `eas build` 產出實際的 App Store/Google Play
安裝檔,不隨 api/web 一起常駐部署。
4. **程序常駐與重啟**:用 systemd unit(或 pm2)分別管理 api/web 兩個
Node 行程,設定 `Restart=on-failure`;健康檢查沿用 `GET /health`
(api)與首頁 200(web),與 `scripts/dev-restart.mjs` 開發環境用的
檢查邏輯一致。
5. **反向代理/TLS**:nginx 或雲端負載平衡器終止 TLS 後轉發到 api/web
對應埠號,對外只曝露 443。
這個路徑刻意不假設 K8s 或容器化,只依賴「Node.js 行程 + systemd + nginx」,
因為這台開發主機本身就是照這個模式跑(`pnpm run restart` 正是這個模式的
開發期簡化版),移植到正式主機時只是把「用 node 直接跑」換成「用 systemd
管理同一組 node 指令」,中間不需要引入額外的容器化工具鏈。
+3 -2
View File
@@ -10,13 +10,14 @@
"start": "node dist/main.js" "start": "node dist/main.js"
}, },
"dependencies": { "dependencies": {
"@kokorone/db": "*", "@kokorone/db": "workspace:*",
"@kokorone/shared": "*", "@kokorone/shared": "workspace:*",
"@nestjs/common": "^11.1.29", "@nestjs/common": "^11.1.29",
"@nestjs/core": "^11.1.29", "@nestjs/core": "^11.1.29",
"@nestjs/event-emitter": "^3.1.0", "@nestjs/event-emitter": "^3.1.0",
"@nestjs/platform-express": "^11.1.29", "@nestjs/platform-express": "^11.1.29",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"ioredis": "^6.0.0",
"node-cron": "^4.6.0", "node-cron": "^4.6.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2" "rxjs": "^7.8.2"
+20
View File
@@ -7,6 +7,16 @@ import { EmotionModule } from "./emotion/emotion.module.js";
import { RelationshipModule } from "./relationship/relationship.module.js"; import { RelationshipModule } from "./relationship/relationship.module.js";
import { LlmModule } from "./llm/llm.module.js"; import { LlmModule } from "./llm/llm.module.js";
import { PersonalityModule } from "./personality/personality.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({ @Module({
imports: [ imports: [
@@ -17,6 +27,16 @@ import { PersonalityModule } from "./personality/personality.module.js";
RelationshipModule, RelationshipModule,
LlmModule, LlmModule,
PersonalityModule, PersonalityModule,
ChatModule,
ScheduleModule,
TaskModule,
RoomModule,
RomanceModule,
CanonModule,
EpilogueModule,
TachieModule,
VoiceModule,
PushModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })
+26
View File
@@ -0,0 +1,26 @@
import { Redis } from "ioredis";
import { log } from "@kokorone/shared";
let client: Redis | null = null;
let loggedUnavailable = false;
// R-2:session 快取/情緒狀態即時讀寫改走 Redis。連線失敗時呼叫端一律要能安全退回原本的行為
// (工作記憶退回行程記憶體 Map、情緒狀態退回直接讀寫 Prisma),Redis 只是加速層,不是新的必要依賴,
// 這樣即使部署環境還沒有 Redis(例如這個沒有 Docker/sudo 的沙盒),既有行為也不會被打斷。
export function getRedisClient(): Redis {
if (!client) {
client = new Redis(process.env.REDIS_URL ?? "redis://127.0.0.1:6379", {
lazyConnect: true,
maxRetriesPerRequest: 1,
retryStrategy: () => null,
connectTimeout: 300,
});
client.on("error", () => {
if (!loggedUnavailable) {
loggedUnavailable = true;
log("啟動", "WRN", `Redis 無法連線(REDIS_URL=${process.env.REDIS_URL ?? "redis://127.0.0.1:6379"}),相關快取將退回原本行為`);
}
});
}
return client;
}
+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 {}
+49 -1
View File
@@ -5,6 +5,9 @@ import { RuleBasedEmotionTagger } from "./emotion-tagger.js";
import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js"; import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js";
import { toResponseStyle, type ResponseStyle } from "./response-style.js"; import { toResponseStyle, type ResponseStyle } from "./response-style.js";
import { getArchetypeParams } from "../personality/archetype-params.js"; import { getArchetypeParams } from "../personality/archetype-params.js";
import { getRedisClient } from "../cache/redis-client.js";
const REDIS_KEY_PREFIX = "kokorone:emotion-state:";
// D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。 // D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。
const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000; const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000;
@@ -56,6 +59,11 @@ export interface ProcessInputOptions extends TransitionContext {
triggerThreshold?: number; triggerThreshold?: number;
halfLifeMs?: number; halfLifeMs?: number;
now?: Date; 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 { function toDomain(characterId: string, dims: Dimensions, updatedAt: Date): EmotionState {
@@ -105,7 +113,12 @@ export class EmotionService {
const rawSignal = this.tagger.tag({ text, triggerThreshold }); 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); const next = nextEmotionState(currentDominant, signal, options);
let updated: Dimensions; let updated: Dimensions;
@@ -123,11 +136,45 @@ export class EmotionService {
return { state: toDomain(characterId, updated, now), style: toResponseStyle(next) }; return { state: toDomain(characterId, updated, now), style: toResponseStyle(next) };
} }
// R-2:情緒狀態機的即時讀寫改走 Redis 當熱路徑快取,Prisma/SQLite(未來 Postgres)
// 仍是唯一的持久真實來源——快取只省掉命中時的資料庫往返,Redis 不可用或未命中都直接退回原本
// 讀 Prisma 的行為,因此「重啟 api 後情緒狀態不遺失」這件事完全不受快取層是否存在影響。
private async readCached(characterId: string): Promise<{ dims: Dimensions; updatedAt: Date } | null> {
try {
const raw = await getRedisClient().get(REDIS_KEY_PREFIX + characterId);
if (!raw) return null;
const parsed = JSON.parse(raw) as Dimensions & { updatedAt: string };
const { updatedAt, ...dims } = parsed;
return { dims, updatedAt: new Date(updatedAt) };
} catch {
return null;
}
}
private async writeCache(characterId: string, dims: Dimensions, updatedAt: Date): Promise<void> {
try {
await getRedisClient().set(
REDIS_KEY_PREFIX + characterId,
JSON.stringify({ ...dims, updatedAt: updatedAt.toISOString() }),
"EX",
60 * 60 * 24,
);
} catch {
// Redis 不可用時,Prisma 仍是持久真實來源,快取寫入失敗不影響正確性。
}
}
private async decayedDimensions( private async decayedDimensions(
characterId: string, characterId: string,
now: Date, now: Date,
halfLifeMs = DEFAULT_HALF_LIFE_MS, halfLifeMs = DEFAULT_HALF_LIFE_MS,
): Promise<{ dims: Dimensions; updatedAt: Date }> { ): Promise<{ dims: Dimensions; updatedAt: Date }> {
const cached = await this.readCached(characterId);
if (cached) {
const elapsedMs = now.getTime() - cached.updatedAt.getTime();
return { dims: decayDimensions(cached.dims, elapsedMs, halfLifeMs), updatedAt: cached.updatedAt };
}
const row = await this.prisma.client.emotionState.upsert({ const row = await this.prisma.client.emotionState.upsert({
where: { characterId }, where: { characterId },
update: {}, update: {},
@@ -144,6 +191,7 @@ export class EmotionService {
where: { characterId }, where: { characterId },
data: { ...dims, updatedAt: now }, data: { ...dims, updatedAt: now },
}); });
await this.writeCache(characterId, dims, now);
} }
} }
@@ -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 } });
}
}
+90 -8
View File
@@ -1,19 +1,101 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import type { LLMProvider } from "./llm-provider.js"; import type { LLMProvider } from "./llm-provider.js";
import type { GenerationContext, GeneratedResponse } from "./types.js"; import type { GenerationContext, GeneratedResponse } from "./types.js";
import { buildMessages } from "./prompt-builder.js";
import { extractActions } from "./action-markup.js";
const NOT_IMPLEMENTED_MESSAGE = "ClaudeProvider 尚未實作(R-3 會接上真實 Claude API)"; interface OpenAiCompatibleChoice {
message?: { content?: string };
delta?: { content?: string };
}
interface OpenAiCompatibleResponse {
choices?: OpenAiCompatibleChoice[];
}
// F-2 ClaudeProvider 空殼:R-3 才會實作真實呼叫,目前僅回報未實作。 function baseUrl(): string {
return process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1";
}
function apiKey(): string {
const key = process.env.CLAUDE_API_KEY;
if (!key) {
throw new Error("CLAUDE_API_KEY 未設定,LLM_PROVIDER=claude 需要這個環境變數才能呼叫真實 API");
}
return key;
}
function model(): string {
return process.env.CLAUDE_MODEL ?? "claude-sonnet-4-5";
}
async function requestChatCompletion(body: Record<string, unknown>): Promise<Response> {
const res = await fetch(`${baseUrl()}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey()}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`ClaudeProvider 呼叫失敗(HTTP ${res.status}):${text.slice(0, 500)}`);
}
return res;
}
// R-3:把 F-2 的空殼換成真的呼叫。這個部署走 CLIProxy(分散式派工代理,見
// gitea.jsc.idv.tw/jiantw83/CLIProxy)提供的 OpenAI 相容端點,而不是直連
// api.anthropic.com——底層仍是 Claude,只是多一層派工代理,對這個介面
// (LLMProvider.generate/stream)完全透明,切換供應商不需要動任何呼叫端程式碼。
@Injectable() @Injectable()
export class ClaudeProvider implements LLMProvider { export class ClaudeProvider implements LLMProvider {
// eslint-disable-next-line @typescript-eslint/no-unused-vars async generate(context: GenerationContext): Promise<GeneratedResponse> {
async generate(_context: GenerationContext): Promise<GeneratedResponse> { const res = await requestChatCompletion({
throw new Error(NOT_IMPLEMENTED_MESSAGE); model: model(),
messages: buildMessages(context),
stream: false,
});
const json = (await res.json()) as OpenAiCompatibleResponse;
const text = json.choices?.[0]?.message?.content ?? "";
if (!text) {
throw new Error("ClaudeProvider 回應內容為空(choices[0].message.content 缺失)");
}
return { text, actions: extractActions(text) };
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars, require-yield async *stream(context: GenerationContext): AsyncIterable<string> {
async *stream(_context: GenerationContext): AsyncIterable<string> { const res = await requestChatCompletion({
throw new Error(NOT_IMPLEMENTED_MESSAGE); model: model(),
messages: buildMessages(context),
stream: true,
});
if (!res.body) {
throw new Error("ClaudeProvider 串流回應沒有 body");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice("data:".length).trim();
if (payload === "[DONE]") return;
let chunk: OpenAiCompatibleResponse;
try {
chunk = JSON.parse(payload);
} catch {
continue;
}
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) yield delta;
}
}
} }
} }
@@ -44,7 +44,7 @@ export class ContextAssemblerService {
? [] ? []
: await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId }); : await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId });
const history = this.workingMemory.getContext(sessionId).map((entry) => ({ const history = (await this.workingMemory.getContext(sessionId)).map((entry) => ({
role: entry.role, role: entry.role,
content: entry.content, content: entry.content,
timestamp: entry.timestamp.toISOString(), timestamp: entry.timestamp.toISOString(),
+68 -7
View File
@@ -1,6 +1,13 @@
import { Inject, Injectable } from "@nestjs/common"; import { Inject, Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js"; import { PrismaService } from "../prisma/prisma.service.js";
import { WorkingMemoryService } from "../memory/working-memory.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 { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js";
import { ContextAssemblerService } from "./context-assembler.service.js"; import { ContextAssemblerService } from "./context-assembler.service.js";
import { OutputFilterService } from "./output-filter.service.js"; import { OutputFilterService } from "./output-filter.service.js";
@@ -18,15 +25,19 @@ export interface HandleMessageResult {
text: string; text: string;
actions: string[]; actions: string[];
isFastChannel: boolean; isFastChannel: boolean;
context: GenerationContext; availability: AvailabilityMode;
exceptionType: ExceptionType | null;
context: GenerationContext | null;
} }
function fastChannelSituation(reason: string): string { function fastChannelSituation(reason: string): string {
return `fast-channel:${reason}`; return `fast-channel:${reason}`;
} }
const DELAYED_ACKNOWLEDGEMENT = "抱歉剛剛在忙,現在才看到——";
// F 群組整合入口:串起雙速通道判斷、上下文組裝、Provider 生成、輸出過濾與工作記憶寫入。 // F 群組整合入口:串起雙速通道判斷、上下文組裝、Provider 生成、輸出過濾與工作記憶寫入。
// H-1 的 POST /chat/:characterId 會以此為基礎擴充(見 todo.md 實作記錄)。 // I 群組擴充:作息可用度/破例規則/作息情緒基線/離線事件生成(見 todo.md 實作記錄)。
@Injectable() @Injectable()
export class DialogueService { export class DialogueService {
constructor( constructor(
@@ -35,6 +46,12 @@ export class DialogueService {
private readonly contextAssembler: ContextAssemblerService, private readonly contextAssembler: ContextAssemblerService,
private readonly outputFilter: OutputFilterService, private readonly outputFilter: OutputFilterService,
private readonly workingMemory: WorkingMemoryService, 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, private readonly prisma: PrismaService,
) {} ) {}
@@ -46,12 +63,48 @@ export class DialogueService {
options: HandleMessageOptions = {}, options: HandleMessageOptions = {},
): Promise<HandleMessageResult> { ): Promise<HandleMessageResult> {
const now = options.now ?? new Date(); const now = options.now ?? new Date();
this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now }); await this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
// I-6:按需生成離線事件(今天只生成一次),成為話題來源。
await this.offlineEvent.maybeGenerate(characterId, now);
// I-5:作息驅動的情緒基線,只在本輪沒有明確情緒訊號時才介入。
const baselineSignal = await this.scheduleBaseline.computeBaseline(characterId, now);
const emotionResult = await this.emotion.processInput(characterId, userInput, {
now,
baselineSignal: baselineSignal ?? undefined,
});
await this.relationship.recordInteraction(characterId, userId, now);
// I-2:目前時段狀態 → 回應可用度;I-4:條件符合且未在冷卻期時才破例。
const scheduleInfo = await this.schedule.getInfo(characterId, now);
const availability = availabilityForStatus(scheduleInfo.slot?.status ?? null);
let exceptionType: ExceptionType | null = null;
let effectiveAvailability = availability;
if (availability !== "NORMAL") {
const { relationship } = await this.relationship.getState(characterId, userId, now);
exceptionType = await this.scheduleException.tryApply(
characterId,
userId,
availability,
{ intimacy: relationship.intimacy, dominantEmotion: dominantState(emotionResult.state), userInput },
now,
);
if (exceptionType) {
effectiveAvailability = "NORMAL";
}
}
if (effectiveAvailability === "NO_REPLY") {
return { text: "", actions: [], isFastChannel: false, availability: effectiveAvailability, exceptionType, context: null };
}
const { isFastChannel, reason } = this.fastChannel.detect(userInput); const { isFastChannel, reason } = this.fastChannel.detect(userInput);
const skipMemoryRetrieval = isFastChannel || effectiveAvailability === "BRIEF";
const context = await this.contextAssembler.assemble(characterId, userId, sessionId, userInput, { const context = await this.contextAssembler.assemble(characterId, userId, sessionId, userInput, {
skipMemoryRetrieval: isFastChannel, skipMemoryRetrieval,
seed: options.seed, seed: options.seed,
now, now,
}); });
@@ -59,7 +112,10 @@ export class DialogueService {
let text: string; let text: string;
let actions: 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({ const rule = await this.prisma.client.proceduralRule.findFirst({
where: { characterId, situation: fastChannelSituation(reason) }, where: { characterId, situation: fastChannelSituation(reason) },
orderBy: { weight: "desc" }, orderBy: { weight: "desc" },
@@ -72,13 +128,18 @@ export class DialogueService {
actions = generated.actions; actions = generated.actions;
} }
// I-2 剛醒/睡前:以拖長語尾的簡單標記呈現迷糊演出(完整口吻交由模板庫日後擴充)。
if (scheduleInfo.isWaking || scheduleInfo.isPreSleep) {
text = `……${text}`;
}
const forbiddenWords = getForbiddenWords(context.character.personalityArchetype); const forbiddenWords = getForbiddenWords(context.character.personalityArchetype);
const filteredText = applySpeechQuirks( const filteredText = applySpeechQuirks(
this.outputFilter.filter(text, forbiddenWords), this.outputFilter.filter(text, forbiddenWords),
context.character.personalityArchetype, context.character.personalityArchetype,
); );
this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now }); await 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 };
} }
} }
+30
View File
@@ -0,0 +1,30 @@
import type { EmotionTag } from "@kokorone/shared";
import { pickTemplates } from "./template-library.js";
export interface FewShotExample {
emotionTag: EmotionTag;
tier: "low" | "high";
userInput: string;
response: string;
}
const SAMPLE_USER_INPUT: Record<"low" | "high", string> = {
low: "今天天氣真好呢。",
high: "謝謝你,我今天好開心!",
};
const SAMPLE_EMOTIONS: EmotionTag[] = ["CALM", "JOY", "SAD", "ALERT", "SHY", "GRUMPY"];
// R-3:F-7 的 Mock 模板庫在真實 Provider 這邊轉為 few-shot 範例——同一份模板資料,
// 從「隨機挑一句直接當回應」變成「示範這個性格原型在各種情緒下該有的語氣」。
export function buildFewShotExamples(personalityArchetype: string): FewShotExample[] {
const examples: FewShotExample[] = [];
for (const tier of ["low", "high"] as const) {
for (const emotionTag of SAMPLE_EMOTIONS) {
const candidates = pickTemplates(personalityArchetype, emotionTag, tier);
if (candidates.length === 0) continue;
examples.push({ emotionTag, tier, userInput: SAMPLE_USER_INPUT[tier], response: candidates[0] });
}
}
return examples;
}
+5 -3
View File
@@ -4,6 +4,7 @@ import { PrismaModule } from "../prisma/prisma.module.js";
import { EmotionModule } from "../emotion/emotion.module.js"; import { EmotionModule } from "../emotion/emotion.module.js";
import { RelationshipModule } from "../relationship/relationship.module.js"; import { RelationshipModule } from "../relationship/relationship.module.js";
import { MemoryModule } from "../memory/memory.module.js"; import { MemoryModule } from "../memory/memory.module.js";
import { ScheduleModule } from "../schedule/schedule.module.js";
import { MockProvider } from "./mock-provider.service.js"; import { MockProvider } from "./mock-provider.service.js";
import { ClaudeProvider } from "./claude-provider.service.js"; import { ClaudeProvider } from "./claude-provider.service.js";
import { LLM_PROVIDER } from "./llm-provider.js"; import { LLM_PROVIDER } from "./llm-provider.js";
@@ -15,17 +16,18 @@ import { DialogueService } from "./dialogue.service.js";
import { DialogueController } from "./dialogue.controller.js"; import { DialogueController } from "./dialogue.controller.js";
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。 // F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
// R-3:ClaudeProvider 已接上真實 API,這裡不再需要特別的「尚未實作」警告。
function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) { function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
const providerName = process.env.LLM_PROVIDER ?? "mock"; const providerName = process.env.LLM_PROVIDER ?? "mock";
if (providerName === "claude") { if (providerName === "claude") {
log("啟動", "ERR", "LLM_PROVIDER=claude 但 ClaudeProvider 尚未實作(R-3),對話生成呼叫時會拋出例外"); log("啟動", "INF", `LLM_PROVIDER=claude,對話生成將呼叫真實 API(CLAUDE_BASE_URL=${process.env.CLAUDE_BASE_URL ?? "http://localhost:3000/api/v1"})`);
return claude; return claude;
} }
return mock; return mock;
} }
@Module({ @Module({
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule], imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, ScheduleModule],
controllers: [DialogueController], controllers: [DialogueController],
providers: [ providers: [
MockProvider, MockProvider,
@@ -37,6 +39,6 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
ContextAssemblerService, ContextAssemblerService,
DialogueService, DialogueService,
], ],
exports: [DialogueService, BehaviorReinforcementService], exports: [DialogueService, BehaviorReinforcementService, LLM_PROVIDER],
}) })
export class LlmModule {} export class LlmModule {}
+62
View File
@@ -0,0 +1,62 @@
import type { GenerationContext } from "./types.js";
import { buildFewShotExamples } from "./few-shot-examples.js";
export interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
const RELATIONSHIP_STAGE_LABEL: Record<string, string> = {
STRANGER: "陌生",
ACQUAINTANCE: "認識",
FRIEND: "朋友",
CLOSE_AMBIGUOUS: "摯友-曖昧",
BONDED: "羈絆",
};
function buildSystemPrompt(context: GenerationContext): string {
const { character, emotion, relationship, retrievedMemories } = context;
const stageLabel = RELATIONSHIP_STAGE_LABEL[relationship.stage] ?? relationship.stage;
const memoryLines =
retrievedMemories.length > 0
? retrievedMemories.map((m) => `- ${m.content}`).join("\n")
: "(目前沒有可用的相關記憶)";
const fewShot = buildFewShotExamples(character.personalityArchetype);
const fewShotLines = fewShot
.map((ex) => `情境:情緒=${ex.emotionTag}、親密度=${ex.tier}\n使用者:${ex.userInput}\n角色:${ex.response}`)
.join("\n\n");
return [
`你正在扮演一個戀愛陪伴系統中的角色,必須完全以第一人稱、角色本人的口吻回應,不要說明你是 AI,不要跳出角色。`,
``,
`【角色設定】`,
`性格原型:${character.personalityArchetype}`,
`說話風格:${character.speechStyle}`,
`喜好/厭惡:${character.likesDislikes}`,
`基本資訊:${character.basicInfo}`,
``,
`【當前狀態】`,
`情緒:${emotion.dominant}(語氣:${emotion.style.tone},句長:${emotion.style.sentenceLength},主動性:${emotion.style.initiative})`,
`關係:親密度 ${relationship.intimacy}、信任度 ${relationship.trust}、階段:${stageLabel}`,
``,
`【相關記憶】`,
memoryLines,
``,
`【輸出格式】`,
`回應為純文字。若有動作/表情描寫,用全形星號包住,例如 *臉紅撇過頭*;不要用任何其他標記語法,不要加角色名稱前綴。`,
``,
`【語氣範例(僅供語氣參考,不要照抄內容)】`,
fewShotLines,
].join("\n");
}
export function buildMessages(context: GenerationContext): ChatMessage[] {
const messages: ChatMessage[] = [{ role: "system", content: buildSystemPrompt(context) }];
for (const turn of context.history) {
messages.push({ role: turn.role === "character" ? "assistant" : "user", content: turn.content });
}
messages.push({ role: "user", content: context.userInput });
return messages;
}
+3
View File
@@ -6,6 +6,9 @@ import { AppModule } from "./app.module.js";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, { logger: false }); 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); const port = Number(process.env.PORT ?? 3001);
await app.listen(port); await app.listen(port);
log("啟動", "INF", `api 服務已啟動,監聽埠 ${port}`); log("啟動", "INF", `api 服務已啟動,監聽埠 ${port}`);
@@ -17,7 +17,7 @@ export class MemoryConsolidationService {
) {} ) {}
async consolidate(characterId: string, sessionId: string): Promise<void> { async consolidate(characterId: string, sessionId: string): Promise<void> {
const entries = this.workingMemory.getContext(sessionId); const entries = await this.workingMemory.getContext(sessionId);
const contentCounts = new Map<string, number>(); const contentCounts = new Map<string, number>();
for (const entry of entries) { for (const entry of entries) {
@@ -53,6 +53,6 @@ export class MemoryConsolidationService {
}); });
} }
this.workingMemory.clear(sessionId); await this.workingMemory.clear(sessionId);
} }
} }
@@ -6,6 +6,8 @@ export interface RetrieveOptions {
limit?: number; limit?: number;
// E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。 // E-5 關係加權檢索接點:與此使用者相關的記憶會被加權排到前面。
relatedUserId?: string; relatedUserId?: string;
// K-3 隱私邊界:群聊等有旁人在場的情境傳 true,標記為私密的記憶不會被檢索到。
excludePrivate?: boolean;
} }
export interface MemoryRetriever { export interface MemoryRetriever {
@@ -37,7 +39,9 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> { async retrieve(characterId: string, query: string, options: RetrieveOptions = {}): Promise<EpisodicMemory[]> {
const limit = options.limit ?? DEFAULT_LIMIT; 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 now = Date.now();
const keywords = query.split(/\s+/).filter(Boolean); const keywords = query.split(/\s+/).filter(Boolean);
@@ -76,6 +80,7 @@ export class KeywordMemoryRetriever implements MemoryRetriever {
lastRetrievedAt: new Date(now).toISOString(), lastRetrievedAt: new Date(now).toISOString(),
source: row.source, source: row.source,
weight: row.weight, weight: row.weight,
isPrivate: row.isPrivate,
})); }));
} }
} }
+7 -5
View File
@@ -27,20 +27,20 @@ export class MemoryController {
) {} ) {}
@Post(":characterId/sessions/:sessionId/messages") @Post(":characterId/sessions/:sessionId/messages")
appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) { async appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
this.workingMemory.append(sessionId, { await this.workingMemory.append(sessionId, {
role: body.role, role: body.role,
content: body.content, content: body.content,
timestamp: new Date(), timestamp: new Date(),
emotionTag: body.emotionTag, emotionTag: body.emotionTag,
emotionIntensity: body.emotionIntensity, emotionIntensity: body.emotionIntensity,
}); });
return { context: this.workingMemory.getContext(sessionId) }; return { context: await this.workingMemory.getContext(sessionId) };
} }
@Get(":characterId/sessions/:sessionId/messages") @Get(":characterId/sessions/:sessionId/messages")
getContext(@Param("sessionId") sessionId: string) { async getContext(@Param("sessionId") sessionId: string) {
return { context: this.workingMemory.getContext(sessionId) }; return { context: await this.workingMemory.getContext(sessionId) };
} }
@Post(":characterId/sessions/:sessionId/consolidate") @Post(":characterId/sessions/:sessionId/consolidate")
@@ -55,10 +55,12 @@ export class MemoryController {
@Query("query") query: string, @Query("query") query: string,
@Query("limit") limit?: string, @Query("limit") limit?: string,
@Query("relatedUserId") relatedUserId?: string, @Query("relatedUserId") relatedUserId?: string,
@Query("excludePrivate") excludePrivate?: string,
) { ) {
const results = await this.retriever.retrieve(characterId, query ?? "", { const results = await this.retriever.retrieve(characterId, query ?? "", {
limit: limit ? Number(limit) : undefined, limit: limit ? Number(limit) : undefined,
relatedUserId, relatedUserId,
excludePrivate: excludePrivate === "true",
}); });
return { results }; return { results };
} }
+64 -23
View File
@@ -1,6 +1,7 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared"; import type { EmotionTag } from "@kokorone/shared";
import { HIGH_EMOTION_THRESHOLD } from "./constants.js"; import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
import { getRedisClient } from "../cache/redis-client.js";
export interface WorkingMemoryEntry { export interface WorkingMemoryEntry {
role: "user" | "character"; role: "user" | "character";
@@ -10,7 +11,14 @@ export interface WorkingMemoryEntry {
emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略 emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略
} }
interface SerializedEntry extends Omit<WorkingMemoryEntry, "timestamp"> {
timestamp: string;
}
const DEFAULT_TOKEN_LIMIT = 200; const DEFAULT_TOKEN_LIMIT = 200;
const REDIS_KEY_PREFIX = "kokorone:working-memory:";
// session 閒置這麼久沒有新訊息就任由 Redis 自然過期,避免無限累積殭屍 session。
const SESSION_TTL_SECONDS = 60 * 60 * 6;
// 粗略估算:Mock 階段不需要真實 tokenizer,先以字元數/2 近似。 // 粗略估算:Mock 階段不需要真實 tokenizer,先以字元數/2 近似。
function estimateTokens(text: string): number { function estimateTokens(text: string): number {
@@ -21,32 +29,11 @@ function isHighEmotion(entry: WorkingMemoryEntry): boolean {
return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD; return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD;
} }
// C-1 工作記憶:session 對話上下文緩衝,只存在於行程記憶體中(如同海馬迴暫存), function evictOverflow(entries: WorkingMemoryEntry[], tokenLimit: number): WorkingMemoryEntry[] {
// 不落地到任何長期記憶表——長期寫入只能由 C-4 睡眠固化觸發。
@Injectable()
export class WorkingMemoryService {
private readonly sessions = new Map<string, WorkingMemoryEntry[]>();
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
append(sessionId: string, entry: WorkingMemoryEntry): void {
const entries = this.sessions.get(sessionId) ?? [];
entries.push(entry);
this.sessions.set(sessionId, this.evictOverflow(entries));
}
getContext(sessionId: string): WorkingMemoryEntry[] {
return [...(this.sessions.get(sessionId) ?? [])];
}
clear(sessionId: string): void {
this.sessions.delete(sessionId);
}
private evictOverflow(entries: WorkingMemoryEntry[]): WorkingMemoryEntry[] {
let result = entries; let result = entries;
let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0); let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0);
while (totalTokens > this.tokenLimit && result.length > 0) { while (totalTokens > tokenLimit && result.length > 0) {
// 最舊的低情緒段落先被裁掉;若全部都是高情緒,最後才犧牲最舊的一則以確保不超出上限。 // 最舊的低情緒段落先被裁掉;若全部都是高情緒,最後才犧牲最舊的一則以確保不超出上限。
let evictIndex = result.findIndex((e) => !isHighEmotion(e)); let evictIndex = result.findIndex((e) => !isHighEmotion(e));
if (evictIndex === -1) { if (evictIndex === -1) {
@@ -58,4 +45,58 @@ export class WorkingMemoryService {
return result; return result;
} }
function serialize(entries: WorkingMemoryEntry[]): string {
const payload: SerializedEntry[] = entries.map((e) => ({ ...e, timestamp: e.timestamp.toISOString() }));
return JSON.stringify(payload);
}
function deserialize(raw: string): WorkingMemoryEntry[] {
const payload = JSON.parse(raw) as SerializedEntry[];
return payload.map((e) => ({ ...e, timestamp: new Date(e.timestamp) }));
}
// C-1 工作記憶:session 對話上下文緩衝,如同海馬迴暫存,長期寫入只能由 C-4 睡眠固化觸發。
// R-2:改走 Redis(session 快取),Redis 不可用時退回行程記憶體 Map,行為(token 上限、高情緒優先保留)不變。
@Injectable()
export class WorkingMemoryService {
private readonly fallback = new Map<string, WorkingMemoryEntry[]>();
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
async append(sessionId: string, entry: WorkingMemoryEntry): Promise<void> {
const entries = await this.getContext(sessionId);
entries.push(entry);
const evicted = evictOverflow(entries, this.tokenLimit);
const redis = getRedisClient();
try {
await redis.set(REDIS_KEY_PREFIX + sessionId, serialize(evicted), "EX", SESSION_TTL_SECONDS);
this.fallback.delete(sessionId);
return;
} catch {
this.fallback.set(sessionId, evicted);
}
}
async getContext(sessionId: string): Promise<WorkingMemoryEntry[]> {
const redis = getRedisClient();
try {
const raw = await redis.get(REDIS_KEY_PREFIX + sessionId);
if (raw !== null) return deserialize(raw);
if (this.fallback.has(sessionId)) return [...this.fallback.get(sessionId)!];
return [];
} catch {
return [...(this.fallback.get(sessionId) ?? [])];
}
}
async clear(sessionId: string): Promise<void> {
this.fallback.delete(sessionId);
const redis = getRedisClient();
try {
await redis.del(REDIS_KEY_PREFIX + sessionId);
} catch {
// Redis 不可用時 fallback 已經在上面清掉,沒有需要額外處理的狀態。
}
}
} }
+37 -2
View File
@@ -6,7 +6,11 @@ export interface ArchetypeParams {
expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣) expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣)
trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上 trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上
invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3) 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 = { const DEFAULT_PARAMS: ArchetypeParams = {
@@ -15,6 +19,10 @@ const DEFAULT_PARAMS: ArchetypeParams = {
trustGrowthRate: 1, trustGrowthRate: 1,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: [], traits: [],
conversationBaseline: 0.4,
romanceGainRate: 1,
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 1,
}; };
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = { export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
@@ -24,6 +32,11 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.5, trustGrowthRate: 0.5,
invertedIntimacyExpression: true, invertedIntimacyExpression: true,
traits: ["denial-then-honest"], traits: ["denial-then-honest"],
conversationBaseline: 0.5,
// 心動值與外顯敵意同步上升——外顯部分交給既有的 G-3 tsundereGap 機制處理,這裡的曲線本身走一般速度。
romanceGainRate: 1,
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 1,
}, },
冷淡: { 冷淡: {
emotionTriggerThreshold: 2.5, emotionTriggerThreshold: 2.5,
@@ -31,13 +44,22 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.3, trustGrowthRate: 0.3,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: ["flat-affect", "short-replies"], traits: ["flat-affect", "short-replies"],
conversationBaseline: 0.15,
romanceGainRate: 0.4, // 極緩慢線性,無明顯階段感
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 1,
}, },
天然呆: { 天然呆: {
emotionTriggerThreshold: 0.7, emotionTriggerThreshold: 0.7,
expressiveness: 0.8, expressiveness: 0.8,
trustGrowthRate: 1.5, trustGrowthRate: 1.5,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: ["misreads-context"], // K-3:口風不緊——群聊中偶爾會不小心把私密記憶說出來,是這個原型的性格設定例外。
traits: ["misreads-context", "loose-lipped"],
conversationBaseline: 0.6,
romanceGainRate: 1,
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 1,
}, },
元氣: { 元氣: {
emotionTriggerThreshold: 0.7, emotionTriggerThreshold: 0.7,
@@ -45,6 +67,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 1.3, trustGrowthRate: 1.3,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: ["talkative", "fast-emotion-decay"], traits: ["talkative", "fast-emotion-decay"],
conversationBaseline: 0.85,
romanceGainRate: 1.3, // 快速上升
romanceBigEventBonus: 1,
romanceAmbiguousStallFactor: 0.3, // 但卡在曖昧期打轉——怕改變現狀
}, },
大小姐: { 大小姐: {
emotionTriggerThreshold: 1.3, emotionTriggerThreshold: 1.3,
@@ -52,6 +78,10 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.4, trustGrowthRate: 0.4,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: ["formal-address", "poor-loser"], traits: ["formal-address", "poor-loser"],
conversationBaseline: 0.55,
romanceGainRate: 0.5, // 平時矜持撐住,小事件幾乎不動
romanceBigEventBonus: 2.2, // 某事件後大幅跳升——階梯狀曲線
romanceAmbiguousStallFactor: 1,
}, },
三無: { 三無: {
emotionTriggerThreshold: 3.5, emotionTriggerThreshold: 3.5,
@@ -59,6 +89,11 @@ export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
trustGrowthRate: 0.2, trustGrowthRate: 0.2,
invertedIntimacyExpression: false, invertedIntimacyExpression: false,
traits: ["very-short-replies", "sudden-overflow"], 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, occurredAt: now,
emotionTag: signal.tag, emotionTag: signal.tag,
emotionIntensity: signal.intensity || 0.3, emotionIntensity: signal.intensity || 0.3,
source: "OFFLINE_GENERATED", // 來自角色設定表本身,不是 I-6 的「離線生成日常事件」,兩者不可共用同一 source 值
// (I-6 用 source=OFFLINE_GENERATED 判斷「今天是否已生成過離線事件」,混用會誤判)。
source: "SOURCE_EXTRACTION",
weight: 2, 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");

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