Files
Kokorone/apps/web/app/room/[roomId]/RoomChatView.tsx
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

478 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import {
ROOM_USER_SPEAKER_ID,
endRoom,
fetchExpression,
getPossessionStatus,
getRoom,
getRoomUsage,
listCharacters,
reportRoomRead,
roomStreamUrl,
sendRoomMessage,
setRoomMuted,
speakAsPossessed,
synthesizeVoice,
type CharacterSummary,
type PossessionStatus,
type RoomDetail,
type RoomTurnKind,
type RoomUsageSummary,
type TachieExpressionResponse,
} from "../../../lib/api";
import { ChatBubble } from "../../../components/ChatBubble";
import { RoomStage, type StageParticipant } from "../../../components/RoomStage";
import { isVoiceAutoplayEnabled } from "../../../lib/voice-autoplay-settings";
// T19-P3:TALK 是一般台詞(既有 ChatBubble 樣式),ACTION 沿用 H-3 的*…*敘述樣式,其餘三種另外標籤。
const TURN_KIND_LABEL: Partial<Record<RoomTurnKind, string>> = {
DELEGATE: "委託",
LEAVE: "離開",
SYSTEM: "系統",
};
const USER_ID = "seed-user-primary";
const USER_LABEL = "你";
// T26:佇列積壓過長(使用者離開視窗一段時間後回來,或連續好幾則自走發言)時,只留最近幾則待播,
// 較舊的直接跳過——語音是「聽個氣氛」,不需要也不該補播全部積壓內容。
const VOICE_QUEUE_MAX_BACKLOG = 3;
// 首次解鎖語音時,在使用者手勢內播放一次極短靜音音檔,換取瀏覽器日後允許非手勢觸發的自動播放。
const SILENT_AUDIO_DATA_URI =
"data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==";
interface VoiceQueueItem {
characterId: string;
text: string;
}
type FeedItem =
| { kind: "turn"; seq: number; speakerId: string; speakerLabel: string; content: string; turnKind: RoomTurnKind }
| { kind: "member"; seq: number; characterId: string; action: "joined" | "left" }
| { kind: "closed"; seq: number; reason: string };
interface RoomChatViewProps {
roomId: string;
}
// T19-P2:通用房間畫面——取代舊版 RoomView/SoloChatView 只能各自對應建房表單或單一固定角色的
// 限制,改為接受大廳呼叫(T5)或既有房間清單開出的任意 roomId,統一走 SSE(T10)即時接收新訊息、
// 並在第一次載入時依 unread(T11)畫出未讀分隔線;分隔線位置在整個瀏覽階段內固定,不隨後續已讀
// 狀態更新而移動(符合一般聊天 App「離開再進來才會重新定位」的直覺)。
export function RoomChatView({ roomId }: RoomChatViewProps) {
const [room, setRoom] = useState<RoomDetail | null>(null);
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
const [feed, setFeed] = useState<FeedItem[]>([]);
const [firstUnreadSeq, setFirstUnreadSeq] = useState<number | null>(null);
const [ended, setEnded] = useState(false);
const [text, setText] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const feedEndRef = useRef<HTMLDivElement | null>(null);
// T19-P5:套用中時,這個房間的輸入區要換成「以套用角色的身分發話」,不提供以自己身分發話的選項。
const [possession, setPossession] = useState<PossessionStatus | null>(null);
useEffect(() => {
void getPossessionStatus(USER_ID).then(setPossession).catch(() => undefined);
}, [roomId]);
// T19-P3:立繪舞台(目前發話者高亮)與用量顯示。
const [expressions, setExpressions] = useState<Record<string, TachieExpressionResponse | null>>({});
const [speakingCharacterId, setSpeakingCharacterId] = useState<string | null>(null);
const [usage, setUsage] = useState<RoomUsageSummary | null>(null);
function refreshExpression(characterId: string) {
fetchExpression(characterId, USER_ID)
.then((expression) => setExpressions((prev) => ({ ...prev, [characterId]: expression })))
.catch(() => undefined);
}
// T26:自走語音自動播放——佇列、播放中旗標、解鎖狀態一律用 ref,避免 SSE handler(只在 roomId
// 變化時重新註冊)讀到註冊當下就固定住的舊 state(stale closure)。
const [autoplayEnabled, setAutoplayEnabled] = useState(true);
const autoplayEnabledRef = useRef(true);
const [unlocked, setUnlocked] = useState(false);
const unlockedRef = useRef(false);
const voiceQueueRef = useRef<VoiceQueueItem[]>([]);
const voicePlayingRef = useRef(false);
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
const voiceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const unmountedRef = useRef(false);
// T31:靜音時不推播、不自動播語音,但自走照常進行——同樣用 ref 供 SSE handler 讀最新值。
const mutedRef = useRef(false);
useEffect(() => {
const sync = () => {
const value = isVoiceAutoplayEnabled();
autoplayEnabledRef.current = value;
setAutoplayEnabled(value);
};
sync();
window.addEventListener("storage", sync);
return () => window.removeEventListener("storage", sync);
}, []);
useEffect(() => {
return () => {
unmountedRef.current = true;
currentAudioRef.current?.pause();
if (voiceTimerRef.current) clearTimeout(voiceTimerRef.current);
voiceQueueRef.current = [];
};
}, []);
function unlockVoiceAutoplay() {
const unlockAudio = new Audio(SILENT_AUDIO_DATA_URI);
void unlockAudio.play().catch(() => undefined);
unlockedRef.current = true;
setUnlocked(true);
void processVoiceQueue();
}
function enqueueVoice(item: VoiceQueueItem) {
if (!autoplayEnabledRef.current || !unlockedRef.current || mutedRef.current) return;
voiceQueueRef.current.push(item);
if (voiceQueueRef.current.length > VOICE_QUEUE_MAX_BACKLOG) {
voiceQueueRef.current.splice(0, voiceQueueRef.current.length - VOICE_QUEUE_MAX_BACKLOG);
}
void processVoiceQueue();
}
async function processVoiceQueue() {
if (voicePlayingRef.current || unmountedRef.current) return;
const next = voiceQueueRef.current.shift();
if (!next) return;
voicePlayingRef.current = true;
try {
const result = await synthesizeVoice(next.characterId, USER_ID, next.text);
if (unmountedRef.current) return;
if (result.audioRef.startsWith("mock://")) {
await new Promise<void>((resolve) => {
voiceTimerRef.current = setTimeout(resolve, Math.max(result.durationEstimateMs, 300));
});
} else {
await new Promise<void>((resolve) => {
const audio = new Audio(result.audioRef);
currentAudioRef.current = audio;
audio.addEventListener("ended", () => resolve());
audio.addEventListener("error", () => resolve());
void audio.play().catch(() => resolve());
});
}
} catch {
// 合成失敗:安靜跳過這一則,不阻斷佇列後續播放、不影響訊息本身顯示。
} finally {
currentAudioRef.current = null;
voicePlayingRef.current = false;
if (!unmountedRef.current) void processVoiceQueue();
}
}
function characterLabel(characterId: string): string {
if (characterId === ROOM_USER_SPEAKER_ID) return USER_LABEL;
return characters.find((c) => c.id === characterId)?.aliases[0]?.formalName ?? characterId;
}
useEffect(() => {
void listCharacters().then(setCharacters);
}, []);
// 初次載入:抓歷程+unread,畫面就緒後才開 SSE(避免歷程還沒到就先收到即時事件而重複或錯序)。
useEffect(() => {
let cancelled = false;
let source: EventSource | null = null;
async function init() {
try {
const detail = await getRoom(roomId);
if (cancelled) return;
setRoom(detail);
mutedRef.current = detail.muted;
setEnded(detail.status === "ENDED");
setFirstUnreadSeq(detail.unread.firstUnreadSeq);
setUsage(detail.usage);
setFeed(
detail.turns.map((turn) => ({
kind: "turn",
seq: turn.seq,
speakerId: turn.speakerId,
speakerLabel: turn.speakerLabel,
content: turn.content,
turnKind: turn.kind,
})),
);
for (const characterId of detail.participants) refreshExpression(characterId);
const lastTurn = detail.turns.at(-1);
if (lastTurn && lastTurn.speakerId !== ROOM_USER_SPEAKER_ID) setSpeakingCharacterId(lastTurn.speakerId);
const lastSeq = detail.turns.length > 0 ? detail.turns[detail.turns.length - 1].seq : -1;
source = new EventSource(roomStreamUrl(roomId, lastSeq));
source.addEventListener("turn", (event) => {
const messageEvent = event as MessageEvent<string>;
const data = JSON.parse(messageEvent.data) as {
speakerId: string;
speakerLabel: string;
content: string;
kind: RoomTurnKind;
};
const { kind: turnKind, ...rest } = data;
const seq = Number(messageEvent.lastEventId);
setFeed((prev) =>
prev.some((item) => item.kind === "turn" && item.seq === seq) ? prev : [...prev, { kind: "turn", seq, turnKind, ...rest }],
);
void reportRoomRead(roomId, USER_ID, seq).catch(() => undefined);
// T26:只有角色的新發言才自動播放,使用者自己的發言不需要念給自己聽。
if (data.speakerId !== ROOM_USER_SPEAKER_ID) {
enqueueVoice({ characterId: data.speakerId, text: data.content });
// T19-P3:新發言者上場——更新立繪舞台的高亮對象與表情,並同步刷新用量顯示。
setSpeakingCharacterId(data.speakerId);
refreshExpression(data.speakerId);
void getRoomUsage(roomId).then(setUsage).catch(() => undefined);
}
});
source.addEventListener("member", (event) => {
const messageEvent = event as MessageEvent<string>;
const data = JSON.parse(messageEvent.data) as { characterId: string; action: "joined" | "left" };
const seq = Number(messageEvent.lastEventId);
setFeed((prev) => [...prev, { kind: "member", seq, ...data }]);
});
source.addEventListener("closed", (event) => {
const messageEvent = event as MessageEvent<string>;
const data = JSON.parse(messageEvent.data) as { reason: string };
const seq = Number(messageEvent.lastEventId);
setFeed((prev) => [...prev, { kind: "closed", seq, reason: data.reason }]);
setEnded(true);
});
} catch {
if (!cancelled) setError("房間查詢失敗,請確認 api 服務是否已啟動");
}
}
void init();
return () => {
cancelled = true;
source?.close();
};
}, [roomId]);
useEffect(() => {
feedEndRef.current?.scrollIntoView({ block: "end" });
}, [feed.length]);
async function handleSend() {
const trimmed = text.trim();
if (!trimmed || pending || ended) return;
setPending(true);
setError(null);
try {
if (possessedCharacterId) {
await speakAsPossessed(roomId, USER_ID, trimmed);
} else {
await sendRoomMessage(roomId, USER_ID, USER_LABEL, trimmed);
}
setText(""); // 新訊息透過 SSE 推送進 feed,這裡不需要也不應該自己 append
} catch (err) {
setError(err instanceof Error ? err.message : "發言失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
async function handleToggleMute() {
if (!room || pending) return;
const next = !room.muted;
try {
await setRoomMuted(roomId, next);
mutedRef.current = next;
setRoom((prev) => (prev ? { ...prev, muted: next } : prev));
} catch {
setError("靜音設定失敗,請確認 api 服務是否已啟動");
}
}
async function handleEnd() {
if (pending || ended) return;
setPending(true);
setError(null);
try {
await endRoom(roomId);
setEnded(true);
} catch {
setError("結束房間失敗,請確認 api 服務是否已啟動");
} finally {
setPending(false);
}
}
if (error && !room) {
return (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
);
}
if (!room) {
return (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
載入中……
</p>
);
}
// T19-P5:只有「套用中且她本人就在這個房間」才切換成以她的身分發話;套用中但她不在這裡時,
// 一般發話入口一律不提供(後端本來就會擋,這裡先不讓使用者白打一輪)。
const possessedElsewhere = Boolean(possession?.possessingCharacterId) && !room.participants.includes(possession!.possessingCharacterId!);
const possessedCharacterId =
possession?.possessingCharacterId && room.participants.includes(possession.possessingCharacterId)
? possession.possessingCharacterId
: null;
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between text-xs" style={{ color: "var(--ink-soft)" }}>
<span>
{room.mode}.{ended ? "已結束" : "進行中"}.參與者:{room.participants.map((id) => characterLabel(id)).join("、") || "(無)"}
{usage && usage.generationCount > 0 && (
<>
.用量 {usage.totalPromptTokens + usage.totalCompletionTokens} tokens({usage.generationCount} 次生成)
</>
)}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleToggleMute()}
disabled={ended}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
{room.muted ? "🔇 已靜音" : "🔊 靜音"}
</button>
<button
type="button"
onClick={() => void handleEnd()}
disabled={pending || ended}
className="rounded-full border px-3 py-1 disabled:opacity-50"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
結束房間
</button>
</div>
</div>
<RoomStage
participants={room.participants.map<StageParticipant>((characterId) => ({
characterId,
label: characterLabel(characterId),
expression: expressions[characterId] ?? null,
}))}
speakingCharacterId={speakingCharacterId}
/>
{autoplayEnabled && !unlocked && !room.muted && (
<button
type="button"
onClick={unlockVoiceAutoplay}
className="rounded-2xl border px-3 py-2 text-left text-xs"
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
>
🔊 點擊開啟自走語音自動播放(瀏覽器需要先有一次互動才能自動播放聲音)
</button>
)}
<div className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
{feed.length === 0 && (
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
還沒有人說話。
</p>
)}
{feed.map((item) => (
<div key={`${item.kind}-${item.seq}`} className="flex flex-col gap-2">
{firstUnreadSeq !== null && item.seq === firstUnreadSeq && (
<div className="flex items-center gap-2 text-[10px]" style={{ color: "var(--accent)" }}>
<span className="h-px flex-1" style={{ background: "var(--accent)" }} />
未讀訊息
<span className="h-px flex-1" style={{ background: "var(--accent)" }} />
</div>
)}
{item.kind === "turn" && (
<div className="flex flex-col" style={{ alignItems: item.speakerId === ROOM_USER_SPEAKER_ID ? "flex-end" : "flex-start" }}>
{TURN_KIND_LABEL[item.turnKind] && (
<span
className="mb-0.5 rounded-full px-2 py-0.5 text-[10px]"
style={{ background: "var(--chip-bg)", color: "var(--accent)" }}
>
{TURN_KIND_LABEL[item.turnKind]}
</span>
)}
<ChatBubble
role={item.speakerId === ROOM_USER_SPEAKER_ID ? "user" : "character"}
text={item.content}
characterId={item.speakerId !== ROOM_USER_SPEAKER_ID ? item.speakerId : undefined}
userId={item.speakerId !== ROOM_USER_SPEAKER_ID ? USER_ID : undefined}
/>
</div>
)}
{item.kind === "member" && (
<p className="self-center text-[10px]" style={{ color: "var(--ink-soft)" }}>
{characterLabel(item.characterId)} {item.action === "joined" ? "進來了" : "離開了"}
</p>
)}
{item.kind === "closed" && (
<p className="self-center text-[10px]" style={{ color: "var(--ink-soft)" }}>
房間已關閉
</p>
)}
</div>
))}
<div ref={feedEndRef} />
</div>
{error && (
<p className="text-xs" style={{ color: "var(--accent)" }}>
{error}
</p>
)}
{!ended && possessedCharacterId && (
<p className="rounded-full px-3 py-1 text-center text-xs font-bold" style={{ background: "var(--chip-bg)", color: "var(--accent)" }}>
你正以「{characterLabel(possessedCharacterId)}」的身分發話
</p>
)}
{!ended && possessedElsewhere && (
<p className="rounded-full border px-3 py-2 text-center text-xs" style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}>
套用中的角色不在這個房間,無法在此發話——請到她所在的房間,或先退出套用。
</p>
)}
{!ended && !possessedElsewhere && (
<div className="flex items-center gap-2">
<input
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSend();
}}
placeholder={possessedCharacterId ? `以「${characterLabel(possessedCharacterId)}」的身分說點什麼……` : "說點什麼……"}
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
disabled={pending}
/>
<button
type="button"
onClick={() => void handleSend()}
disabled={pending || !text.trim()}
className="rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
style={{ background: "var(--accent-bright)" }}
>
送出
</button>
</div>
)}
</div>
);
}