新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
169 lines
6.3 KiB
TypeScript
169 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { getOrCreateSoloRoom, sendSoloMessage, 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 { RomanceSection } from "../../../components/RomanceSection";
|
||
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;
|
||
}
|
||
|
||
// T38:既有 1:1 聊天併入 SOLO 房——原 apps/web/app/chat/ChatView.tsx 的內容搬移至此,
|
||
// 呼叫端改用房間端點(getOrCreateSoloRoom/sendSoloMessage)取代舊版 sessionId 自行管理。
|
||
export function SoloChatView() {
|
||
const [roomId, setRoomId] = useState<string | null>(null);
|
||
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);
|
||
const [romanceRefreshKey, setRomanceRefreshKey] = useState(0);
|
||
|
||
useEffect(() => {
|
||
getOrCreateSoloRoom(USER_ID, CHARACTER_ID)
|
||
.then((room) => setRoomId(room.id))
|
||
.catch(() => setError("房間建立失敗,請確認 api 服務是否已啟動"));
|
||
}, []);
|
||
|
||
async function handleSend() {
|
||
const text = input.trim();
|
||
if (!text || sending || !roomId) {
|
||
return;
|
||
}
|
||
setSending(true);
|
||
setError(null);
|
||
setInput("");
|
||
setMessages((prev) => [...prev, { role: "user", text }]);
|
||
|
||
try {
|
||
const result = await sendSoloMessage(roomId, USER_ID, 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 }]);
|
||
// L-9:心動值與戀愛階段隨事件即時更新——每輪對話後都重新查詢一次。
|
||
setRomanceRefreshKey((key) => key + 1);
|
||
} 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}
|
||
characterId={CHARACTER_ID}
|
||
userId={USER_ID}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="text-xs" style={{ color: "var(--accent)" }}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<RomanceSection characterId={CHARACTER_ID} userId={USER_ID} refreshKey={romanceRefreshKey} />
|
||
|
||
<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>
|
||
);
|
||
}
|