/* ===================================================================== Session — 상담 세션 메인 화면 (/learn/session/:sessionId) DESIGN_CONCEPT §5: 3-column 상담실 골격. · 좌(296): 회기단계 세로 트랙커 + 내담자 컨텍스트 카드 · 중앙: 어두운 STAGE(bg-stage) — ClientAvatar + 음성 오브(4상태) + 실시간 자막(대본) · 우(300): 라이브 신호(앰비언트 도트 1개, 6초 페이드) + 내담자 상태 미터 + 안전 점검 · 하단(80): 마이크 + 일시정지 + [몰입|은은|코칭] segmented + 종료 확인 실제 STT/TTS 는 voice 트랙 소관. 여기선 UI + 텍스트 입력 대체 경로로 1턴 왕복: lib/api 의 sessionApi.stream(POST SSE)으로 내담자 응답을 받아 자막/오브/아바타 상태를 구동한다. 철칙: border-left·이모지·카드덤프·순흑백·과한 라운드 금지. 강조는 weight/tint/kicker/dot. 빨강은 종료에만. ===================================================================== */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { AppShell } from "../components/shell/AppShell"; import { AVATAR_EXPRESSION_LIBRARY, ClientAvatar, expressionLabelFor, } from "../components/avatar/ClientAvatar"; import type { AvatarState, AvatarAffect, AvatarPersona } from "../components/avatar/ClientAvatar"; import { Kicker, Button, Icon } from "../components/ui"; import { ApiError, apiWsUrl, personaApi, sessionApi, type CrisisResource, type LiveCoachEvent, type LiveCoachSuggestion, type PersonaSummary, type SessionDetailResponse, type SessionStage, } from "../lib/api"; import { useAuth } from "../lib/auth"; import { formatElapsed, formatTimecode, clamp01 } from "../lib/format"; import { DIFFICULTY_LABEL, demographicText, isUsablePersona, personaAgeBand, personaMeta, shortPersonaName, unavailablePersonaMessage, } from "../lib/personaViewModel"; import "./session/session.css"; /* ── 도메인 상수/타입 ───────────────────────────────────────────────── */ type FeedbackMode = "immersive" | "ambient" | "coached"; type TheoryMode = "humanistic" | "cbt" | "integrative"; type Speaker = "client" | "learner"; type SignalTone = "pos" | "warn" | "neutral"; type VoiceStatus = | "idle" | "requesting" | "connecting" | "recording" | "thinking" | "speaking" | "degraded" | "error"; type VoiceServerState = "idle" | "listening" | "thinking" | "speaking"; interface VoiceEvent { type?: string; state?: VoiceServerState; text?: string; final?: boolean; reason?: string; detail?: string; stage?: SessionStage; effective_openness?: number; turn_seq?: number; safety_flagged?: boolean; crisis_resource?: CrisisResource | null; conversation_stopped?: boolean; } type AudioContextWindow = Window & { webkitAudioContext?: typeof AudioContext }; interface Utterance { id: number; speaker: Speaker; text: string; turnSeq?: number; /** 발화 시작 시점(세션 경과 초) */ at: number; /** 진행 중(스트리밍/타이핑) 발화 여부 */ partial?: boolean; } interface PhaseInfo { key: SessionStage; desc: string; } interface ClientContext { initial: string; name: string; meta: string; rows: { l: string; v: string }[]; chips: { t: string; clay: boolean }[]; } const SESSION_PHASES: PhaseInfo[] = [ { key: "라포", desc: "첫 인사와 안전감 형성" }, { key: "탐색", desc: "호소 문제와 일상을 함께 이해" }, { key: "개입", desc: "감정과 생각을 다루는 기법 적용" }, { key: "정리", desc: "오늘의 대화 정리와 다음 약속" }, ]; const THEORY_MODE_OPTIONS: { value: TheoryMode; label: string; detail: string }[] = [ { value: "humanistic", label: "인간중심", detail: "공감·반영" }, { value: "cbt", label: "CBT", detail: "생각·행동" }, { value: "integrative", label: "통합", detail: "혼합 접근" }, ]; function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode { const firstSupported = summary?.theory_target.find( (target): target is TheoryMode => target === "humanistic" || target === "cbt" || target === "integrative", ); return firstSupported ?? "humanistic"; } const PERSONA_AVATAR_LOOKS = { P1: { skinTone: "#F0DDC4", hair: { style: "long-straight", color: "#5A4030" }, outfitColor: "#7C8A92", eyeColor: "#7A5A3C", accentColor: "#6B5E7D", rasterArtSet: "seoyeon-live2d-psd-v2", expressionBias: "sad", }, P4: { skinTone: "#E5C0A0", hair: { style: "long-straight", color: "#35261F" }, outfitColor: "#5F6F88", eyeColor: "#2F3438", accentColor: "#3B6E8F", rasterArtSet: "p4-live2d-generated", expressionBias: "anxious", }, P5: { skinTone: "#D7AD8D", hair: { style: "messy", color: "#28231F" }, outfitColor: "#6F766F", eyeColor: "#24292A", accentColor: "#7D8784", rasterArtSet: "p5-live2d-generated", expressionBias: "guarded", }, P6: { skinTone: "#E6BE9A", hair: { style: "soft-wave", color: "#2D241F" }, outfitColor: "#776F85", eyeColor: "#2E2B35", accentColor: "#6B5E7D", rasterArtSet: "p6-live2d-generated", expressionBias: "conflicted", }, P7: { skinTone: "#D5AA88", hair: { style: "side-part", color: "#23211E" }, outfitColor: "#596367", eyeColor: "#25292B", accentColor: "#6F7777", rasterArtSet: "p7-live2d-generated", expressionBias: "tired", }, } satisfies Record>; let _uid = 100; const nextId = () => ++_uid; function normalizePersonaCode(raw?: string): string { return (raw ?? "").trim().toUpperCase(); } function looksLikeSessionId(raw?: string): boolean { const value = (raw ?? "").trim(); return ( /^[0-9a-f]{32}$/i.test(value) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) ); } function secondsBetween(startedAt: string, createdAt: string): number { const start = Date.parse(startedAt); const created = Date.parse(createdAt); if (!Number.isFinite(start) || !Number.isFinite(created)) return 0; return Math.max(0, Math.round((created - start) / 1000)); } function elapsedFromSession(detail: SessionDetailResponse): number { const start = Date.parse(detail.started_at); const end = detail.ended_at ? Date.parse(detail.ended_at) : Date.now(); if (!Number.isFinite(start) || !Number.isFinite(end)) return 0; return Math.max(0, Math.round((end - start) / 1000)); } function buildPersonaUi( summary: PersonaSummary | null, code: string, ): { avatar: AvatarPersona; context: ClientContext } { if (!summary) { return { avatar: { label: "내담자 정보 확인 중", ageBand: "adult", skinTone: "#D7CCC3", hair: { style: "short", color: "#7A7068" }, realism: 0.3, }, context: { initial: "?", name: "내담자 확인 중", meta: code ? `${code} · DB 카탈로그 확인 중` : "DB 카탈로그 확인 중", rows: [ { l: "상태", v: "실제 연습 대상 정보를 확인하고 있습니다." }, ], chips: [], }, }; } const name = shortPersonaName(summary.display_name); const codeKey = summary.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS; const look = PERSONA_AVATAR_LOOKS[codeKey] ?? {}; const sex = demographicText(summary.demographics.sex); const isMale = sex === "male" || sex === "남성"; const ageBand = personaAgeBand(summary); const difficulty = DIFFICULTY_LABEL[summary.difficulty] ?? summary.difficulty; const theories = summary.theory_target.length ? summary.theory_target.join(", ") : "공통"; const chips = [ { t: summary.code, clay: false }, { t: difficulty, clay: summary.difficulty === "hard" }, ...summary.theory_target.slice(0, 2).map((target) => ({ t: target, clay: false })), ]; return { avatar: { code: summary.code, label: `${name} · ${personaMeta(summary)}`, ageBand, skinTone: look.skinTone ?? (isMale ? "#D8B18F" : "#E3BE9B"), hair: look.hair ?? { style: ageBand === "teen" ? "long-straight" : isMale ? "short" : "bob", color: isMale ? "#25221F" : "#31251E", }, outfitColor: look.outfitColor ?? (isMale ? "#5E676A" : "#6A6874"), eyeColor: look.eyeColor ?? "#2B2B2B", accentColor: look.accentColor, rasterArtSet: (look as Partial).rasterArtSet, realism: 0.4, resistance: summary.difficulty === "hard" ? 0.68 : summary.difficulty === "moderate" ? 0.45 : 0.3, expressionBias: look.expressionBias, }, context: { initial: name.slice(0, 1) || summary.code.slice(0, 1), name, meta: personaMeta(summary), rows: [ { l: "호소", v: summary.presenting_summary || "요약 정보 없음" }, { l: "대상", v: theories }, { l: "난도", v: difficulty }, ], chips, }, }; } function recorderMimeType(): string { if (typeof MediaRecorder === "undefined") return ""; const supported = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"]; return supported.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? ""; } function voiceFormatFromMime(mime: string): string { if (mime.includes("ogg")) return "ogg"; if (mime.includes("mp4")) return "mp4"; return "webm"; } /* ── 작은 표현 유틸 ─────────────────────────────────────────────────── */ // 백엔드가 반환한 effective_openness(0~1)를 실시간 관찰 신호로만 변환한다. // 점수/정밀 평가는 회기 종료 후 리뷰 화면에서 다룬다. function metersFromOpenness(openness: number) { const o = clamp01(openness); return { rapport: o, // 마음 열림(라포) resistance: clamp01(1 - o * 0.9), // 저항감(반비례) anxiety: clamp01(0.7 - o * 0.4), // 불안·위축(완만히 감소) }; } function baselineExpressionFor(summary: PersonaSummary | null): AvatarAffect { if (!summary) return "neutral"; const codeKey = summary.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS; return PERSONA_AVATAR_LOOKS[codeKey]?.expressionBias ?? "neutral"; } function expressionForSession({ state, openness, paused, safety, summary, stage, }: { state: AvatarState; openness: number; paused: boolean; safety: string | null; summary: PersonaSummary | null; stage: SessionStage; }): AvatarAffect { const o = clamp01(openness); const code = summary?.code.toUpperCase() ?? ""; const baseline = baselineExpressionFor(summary); if (safety) return "startled"; if (paused) return "tired"; if (state === "thinking") { if (code === "P4" && o < 0.38) return "anxious"; if (code === "P6") return o < 0.55 ? "conflicted" : "determined"; if (code === "P7") return o < 0.45 ? "overwhelmed" : "tired"; if (o < 0.32) return "guarded"; return "confused"; } if (state === "listening") { if (o < 0.28) return code === "P4" ? "panic" : "guarded"; if (o < 0.48) return baseline === "tired" ? "bored" : baseline; if (o > 0.72) return "warm"; return "calm"; } if (state === "speaking") { if (o < 0.22) return code === "P7" ? "bored" : "resistant"; if (o < 0.38) return code === "P5" ? "skeptical" : baseline; if (stage === "정리") return "relief"; if (o > 0.78) return "hopeful"; if (o > 0.62) return "warm"; return "neutral"; } if (o < 0.3) return baseline; if (o > 0.76) return "relief"; return "neutral"; } /* ===================================================================== */ export default function Session() { const { sessionId: routeId } = useParams<{ sessionId: string }>(); const navigate = useNavigate(); const { user, acceptConsent } = useAuth(); const routeParam = routeId ?? ""; const routeIsSessionId = useMemo(() => looksLikeSessionId(routeParam), [routeParam]); const [personaCode, setPersonaCode] = useState(() => looksLikeSessionId(routeId) ? "" : normalizePersonaCode(routeId), ); const [personaSummary, setPersonaSummary] = useState(null); const [personaLoadState, setPersonaLoadState] = useState<"loading" | "ready" | "missing" | "error">( "loading", ); const personaUi = useMemo( () => buildPersonaUi(personaSummary, personaCode), [personaSummary, personaCode], ); const clientName = personaUi.context.name; const primaryContext = personaUi.context.rows[0] ?? null; // ── 세션 진행 상태 ── const [started, setStarted] = useState(false); // start 가 백엔드에서 발급한 진짜 세션 id (turn/stream 은 이걸 써야 함. URL :sessionId 는 라우팅용) const [liveSessionId, setLiveSessionId] = useState(null); const [sessionEnded, setSessionEnded] = useState(false); const [reviewReady, setReviewReady] = useState(false); const [starting, setStarting] = useState(false); const [startError, setStartError] = useState(null); const [consentChecked, setConsentChecked] = useState(false); const [consentBusy, setConsentBusy] = useState(false); const [selectedTheoryMode, setSelectedTheoryMode] = useState("humanistic"); // ── 회기/대화 상태 ── const [stage, setStage] = useState("라포"); const [utterances, setUtterances] = useState([]); const [openness, setOpenness] = useState(0); const [safety, setSafety] = useState(null); const [crisisResource, setCrisisResource] = useState(null); const [turnError, setTurnError] = useState(null); // ── 음성/턴 UI 상태 ── const [avatarState, setAvatarState] = useState("idle"); const [micOn, setMicOn] = useState(false); const [paused, setPaused] = useState(false); const [feedbackMode, setFeedbackMode] = useState("ambient"); const [composeText, setComposeText] = useState(""); const [sending, setSending] = useState(false); const [clientReplyPending, setClientReplyPending] = useState(false); const [voiceStatus, setVoiceStatus] = useState("idle"); const [voiceDetail, setVoiceDetail] = useState( "마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.", ); // 음성 캐스케이드 가용성: null=미확인, true=가능, false=provider 키 미설정(버튼 사전 비활성). const [voiceAvailable, setVoiceAvailable] = useState(null); const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false); const [voiceAnalyser, setVoiceAnalyser] = useState(null); const [endDialogOpen, setEndDialogOpen] = useState(false); const [ending, setEnding] = useState(false); const endCancelRef = useRef(null); const endConfirmRef = useRef(null); const endPreviousFocusRef = useRef(null); // ── 라이브 신호(앰비언트 도트) ── const [liveSignal, setLiveSignal] = useState<{ tone: SignalTone; text: string } | null>(null); const [signalFaded, setSignalFaded] = useState(false); const [signalSeq, setSignalSeq] = useState([]); const [coachSuggestion, setCoachSuggestion] = useState(null); const [coachLoading, setCoachLoading] = useState(false); const [coachError, setCoachError] = useState(null); const [coachEvidenceOpen, setCoachEvidenceOpen] = useState(false); const [coachHistory, setCoachHistory] = useState([]); const [coachHistoryOpen, setCoachHistoryOpen] = useState(false); const [coachHistoryTurnSeq, setCoachHistoryTurnSeq] = useState(null); // ── 경과 타이머 ── const [elapsed, setElapsed] = useState(0); // ── 자동 스크롤 ── const scrollRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); const fadeTimerRef = useRef(null); const voiceSocketRef = useRef(null); const recorderRef = useRef(null); const micStreamRef = useRef(null); const audioContextRef = useRef(null); const ttsChunksRef = useRef([]); const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null); const pendingVoiceLearnerIdRef = useRef(null); const pendingVoiceLearnerTextRef = useRef(""); const coachEvidenceCloseRef = useRef(null); const coachHistoryCloseRef = useRef(null); const ensureVoiceAudioContext = useCallback(() => { const existing = audioContextRef.current; if (existing && existing.state !== "closed") return existing; const AudioContextCtor = window.AudioContext ?? (window as AudioContextWindow).webkitAudioContext; if (!AudioContextCtor) return null; const ctx = new AudioContextCtor(); audioContextRef.current = ctx; return ctx; }, []); const primeVoicePlayback = useCallback(async () => { const ctx = ensureVoiceAudioContext(); if (!ctx || ctx.state === "closed") return; try { await ctx.resume(); } catch { /* 브라우저가 아직 unlock을 거부하면 실제 재생 시 fallback한다. */ } }, [ensureVoiceAudioContext]); const stopTtsPlayback = useCallback(() => { const cleanup = ttsPlaybackCleanupRef.current; ttsPlaybackCleanupRef.current = null; if (cleanup) cleanup(); setVoiceAnalyser(null); }, []); useEffect(() => { if (routeIsSessionId) return; if (!routeParam.trim()) { navigate("/learn", { replace: true }); return; } setPersonaCode(normalizePersonaCode(routeParam)); setLiveSessionId(null); setSessionEnded(false); setReviewReady(false); setStarted(false); setUtterances([]); setElapsed(0); setSafety(null); setCrisisResource(null); setResumedSessionLoaded(false); setClientReplyPending(false); setStartError(null); setCoachSuggestion(null); setCoachError(null); setCoachEvidenceOpen(false); setCoachHistory([]); setCoachHistoryOpen(false); setCoachHistoryTurnSeq(null); }, [navigate, routeIsSessionId, routeParam]); useEffect(() => { let alive = true; if (routeIsSessionId && !personaCode) { setPersonaLoadState("loading"); setPersonaSummary(null); return () => { alive = false; }; } setPersonaLoadState("loading"); setPersonaSummary(null); (async () => { try { const personas = await personaApi.list(); if (!alive) return; const matched = personas.find((persona) => persona.code.toUpperCase() === personaCode) ?? null; setPersonaSummary(matched); setPersonaLoadState(matched ? "ready" : "missing"); } catch { if (!alive) return; setPersonaSummary(null); setPersonaLoadState("error"); } })(); return () => { alive = false; }; }, [personaCode]); useEffect(() => { if (started) return; setSelectedTheoryMode(preferredTheoryMode(personaSummary)); }, [personaSummary, started]); // 현재 단계 인덱스 const stageIdx = useMemo(() => SESSION_PHASES.findIndex((p) => p.key === stage), [stage]); const currentPhase = SESSION_PHASES[Math.max(0, stageIdx)] ?? SESSION_PHASES[0]; // ── 경과 타이머 진행(일시정지 시 멈춤) ── useEffect(() => { if (!started || paused) return; const t = window.setInterval(() => setElapsed((s) => s + 1), 1000); return () => window.clearInterval(t); }, [started, paused]); // ── 자막 자동 스크롤(아래로) ── useEffect(() => { if (!autoScroll) return; const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [utterances, clientReplyPending, autoScroll]); // ── 라이브 신호 6초 페이드(§5.5) ── useEffect(() => { if (!liveSignal) return; setSignalFaded(false); if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current); fadeTimerRef.current = window.setTimeout(() => setSignalFaded(true), 6000); return () => { if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current); }; }, [liveSignal]); // 사용자가 위로 스크롤하면 자동스크롤 해제 const onScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24; setAutoScroll(atBottom); }, []); const jumpToLatest = () => { const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; setAutoScroll(true); }; // 라이브 신호 push + 시퀀스 누적 (최근 5개 유지) const pushSignal = useCallback((tone: SignalTone, text: string) => { setLiveSignal({ tone, text }); setSignalSeq((seq) => [...seq.slice(-4), tone]); }, []); const applyCrisisGate = useCallback((resource?: CrisisResource | null) => { const fallback: CrisisResource = { title: "자살예방상담전화 109", number: "109", message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.", }; const next = resource ?? fallback; setCrisisResource(next); setSafety(next.message); setPaused(true); setMicOn(false); setClientReplyPending(false); setVoiceStatus("idle"); setVoiceDetail("위기 신호가 감지되어 연습을 중단했습니다."); pushSignal("warn", "위기 안전게이트 작동"); }, [pushSignal]); const refreshCoachHistory = useCallback( async (sessionId = liveSessionId) => { if (!sessionId) { setCoachHistory([]); return; } try { const history = await sessionApi.liveCoachHistory(sessionId); setCoachHistory(history.events ?? []); } catch { /* 코칭 이력 조회 실패는 회기 진행을 막지 않는다. */ } }, [liveSessionId], ); const openCoachHistory = useCallback( (turnSeq: number | null = null) => { setCoachHistoryTurnSeq(turnSeq); setCoachEvidenceOpen(false); setCoachHistoryOpen(true); void refreshCoachHistory(); }, [refreshCoachHistory], ); const requestLiveCoach = useCallback( async ({ learnerText, clientReply, turnSeq, }: { learnerText: string; clientReply?: string | null; turnSeq?: number; }) => { if (!liveSessionId || feedbackMode !== "coached") return; setCoachLoading(true); setCoachError(null); try { const suggestion = await sessionApi.liveCoach(liveSessionId, { learner_text: learnerText, client_reply: clientReply ?? null, turn_seq: turnSeq, }); setCoachSuggestion(suggestion); pushSignal(suggestion.tone, suggestion.title); void refreshCoachHistory(); } catch { setCoachError("코칭 근거를 불러오지 못했습니다. 다음 턴에서 다시 시도합니다."); pushSignal("warn", "코칭 연결 실패"); } finally { setCoachLoading(false); } }, [feedbackMode, liveSessionId, pushSignal, refreshCoachHistory], ); useEffect(() => { if (!started || !liveSessionId) return; void refreshCoachHistory(liveSessionId); }, [liveSessionId, refreshCoachHistory, started]); useEffect(() => { if (!routeIsSessionId) return; let alive = true; setStarting(true); setStartError(null); setResumedSessionLoaded(false); (async () => { try { const detail = await sessionApi.get(routeParam); if (!alive) return; const ended = detail.status === "ended"; setPersonaCode(detail.persona_code); setLiveSessionId(detail.session_id); setSessionEnded(ended); setReviewReady(detail.review_ready); setStage(detail.stage); setOpenness(detail.effective_openness); setUtterances( (detail.turns ?? []).map((turn) => ({ id: nextId(), speaker: turn.speaker === "client" ? "client" : "learner", text: turn.text, turnSeq: turn.turn_seq, at: secondsBetween(detail.started_at, turn.created_at), })), ); setElapsed(elapsedFromSession(detail)); setStarted(true); setPaused(ended); setSafety(null); setCrisisResource(null); setAvatarState("idle"); setMicOn(false); setVoiceStatus(ended ? "degraded" : "idle"); setVoiceDetail( ended ? "종료된 회기입니다. 대화 기록은 이 화면에 남기고 리뷰로 이동할 수 있습니다." : "이전 회기 기록을 불러왔습니다. 이어서 말하거나 회기를 종료할 수 있습니다.", ); setResumedSessionLoaded(true); setCoachSuggestion(null); setCoachError(null); setCoachEvidenceOpen(false); setCoachHistoryOpen(false); setCoachHistoryTurnSeq(null); setClientReplyPending(false); pushSignal("neutral", ended ? "종료된 회기 기록" : "기존 회기 이어하기"); } catch { if (!alive) return; setStarted(false); setSessionEnded(false); setReviewReady(false); setResumedSessionLoaded(false); setStartError("기존 회기를 불러오지 못했습니다. 학습자 홈에서 다시 선택해 주세요."); } finally { if (alive) setStarting(false); } })(); return () => { alive = false; }; }, [navigate, pushSignal, routeIsSessionId, routeParam]); /* ── 세션 시작 ───────────────────────────────────────────────────── */ const handleStart = useCallback(async () => { if (!personaSummary) { setStartError("페르소나 정보를 확인한 뒤 회기를 시작할 수 있습니다."); return; } setStarting(true); setStartError(null); setResumedSessionLoaded(false); setClientReplyPending(false); try { const res = await sessionApi.start(personaCode, selectedTheoryMode); setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브 setSessionEnded(false); setReviewReady(false); setStage(res.stage); setOpenness(res.effective_openness); setUtterances([]); setSafety(null); setCrisisResource(null); setElapsed(0); setCoachSuggestion(null); setCoachError(null); setCoachEvidenceOpen(false); setCoachHistory([]); setCoachHistoryOpen(false); setCoachHistoryTurnSeq(null); if (res.degraded) { pushSignal("neutral", "서버 기록 제한"); } setStarted(true); setAvatarState("idle"); setMicOn(false); setVoiceStatus("idle"); setVoiceDetail("마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다."); navigate(`/learn/session/${res.session_id}`, { replace: true }); // 음성 가용성 사전 확인 — provider 키 미설정이면 마이크 버튼을 비활성화하고 명확히 안내한다. // (클릭→권한요청→마이크 깜빡→WS degraded 종료의 혼란스러운 "바로 꺼짐"을 방지.) void sessionApi.voiceHealth().then(({ available }) => { setVoiceAvailable(available); if (!available) { setVoiceStatus("degraded"); setVoiceDetail( "음성 기능이 설정되지 않았습니다(STT/TTS provider 키 필요). 아래 텍스트 입력으로 회기를 진행하세요.", ); } }); } catch (err) { if (err instanceof ApiError && err.detail === "consent_required") { setStartError("개인정보 및 연습 기록 처리 동의 후 회기를 시작할 수 있습니다."); } else { setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요."); } } finally { setStarting(false); } }, [navigate, personaCode, personaSummary, pushSignal, selectedTheoryMode]); const handleAcceptConsent = useCallback(async () => { if (!consentChecked) { setStartError("동의 항목을 확인해야 회기를 시작할 수 있습니다."); return; } setConsentBusy(true); setStartError(null); try { await acceptConsent(); pushSignal("pos", "동의 확인"); } catch { setStartError("동의 상태를 저장하지 못했습니다. 잠시 뒤 다시 시도해 주세요."); } finally { setConsentBusy(false); } }, [acceptConsent, consentChecked, pushSignal]); const appendServerClientReply = useCallback( (replyText: string | null, at = elapsed) => { setClientReplyPending(false); if (!replyText) { setAvatarState("listening"); pushSignal("warn", "내담자 응답 없음"); return; } setUtterances((prev) => [ ...prev, { id: nextId(), speaker: "client", text: replyText, at }, ]); setAvatarState("speaking"); pushSignal("neutral", "내담자 응답 수신"); }, [elapsed, pushSignal], ); /* ── 학습자 발화 전송(텍스트 입력 폴백 = 음성 1턴 왕복 대역) ──────── */ const handleSend = useCallback(async () => { const text = composeText.trim(); if (!text || sending || paused || voiceStatus === "thinking") return; if (sessionEnded) { setTurnError("종료된 회기에서는 새 발화를 보낼 수 없습니다. 리뷰에서 기록을 확인해 주세요."); return; } if (!liveSessionId) { setTurnError("회기가 아직 열리지 않았습니다. 회기를 다시 시작해 주세요."); pushSignal("warn", "회기 연결 없음"); return; } const at = elapsed; const learnerId = nextId(); setSending(true); setComposeText(""); setUtterances((prev) => [ ...prev, { id: learnerId, speaker: "learner", text, at }, ]); setClientReplyPending(true); setAvatarState("thinking"); setTurnError(null); let clientId: number | null = null; let clientReply = ""; try { const done = await sessionApi.stream(liveSessionId, text, { onToken: (chunk) => { if (!chunk) return; clientReply += chunk; setClientReplyPending(false); setAvatarState("speaking"); if (clientId == null) { clientId = nextId(); const id = clientId; setUtterances((prev) => [ ...prev, { id, speaker: "client", text: clientReply, at, partial: true }, ]); return; } const id = clientId; setUtterances((prev) => prev.map((u) => (u.id === id ? { ...u, text: clientReply } : u)), ); }, onDone: (data) => { if (data.stage) setStage(data.stage); if (typeof data.effective_openness === "number") { setOpenness(data.effective_openness); } if (data.safety_flagged) { setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요."); } if (data.conversation_stopped || data.crisis_resource) { applyCrisisGate(data.crisis_resource); } }, onSafety: (payload) => { setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요."); if (payload && typeof payload === "object") { const resource = (payload as { crisis_resource?: CrisisResource }).crisis_resource; const stopped = (payload as { conversation_stopped?: boolean }).conversation_stopped; if (resource || stopped) applyCrisisGate(resource); } }, }); if (clientId != null) { const id = clientId; setUtterances((prev) => { const existingIndex = prev.findIndex((u) => u.id === id); if (existingIndex === -1) { return [ ...prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)), { id, speaker: "client", text: clientReply, at }, ]; } return prev.map((u) => u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u.id === id ? { ...u, partial: false } : u, ); }); pushSignal("neutral", "내담자 응답 수신"); } else { setClientReplyPending(false); setUtterances((prev) => prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)), ); pushSignal("warn", "내담자 응답 없음"); } setAvatarState("listening"); void requestLiveCoach({ learnerText: text, clientReply, turnSeq: done.turn_seq, }); } catch (err) { setComposeText(text); setClientReplyPending(false); if (clientId != null) { const id = clientId; setUtterances((prev) => prev.filter((u) => u.id !== id && u.id !== learnerId)); } else { setUtterances((prev) => prev.filter((u) => u.id !== learnerId)); } const detail = err instanceof ApiError ? err.detail : err instanceof Error ? err.message : "AI 엔진 연결에 실패했습니다."; const normalized = detail.includes("Not logged in") ? "AI 엔진 로그인이 필요합니다. 관리자에게 엔진 상태 확인을 요청하세요." : detail.includes("engine unavailable") ? "AI 엔진이 응답하지 않습니다. 잠시 뒤 다시 시도하거나 관리자에게 알려 주세요." : "내담자 응답을 생성하지 못했습니다. 잠시 뒤 다시 시도해 주세요."; setTurnError(normalized); pushSignal("warn", "AI 엔진 연결 실패"); setAvatarState("listening"); } finally { setSending(false); } }, [ composeText, sending, paused, voiceStatus, sessionEnded, liveSessionId, elapsed, pushSignal, requestLiveCoach, ]); const onComposeKey = (e: ReactKeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSend(); } }; const stopMicStream = useCallback(() => { micStreamRef.current?.getTracks().forEach((track) => track.stop()); micStreamRef.current = null; }, []); const closeVoiceSocket = useCallback(() => { const ws = voiceSocketRef.current; voiceSocketRef.current = null; if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) return; try { ws.onclose = null; ws.onerror = null; if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "close" })); ws.close(); } catch { /* 이미 닫힌 소켓은 무시 */ } }, []); const shutdownVoice = useCallback( (status: VoiceStatus = "idle", detail = "마이크 꺼짐") => { stopTtsPlayback(); const recorder = recorderRef.current; recorderRef.current = null; if (recorder && recorder.state !== "inactive") { try { recorder.stop(); } catch { /* recorder stop race 무시 */ } } stopMicStream(); closeVoiceSocket(); setMicOn(false); setVoiceStatus(status); setVoiceDetail(detail); setVoiceAnalyser(null); }, [closeVoiceSocket, stopMicStream, stopTtsPlayback], ); const finishVoiceUtterance = useCallback(() => { const recorder = recorderRef.current; if (recorder && recorder.state !== "inactive") { try { recorder.stop(); } catch { stopMicStream(); } } else { stopMicStream(); } setMicOn(false); setAvatarState("thinking"); setVoiceStatus("thinking"); setVoiceDetail("녹음을 마쳤습니다. 전사 중입니다."); }, [stopMicStream]); const playTtsAudio = useCallback(async () => { const chunks = ttsChunksRef.current.splice(0); if (!chunks.length) return; const blob = new Blob(chunks, { type: "audio/mpeg" }); stopTtsPlayback(); const finishPlayback = () => { ttsPlaybackCleanupRef.current = null; setVoiceAnalyser(null); setAvatarState("listening"); setVoiceStatus("idle"); setVoiceDetail("응답이 끝났습니다. 마이크를 다시 켜 발화하세요."); closeVoiceSocket(); }; const failPlayback = (detail: string, status: VoiceStatus = "error") => { ttsPlaybackCleanupRef.current = null; setVoiceAnalyser(null); setAvatarState("listening"); setVoiceStatus(status); setVoiceDetail(detail); closeVoiceSocket(); }; const ctx = ensureVoiceAudioContext(); if (ctx && ctx.state !== "closed") { try { if (ctx.state === "suspended") await ctx.resume(); const arrayBuffer = await blob.arrayBuffer(); const decoded = await ctx.decodeAudioData(arrayBuffer.slice(0)); const analyser = ctx.createAnalyser(); analyser.fftSize = 1024; const source = ctx.createBufferSource(); source.buffer = decoded; source.connect(analyser); analyser.connect(ctx.destination); let completed = false; const cleanup = () => { if (completed) return; completed = true; source.onended = null; try { source.stop(); } catch { /* 이미 종료된 source는 무시 */ } try { source.disconnect(); analyser.disconnect(); } catch { /* disconnect race 무시 */ } }; ttsPlaybackCleanupRef.current = cleanup; source.onended = () => { if (completed) return; completed = true; try { source.disconnect(); analyser.disconnect(); } catch { /* disconnect race 무시 */ } finishPlayback(); }; setVoiceAnalyser(analyser); setAvatarState("speaking"); setVoiceStatus("speaking"); setVoiceDetail(`${clientName} 음성을 재생 중입니다.`); source.start(); return; } catch { setVoiceAnalyser(null); } } const url = URL.createObjectURL(blob); const audio = new Audio(url); let cleaned = false; const cleanupElement = () => { if (cleaned) return; cleaned = true; audio.pause(); audio.removeAttribute("src"); URL.revokeObjectURL(url); }; ttsPlaybackCleanupRef.current = cleanupElement; setAvatarState("speaking"); setVoiceStatus("speaking"); setVoiceDetail(`${clientName} 음성을 재생 중입니다.`); audio.onended = () => { cleanupElement(); finishPlayback(); }; audio.onerror = () => { cleanupElement(); failPlayback("음성 재생에 실패했습니다. 자막 응답은 화면에 남겼습니다."); }; try { await audio.play(); } catch { cleanupElement(); failPlayback("브라우저가 자동 재생을 막았습니다. 자막 응답은 화면에 남겼습니다.", "degraded"); } }, [clientName, closeVoiceSocket, ensureVoiceAudioContext, stopTtsPlayback]); const startVoiceCapture = useCallback(async () => { if (!liveSessionId || paused || sending || sessionEnded) return; if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") { setVoiceStatus("error"); setVoiceDetail("이 브라우저는 마이크 녹음을 지원하지 않습니다."); pushSignal("warn", "마이크 미지원"); return; } shutdownVoice("requesting", "Chrome 마이크 권한을 요청합니다."); await primeVoicePlayback(); let stream: MediaStream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }); } catch { setVoiceStatus("error"); setVoiceDetail("마이크 권한이 거부됐습니다. Chrome 주소창 왼쪽 권한에서 마이크를 허용해 주세요."); pushSignal("warn", "마이크 권한 필요"); return; } micStreamRef.current = stream; setVoiceStatus("connecting"); setVoiceDetail("음성 연결을 준비하는 중입니다."); const mimeType = recorderMimeType(); const format = voiceFormatFromMime(mimeType); const ws = new WebSocket( apiWsUrl(`/voice/ws?session_id=${encodeURIComponent(liveSessionId)}`), ); ws.binaryType = "arraybuffer"; voiceSocketRef.current = ws; ttsChunksRef.current = []; let recorder: MediaRecorder; try { recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); } catch { stopMicStream(); closeVoiceSocket(); setVoiceStatus("error"); setVoiceDetail("마이크 녹음기를 만들지 못했습니다. 브라우저 오디오 설정을 확인해 주세요."); return; } recorderRef.current = recorder; setClientReplyPending(false); recorder.ondataavailable = (event) => { if (!event.data.size || ws.readyState !== WebSocket.OPEN) return; ws.send(event.data); }; recorder.onstop = () => { stopMicStream(); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "audio_end", format })); } }; ws.onopen = () => { ws.send(JSON.stringify({ type: "audio_start", format })); recorder.start(500); setMicOn(true); setAvatarState("listening"); setVoiceStatus("recording"); setVoiceDetail("녹음 중입니다. 다시 누르면 발화를 보냅니다."); pushSignal("neutral", "마이크 연결됨"); }; ws.onmessage = (event) => { if (typeof event.data !== "string") { ttsChunksRef.current.push(event.data as BlobPart); return; } let payload: VoiceEvent; try { payload = JSON.parse(event.data) as VoiceEvent; } catch { return; } if (payload.type === "ready") { setVoiceDetail("음성 연결됨. 말한 뒤 버튼을 다시 눌러 보내세요."); return; } if (payload.type === "state") { if (payload.state === "thinking") { setMicOn(false); setAvatarState("thinking"); setVoiceStatus("thinking"); setVoiceDetail("전사와 응답 생성을 기다리는 중입니다."); } else if (payload.state === "speaking") { setAvatarState("speaking"); setVoiceStatus("speaking"); setVoiceDetail(`${clientName} 음성을 받는 중입니다.`); } else if (payload.state === "idle" && recorderRef.current?.state !== "recording") { setAvatarState("listening"); setVoiceStatus("idle"); setVoiceDetail("마이크를 다시 켜 발화하세요."); } return; } if (payload.type === "transcript" && payload.final && payload.text) { const id = nextId(); pendingVoiceLearnerIdRef.current = id; pendingVoiceLearnerTextRef.current = payload.text; setClientReplyPending(true); setUtterances((prev) => [ ...prev, { id, speaker: "learner", text: payload.text ?? "", at: elapsed, partial: true }, ]); return; } if (payload.type === "reply") { setClientReplyPending(false); if (payload.stage) setStage(payload.stage); if (typeof payload.effective_openness === "number") { setOpenness(payload.effective_openness); } if (payload.safety_flagged) { setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요."); } if (payload.conversation_stopped || payload.crisis_resource) { applyCrisisGate(payload.crisis_resource); } const pendingId = pendingVoiceLearnerIdRef.current; if (pendingId != null) { setUtterances((prev) => prev.map((u) => u.id === pendingId ? { ...u, partial: false, turnSeq: payload.turn_seq } : u, ), ); pendingVoiceLearnerIdRef.current = null; } appendServerClientReply(payload.text ?? null); const learnerText = pendingVoiceLearnerTextRef.current; pendingVoiceLearnerTextRef.current = ""; if (learnerText) { void requestLiveCoach({ learnerText, clientReply: payload.text ?? null, turnSeq: payload.turn_seq, }); } return; } if (payload.type === "tts_end") { void playTtsAudio(); return; } if (payload.type === "degraded") { setClientReplyPending(false); const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다."; const reason = payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason; shutdownVoice("degraded", reason); pushSignal("warn", "음성 기능 미설정"); return; } if (payload.type === "error") { setClientReplyPending(false); if (pendingVoiceLearnerIdRef.current != null && payload.detail?.includes("engine unavailable")) { const pendingId = pendingVoiceLearnerIdRef.current; setUtterances((prev) => prev.filter((u) => u.id !== pendingId)); pendingVoiceLearnerIdRef.current = null; pendingVoiceLearnerTextRef.current = ""; } shutdownVoice("error", payload.detail || "음성 처리 중 오류가 발생했습니다."); pushSignal("warn", "음성 오류"); } }; ws.onerror = () => { setClientReplyPending(false); shutdownVoice("error", "음성 연결을 확인하지 못했습니다."); pushSignal("warn", "음성 연결 실패"); }; ws.onclose = () => { recorderRef.current = null; stopMicStream(); setMicOn(false); setVoiceStatus((current) => (current === "degraded" || current === "error" ? current : "idle")); setVoiceDetail((current) => { if ( current.includes("비활성화") || current.includes("오류") || current.includes("권한") ) { return current; } return "마이크 꺼짐"; }); }; }, [ clientName, appendServerClientReply, closeVoiceSocket, elapsed, liveSessionId, paused, sessionEnded, playTtsAudio, primeVoicePlayback, pushSignal, requestLiveCoach, sending, shutdownVoice, stopMicStream, ]); const skipVoicePlayback = useCallback(() => { ttsChunksRef.current = []; stopTtsPlayback(); closeVoiceSocket(); setVoiceAnalyser(null); setAvatarState("listening"); setVoiceStatus("idle"); setVoiceDetail("음성을 건너뛰었습니다. 다음 발화를 입력하거나 마이크를 켜세요."); pushSignal("neutral", "음성 건너뜀"); }, [closeVoiceSocket, pushSignal, stopTtsPlayback]); const toggleMic = useCallback(() => { if (paused || sending || sessionEnded) return; if (voiceAvailable === false) { pushSignal("warn", "음성 미설정 — 텍스트로 진행"); return; } if ( voiceStatus === "requesting" || voiceStatus === "connecting" || voiceStatus === "thinking" || voiceStatus === "speaking" ) { return; } if (micOn || voiceStatus === "recording") { finishVoiceUtterance(); } else { void startVoiceCapture(); } }, [finishVoiceUtterance, micOn, paused, sending, sessionEnded, startVoiceCapture, voiceAvailable, voiceStatus, pushSignal]); const togglePause = useCallback(() => { if (sessionEnded) return; setPaused((p) => { const next = !p; if (next) { if (voiceStatus !== "idle") setClientReplyPending(false); shutdownVoice("idle", "일시정지 중입니다."); setAvatarState("idle"); } else { setAvatarState("listening"); setVoiceDetail("마이크를 켜면 Chrome 권한 요청 후 음성으로 회기를 진행합니다."); } return next; }); }, [sessionEnded, shutdownVoice, voiceStatus]); // ── 키보드 단축키 (§5.6: Space=마이크, P=일시정지). 입력 중엔 무시 ── useEffect(() => { if (!started) return; const onKey = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement | null)?.tagName; const typing = tag === "TEXTAREA" || tag === "INPUT"; if (typing) return; if (e.code === "Space") { e.preventDefault(); void toggleMic(); } else if (e.key === "p" || e.key === "P") { togglePause(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [started, toggleMic, togglePause]); useEffect(() => { return () => { shutdownVoice(); audioContextRef.current?.close().catch(() => undefined); }; }, [shutdownVoice]); useEffect(() => { if (!coachEvidenceOpen) return; window.setTimeout(() => coachEvidenceCloseRef.current?.focus(), 0); const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") { setCoachEvidenceOpen(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [coachEvidenceOpen]); useEffect(() => { if (!coachHistoryOpen) return; window.setTimeout(() => coachHistoryCloseRef.current?.focus(), 0); const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") { setCoachHistoryOpen(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [coachHistoryOpen]); useEffect(() => { if (!endDialogOpen) return; endPreviousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; window.setTimeout(() => endCancelRef.current?.focus(), 0); const onKey = (event: KeyboardEvent) => { if (event.key === "Escape" && !ending) { setEndDialogOpen(false); return; } if (event.key === "Tab") { const controls = [endCancelRef.current, endConfirmRef.current].filter( (control): control is HTMLButtonElement => control !== null && !control.disabled, ); if (controls.length === 0) return; const first = controls[0]; const last = controls[controls.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [endDialogOpen, ending]); useEffect(() => { if (endDialogOpen || ending) return; endPreviousFocusRef.current?.focus(); endPreviousFocusRef.current = null; }, [endDialogOpen, ending]); // 회기 종료 — 명시 확인 후 end 호출, 현재 화면을 정지 상태로 둔다. const handleEnd = useCallback(async () => { if (ending || sessionEnded) return; setEnding(true); try { if (liveSessionId) await sessionApi.end(liveSessionId); } catch { /* 종료 실패는 UI 종료 처리를 막지 않는다. */ } setEndDialogOpen(false); setSessionEnded(true); setReviewReady(true); setMicOn(false); setPaused(true); setAvatarState("idle"); pushSignal("neutral", "회기 종료"); if (liveSessionId) { navigate(`/learn/session/${liveSessionId}/review`, { replace: true }); } else { setEnding(false); } }, [ending, liveSessionId, navigate, pushSignal, sessionEnded]); const meters = metersFromOpenness(openness); const avatarAffect: AvatarAffect = useMemo( () => expressionForSession({ state: avatarState, openness, paused, safety, summary: personaSummary, stage, }), [avatarState, openness, paused, personaSummary, safety, stage], ); const avatarExpressionLabel = expressionLabelFor(avatarAffect); const avatarExpressionCount = AVATAR_EXPRESSION_LIBRARY.length; // 음성 오브 상태(아바타 state → 오브 data-orb) const orbState = paused ? "thinking" : avatarState === "speaking" ? "client" : avatarState === "listening" ? "learner" : avatarState === "thinking" ? "thinking" : "idle"; const micBusy = voiceStatus === "requesting" || voiceStatus === "connecting" || voiceStatus === "thinking" || voiceStatus === "speaking"; const micLabel = sessionEnded ? "종료됨" : paused ? "일시정지" : voiceStatus === "requesting" ? "권한 요청 중" : voiceStatus === "connecting" ? "연결 중" : voiceStatus === "recording" ? "녹음 중" : voiceStatus === "thinking" ? "전사 중" : voiceStatus === "speaking" ? "재생 중" : voiceStatus === "degraded" ? "음성 미설정" : voiceStatus === "error" ? "마이크 오류" : "마이크 꺼짐"; const sessionStatusLabel = sessionEnded ? "회기 종료됨" : paused ? "일시정지" : voiceStatus === "recording" ? "학습자 발화 수신 중" : voiceStatus === "thinking" ? "내담자 응답 준비 중" : voiceStatus === "speaking" ? `${clientName} 응답 중` : "회기 진행 중"; const transcriptLiveLabel = voiceStatus === "recording" ? "받아쓰는 중" : voiceStatus === "thinking" ? "전사 중" : voiceStatus === "speaking" ? "응답 표시 중" : sending ? "응답 대기" : utterances.length > 0 ? "기록 중" : "준비됨"; const transcriptIsLive = voiceStatus === "recording" || voiceStatus === "thinking" || voiceStatus === "speaking" || sending; const textTurnBlocked = sending || voiceStatus === "thinking"; const elapsedLabel = formatElapsed(elapsed); const recommendedSessionSeconds = 30 * 60; const remainingLabel = formatElapsed(Math.max(0, recommendedSessionSeconds - elapsed)); const turnCount = utterances.filter((utterance) => !utterance.partial).length; const latestClientUtterance = [...utterances] .reverse() .find((utterance) => utterance.speaker === "client" && utterance.text.trim()); const stageClientLine = latestClientUtterance?.text ?? primaryContext?.v ?? `${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`; const voiceInputStatus = voiceAvailable === false ? "음성 미설정" : micOn ? "수신 중" : voiceStatus === "requesting" ? "권한 요청" : "대기"; const responseStatus = sending || clientReplyPending ? "응답 대기" : voiceStatus === "thinking" ? "전사 중" : voiceStatus === "speaking" ? "재생 중" : "안정"; const safetyStatusText = safety ? "확인 필요" : "안전"; const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false; const consentRequired = user?.role === "learner" && user.consentAt == null; const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired; const prestartTitle = canStartSession ? `${clientName}님과의 회기를 시작할까요?` : consentRequired ? "동의 확인 후 회기를 시작할 수 있습니다." : personaLoadState === "ready" && personaSummary ? "이 내담자는 현재 연습에 사용할 수 없습니다." : "연습 대상 정보를 확인하고 있습니다."; const personaStatusMessage = personaLoadState === "loading" ? "페르소나 정보를 불러오는 중입니다." : personaLoadState === "missing" ? `${personaCode} 페르소나는 현재 연습 목록에 없습니다. 학습자 홈에서 사용 가능한 페르소나를 선택해 주세요.` : personaLoadState === "error" ? "연습 목록을 불러오지 못했습니다. 잠시 뒤 다시 시도해 주세요." : personaSummary && !personaIsUsable ? unavailablePersonaMessage(personaSummary) : null; const coachSources = coachSuggestion?.sources ?? []; const coachTone = coachSuggestion?.tone ?? "neutral"; const coachStatusText = coachLoading ? "코치가 근거를 확인 중" : coachSuggestion?.status === "degraded" ? "규칙 기반 코칭" : coachSuggestion ? "근거 확인 완료" : "턴 완료 후 개입"; const coachHistoryByTurn = useMemo(() => { const grouped = new Map(); for (const event of coachHistory) { const turnSeq = event.turn_seq; grouped.set(turnSeq, [...(grouped.get(turnSeq) ?? []), event]); } return grouped; }, [coachHistory]); const activeCoachEvents = useMemo(() => { if (coachHistoryTurnSeq == null) return coachHistory; return coachHistoryByTurn.get(coachHistoryTurnSeq) ?? []; }, [coachHistory, coachHistoryByTurn, coachHistoryTurnSeq]); const coachHistoryTitle = coachHistoryTurnSeq == null ? "라이브 코칭 이력" : `${coachHistoryTurnSeq}번 턴 코칭`; // stage 상단 상태 라벨 텍스트 const stageStateText: Record = { idle: "잠시 시선을 내리고 말을 고르는 중", listening: "당신의 말을 듣고 있어요", thinking: "잠시 생각하는 중", speaking: `${personaSummary ? clientName : "내담자"}이 이야기하는 중`, }; return (
{/* ── 페이지 헤드라인 + 가로 단계 미니 ── */}
상담 연습 · {personaCode}

지금은 {stage} 단계입니다.

{clientName}의 말에 귀를 기울이세요. 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 봅니다.
{SESSION_PHASES.map((p, i) => { const state = i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : ""; return (
{i > 0 ? ( ) : null}
{p.key} {i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"}
); })}
{started ? ( ) : null} {!started ? ( /* ── 시작 전: 준비 화면(한 화면 한 의도 = 세션 시작) ── */
{personaUi.context.name}

{personaUi.context.meta}

    {personaUi.context.rows.slice(0, 3).map((row) => (
  • {row.l} {row.v}
  • ))}

{prestartTitle}

짧은 첫 인사로 안전감을 만들고, 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 확인합니다.

{personaUi.context.rows.map((row) => (
{row.l}
{row.v}
))}
{personaUi.context.chips.map((chip) => ( {chip.t} ))}
이론모드
{THEORY_MODE_OPTIONS.map((option) => ( ))}
{personaStatusMessage ? (

{personaStatusMessage}

) : null} {startError ?

{startError}

: null} {consentRequired ? (
) : null}
실시간에는 대화 흐름만 낮은 강도로 표시됩니다.
진행 초점
) : ( <> {/* ── 3-region 그리드 ── */}
{sessionStatusLabel} {elapsedLabel} {clientName} 내담자 {micLabel} 마이크
{currentPhase.desc} {stage} {primaryContext ? ( {primaryContext.v} {primaryContext.l} ) : null}
{meters.resistance > 0.55 ? "방어 신호" : "완화 신호"} 관찰 신호 {meters.anxiety > 0.5 ? "위축 신호" : "안정 신호"} 관찰 신호 {safety ? "확인 필요" : "안정"} 안전 점검