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 群組冒煙測試無回歸。
This commit is contained in:
Jeffery
2026-08-13 12:12:49 +08:00
parent 503024c92f
commit 9e5036f0a0
16 changed files with 550 additions and 29 deletions
+90
View File
@@ -0,0 +1,90 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const WEB_PORT = process.env.PORT_WEB ?? "3100";
const CHARACTER_ID = "seed-character-genki";
const USER_ID = "seed-user-primary";
async function post(path, body) {
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}`);
}
return res.json();
}
async function get(path) {
const res = await fetch(`http://localhost:${API_PORT}${path}`);
if (!res.ok) {
throw new Error(`GET ${path} 回傳 ${res.status}`);
}
return res.json();
}
export default async function smokeH() {
const sessionId = "smoke-h-" + Date.now();
await prisma.emotionState.update({
where: { characterId: CHARACTER_ID },
data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date() },
});
const before = await get(`/relationships/${CHARACTER_ID}/${USER_ID}`);
const intimacyBefore = before.relationship.intimacy;
// H-1 對話 API:一輪完整對話回傳回應、情緒狀態與親密度變化
const result = await post(`/chat/${CHARACTER_ID}`, {
userId: USER_ID,
sessionId,
text: "謝謝你,我今天好開心!",
});
if (!result.text || typeof result.text !== "string") {
throw new Error("對話 API 未回傳文字內容");
}
if (result.context.emotion.dominant !== "JOY") {
throw new Error(`對話後情緒狀態應變為愉悅,實際為 ${result.context.emotion.dominant}`);
}
if (result.context.relationship.intimacy <= intimacyBefore) {
throw new Error("對話後親密度應有變化(累積日常互動)");
}
// H-1 session 生命週期:POST /session/:id/end 觸發睡眠固化,工作記憶應被清空
const contextBefore = await get(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`);
if (contextBefore.context.length === 0) {
throw new Error("對話後工作記憶應保留本輪內容,session 結束前不應為空");
}
await post(`/session/${sessionId}/end`, { characterId: CHARACTER_ID });
const contextAfter = await get(`/memory/${CHARACTER_ID}/sessions/${sessionId}/messages`);
if (contextAfter.context.length !== 0) {
throw new Error("session 結束後應觸發睡眠固化並清空工作記憶");
}
// H-2~H-5:對話頁面應包含情緒晶片、親密度顯示、心跳波形、立繪佔位區塊的結構標記
const pageRes = await fetch(`http://localhost:${WEB_PORT}/chat`);
if (!pageRes.ok) {
throw new Error(`GET /chat 回傳 ${pageRes.status}`);
}
const html = await pageRes.text();
if (!html.includes("親密度")) {
throw new Error("對話頁面缺少親密度顯示(H-2)");
}
if (!html.includes("heartbeat-wave-line")) {
throw new Error("對話頁面缺少心跳波形母題(H-4)");
}
if (!html.includes("立繪")) {
throw new Error("對話頁面缺少立繪佔位區塊(H-5)");
}
// 清理本次測試造成的狀態變化,避免影響其他群組或人工檢視
await prisma.relationship.update({
where: { characterId_userId: { characterId: CHARACTER_ID, userId: USER_ID } },
data: { intimacy: 25 },
});
await prisma.emotionState.update({
where: { characterId: CHARACTER_ID },
data: { calm: 70, joy: 20, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date() },
});
}