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
+2
View File
@@ -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],
})
+34
View File
@@ -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 };
}
}
+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 {}
+8
View File
@@ -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, {
+1
View File
@@ -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}`);
+138
View File
@@ -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<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [dominant, setDominant] = useState<EmotionTag>("CALM");
const [intimacy, setIntimacy] = useState(0);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-dvh flex-col md:flex-row">
<div
className="relative flex h-[38vh] shrink-0 items-end justify-center overflow-hidden md:order-2 md:h-auto md:w-[38%] md:min-w-[280px]"
style={{ background: "linear-gradient(180deg, var(--bg-soft), var(--card))" }}
>
<span className="absolute top-3.5 left-3.5 text-[11px]" style={{ color: "var(--ink-soft)" }}>
親密度 <b style={{ color: "var(--accent)" }}>{intimacy}</b>
</span>
<span
className="absolute top-3.5 right-3.5 rounded-full px-3 py-1 text-xs font-bold"
style={{ background: "var(--chip-bg)", color: "var(--accent)" }}
>
{EMOTION_LABELS[dominant]}
</span>
<div className="mb-6 flex flex-col items-center gap-2">
<div
className="relative h-[190px] w-[120px] rounded-t-[60px] rounded-b-[24px] opacity-85"
style={{ background: "linear-gradient(180deg, var(--accent-bright), var(--violet))" }}
>
<div
className="absolute -top-11 left-1/2 h-21 w-21 -translate-x-1/2 rounded-full"
style={{ background: "linear-gradient(180deg, var(--accent-bright), var(--violet) 140%)" }}
/>
</div>
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
立繪(表情差分即時變化)
</p>
</div>
</div>
<div
className="flex flex-1 flex-col gap-2.5 overflow-y-auto p-4 md:order-1 md:border-r"
style={{ borderColor: "var(--line)" }}
>
<div className="mb-2 flex items-center gap-3">
<HeartbeatWave amplitude={intimacy} className="h-6 w-24" />
<h1 className="text-sm font-bold" style={{ color: "var(--accent)" }}>
心音 Kokorone
</h1>
</div>
<div className="flex flex-1 flex-col gap-2.5 overflow-y-auto">
{messages.length === 0 && (
<p className="text-sm" style={{ color: "var(--ink-soft)" }}>
傳個訊息,開始今天的對話吧。
</p>
)}
{messages.map((message, index) => (
<ChatBubble key={index} role={message.role} text={message.text} />
))}
</div>
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
<div
className="mt-2 flex items-center justify-between gap-2 rounded-full border px-4 py-2 text-sm"
style={{ borderColor: "var(--line)" }}
>
<input
value={input}
onChange={(event) => 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}
/>
<button
type="button"
onClick={() => void handleSend()}
disabled={sending}
className="font-bold disabled:opacity-50"
style={{ color: "var(--accent)" }}
>
送出
</button>
</div>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { ChatView } from "./ChatView";
export default function ChatPage() {
return <ChatView />;
}
+87 -9
View File
@@ -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;
}
}
+10 -14
View File
@@ -1,24 +1,20 @@
import Link from "next/link";
import { HeartbeatWave } from "../components/HeartbeatWave";
export default function Home() {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-8 p-8">
<h1 className="text-4xl font-bold tracking-wide">
心音 <span className="text-[var(--color-heartbeat-pink)]">Kokorone</span>
</h1>
<svg
viewBox="0 0 400 100"
className="w-full max-w-md text-[var(--color-heartbeat-pink)]"
role="img"
aria-label="心跳波形"
<HeartbeatWave amplitude={60} className="w-full max-w-md text-[var(--color-heartbeat-pink)]" />
<Link
href="/chat"
className="rounded-full px-6 py-2 text-sm font-bold text-white"
style={{ background: "var(--accent-bright)" }}
>
<polyline
points="0,50 60,50 80,50 95,15 110,85 125,50 160,50 180,50 195,25 210,75 225,50 400,50"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
開始聊天
</Link>
</main>
);
}
+46
View File
@@ -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 (
<span key={index} className="text-xs" style={{ color: "var(--violet)" }}>
{match[1]}
</span>
);
}
return <span key={index}>{part}</span>;
});
}
export function ChatBubble({ role, text }: ChatBubbleProps) {
const isUser = role === "user";
return (
<div
className="max-w-[78%] rounded-2xl px-3.5 py-2 text-sm leading-relaxed"
style={
isUser
? {
alignSelf: "flex-end",
background: "var(--accent-bright)",
color: "#fff",
borderBottomRightRadius: 4,
}
: {
alignSelf: "flex-start",
background: "var(--bg-soft)",
border: "1px solid var(--line)",
borderBottomLeftRadius: 4,
}
}
>
{renderContent(text)}
</div>
);
}
+46
View File
@@ -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 (
<svg viewBox={`0 0 ${WIDTH} 100`} className={className} role="img" aria-label="心跳波形">
<polyline
points={buildPoints(amplitude)}
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className={animateOnMount ? "heartbeat-wave-line heartbeat-wave-animate" : "heartbeat-wave-line"}
/>
</svg>
);
}
+46
View File
@@ -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<ChatResult> {
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<void> {
await fetch(`${API_BASE_URL}/session/${sessionId}/end`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ characterId }),
});
}
+10
View File
@@ -0,0 +1,10 @@
import type { EmotionTag } from "@kokorone/shared";
export const EMOTION_LABELS: Record<EmotionTag, string> = {
CALM: "平靜",
JOY: "愉悅",
SAD: "低落",
ALERT: "警戒",
SHY: "害羞",
GRUMPY: "彆扭",
};