From 9e5036f0a079edc3095a2512beedc2bd4f37852e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 13 Aug 2026 12:12:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20H=20=E7=BE=A4?= =?UTF-8?q?=E7=B5=84=20=E2=80=94=20=E7=B6=B2=E9=A0=81=E5=B0=8D=E8=A9=B1?= =?UTF-8?q?=E4=BB=8B=E9=9D=A2=EF=BC=88=E7=AC=AC=E4=B8=80=E5=80=8B=E5=8F=AF?= =?UTF-8?q?=E5=AF=A6=E9=9A=9B=E4=BD=BF=E7=94=A8=E7=9A=84=E5=9E=82=E7=9B=B4?= =?UTF-8?q?=E5=88=87=E7=89=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 群組冒煙測試無回歸。 --- .gitignore | 1 + apps/api/src/app.module.ts | 2 + apps/api/src/chat/chat.controller.ts | 34 +++++++ apps/api/src/chat/chat.module.ts | 10 ++ apps/api/src/llm/dialogue.service.ts | 8 ++ apps/api/src/main.ts | 1 + apps/web/app/chat/ChatView.tsx | 138 ++++++++++++++++++++++++++ apps/web/app/chat/page.tsx | 5 + apps/web/app/globals.css | 96 ++++++++++++++++-- apps/web/app/page.tsx | 24 ++--- apps/web/components/ChatBubble.tsx | 46 +++++++++ apps/web/components/HeartbeatWave.tsx | 46 +++++++++ apps/web/lib/api.ts | 46 +++++++++ apps/web/lib/emotion-labels.ts | 10 ++ scripts/smoke/H.mjs | 90 +++++++++++++++++ todo.md | 22 ++-- 16 files changed, 550 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/chat/chat.controller.ts create mode 100644 apps/api/src/chat/chat.module.ts create mode 100644 apps/web/app/chat/ChatView.tsx create mode 100644 apps/web/app/chat/page.tsx create mode 100644 apps/web/components/ChatBubble.tsx create mode 100644 apps/web/components/HeartbeatWave.tsx create mode 100644 apps/web/lib/api.ts create mode 100644 apps/web/lib/emotion-labels.ts create mode 100644 scripts/smoke/H.mjs diff --git a/.gitignore b/.gitignore index 74313f9..b932d90 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ coverage/ *.tsbuildinfo packages/db/generated/ +.claude/scheduled_tasks.lock diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5b825fe..9f4598e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { EmotionModule } from "./emotion/emotion.module.js"; import { RelationshipModule } from "./relationship/relationship.module.js"; import { LlmModule } from "./llm/llm.module.js"; import { PersonalityModule } from "./personality/personality.module.js"; +import { ChatModule } from "./chat/chat.module.js"; @Module({ imports: [ @@ -17,6 +18,7 @@ import { PersonalityModule } from "./personality/personality.module.js"; RelationshipModule, LlmModule, PersonalityModule, + ChatModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/chat/chat.controller.ts b/apps/api/src/chat/chat.controller.ts new file mode 100644 index 0000000..1c219ae --- /dev/null +++ b/apps/api/src/chat/chat.controller.ts @@ -0,0 +1,34 @@ +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; +} + +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); + } + + @Post("session/:sessionId/end") + async endSession(@Param("sessionId") sessionId: string, @Body() body: EndSessionBody) { + await this.consolidation.consolidate(body.characterId, sessionId); + return { ok: true }; + } +} diff --git a/apps/api/src/chat/chat.module.ts b/apps/api/src/chat/chat.module.ts new file mode 100644 index 0000000..67e9798 --- /dev/null +++ b/apps/api/src/chat/chat.module.ts @@ -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 {} diff --git a/apps/api/src/llm/dialogue.service.ts b/apps/api/src/llm/dialogue.service.ts index 3e0d255..821c4b6 100644 --- a/apps/api/src/llm/dialogue.service.ts +++ b/apps/api/src/llm/dialogue.service.ts @@ -1,6 +1,8 @@ import { Inject, Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service.js"; import { WorkingMemoryService } from "../memory/working-memory.service.js"; +import { EmotionService } from "../emotion/emotion.service.js"; +import { RelationshipService } from "../relationship/relationship.service.js"; import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js"; import { ContextAssemblerService } from "./context-assembler.service.js"; import { OutputFilterService } from "./output-filter.service.js"; @@ -35,6 +37,8 @@ export class DialogueService { private readonly contextAssembler: ContextAssemblerService, private readonly outputFilter: OutputFilterService, private readonly workingMemory: WorkingMemoryService, + private readonly emotion: EmotionService, + private readonly relationship: RelationshipService, private readonly prisma: PrismaService, ) {} @@ -48,6 +52,10 @@ export class DialogueService { const now = options.now ?? new Date(); this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now }); + // H-1:每輪對話先標記情緒、累積日常互動的親密度,讓後續組裝的上下文反映本輪的變化。 + await this.emotion.processInput(characterId, userInput, { now }); + await this.relationship.recordInteraction(characterId, userId, now); + const { isFastChannel, reason } = this.fastChannel.detect(userInput); const context = await this.contextAssembler.assemble(characterId, userId, sessionId, userInput, { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 5ce75a8..cc064b5 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -6,6 +6,7 @@ import { AppModule } from "./app.module.js"; async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: false }); + app.enableCors({ origin: process.env.WEB_ORIGIN ?? "http://localhost:3100" }); const port = Number(process.env.PORT ?? 3001); await app.listen(port); log("啟動", "INF", `api 服務已啟動,監聽埠 ${port}`); diff --git a/apps/web/app/chat/ChatView.tsx b/apps/web/app/chat/ChatView.tsx new file mode 100644 index 0000000..6913a73 --- /dev/null +++ b/apps/web/app/chat/ChatView.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { sendChatMessage } from "../../lib/api"; +import { EMOTION_LABELS } from "../../lib/emotion-labels"; +import { ChatBubble } from "../../components/ChatBubble"; +import { HeartbeatWave } from "../../components/HeartbeatWave"; +import type { EmotionTag } from "@kokorone/shared"; + +const CHARACTER_ID = "seed-character-genki"; +const USER_ID = "seed-user-primary"; + +interface Message { + role: "user" | "character"; + text: string; +} + +export function ChatView() { + const [sessionId] = useState(() => crypto.randomUUID()); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [sending, setSending] = useState(false); + const [dominant, setDominant] = useState("CALM"); + const [intimacy, setIntimacy] = useState(0); + const [error, setError] = useState(null); + + async function handleSend() { + const text = input.trim(); + if (!text || sending) { + return; + } + setSending(true); + setError(null); + setInput(""); + setMessages((prev) => [...prev, { role: "user", text }]); + + try { + const result = await sendChatMessage(CHARACTER_ID, USER_ID, sessionId, text); + setMessages((prev) => [...prev, { role: "character", text: result.text }]); + setDominant(result.context.emotion.dominant); + setIntimacy(result.context.relationship.intimacy); + } catch { + setError("訊息傳送失敗,請確認 api 服務是否已啟動"); + } finally { + setSending(false); + } + } + + return ( +
+
+ + 親密度 {intimacy} + + + {EMOTION_LABELS[dominant]} + +
+
+
+
+

+ 立繪(表情差分即時變化) +

+
+
+ +
+
+ +

+ 心音 Kokorone +

+
+ +
+ {messages.length === 0 && ( +

+ 傳個訊息,開始今天的對話吧。 +

+ )} + {messages.map((message, index) => ( + + ))} +
+ + {error && ( +

+ {error} +

+ )} + +
+ setInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleSend(); + } + }} + placeholder="輸入訊息……" + className="flex-1 bg-transparent outline-none" + style={{ color: "var(--ink)" }} + disabled={sending} + /> + +
+
+
+ ); +} diff --git a/apps/web/app/chat/page.tsx b/apps/web/app/chat/page.tsx new file mode 100644 index 0000000..ad143c1 --- /dev/null +++ b/apps/web/app/chat/page.tsx @@ -0,0 +1,5 @@ +import { ChatView } from "./ChatView"; + +export default function ChatPage() { + return ; +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 3711c0d..555483b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,10 +1,25 @@ @import "tailwindcss"; :root { - --color-heartbeat-pink: #ff7e9d; - --color-dusk-purple: #8c7ae6; - --background: #fff8fa; - --foreground: #3a3242; + --bg: #fff8fa; + --bg-soft: #fdeef3; + --ink: #3a3242; + --ink-soft: #7a6e85; + --accent: #e85d80; + --accent-bright: #ff7e9d; + --violet: #8c7ae6; + --line: #f0dce4; + --card: #ffffff; + --chip-bg: #fde7ee; + --wave: #ff7e9d; + --frame: #e8d8df; + --shadow: 0 12px 40px rgba(232, 93, 128, 0.1); + + /* 向下相容 A-5 首頁使用的別名 */ + --color-heartbeat-pink: var(--accent-bright); + --color-dusk-purple: var(--violet); + --background: var(--bg); + --foreground: var(--ink); } @theme inline { @@ -12,19 +27,82 @@ --color-dusk-purple: var(--color-dusk-purple); --color-background: var(--background); --color-foreground: var(--foreground); + --color-bg: var(--bg); + --color-bg-soft: var(--bg-soft); + --color-ink: var(--ink); + --color-ink-soft: var(--ink-soft); + --color-accent: var(--accent); + --color-accent-bright: var(--accent-bright); + --color-violet: var(--violet); + --color-line: var(--line); + --color-card: var(--card); + --color-chip-bg: var(--chip-bg); + --color-frame: var(--frame); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } @media (prefers-color-scheme: dark) { :root { - --background: #1a1430; - --foreground: #fff8fa; + --bg: #1a1430; + --bg-soft: #241b40; + --ink: #f2e9f2; + --ink-soft: #a99bbf; + --accent: #ff7e9d; + --accent-bright: #ff9db5; + --violet: #a796f5; + --line: #372b55; + --card: #221a3d; + --chip-bg: #33244a; + --wave: #ff7e9d; + --frame: #3b2f5c; + --shadow: 0 12px 40px rgba(0, 0, 0, 0.45); } } body { - background: var(--background); - color: var(--foreground); - font-family: var(--font-sans), Arial, Helvetica, sans-serif; + background: var(--bg); + color: var(--ink); + font-family: var(--font-sans), ui-rounded, "PingFang TC", "Microsoft JhengHei", sans-serif; +} + +/* H-4 心跳波形母題:載入動畫由左至右畫出 */ +.heartbeat-wave-line { + color: var(--wave); +} +.heartbeat-wave-animate { + stroke-dasharray: 600; + stroke-dashoffset: 600; + animation: heartbeat-draw 1.6s ease-out forwards; +} +@keyframes heartbeat-draw { + to { + stroke-dashoffset: 0; + } +} + +/* H-4 通知紅點:一次小小的心跳脈動 */ +.heartbeat-dot { + animation: heartbeat-pulse 1.2s ease-in-out infinite; +} +@keyframes heartbeat-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.3); + opacity: 0.7; + } +} + +@media (prefers-reduced-motion: reduce) { + .heartbeat-wave-animate { + stroke-dashoffset: 0; + animation: none; + } + .heartbeat-dot { + animation: none; + } } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 59d2c28..0739867 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,24 +1,20 @@ +import Link from "next/link"; +import { HeartbeatWave } from "../components/HeartbeatWave"; + export default function Home() { return (

心音 Kokorone

- + - - + 開始聊天 +
); } diff --git a/apps/web/components/ChatBubble.tsx b/apps/web/components/ChatBubble.tsx new file mode 100644 index 0000000..84da180 --- /dev/null +++ b/apps/web/components/ChatBubble.tsx @@ -0,0 +1,46 @@ +interface ChatBubbleProps { + role: "user" | "character"; + text: string; +} + +// H-3 動作描寫標記渲染:把 *…* 標記以暮空紫小字呈現,與台詞區分。 +function renderContent(text: string) { + const parts = text.split(/(*[^*]+*)/g).filter(Boolean); + return parts.map((part, index) => { + const match = part.match(/^*([^*]+)*$/); + if (match) { + return ( + + {match[1]} + + ); + } + return {part}; + }); +} + +export function ChatBubble({ role, text }: ChatBubbleProps) { + const isUser = role === "user"; + return ( +
+ {renderContent(text)} +
+ ); +} diff --git a/apps/web/components/HeartbeatWave.tsx b/apps/web/components/HeartbeatWave.tsx new file mode 100644 index 0000000..60970c5 --- /dev/null +++ b/apps/web/components/HeartbeatWave.tsx @@ -0,0 +1,46 @@ +const WIDTH = 400; +const MID_Y = 50; + +// H-4:親密度以波形振幅呈現——關係越深,波峰越高。 +function buildPoints(amplitude: number): string { + const clamped = Math.max(0, Math.min(100, amplitude)); + const scale = 0.4 + (clamped / 100) * 1.1; + const up1 = MID_Y - 15 * scale; + const down = MID_Y + 26 * scale; + const up2 = MID_Y - 22 * scale; + + const points: Array<[number, number]> = [ + [0, MID_Y], + [150, MID_Y], + [168, MID_Y], + [178, up1], + [188, down], + [198, up2], + [208, MID_Y], + [WIDTH, MID_Y], + ]; + + return points.map(([x, y]) => `${x},${y.toFixed(1)}`).join(" "); +} + +export interface HeartbeatWaveProps { + amplitude?: number; // 0~100,對應親密度 + animateOnMount?: boolean; // 是否播放「由左至右畫出」的載入動畫 + className?: string; +} + +export function HeartbeatWave({ amplitude = 50, animateOnMount = true, className }: HeartbeatWaveProps) { + return ( + + + + ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts new file mode 100644 index 0000000..983e16b --- /dev/null +++ b/apps/web/lib/api.ts @@ -0,0 +1,46 @@ +import type { EmotionTag, RelationshipStage } from "@kokorone/shared"; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; + +export interface ChatResponseStyle { + tone: string; + sentenceLength: "short" | "medium" | "long"; + initiative: "low" | "medium" | "high"; +} + +export interface ChatContext { + emotion: { dominant: EmotionTag; style: ChatResponseStyle }; + relationship: { intimacy: number; trust: number; stage: RelationshipStage }; +} + +export interface ChatResult { + text: string; + actions: string[]; + isFastChannel: boolean; + context: ChatContext; +} + +export async function sendChatMessage( + characterId: string, + userId: string, + sessionId: string, + text: string, +): Promise { + const res = await fetch(`${API_BASE_URL}/chat/${characterId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, sessionId, text }), + }); + if (!res.ok) { + throw new Error(`對話請求失敗(${res.status})`); + } + return res.json(); +} + +export async function endChatSession(sessionId: string, characterId: string): Promise { + await fetch(`${API_BASE_URL}/session/${sessionId}/end`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ characterId }), + }); +} diff --git a/apps/web/lib/emotion-labels.ts b/apps/web/lib/emotion-labels.ts new file mode 100644 index 0000000..e612448 --- /dev/null +++ b/apps/web/lib/emotion-labels.ts @@ -0,0 +1,10 @@ +import type { EmotionTag } from "@kokorone/shared"; + +export const EMOTION_LABELS: Record = { + CALM: "平靜", + JOY: "愉悅", + SAD: "低落", + ALERT: "警戒", + SHY: "害羞", + GRUMPY: "彆扭", +}; diff --git a/scripts/smoke/H.mjs b/scripts/smoke/H.mjs new file mode 100644 index 0000000..dc1cdd4 --- /dev/null +++ b/scripts/smoke/H.mjs @@ -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() }, + }); +} diff --git a/todo.md b/todo.md index 9a418bc..2aafe2b 100644 --- a/todo.md +++ b/todo.md @@ -228,12 +228,22 @@ flowchart TB ### H. 網頁對話介面(第一個可實際使用的垂直切片) -- [ ] **H-1 對話 API 與 session 生命週期(S)**:`POST /chat/:characterId` 走完整管線(標記情緒 → 檢索記憶 → 組裝 → 生成 → 過濾),`POST /session/:id/end` 觸發睡眠固化。驗收:一輪完整對話回傳回應、情緒狀態與親密度變化。 -- [ ] **H-2 情緒晶片與親密度顯示(S)**:對話畫面顯示當前情緒(愉悅/害羞/彆扭…)與親密度數值。驗收:畫面數值與 API 回傳一致並即時更新。依據:主視覺與資產§電腦網頁 mockup 的「親密度 62」「愉悅」晶片。 -- [ ] **H-3 動作描寫標記渲染(S)**:把 `*…*` 標記以暮空紫小字呈現,與台詞區分。驗收:含動作描寫的回應在畫面上樣式正確。依據:主視覺與資產 mockup `.bubble .act` 樣式。 -- [ ] **H-4 心跳波形母題(S)**:載入動畫為波形由左至右畫出、親密度以波形振幅呈現、通知紅點為心跳脈動;尊重 `prefers-reduced-motion`。驗收:三處母題皆可見且降低動態偏好下不動畫。依據:主視覺與資產§視覺母題。 -- [ ] **H-5 對話頁版面(M)**:電腦版雙欄(左對話流、右大幅立繪區,先以佔位圖形);行動版單欄、立繪半身像置頂佔 40% 為背景層、對話流覆蓋其上;同一份 RWD 程式。驗收:桌面與 720px 以下寬度各自呈現正確版面。依據:§三種載體的版面。 -- [ ] **H-V 階段驗證(S)**:`npm run restart && npm run smoke -- H`(H.mjs 端到端:送訊息 → 收回應 → 情緒與親密度變化可見 → 結束 session 觸發固化),並實際開啟瀏覽器操作一次確認畫面無誤。 +- [x] **H-1 對話 API 與 session 生命週期(S)**:`POST /chat/:characterId` 走完整管線(標記情緒 → 檢索記憶 → 組裝 → 生成 → 過濾),`POST /session/:id/end` 觸發睡眠固化。驗收:一輪完整對話回傳回應、情緒狀態與親密度變化。 +- [x] **H-2 情緒晶片與親密度顯示(S)**:對話畫面顯示當前情緒(愉悅/害羞/彆扭…)與親密度數值。驗收:畫面數值與 API 回傳一致並即時更新。依據:主視覺與資產§電腦網頁 mockup 的「親密度 62」「愉悅」晶片。 +- [x] **H-3 動作描寫標記渲染(S)**:把 `*…*` 標記以暮空紫小字呈現,與台詞區分。驗收:含動作描寫的回應在畫面上樣式正確。依據:主視覺與資產 mockup `.bubble .act` 樣式。 +- [x] **H-4 心跳波形母題(S)**:載入動畫為波形由左至右畫出、親密度以波形振幅呈現、通知紅點為心跳脈動;尊重 `prefers-reduced-motion`。驗收:三處母題皆可見且降低動態偏好下不動畫。依據:主視覺與資產§視覺母題。 +- [x] **H-5 對話頁版面(M)**:電腦版雙欄(左對話流、右大幅立繪區,先以佔位圖形);行動版單欄、立繪半身像置頂佔 40% 為背景層、對話流覆蓋其上;同一份 RWD 程式。驗收:桌面與 720px 以下寬度各自呈現正確版面。依據:§三種載體的版面。 +- [x] **H-V 階段驗證(S)**:`npm run restart && npm run smoke -- H`(H.mjs 端到端:送訊息 → 收回應 → 情緒與親密度變化可見 → 結束 session 觸發固化),並實際開啟瀏覽器操作一次確認畫面無誤。 + +> **實作記錄(H 群組)**: +> - `POST /chat/:characterId`(`apps/api/src/chat/`)才是正式對外端點;F 群組的 `DialogueService.handleMessage` 補上了兩行呼叫(`EmotionService.processInput`、`RelationshipService.recordInteraction`),讓每輪對話會**真的**標記情緒、累積親密度,而不是只讀現有狀態——F 群組當時的 `/dialogue/*` 端點刻意沒做這件事(見 F 群組實作記錄),H 群組把它補上。往後任何人再動 `DialogueService.handleMessage`,記得這兩行是新對話語意變化的入口,不要誤刪。 +> - NestJS 這邊加了 `app.enableCors({ origin: WEB_ORIGIN ?? "http://localhost:3100" })`(`main.ts`)——這是本清單第一次有瀏覽器直接打 api,之前所有群組都只靠 curl/smoke test,不會撞到 CORS。 +> - 前端色票整份換成 wiki「主視覺與資產」頁完整 HTML 提案裡的 token 組(`--bg`/`--bg-soft`/`--ink`/`--ink-soft`/`--accent`/`--accent-bright`/`--violet`/`--line`/`--card`/`--chip-bg`/`--wave`/`--frame`/`--shadow`,含官方給的深色模式數值),比 A-5 當時只做的四色簡化版完整很多;`--color-heartbeat-pink`/`--color-dusk-purple`/`--background`/`--foreground` 留著當作 A-5 首頁的別名,沒有刪除舊頁面的相依。 +> - **重要**:apps/web 用 Next.js 的 `moduleResolution: "bundler"`,相對匯入**不可以**加 `.js` 副檔名(跟 apps/api 的 NodeNext 慣例相反,那邊要求一定要加)。這次寫元件時把後端習慣帶過來,加了 `.js` 結果 Turbopack 直接找不到檔案、整個 web 健康檢查逾時 60 秒。**以後在 apps/web 底下新增檔案,相對匯入一律不要加副檔名;只有 apps/api、packages/shared、packages/db 底下才需要加 `.js`。** +> - 對話頁走固定的 `CHARACTER_ID`/`USER_ID`(種子角色與種子使用者),因為角色選擇、登入機制都還沒有 wiki 依據(見「需人工確認」清單);`sessionId` 用 `crypto.randomUUID()` 在元件掛載時產生一次。 +> - 心跳波形(`HeartbeatWave` 元件)用同一份 SVG/CSS 動畫邏輯同時服務首頁(A-5)與對話頁,振幅由親密度(0~100)換算縮放係數 0.4~1.5;`prefers-reduced-motion` 已在 `globals.css` 用 media query 關閉動畫,沒有另外寫 JS 判斷。 +> - **驗證方式與環境限定備註**:這台機器目前是共用的重載主機(`uptime` 曾量到 load average 39+,遠超這台機器的核心數,來自其他無關的並行 session),單一 API 請求偶爾會被系統排擠到 30~50 秒——**這不是本群組程式碼的效能問題**,同一請求在負載降下來後可以在 3 秒內完成。之後如果又遇到「重啟或 smoke test 突然變得異常慢」,先用 `uptime` 確認 load average 是否異常,不要急著去改程式碼。 +> - 本機沒有 GUI 瀏覽器,用 Playwright(暫裝在 scratchpad,非專案相依)+ headless Chromium 驗證畫面;headless shell 需要 `libasound.so.2`(本機沒有 root 權限跑 `apt-get install`),改用其他 session 已解壓好的 `.deb` 內容夾搭配 `LD_LIBRARY_PATH` 繞過,沒有動到系統套件。畫面截圖裡中文顯示為方塊字,是測試用無頭瀏覽器缺中文字型,**不是程式或 CSS 的問題**(DOM 文字內容本身是正確的繁體中文,実際使用者瀏覽器有系統字型即可正常顯示)。 ### I. 生活作息與離線生活