Files
JefferyandClaude Sonnet 5 0f3d16059c feat: 完成 O 群組 — 立繪子系統
後端(apps/api/src/tachie/):立繪 manifest(身體姿勢/服裝/表情眉眼口/
效果層/微動態的資產目錄,服裝目錄有 M-1 登錄時優先採用);表情解析依
情緒引擎六狀態對照眉/眼/口/效果/姿勢,依 RelationshipStage 分層解鎖
(陌生~認識/朋友/摯友/羈絆,哭臉限羈絆才會出現),依角色外顯度縮放
差分幅度(magnitude = expressiveness × 情緒強度);emotionTag(實際
套用)與 rawEmotionTag(情緒引擎真實判定)分離,讓文字生成與表情解析
維持兩條獨立資料流,「文字與立繪故意不一致」是這個設計的自然結果,
不需要額外邏輯;視線方向依思考/迴避/警戒切換。

前端(apps/web/components/TachieStage.tsx):以 PixiJS 實作分層合成
與眨眼/呼吸微動態,預留 TachieRenderer 介面供未來 Live2D Cubism SDK
實作(未引入任何 Live2D 相依或授權,純架構佔位);接上 ChatView 取代
H 群組的佔位立繪,聊天回應後先查詢表情、延遲約 220ms 才顯示文字,
在非串流架構下盡力模擬「表情先變化、文字後出現」。

以 scratchpad 暫裝 playwright-core 實測瀏覽器行為(本環境無
chromium-cli),確認情緒變化即時反映在立繪、微動態持續運作。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:46:50 +08:00

149 lines
5.3 KiB
TypeScript

"use client";
import { useState } from "react";
import { sendChatMessage, fetchExpression, type TachieExpressionResponse } from "../../lib/api";
import { EMOTION_LABELS } from "../../lib/emotion-labels";
import { ChatBubble } from "../../components/ChatBubble";
import { HeartbeatWave } from "../../components/HeartbeatWave";
import { TachieStage } from "../../components/TachieStage";
import type { EmotionTag } from "@kokorone/shared";
// O-2 表情演出時機:表情差分要在文字回覆出現前先變化——順序要對才有真實感。
const EXPRESSION_LEAD_MS = 220;
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 [expression, setExpression] = useState<TachieExpressionResponse | null>(null);
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);
if (result.availability === "NO_REPLY") {
// I-2:作息狀態為睡眠且未破例時,這輪不會有真的回覆。
return;
}
if (result.context) {
setDominant(result.context.emotion.dominant);
setIntimacy(result.context.relationship.intimacy);
}
// O-2:情緒引擎在文字生成前就已經更新完畢,這裡先套用新表情,文字訊息延後一小段時間才出現。
try {
setExpression(await fetchExpression(CHARACTER_ID, USER_ID));
} catch {
// 表情查詢失敗不影響對話本身。
}
await new Promise((resolve) => setTimeout(resolve, EXPRESSION_LEAD_MS));
setMessages((prev) => [...prev, { role: "character", text: result.text }]);
} 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">
<TachieStage expression={expression} />
<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>
);
}