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