Files
Kokorone/docs/操作手冊.md
T

435 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 心音(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 常駐部署