"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> = { 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(null); const [characters, setCharacters] = useState([]); const [feed, setFeed] = useState([]); const [firstUnreadSeq, setFirstUnreadSeq] = useState(null); const [ended, setEnded] = useState(false); const [text, setText] = useState(""); const [pending, setPending] = useState(false); const [error, setError] = useState(null); const feedEndRef = useRef(null); // T19-P5:套用中時,這個房間的輸入區要換成「以套用角色的身分發話」,不提供以自己身分發話的選項。 const [possession, setPossession] = useState(null); useEffect(() => { void getPossessionStatus(USER_ID).then(setPossession).catch(() => undefined); }, [roomId]); // T19-P3:立繪舞台(目前發話者高亮)與用量顯示。 const [expressions, setExpressions] = useState>({}); const [speakingCharacterId, setSpeakingCharacterId] = useState(null); const [usage, setUsage] = useState(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([]); const voicePlayingRef = useRef(false); const currentAudioRef = useRef(null); const voiceTimerRef = useRef | 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((resolve) => { voiceTimerRef.current = setTimeout(resolve, Math.max(result.durationEstimateMs, 300)); }); } else { await new Promise((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; 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; 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; 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 (

{error}

); } if (!room) { return (

載入中……

); } // 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 (
{room.mode}.{ended ? "已結束" : "進行中"}.參與者:{room.participants.map((id) => characterLabel(id)).join("、") || "(無)"} {usage && usage.generationCount > 0 && ( <> .用量 {usage.totalPromptTokens + usage.totalCompletionTokens} tokens({usage.generationCount} 次生成) )}
((characterId) => ({ characterId, label: characterLabel(characterId), expression: expressions[characterId] ?? null, }))} speakingCharacterId={speakingCharacterId} /> {autoplayEnabled && !unlocked && !room.muted && ( )}
{feed.length === 0 && (

還沒有人說話。

)} {feed.map((item) => (
{firstUnreadSeq !== null && item.seq === firstUnreadSeq && (
未讀訊息
)} {item.kind === "turn" && (
{TURN_KIND_LABEL[item.turnKind] && ( {TURN_KIND_LABEL[item.turnKind]} )}
)} {item.kind === "member" && (

{characterLabel(item.characterId)} {item.action === "joined" ? "進來了" : "離開了"}

)} {item.kind === "closed" && (

房間已關閉

)}
))}
{error && (

{error}

)} {!ended && possessedCharacterId && (

你正以「{characterLabel(possessedCharacterId)}」的身分發話

)} {!ended && possessedElsewhere && (

套用中的角色不在這個房間,無法在此發話——請到她所在的房間,或先退出套用。

)} {!ended && !possessedElsewhere && (
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} />
)}
); }