Merge pull request 'docs(操作手冊): 新增心音操作手冊' (#2) from develop into master

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-09-07 06:38:10 +00:00
+434
View File
@@ -0,0 +1,434 @@
# 心音(Kokorone)操作手冊
> 更新時間:2026/08/17 18:26:47(Asia/Taipei)
> 適用版本:`kokorone@0.1.0`(monorepo:pnpm workspace + Turborepo)
> 本手冊只描述「怎麼操作」;系統設計理念與部署背景見 [`README.md`](../README.md)。
---
## 1. 系統組成
| 元件 | 目錄 | 技術 | 預設埠 | 啟動方式 |
| --- | --- | --- | --- | --- |
| api | `apps/api` | NestJS(ESM) | `3001` | `node apps/api/dist/main.js` |
| web | `apps/web` | Next.js 16 + Tailwind + pixi.js | `3100` | `next dev` / `next start` |
| mobile | `apps/mobile` | Expo + expo-router | `8081`(Expo web) | `pnpm --filter @kokorone/mobile start` |
| shared | `packages/shared` | TypeScript 型別/`log()` | — | 由 api、web、mobile、腳本共用 |
| db | `packages/db` | Prisma Client 包裝(`@kokorone/db`) | — | 由 api 與冒煙測試共用 |
| 資料庫 | `prisma/dev.db` | SQLite(可換 PostgreSQL) | — | Prisma migrate |
| 快取 | 外部 | Redis(**選用**) | `6379` | 連不到時自動退回 DB/行程記憶體 |
| LLM | 外部 | CLIProxy → Claude(或直連官方 API) | `3000` | `LLM_PROVIDER=claude` 時才呼叫 |
```mermaid
flowchart LR
subgraph 用戶端
W["web :3100<br/>/ 與 /chat"]
M["mobile APP<br/>聊天/角色/日常/設定"]
end
W -->|NEXT_PUBLIC_API_URL| A
M -->|API_BASE_URL| A
A["api :3001<br/>NestJS 17 個模組"]
A --> DB[("SQLite / PostgreSQL<br/>Prisma")]
A -.選用.-> R[("Redis :6379<br/>情緒/session 快取")]
A -.LLM_PROVIDER=claude.-> P["CLIProxy :3000<br/>→ Claude"]
A -.PUSH_PROVIDER=expo.-> E["Expo Push"]
```
---
## 2. 首次安裝
前置條件:Node.js ≥ 20(本機為 v26.7.0)、pnpm 11.21.0(`packageManager` 已鎖定)。
不需要 Docker——本專案刻意以「Node 行程直跑」為部署模式。
```bash
cd /root/jiantw83/Kokorone
pnpm install # 安裝所有 workspace 套件
cp .env.example .env # 依需要調整(見第 4 節)
npx prisma migrate deploy # 套用 prisma/migrations(15 筆)
npx prisma db seed # 建立種子使用者/角色(見第 8 節)
pnpm run build # turbo:shared → db → api → web
pnpm run restart # 停舊行程、tsc -b、啟動 api/web 並等健康檢查
```
`pnpm run restart` 全綠時會輸出:
```
[…][重啟][INF]: 啟動 api(pid=…,port=3001)
[…][重啟][INF]: 啟動 web(pid=…,port=3100)
[…][重啟][INF]: api /health 健康檢查通過
[…][重啟][INF]: web 首頁 健康檢查通過
[…][重啟][INF]: api 與 web 皆已就緒
```
---
## 3. 日常操作指令
| 目的 | 指令 | 說明 |
| --- | --- | --- |
| 建置全部 | `pnpm run build` | turbo 依相依順序建置,含 web production bundle |
| 只建 TypeScript | `npx tsc -b` | `restart` 內部使用的同一步 |
| 重啟 api/web | `pnpm run restart` | 冪等;會先殺掉 `.dev-pids` 記錄的整個 process group |
| 單一群組冒煙測試 | `pnpm run smoke A` | **不要加 `--`**(見下方注意事項) |
| 全部冒煙測試 | `pnpm run smoke all` | 依字母序跑 `scripts/smoke/*.mjs` |
| 看 api 日誌 | `tail -f .dev-logs/api.log` | `restart` 啟動的 stdout/stderr |
| 看 web 日誌 | `tail -f .dev-logs/web.log` | 同上 |
| 查目前行程 | `cat .dev-pids` | `{"api": pid, "web": pid}` |
| 手動全停 | `kill -TERM -<pid>`(pid 取自 `.dev-pids`,**前面的減號代表整個 process group**) | 沒有獨立的 stop script;停止邏輯已包在 `restart` 的第一步 |
| 啟動 mobile | `pnpm --filter @kokorone/mobile start` | 另有 `android`/`ios`/`web` 三個 script |
> **`--` 注意事項**:pnpm 不像 npm 會吃掉分隔符。`pnpm run smoke -- A` 會把字面上的 `--`
> 當成群組代號而找不到檔案;請寫 `pnpm run smoke A`。
行程管理細節(`scripts/dev-restart.mjs`):
- 子行程以 `detached: true` 啟動,因此訊號一律送給 **整個 process group**(`kill -pid`),
避免 `next dev` 多層 fork 留下孤兒佔用埠號。
- SIGTERM 後 5 秒未結束會補 SIGKILL。
- 健康檢查逾時 60 秒(每秒輪詢):api 檢查 `GET /health` 的 `status === "ok"`,web 檢查首頁 200。
- 埠號可用 `PORT_API`/`PORT_WEB` 覆寫。
---
## 4. 環境變數與 Provider 切換
根目錄 `.env`(api 與腳本共用);`apps/web/.env.local` 另外設定瀏覽器端要用的 `NEXT_PUBLIC_API_URL`。
| 變數 | 預設 | 操作重點 |
| --- | --- | --- |
| `DATABASE_URL` | `file:./prisma/dev.db` | 換 PostgreSQL 時同步把 `prisma/schema.prisma` 的 `provider` 改成 `postgresql` |
| `REDIS_URL` | `redis://127.0.0.1:6379` | **選用**。連不到只會出現一次 `[啟動][WRN]` 並退回 DB/行程記憶體 |
| `PORT` | `3001` | api 監聽埠 |
| `PORT_API` / `PORT_WEB` | `3001` / `3100` | 僅 `pnpm run restart` 使用 |
| `WEB_ORIGIN` | `http://localhost:3100` | CORS 白名單;`http://localhost:8081`(Expo web)已硬編加入 |
| `NEXT_PUBLIC_API_URL` | `http://localhost:3001` | **build time 注入**,改了要重新 build web |
| `LLM_PROVIDER` | `mock` | `mock`=模板庫(冒煙測試固定用這個);`claude`=真的打 API |
| `CLAUDE_BASE_URL` | `http://localhost:3000/api/v1` | 走 CLIProxy;直連官方改 `https://api.anthropic.com/v1` |
| `CLAUDE_API_KEY` | (空) | `LLM_PROVIDER=claude` 時必填 |
| `CLAUDE_MODEL` | `claude-sonnet-4-5` | CLIProxy 端需有能接此模型的 Worker,否則回報「沒有任何可用的組合」 |
| `TTS_PROVIDER` | `mock` | `real` 目前是空殼(P-4),尚未接真實供應商 |
| `PUSH_PROVIDER` | `mock` | `expo` 可直接用,不需金鑰 |
切換 provider 的操作順序:**改 `.env` → `pnpm run restart`**。
provider 是在模組初始化時決定的(`llm.module.ts`/`voice.module.ts`/`push.module.ts`),不重啟不會生效。
切到 `claude` 時啟動日誌會有一行 `[啟動][INF]: LLM_PROVIDER=claude,對話生成將呼叫真實 API(CLAUDE_BASE_URL=…)`,可用來確認生效。
---
## 5. 使用者端操作
### 5.1 web(`http://localhost:3100`)
| 頁面 | 路徑 | 操作 |
| --- | --- | --- |
| 首頁 | `/` | 心跳波動畫 + 「開始聊天」按鈕 |
| 對話 | `/chat` | 輸入訊息送出;右側/上方立繪即時換表情,左上顯示親密度、右上顯示情緒標籤 |
`/chat` 的行為要點:
- 角色與使用者目前是寫死的 `seed-character-genki`/`seed-user-primary`;`sessionId` 每次開頁用 `crypto.randomUUID()` 產生。
- 送出後會先查 `GET /tachie/:characterId/:userId/expression` 套用表情,**220ms 後**才顯示文字,讓表情先於台詞。
- 若回應 `availability === "NO_REPLY"`(她在睡覺且未破例),這一輪不會有訊息出現——這是預期行為,不是壞掉。
- 錯誤訊息「訊息傳送失敗,請確認 api 服務是否已啟動」=api 沒起來或 CORS 被擋。
### 5.2 mobile(Expo)
```bash
pnpm --filter @kokorone/mobile start # 掃 QR 進 Expo Go
pnpm --filter @kokorone/mobile web # 瀏覽器版(:8081,已在 api CORS 白名單)
```
| 分頁 | 功能 | 備註 |
| --- | --- | --- |
| 聊天 | 同 web 的對話流程 | 底部分頁籤是 APP 相對行動網頁的唯一差異 |
| 角色 | 角色資訊 | — |
| 日常 | 顯示 `GET /schedule/:characterId/info`:作息類型、目前時段、回應可用度、剛睡醒/快睡了 | 角色沒掛作息表時 slot 為 `null`,顯示「(未設定作息表)」 |
| 設定 | 「檢查連線」按 `/health`;「啟用推播」請求權限→取 Expo token→`POST /push/register` | 推播 token **只有實機/支援的模擬器**取得到,其他環境會顯示提示訊息 |
---
## 6. API 操作參考
Base URL:`http://localhost:3001`(無全域前綴)。所有 `now?` 參數都是測試用的「模擬現在時間」(ISO 字串),
`seed?` 用來固定隨機結果,正式呼叫可省略。
### 6.1 健康檢查與角色
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| GET | `/health` | `{ status, version, uptime }`,`restart` 與冒煙測試 A 都靠這支 |
| GET | `/characters` | 角色清單 |
### 6.2 對話(正式使用者端點)
| 方法 | 路徑 | Body |
| --- | --- | --- |
| POST | `/chat/:characterId` | `{ userId, sessionId, text, seed?, now? }` → 走完整管線(記憶檢索→情緒→關係→作息→生成→輸出過濾) |
| POST | `/session/:sessionId/end` | `{ characterId }` → 觸發「睡眠固化」(記憶整併) |
`/dialogue/*` 是 F 群組留下的**工程內部驗證端點**,不是給前端用的:
| 方法 | 路徑 | Body/Query |
| --- | --- | --- |
| POST | `/dialogue/:characterId/sessions/:sessionId/messages` | `{ userId, text, seed?, now? }` |
| POST | `/dialogue/:characterId/reinforce` | `{ situation, responsePattern }` |
| POST | `/dialogue/:characterId/correct` | `{ situation, oldResponsePattern, newResponsePattern }` |
| GET | `/dialogue/:characterId/top-rule` | `?situation=` |
### 6.3 記憶
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| POST | `/memory/:characterId/sessions/:sessionId/messages` | `{ role: "user"\|"character", content, emotionTag?, emotionIntensity? }` 寫入工作記憶 |
| GET | `/memory/:characterId/sessions/:sessionId/messages` | 取回該 session 上下文 |
| POST | `/memory/:characterId/sessions/:sessionId/consolidate` | 手動固化該 session |
| GET | `/memory/:characterId/retrieve` | `?query=&limit=` 記憶檢索 |
| POST | `/memory/forgetting-sweep` | `{ now? }` 手動跑遺忘掃描,回 `{ decayed, deleted }` |
| POST/GET | `/memory/test-scheduled-job`、`/memory/test-scheduled-job/:id` | 排程佇列自測用 |
### 6.4 情緒
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| GET | `/emotion/:characterId` | `?now=` 目前六維情緒狀態(calm/joy/sad/alert/shy/grumpy) |
| GET | `/emotion/:characterId/response-style` | `?now=` 由情緒推出的語氣/句長/主動性 |
| POST | `/emotion/:characterId/input` | `{ text, triggerThreshold?, halfLifeMs?, now?, threatConfirmedSafe?, conflictContinues?, comforted?, placated? }` |
### 6.5 關係
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| GET | `/relationships/stage-events` | 階段事件定義表 |
| GET | `/relationships/:characterId/:userId` | `?now=` 親密度/信任/階段 |
| POST | `/relationships/:characterId/:userId/interactions` | `{ intimacyDelta?, now? }` |
| POST | `/relationships/:characterId/:userId/events` | `{ event, weight, negativeBiasMultiplier?, now? }` 記入情感帳本 |
| GET | `/relationships/:characterId/:userId/ledger-net` | 帳本淨值 |
| GET | `/relationships/:characterId/:userId/intent` | `?text=` 意圖判讀 |
### 6.6 人格與角色建立
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| POST | `/personality/characters` | 建角色。必填 `source`(`ORIGINAL`\|`EXISTING_WORK`)、`formalName`、`basicInfo`、`backgroundStory`、`personalityArchetype`、`likesDislikes`、`goalsObsessions`、`speechStyle`;選填 `id`、`buildStatus`、`workId`、`initialRelationValue`、`initialRelationship{userId,intimacy?,trust?}`。會一併產生別名、初始語意/情節記憶、情緒狀態與關係初值 |
| GET | `/personality/archetypes`、`/personality/archetypes/:archetype` | 原型參數表 |
| GET | `/personality/:archetype/expressed-intimacy` | `?intimacy=` 外顯親密度 |
| GET | `/personality/address` | `?userName=&intimacy=` 稱呼 |
| GET | `/personality/address-transition` | `?userName=&previous=&current=` 稱呼變化 |
| POST | `/personality/:characterId/contrast-trigger` | `{ intimacy, emotionTag, now?, roll? }` |
| GET | `/personality/cuteness/spoiled` | `?intimacy=&emotionTag=` |
| GET | `/personality/cuteness/grumpy-recovery` | `?turns=` |
| GET | `/personality/cuteness/third-party` | `?text=` |
| GET | `/personality/cuteness/exclusive` | `?trust=` |
### 6.7 作息
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| POST | `/schedule/:characterId/seed-default` | 掛上預設作息表(**新角色要先跑這支**,日常分頁才看得到時段) |
| POST | `/schedule/:characterId/special-days` | `{ date, label }` |
| GET | `/schedule/:characterId/info` | `?now=` 目前時段/可用度 |
| POST | `/schedule/:characterId/exception` | `{ userId, mode, intimacy, dominantEmotion, userInput, now? }` 嘗試破例 |
| GET | `/schedule/:characterId/baseline` | `?now=` 作息造成的情緒基線 |
| POST | `/schedule/:characterId/offline-event` | `{ now? }` 生成離線日常事件(每天一次) |
### 6.8 任務
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| GET | `/task?characterId=&userId=` | 任務清單 |
| GET | `/task/:taskId` | 單筆 |
| POST | `/task/:characterId/parse` | `{ userId, text, now? }` 自然語言解析成任務草稿 |
| POST | `/task/:characterId` | `{ userId, type, content, rawText, triggerAt?, cronExpression?, condition?, now? }`;type:`ONE_TIME`\|`RECURRING`\|`CONDITIONAL`\|`QUERY_ORGANIZE`\|`TODO_TRACKING` |
| POST | `/task/:taskId/fire-now` | `{ now? }` 立刻觸發 |
| POST | `/task/:taskId/check-overdue`/`complete`/`fail` | `{ now? }` |
| POST | `/task/proactive-care/:characterId` | `{ userId, now? }` 主動關心 |
### 6.9 多人房間
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| POST | `/room` | `{ userId, characterIds[], mode?: "GROUP"\|"SELF_CHAT", topicSeed? }` |
| GET | `/room/:roomId` | 房間狀態與發言記錄 |
| POST | `/room/:roomId/message` | `{ userId, userLabel?, text, now? }` |
| POST | `/room/:roomId/self-chat/advance` | `{ now? }` 推進角色間自聊 |
| POST | `/room/:roomId/interject` | 同 message body,插話 |
| POST | `/room/:roomId/end` | 結束並固化 |
| POST | `/room/relationship/:characterId` | `{ otherCharacterId, affinity, dynamic? }` 設定角色間關係 |
### 6.10 戀愛線與分級
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| POST | `/romance/:characterId/age-evidence` | `{ evidenceType, impliesAdult, isEpilogue?, description }` |
| GET | `/romance/:characterId/age-determination` | 年齡判定結果 |
| GET | `/romance/:characterId/eligibility` | 戀愛線可用性 |
| POST | `/romance/adult-mode/consent` | `{ userId, verifiedAdult, optedIn }` |
| POST | `/romance/:characterId/adult-content-check` | `{ userId, now? }`;被拒會寫入嘗試記錄 |
| GET | `/romance/:characterId/:userId/state` | `?now=` 戀愛階段狀態 |
| POST | `/romance/:characterId/event` | `{ userId, eventType, severe?, now? }` |
| POST | `/romance/:characterId/confess` | `{ userId, now? }` |
| POST | `/romance/:characterId/milestone` | `{ userId, milestoneType, now? }` |
### 6.11 正史研究管線(canon)
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| POST | `/canon/scenes` | 匯入場景(自動做台詞歸屬判定) |
| GET | `/canon/scenes/:sceneId`、`/canon/works/:workId/scenes` | 查場景 |
| POST | `/canon/scene-lines/:lineId/adjudicate` | `{ speakerCharacterId }` |
| POST | `/canon/scenes/:sceneId/reattempt-quarantined` | 重試被隔離的台詞 |
| POST | `/canon/scene-lines/:lineId/correct` | `{ newSpeakerCharacterId, now? }` 人工更正 |
| GET | `/canon/characters/:characterId/roster-progress` | 角色表建構進度 |
| POST | `/canon/illustrations`、GET `/canon/scenes/:sceneId/illustrations` | 插圖登錄/查詢 |
| GET | `/canon/characters/:characterId/costume-catalog`、`.../pose-vocabulary` | 服裝/姿勢詞彙 |
| POST | `/canon/works/:workId/anchor` | `{ storyOrder }` 設定時間軸錨點 |
| POST/GET | `/canon/works/:workId/detect-contradictions`、`/contradictions` | 矛盾偵測與清單 |
### 6.12 後日談(epilogue)
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| POST | `/epilogue/works/:workId/check-transition` | 檢查是否進入後日談 |
| POST | `/epilogue/works/:workId/time-flow` | `{ mode }` 時間流速 |
| GET | `/epilogue/characters/:characterId/drift` | `?atStoryOrder=` 人格漂移 |
| POST | `/epilogue/characters/:characterId/milestone` | 提案人生里程碑 |
| POST | `/epilogue/works/:workId/reclaim` | `{ newCanonScenes: [...] }` 正史回收 |
| POST | `/epilogue/works/:workId/branch` | 分支 |
### 6.13 立繪與語音
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| GET | `/tachie/:characterId/manifest` | 立繪素材清單 |
| GET | `/tachie/:characterId/:userId/expression` | `?isThinking=&isDeflecting=` 目前表情差分 |
| POST | `/voice/:characterId/voice-sheet/seed-default` | `{ verbalTics?, forbiddenSounds? }` |
| GET | `/voice/:characterId/voice-sheet` | 聲音設定表 |
| GET | `/voice/:characterId/:userId/prosody`、`.../nonverbal-cue` | 語調/非語言提示 |
| POST | `/voice/:characterId/:userId/synthesize` | `{ text, now?, seed? }`(`TTS_PROVIDER=mock` 時回模擬結果) |
| POST | `/voice/:characterId/analyze-input` | `{ userId, text, paralinguistic, now? }` |
### 6.14 推播
| 方法 | 路徑 | 說明 |
| --- | --- | --- |
| POST | `/push/register` | `{ userId, token, platform }` |
| POST | `/push/send-test` | `{ userId, title, body }`;`PUSH_PROVIDER=mock` 只寫記錄不真的送 |
---
## 7. 冒煙測試
```bash
pnpm run smoke A # 單一群組
pnpm run smoke all # 全部群組(依字母序)
```
- 執行前必須先 `pnpm run restart`——測試是打真實 HTTP 端點 + 直接讀寫資料庫的。
- `LLM_PROVIDER` 請保持 `mock`,群組斷言依賴模板庫的確定性輸出。
- 全綠時每組輸出 `[冒煙測試][INF]: X 群組冒煙測試全綠`;任一組失敗整體 exit code 為 1,
但 `all` 不會中斷,會把每組跑完再回報。
| 群組 | 覆蓋範圍 | 主要驗證對象 |
| --- | --- | --- |
| A | 基礎環境 | `/health` 200、web 首頁可服務 |
| B | 資料庫 schema | 每張表都 `count()` 得動(缺表就拋錯) |
| C | 記憶系統 | 工作記憶寫入/固化、`retrieve`、遺忘掃描、排程佇列 |
| D | 情緒引擎 | 六維狀態機、衰減、標記器(直接操作 `emotionState`) |
| E | 關係系統 | 互動/事件、情感帳本淨值、階段事件、意圖判讀 |
| F | LLM 對話管線 | `/dialogue/*`、行為強化與更正、(會 spawn 子行程驗證重啟行為) |
| G | 人格 | 原型參數、稱呼、反差觸發、角色建立、可愛度行為 |
| H | 對話 API + 前端 | `/chat/:id`、`/session/:id/end`、關係與工作記憶連動、web 頁面 |
| I | 作息 | 預設作息表、時段判定、破例、情緒基線、離線事件(固定時間軸) |
| J | 任務 | 解析、建立、觸發、逾期、完成/失敗、主動關心 |
| K | 多人房間 | 建房、發言權、自聊推進、插話、結束固化、跨角色記憶 |
| L | 戀愛與分級 | 年齡判定、成人模式同意、內容閘門、告白、里程碑 |
| M | 正史管線 | 場景匯入、台詞歸屬、隔離重試、人工更正、插圖、矛盾偵測 |
| N | 後日談 | 時間流速、人格漂移、正史回收 |
| O | 立繪表情 | manifest、表情差分(含 thinking/deflecting)、與對話的先後順序 |
| P | 語音 | voice sheet、語調、非語言提示、合成、輸入分析 |
| Q | 推播與 APP | `/health`、token 註冊、測試推播、任務觸發推播 |
> 註:README 提到「A~R」,但 `scripts/smoke/` 目前只有 **A~Q**(17 個檔案)。
> R 群組(Redis 快取、PostgreSQL 遷移、真實 LLM provider)沒有對應的冒煙測試腳本,
> `pnpm run smoke all` 也就不會涵蓋——R 的變更要靠 `.env` 切換後手動驗證。
---
## 8. 資料庫操作
| 目的 | 指令 |
| --- | --- |
| 套用既有遷移(正式/CI) | `npx prisma migrate deploy` |
| 開發時改 schema 後產生遷移 | `npx prisma migrate dev --name <說明>` |
| 檢查遷移狀態 | `npx prisma migrate status` |
| 重新產生 Prisma Client | `npx prisma generate` |
| 灌種子資料 | `npx prisma db seed` |
| 視覺化瀏覽 | `npx prisma studio` |
`prisma.config.ts` 已指定 `schema: prisma/schema.prisma`、`migrations.path: prisma/migrations`、
`seed: node prisma/seed.ts`(Node 26 直接跑 TS,不需要額外 loader)。
種子資料(`prisma/seed.ts`,**upsert 冪等,可重複執行**):
| 項目 | ID | 內容 |
| --- | --- | --- |
| 使用者 | `seed-user-primary` | 測試使用者 |
| 角色 | `seed-character-genki` | 別名「小陽」,元氣型原型 |
| 關係 | `seed-relationship-genki-primary` | 親密度 25/信任 10/階段 `ACQUAINTANCE` |
| 情緒 | — | `calm=70, joy=20`,其餘 0 |
| 記憶 | `seed-memory-episodic-cheer`、`seed-memory-semantic-sport`、`seed-rule-greeting` | 情節/語意/程序記憶各一 |
| 帳本 | `seed-ledger-first-help` | 權重 8 的正向事件 |
web 與 mobile 目前都寫死用這兩個 ID,所以**沒灌種子資料前端會直接報錯**。
種子角色沒有掛作息表,要看「日常」分頁的時段資訊,需先呼叫
`POST /schedule/seed-character-genki/seed-default`。
切換到 PostgreSQL 的操作順序:改 `DATABASE_URL` → 改 `schema.prisma` 的 `provider`
→ `npx prisma migrate deploy` → `npx prisma db seed` → `pnpm run restart`。
---
## 9. 背景排程與快取行為
| 機制 | 位置 | 操作影響 |
| --- | --- | --- |
| 遺忘掃描 | `memory.module.ts`,cron `0 * * * *`(每小時整點) | 情緒強度低且 24 小時未被提取的情節記憶權重 ×0.5,低於 0.05 直接刪除。要立即驗證用 `POST /memory/forgetting-sweep` |
| 任務排程 | `InProcessJobQueue`(`setTimeout` + `node-cron`) | **計時器只在記憶體裡,重啟即消失**;api 啟動時 `TaskSchedulerService.onModuleInit` 會重掃 `status=PENDING` 且有 `triggerAt` 的任務重新掛回,已逾期的立刻補觸發 |
| 排程錯誤 | 同上 | 失敗只記 `[排程][ERR]: 工作 <name> 執行失敗:…`,不會讓行程崩掉——排查請看 `.dev-logs/api.log` |
| Redis 快取 | `cache/redis-client.ts` | 情緒狀態即時讀寫與 session 快取的加速層。連不到時只在啟動印一次 WRN,之後靜默退回 Prisma/行程記憶體 Map;**功能不會少,只是慢** |
---
## 10. 疑難排解
| 症狀 | 判斷與處置 |
| --- | --- |
| `restart` 報「健康檢查逾時」 | 看 `.dev-logs/api.log`/`web.log`;常見是埠被占用或 `tsc -b` 產物過舊 |
| `restart` 報「行程已提前結束」 | 同上看 log;api 多為 `DATABASE_URL` 指向不存在的資料庫或遷移未套用 |
| 埠號被占用但 `.dev-pids` 是空的 | 上次不是用 `restart` 停的,孤兒行程仍在:`lsof -i :3001`/`:3100` 找出後 `kill -TERM -<pgid>` |
| web 顯示「訊息傳送失敗」 | api 沒起來,或前端來源不在 CORS 白名單(設 `WEB_ORIGIN` 後重啟 api) |
| 前端 API 位址改不動 | `NEXT_PUBLIC_API_URL` 是 build time 注入,改完要重新 `pnpm run build`/重啟 `next dev` |
| 送訊息沒有任何回覆 | 檢查回應的 `availability`:`NO_REPLY` 是作息(睡眠)造成的預期行為,可用 `?now=` 或作息破例端點驗證 |
| 冒煙測試找不到群組 | 用了 `pnpm run smoke -- A`;改成 `pnpm run smoke A` |
| 冒煙測試 B 群組拋錯 | 遷移沒套用完:`npx prisma migrate deploy` |
| 對話內容變成模板句 | `LLM_PROVIDER` 還是 `mock`;要真實生成需設 `claude` + `CLAUDE_API_KEY` 後重啟 |
| CLIProxy 回「沒有任何可用的 {模型}-{執行者}-{工具} 組合」 | CLIProxy 端沒有能接 `CLAUDE_MODEL` 的 Worker 註冊,屬代理端問題 |
| 啟動時出現 Redis WRN | 正常,Redis 是選用加速層;要消掉就把 Redis 跑起來或忽略 |
| 推播拿不到 token | 非實機環境取不到 Expo push token(設定頁會顯示提示),改用 `POST /push/send-test` 驗證後端流程 |
---
## 11. 正式環境部署(摘要)
完整說明見 [`README.md`](../README.md) 的「部署路徑」。操作順序:
1. `pnpm install --frozen-lockfile && pnpm run build`
2. `npx prisma migrate deploy`
3. 啟動:api `node apps/api/dist/main.js`;web 在 `apps/web` 下 `npx next start -p <PORT_WEB>`
4. 用 systemd(或 pm2)常駐兩個 Node 行程,`Restart=on-failure`,健康檢查沿用 `GET /health` 與 web 首頁 200
5. nginx/負載平衡器終止 TLS 後轉發,對外只開 443
6. mobile 走 `eas build` 產出安裝檔,不隨 api/web 常駐部署