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:
@@ -13,3 +13,4 @@ coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
packages/db/generated/
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ChatView } from "./ChatView";
|
||||
|
||||
export default function ChatPage() {
|
||||
return <ChatView />;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { EmotionTag } from "@kokorone/shared";
|
||||
|
||||
export const EMOTION_LABELS: Record<EmotionTag, string> = {
|
||||
CALM: "平靜",
|
||||
JOY: "愉悅",
|
||||
SAD: "低落",
|
||||
ALERT: "警戒",
|
||||
SHY: "害羞",
|
||||
GRUMPY: "彆扭",
|
||||
};
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
@@ -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. 生活作息與離線生活
|
||||
|
||||
|
||||
Reference in New Issue
Block a user