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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user