/* ===================================================================== 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, useSearchParams } 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, surfaceClassName } from "../components/ui"; import { ApiError, apiWsUrl, personaApi, sessionApi, type CrisisResource, type LiveCoachCreditEvent, type LiveCoachEvent, type LiveCoachHistoryResponse, type LiveCoachQuota, type LiveCoachSuggestion, type PersonaSummary, type SessionDetailResponse, type SessionProgress, type SessionStage, } from "../lib/api"; import { useAuth } from "../lib/auth"; import { formatElapsed, formatTimecode, clamp01 } from "../lib/format"; import { displayPiiSafeText } from "../lib/piiDisplay"; import { parseVoicePracticeContext, voicePracticeSearch, VOICE_SCENE_LABEL, } from "../lib/voicePracticeContext"; import { parsePracticeLaunchIntent, practiceCriterionLabel, practiceLaunchSearch, PRACTICE_SOURCE_SESSION_LABEL, PRACTICE_MODE_LABEL, PRACTICE_NOVELTY_LABEL, } from "../lib/practiceLaunchIntent"; import { DIFFICULTY_LABEL, PENDING_PERSONA_AVATAR_APPEARANCE, isUsablePersona, personaAvatarAppearance, personaBaselineExpression, personaMeta, personaTheoryLabel, shortPersonaName, unavailablePersonaMessage, } from "../lib/personaViewModel"; import { VOICE_CONNECTION_SAVE_FAILED, createAudioWorkletCapture, createMediaRecorderCapture, isVoiceStatusBusy, sendVoiceControl, sessionVoiceStatusView, supportsAudioWorkletCapture, userFacingVoiceError, type AudioContextWindow, type VoiceCaptureController, type VoiceEvent, type VoiceStatus, } from "./session/voiceCapture"; import { AllianceCheckpointPrompt } from "./session/AllianceCheckpointPrompt"; import { multimodalAllianceApi, type MultimodalConsentRequest, } from "./session-review/multimodalAllianceApi"; import "./session/session.css"; import { randomUuid } from "../lib/uuid"; /* ── 도메인 상수/타입 ───────────────────────────────────────────────── */ type FeedbackMode = "immersive" | "ambient" | "coached"; type TheoryMode = "humanistic" | "cbt" | "integrative"; type Speaker = "client" | "learner"; type SignalTone = "pos" | "warn" | "neutral"; const AI_VOICE_DISCLOSURE = "내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다."; interface Utterance { id: number; speaker: Speaker; text: string; turnSeq?: number; /** 발화 시작 시점(세션 경과 초) */ at: number; /** 진행 중(스트리밍/타이핑) 발화 여부 */ partial?: boolean; /** 음성 WebSocket이 reply 전에 실패해 DB 턴으로 확정되지 않은 발화 */ failed?: boolean; /** 실시간 음성 전사의 현재 확정 단계 */ voiceTranscriptState?: "interim" | "finalizing"; } 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; focus: string; }[] = [ { value: "humanistic", label: "인간중심", detail: "공감·반영", focus: "감정과 욕구를 반영한 뒤 열린 질문으로 이어갑니다.", }, { value: "cbt", label: "CBT", detail: "생각·행동", focus: "상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.", }, { value: "integrative", label: "통합", detail: "혼합 접근", focus: "공감적 반영을 먼저 두고 필요한 지점만 구조화합니다.", }, ]; const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 }; 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"; } 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: "내담자 정보 확인 중", ...PENDING_PERSONA_AVATAR_APPEARANCE, realism: 0.3, }, context: { initial: "?", name: "내담자 확인 중", meta: code ? `${code} · DB 카탈로그 확인 중` : "DB 카탈로그 확인 중", rows: [ { l: "상태", v: "실제 연습 대상 정보를 확인하고 있습니다." }, ], chips: [], }, }; } const name = shortPersonaName(summary.display_name); const difficulty = DIFFICULTY_LABEL[summary.difficulty] ?? summary.difficulty; const theories = summary.theory_target.length ? summary.theory_target.map(personaTheoryLabel).join(" · ") : "공통"; const chips = [ { t: summary.code, clay: false }, { t: difficulty, clay: summary.difficulty === "hard" }, ...summary.theory_target.slice(0, 2).map((target) => ({ t: personaTheoryLabel(target), clay: false, })), ]; return { avatar: { code: summary.code, label: `${name} · ${personaMeta(summary)}`, ...personaAvatarAppearance(summary), }, 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, }, }; } /* D3(가운뎃점 정리) — "16-18 · 고2 · 자퇴 고민 · 교육용 가상 내담자" 처럼 한 줄에 · 가 2개 이상 몰린 소개 문자열을, 줄당 · 1개 이하가 되도록 두 항목씩 묶어 여러 줄로 쪼갠다. 원본 personaMeta() 는 다른 화면도 함께 쓰는 공용 유틸이라 건드리지 않고 표시 단계에서만 나눈다. */ function splitMetaLines(meta: string): string[] { const parts = meta .split("·") .map((part) => part.trim()) .filter(Boolean); if (parts.length <= 2) return [meta]; const lines: string[] = []; for (let i = 0; i < parts.length; i += 2) { lines.push(parts.slice(i, i + 2).join(" · ")); } return lines; } /* ── 작은 표현 유틸 ─────────────────────────────────────────────────── */ // 백엔드가 반환한 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 { return personaBaselineExpression(summary); } 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 [searchParams] = useSearchParams(); const { user, acceptConsent } = useAuth(); const voicePracticeRequested = searchParams.get("mode") === "voice"; const voicePracticeContext = useMemo( () => parseVoicePracticeContext(searchParams), [searchParams], ); const practiceLaunchRequested = searchParams.has("launch"); const practiceLaunchIntent = useMemo( () => parsePracticeLaunchIntent(searchParams), [searchParams], ); const practiceContextSearch = useMemo( () => practiceLaunchIntent ? practiceLaunchSearch(practiceLaunchIntent) : voicePracticeContext ? voicePracticeSearch(voicePracticeContext) : "", [practiceLaunchIntent, voicePracticeContext], ); const practiceReviewPath = useCallback( (sessionId: string) => `/learn/session/${sessionId}/review${practiceContextSearch ? `?${practiceContextSearch}` : ""}`, [practiceContextSearch], ); const routeParam = routeId ?? ""; const routeIsSessionId = useMemo(() => looksLikeSessionId(routeParam), [routeParam]); // 이 화면에서 방금 시작해 URL만 세션 ID로 바뀐 회기 — resume 재로딩 대상에서 제외한다. const justStartedSessionRef = useRef(null); 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; // D3 — 내담자 한 줄 소개를 줄당 가운뎃점 1개 이하로 나눈 표시용 배열 const clientMetaLines = useMemo( () => splitMetaLines(personaUi.context.meta), [personaUi.context.meta], ); // ── 세션 진행 상태 ── 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 [alliancePreGateBlocked, setAlliancePreGateBlocked] = useState(false); const [consentChecked, setConsentChecked] = useState(false); const [consentBusy, setConsentBusy] = useState(false); const [selectedTheoryMode, setSelectedTheoryMode] = useState("humanistic"); // 이번 회기 목표(2026-07-13 회의 P1): 4단계 전부가 아니라 1~2개를 고르고 시작한다. const [selectedGoals, setSelectedGoals] = useState(["라포", "탐색"]); // ── 회기/대화 상태 ── const [stage, setStage] = useState("라포"); const [goalStages, setGoalStages] = useState([]); // P2 단계 누적 게이지·상세 수치 — 서버 결정론 파생값(턴/복원 시 갱신). const [progress, setProgress] = useState(null); // 시간 기반 회기(회의 P1): 서버 계약값. 0이면 기본 60분/10분으로 보정한다. const [durationLimitSeconds, setDurationLimitSeconds] = useState(60 * 60); const [warningBeforeEndSeconds, setWarningBeforeEndSeconds] = useState(10 * 60); 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); // 음성 입력 선택은 서버 consent ledger와 분리된 현재 회기 메모리 상태다. const [voiceConsentSessionId, setVoiceConsentSessionId] = useState(null); const [voiceConsentDialogOpen, setVoiceConsentDialogOpen] = useState(false); const [voiceConsentSaving, setVoiceConsentSaving] = useState(false); const [voiceConsentError, setVoiceConsentError] = 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 voiceConsentTextRef = useRef(null); const voiceConsentAcceptRef = useRef(null); const voiceConsentPreviousFocusRef = useRef(null); const voiceConsentSubmissionRef = useRef<{ sessionId: string; body: MultimodalConsentRequest; } | null>(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 [coachQuota, setCoachQuota] = useState(DEFAULT_COACH_QUOTA); const [coachCreditEvents, setCoachCreditEvents] = useState([]); const [coachCreditPulse, setCoachCreditPulse] = useState(null); const [coachHistoryOpen, setCoachHistoryOpen] = useState(false); const [coachHistoryTurnSeq, setCoachHistoryTurnSeq] = useState(null); const [coachHistoryLoading, setCoachHistoryLoading] = useState(false); const [coachHistoryError, setCoachHistoryError] = useState(null); const [coachPersistenceSource, setCoachPersistenceSource] = useState<"database" | "runtime" | null>(null); const [coachSyncWarning, setCoachSyncWarning] = useState(null); // 코칭 탭이 닫혀 있을 때 도착한 코칭 기회 — 기회를 소모하지 않고 넛지로 대기시킨다. const [pendingCoachTurn, setPendingCoachTurn] = useState<{ learnerText: string; clientReply?: string | null; turnSeq?: number; } | null>(null); // ── 경과 타이머 ── const [elapsed, setElapsed] = useState(0); // 시간 알람 1회성 상태(회의 P1): 10분 전 경고·시간 만료 안내는 회기당 한 번만 강제 노출. const timeWarningShownRef = useRef(false); const timeUpShownRef = useRef(false); const goalNudgeShownRef = useRef(false); const [timeUp, setTimeUp] = useState(false); // ── 패널 컬랩싱(과밀 완화) — 접힌 패널은 상황에 맞춰 넛지/강제 오픈 ── const [ctxCollapsed, setCtxCollapsed] = useState(true); const [metersCollapsed, setMetersCollapsed] = useState(false); const [safetyCollapsed, setSafetyCollapsed] = useState(true); // ── 자동 스크롤 ── const scrollRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); const fadeTimerRef = useRef(null); const voiceSocketRef = useRef(null); const voiceCaptureRef = useRef(null); const voiceCaptureAttemptRef = useRef(0); const voiceCaptureMountedRef = useRef(true); const activeVoiceSessionRef = useRef(liveSessionId); const micStreamRef = useRef(null); const audioContextRef = useRef(null); const ttsChunksRef = useRef([]); const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null); const ttsPlaybackRequestRef = useRef(0); const ttsPlaybackActiveRef = useRef(false); const ttsRequestAbortRef = useRef(null); const playTtsAudioRef = useRef<(() => Promise) | null>(null); const pendingVoiceLearnerIdRef = useRef(null); const pendingVoiceLearnerTextRef = useRef(""); const coachEvidenceCloseRef = useRef(null); const coachHistoryCloseRef = useRef(null); const coachCreditSeenRef = useRef>(new Set()); const coachCreditPulseTimerRef = useRef(null); activeVoiceSessionRef.current = liveSessionId; const failPendingVoiceLearnerTurn = useCallback(() => { const pendingId = pendingVoiceLearnerIdRef.current; pendingVoiceLearnerIdRef.current = null; pendingVoiceLearnerTextRef.current = ""; if (pendingId == null) return false; setUtterances((prev) => prev.map((utterance) => utterance.id === pendingId ? { ...utterance, partial: false, failed: true, turnSeq: undefined, voiceTranscriptState: undefined, } : utterance, ), ); return true; }, []); 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(() => { ttsPlaybackActiveRef.current = false; ttsPlaybackRequestRef.current += 1; ttsRequestAbortRef.current?.abort(); ttsRequestAbortRef.current = null; 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([]); setCoachQuota(DEFAULT_COACH_QUOTA); setCoachCreditEvents([]); setCoachCreditPulse(null); setCoachPersistenceSource(null); setCoachSyncWarning(null); coachCreditSeenRef.current = new Set(); 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, turnError, 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]); }, []); // ── 시간 기반 회기 진행(회의 P1): 남은 시간·10분 전 알람·시간 만료 ── const sessionLimitSeconds = durationLimitSeconds > 0 ? durationLimitSeconds : 60 * 60; const sessionWarningSeconds = warningBeforeEndSeconds > 0 ? warningBeforeEndSeconds : 10 * 60; const remainingSeconds = Math.max(0, sessionLimitSeconds - elapsed); const inWarningWindow = started && !sessionEnded && !timeUp && remainingSeconds <= sessionWarningSeconds; useEffect(() => { if (!started || sessionEnded) return; const remaining = sessionLimitSeconds - elapsed; if (remaining <= 0) { if (!timeUpShownRef.current) { timeUpShownRef.current = true; setTimeUp(true); pushSignal("warn", "회기 시간 종료"); setEndDialogOpen(true); } return; } if (remaining <= sessionWarningSeconds && !timeWarningShownRef.current) { timeWarningShownRef.current = true; pushSignal("warn", `종료 ${Math.ceil(sessionWarningSeconds / 60)}분 전`); } }, [elapsed, started, sessionEnded, sessionLimitSeconds, sessionWarningSeconds, pushSignal]); // 이번 회기 목표 달성 판정 — 목표 단계를 "완료"(다음 단계로 전이)해야 달성이다. // 마지막 단계(정리)만 진입 자체가 달성. 달성해도 종료하지 않는다(시간이 남으면 계속, 회의 합의). const goalStageAchieved = useCallback( (phaseIdx: number) => stageIdx > phaseIdx || (phaseIdx === SESSION_PHASES.length - 1 && stageIdx === phaseIdx), [stageIdx], ); const goalsAchieved = useMemo(() => { if (goalStages.length === 0) return false; return goalStages.every((goal) => { const idx = SESSION_PHASES.findIndex((p) => p.key === goal); return idx >= 0 && goalStageAchieved(idx); }); }, [goalStages, goalStageAchieved]); useEffect(() => { if (!started || sessionEnded || !goalsAchieved) return; if (goalNudgeShownRef.current) return; goalNudgeShownRef.current = true; pushSignal("pos", "이번 회기 목표 달성"); }, [started, sessionEnded, goalsAchieved, pushSignal]); const showCoachCreditPulse = useCallback( (event: LiveCoachCreditEvent) => { setCoachCreditPulse(event); if (coachCreditPulseTimerRef.current) { window.clearTimeout(coachCreditPulseTimerRef.current); } coachCreditPulseTimerRef.current = window.setTimeout(() => { setCoachCreditPulse(null); coachCreditPulseTimerRef.current = null; }, 2600); pushSignal( event.event_type === "recharge" ? "pos" : "neutral", event.event_type === "recharge" ? "코칭 기회 충전" : "코칭 기회 사용", ); }, [pushSignal], ); const applyCoachCreditEvents = useCallback( (events: LiveCoachCreditEvent[], animate: boolean) => { setCoachCreditEvents(events); const fresh: LiveCoachCreditEvent[] = []; for (const event of events) { if (coachCreditSeenRef.current.has(event.event_id)) continue; coachCreditSeenRef.current.add(event.event_id); fresh.push(event); } if (animate && fresh.length > 0) { showCoachCreditPulse(fresh[fresh.length - 1]); } }, [showCoachCreditPulse], ); useEffect(() => { return () => { if (coachCreditPulseTimerRef.current) { window.clearTimeout(coachCreditPulseTimerRef.current); } }; }, []); // 위기/안전 신호가 생기면 접힌 안전 점검 패널을 강제로 연다(컨텍스추얼 강제 오픈). useEffect(() => { if (safety) setSafetyCollapsed(false); }, [safety]); 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, options: { animateCredits?: boolean; surfaceErrors?: boolean; warnOnFailure?: boolean; applyQuota?: boolean; } = {}, ): Promise => { if (options.surfaceErrors) { setCoachHistoryLoading(true); setCoachHistoryError(null); } if (!sessionId) { setCoachHistory([]); setCoachQuota(DEFAULT_COACH_QUOTA); setCoachCreditEvents([]); setCoachHistoryError(null); setCoachPersistenceSource(null); setCoachSyncWarning(null); if (options.surfaceErrors) setCoachHistoryLoading(false); return null; } try { const history = await sessionApi.liveCoachHistory(sessionId); setCoachHistory(history.events ?? []); if (options.applyQuota !== false) { setCoachQuota(history.quota ?? DEFAULT_COACH_QUOTA); } setCoachPersistenceSource(history.source); setCoachSyncWarning( history.source === "runtime" ? "코칭 이력이 DB에 확정 저장되지 않아 임시 이력으로 표시됩니다." : null, ); setCoachHistoryError(null); applyCoachCreditEvents(history.credit_events ?? [], !!options.animateCredits); return history; } catch { if (options.surfaceErrors) { setCoachHistoryError("코칭 이력을 불러오지 못했습니다. 잠시 뒤 다시 열어 주세요."); } if (options.warnOnFailure) { setCoachSyncWarning("코칭은 표시됐지만 이력 동기화를 확인하지 못했습니다. 이력 보기에서 다시 확인해 주세요."); } return null; } finally { if (options.surfaceErrors) setCoachHistoryLoading(false); } }, [applyCoachCreditEvents, liveSessionId], ); const openCoachHistory = useCallback( (turnSeq: number | null = null) => { setCoachHistoryTurnSeq(turnSeq); setCoachHistoryError(null); setCoachEvidenceOpen(false); setCoachHistoryOpen(true); void refreshCoachHistory(liveSessionId, { surfaceErrors: true }); }, [liveSessionId, refreshCoachHistory], ); const requestLiveCoach = useCallback( async ({ learnerText, clientReply, turnSeq, }: { learnerText: string; clientReply?: string | null; turnSeq?: number; }) => { if (!liveSessionId) return; if (feedbackMode !== "coached") { // 코칭 화면이 닫혀 있으면 기회를 소모하지 않고 컨텍스추얼 넛지로 대기시킨다. setPendingCoachTurn({ learnerText, clientReply, turnSeq }); return; } setPendingCoachTurn(null); let availableCoachCredits = coachQuota.remaining ?? 0; if (availableCoachCredits <= 0) { setCoachError(null); const refreshed = await refreshCoachHistory(liveSessionId, { animateCredits: true, warnOnFailure: true, }); if (!refreshed) { setCoachError("코칭 기회를 확인하지 못했습니다. 잠시 뒤 다시 시도해 주세요."); pushSignal("warn", "코칭 기회 확인 실패"); return; } availableCoachCredits = refreshed.quota?.remaining ?? 0; } if (availableCoachCredits <= 0) { setCoachError( "코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자가 변화하거나 회기가 6턴 진행될 때마다 1개씩 충전됩니다.", ); pushSignal("warn", "코칭 기회 소진"); return; } setCoachLoading(true); setCoachError(null); try { const suggestion = await sessionApi.liveCoach(liveSessionId, { learner_text: learnerText, client_reply: clientReply ?? null, turn_seq: turnSeq, }); setCoachSuggestion(suggestion); setCoachPersistenceSource(suggestion.persistence_source ?? null); setCoachSyncWarning( suggestion.persistence_source === "runtime" ? "코칭 이력이 DB에 확정 저장되지 않아 임시 이력으로 표시됩니다." : null, ); if (suggestion.quota) { setCoachQuota(suggestion.quota); } if (suggestion.credit_events?.length) { applyCoachCreditEvents(suggestion.credit_events, true); } pushSignal(suggestion.tone, suggestion.title); void refreshCoachHistory(liveSessionId, { animateCredits: false, warnOnFailure: true }); } catch (err) { if (err instanceof ApiError && err.status === 409) { setCoachQuota((current) => ({ ...current, remaining: 0 })); setCoachError( "코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자가 변화하거나 회기가 6턴 진행될 때마다 1개씩 충전됩니다.", ); pushSignal("warn", "코칭 기회 소진"); } else { setCoachError("코칭 근거를 불러오지 못했습니다. 다음 턴에서 다시 시도합니다."); pushSignal("warn", "코칭 연결 실패"); } } finally { setCoachLoading(false); } }, [ applyCoachCreditEvents, coachQuota.remaining, feedbackMode, liveSessionId, pushSignal, refreshCoachHistory, ], ); useEffect(() => { if (!started || !liveSessionId) return; // 방금 시작한 회기는 기본 코칭 기회로 출발한다 — 초기 이력 동기화는 수행하되, // 그 응답의 stale quota가 기본 기회 수를 덮어쓰지 않게 한다 (2026-07-15). const isFreshSession = justStartedSessionRef.current === liveSessionId; void refreshCoachHistory(liveSessionId, isFreshSession ? { applyQuota: false } : {}); }, [liveSessionId, refreshCoachHistory, started]); // 넛지로 대기 중인 코칭이 있을 때 코칭 화면을 열면 그 턴에 대한 코칭을 바로 요청한다. useEffect(() => { if (feedbackMode !== "coached" || !pendingCoachTurn) return; const payload = pendingCoachTurn; setPendingCoachTurn(null); void requestLiveCoach(payload); }, [feedbackMode, pendingCoachTurn, requestLiveCoach]); useEffect(() => { if (!routeIsSessionId) return; // 이 화면에서 방금 시작한 회기의 URL 전환은 재개(resume)가 아니다 — 서버 스냅샷(0턴) // 재로딩이 전송 직후의 낙관적 발화를 덮어쓰는 race를 막는다 (2026-07-15). if (justStartedSessionRef.current === routeParam) { justStartedSessionRef.current = null; 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: displayPiiSafeText(turn.text), turnSeq: turn.turn_seq, at: secondsBetween(detail.started_at, turn.created_at), })), ); setElapsed(elapsedFromSession(detail)); setGoalStages(detail.goal_stages ?? []); setProgress(detail.progress ?? null); if (detail.duration_limit_seconds) setDurationLimitSeconds(detail.duration_limit_seconds); if (detail.warning_before_end_seconds) { setWarningBeforeEndSeconds(detail.warning_before_end_seconds); } timeWarningShownRef.current = false; timeUpShownRef.current = ended; goalNudgeShownRef.current = false; setTimeUp(false); setStarted(true); setPaused(ended); setSafety(null); setCrisisResource(null); setAvatarState("idle"); setMicOn(false); setVoiceStatus(ended ? "degraded" : "idle"); setVoiceDetail( ended ? "종료된 회기입니다. 대화 기록은 이 화면에 남기고 리뷰로 이동할 수 있습니다." : voicePracticeContext ? "선택 장면의 음성 재연습입니다. 준비되면 마이크 버튼을 직접 눌러 시작하세요." : "이전 회기 기록을 불러왔습니다. 이어서 말하거나 회기를 종료할 수 있습니다.", ); setResumedSessionLoaded(true); setCoachSuggestion(null); setCoachError(null); setCoachEvidenceOpen(false); setCoachHistory([]); setCoachQuota(DEFAULT_COACH_QUOTA); setCoachCreditEvents([]); setCoachCreditPulse(null); setCoachPersistenceSource(null); setCoachSyncWarning(null); setPendingCoachTurn(null); coachCreditSeenRef.current = new Set(); 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, voicePracticeContext]); // 이번 회기 목표 토글 — 1~4개 자유 선택(소유자 지시 2026-07-15). const toggleGoal = useCallback((goal: SessionStage) => { setSelectedGoals((prev) => prev.includes(goal) ? prev.filter((g) => g !== goal) : [...prev, goal], ); }, []); /* ── 세션 시작 ───────────────────────────────────────────────────── */ const handleStart = useCallback(async () => { if (voicePracticeRequested && !voicePracticeContext) { setStartError("음성 재연습의 출처를 검증할 수 없습니다. 회기 리뷰에서 장면을 다시 선택해 주세요."); return; } if (practiceLaunchRequested && !practiceLaunchIntent) { setStartError("연습 처방의 출처를 검증할 수 없습니다. 회기 리뷰에서 처방을 다시 선택해 주세요."); return; } if (!personaSummary) { setStartError("페르소나 정보를 확인한 뒤 회기를 시작할 수 있습니다."); return; } setStarting(true); setStartError(null); setResumedSessionLoaded(false); setClientReplyPending(false); try { const res = await sessionApi.start(personaCode, selectedTheoryMode, selectedGoals); setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브 setSessionEnded(false); setReviewReady(false); setStage(res.stage); setOpenness(res.effective_openness); setUtterances([]); setSafety(null); setCrisisResource(null); setElapsed(0); setGoalStages(res.goal_stages ?? []); setProgress(null); if (res.duration_limit_seconds) setDurationLimitSeconds(res.duration_limit_seconds); if (res.warning_before_end_seconds) setWarningBeforeEndSeconds(res.warning_before_end_seconds); timeWarningShownRef.current = false; timeUpShownRef.current = false; goalNudgeShownRef.current = false; setTimeUp(false); setCoachSuggestion(null); setCoachError(null); setCoachEvidenceOpen(false); setCoachHistory([]); setCoachQuota(DEFAULT_COACH_QUOTA); setCoachCreditEvents([]); setCoachCreditPulse(null); setCoachPersistenceSource(null); setCoachSyncWarning(null); setPendingCoachTurn(null); coachCreditSeenRef.current = new Set(); setCoachHistoryOpen(false); setCoachHistoryTurnSeq(null); if (res.degraded) { pushSignal("neutral", "서버 기록 제한"); } setStarted(true); setAvatarState("idle"); setMicOn(false); setVoiceStatus("idle"); setVoiceDetail( voicePracticeContext ? "선택 장면의 음성 재연습입니다. 준비되면 마이크 버튼을 직접 눌러 시작하세요." : "마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.", ); if (voicePracticeContext) pushSignal("neutral", "음성 장면 재연습 준비"); justStartedSessionRef.current = res.session_id; const practiceSuffix = practiceContextSearch ? `?${practiceContextSearch}` : ""; navigate(`/learn/session/${res.session_id}${practiceSuffix}`, { 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, practiceContextSearch, practiceLaunchIntent, practiceLaunchRequested, pushSignal, selectedGoals, selectedTheoryMode, voicePracticeContext, voicePracticeRequested]); const handleAlliancePreGateChange = useCallback((blocked: boolean) => { setAlliancePreGateBlocked(blocked); }, []); 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 speakTextClientTurn = useCallback( async (sessionId: string, turnSeq: number) => { const requestId = ttsPlaybackRequestRef.current + 1; ttsPlaybackRequestRef.current = requestId; const abortController = new AbortController(); ttsRequestAbortRef.current?.abort(); ttsRequestAbortRef.current = abortController; setVoiceStatus("speaking"); setVoiceDetail("내담자 음성을 준비하고 있습니다. 입력은 계속할 수 있습니다."); try { const audio = await sessionApi.speakClientTurn( sessionId, turnSeq, abortController.signal, ); if (ttsPlaybackRequestRef.current !== requestId) return; ttsChunksRef.current = [audio]; const play = playTtsAudioRef.current; if (!play) throw new Error("voice playback is not ready"); await play(); } catch (error) { if (ttsPlaybackRequestRef.current !== requestId) return; if (error instanceof DOMException && error.name === "AbortError") return; setAvatarState("listening"); setVoiceStatus("degraded"); setVoiceDetail("내담자 음성을 재생하지 못했습니다. 자막 응답은 화면에 남겼습니다."); pushSignal("warn", "AI 음성 재생 실패"); } finally { if (ttsRequestAbortRef.current === abortController) { ttsRequestAbortRef.current = null; } } }, [pushSignal], ); const appendServerClientReply = useCallback( (replyText: string | null, at = elapsed, options?: { suppressEmptyWarning?: boolean }) => { setClientReplyPending(false); if (!replyText) { setAvatarState("listening"); if (!options?.suppressEmptyWarning) { 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 (alliancePreGateBlocked) { setTurnError("첫 발화 전에 회기 전 자기점검을 잠가 주세요."); pushSignal("warn", "회기 전 자기점검 필요"); 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); stopTtsPlayback(); void primeVoicePlayback(); let clientId: number | null = null; let clientReply = ""; let conversationStopped = false; 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.progress) setProgress(data.progress); if (data.safety_flagged) { setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요."); } if (data.conversation_stopped || data.crisis_resource) { conversationStopped = true; 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) { conversationStopped = true; applyCrisisGate(resource); } } }, }); if (done.conversation_stopped || done.crisis_resource) { conversationStopped = true; } const qualityRetryable = done.output_error === "client_reply_quality_retryable"; if (qualityRetryable) { setTurnError("내담자 응답 품질을 확인하지 못했습니다. 발화를 조금 다듬어 다시 시도해 주세요."); pushSignal("warn", "내담자 응답 재시도 필요"); } 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)), ); if (!conversationStopped && !qualityRetryable) { pushSignal("warn", "내담자 응답 없음"); } } setAvatarState("listening"); if (!conversationStopped && !qualityRetryable) { if (clientReply && typeof done.turn_seq === "number") { void speakTextClientTurn(liveSessionId, done.turn_seq); } 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 엔진 연결에 실패했습니다."; if (detail.includes("session_time_over")) { // 시간 만료 + 정리 유예 종료: 새 발화는 막히고 종료만 남는다(회의 P1). setTurnError("회기 시간이 모두 지나 새 발화를 보낼 수 없습니다. 회기를 종료하고 리뷰를 확인하세요."); pushSignal("warn", "회기 시간 종료"); setTimeUp(true); setEndDialogOpen(true); setAvatarState("idle"); return; } const normalized = detail.includes("Not logged in") ? "AI 엔진 로그인이 필요합니다. 관리자에게 엔진 상태 확인을 요청하세요." : detail.includes("engine unavailable") ? "AI 엔진이 응답하지 않습니다. 잠시 뒤 다시 시도하거나 관리자에게 알려 주세요." : "내담자 응답을 생성하지 못했습니다. 잠시 뒤 다시 시도해 주세요."; setTurnError(normalized); pushSignal("warn", "AI 엔진 연결 실패"); setAvatarState("listening"); } finally { setSending(false); } }, [ composeText, alliancePreGateBlocked, sending, paused, voiceStatus, sessionEnded, liveSessionId, elapsed, pushSignal, primeVoicePlayback, requestLiveCoach, speakTextClientTurn, stopTtsPlayback, ]); 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 = "마이크 꺼짐") => { voiceCaptureAttemptRef.current += 1; stopTtsPlayback(); const capture = voiceCaptureRef.current; voiceCaptureRef.current = null; capture?.abort(); stopMicStream(); closeVoiceSocket(); setMicOn(false); setVoiceStatus(status); setVoiceDetail(detail); setVoiceAnalyser(null); }, [closeVoiceSocket, stopMicStream, stopTtsPlayback], ); const finishVoiceUtterance = useCallback(() => { const capture = voiceCaptureRef.current; voiceCaptureRef.current = null; if (capture) { capture.finish(); } else { stopMicStream(); } setMicOn(false); setAvatarState("thinking"); setVoiceStatus("thinking"); setVoiceDetail("녹음을 마쳤습니다. 전사 중입니다."); }, [stopMicStream]); const playTtsAudio = useCallback(async () => { const chunks = ttsChunksRef.current.splice(0); if (!chunks.length) { ttsPlaybackActiveRef.current = false; return; } const blob = new Blob(chunks, { type: "audio/mpeg" }); stopTtsPlayback(); ttsPlaybackActiveRef.current = true; const finishPlayback = () => { ttsPlaybackActiveRef.current = false; ttsPlaybackCleanupRef.current = null; setVoiceAnalyser(null); setAvatarState("listening"); setVoiceStatus("idle"); setVoiceDetail("응답이 끝났습니다. 마이크를 다시 켜 발화하세요."); closeVoiceSocket(); }; const failPlayback = (detail: string, status: VoiceStatus = "error") => { ttsPlaybackActiveRef.current = false; 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]); useEffect(() => { playTtsAudioRef.current = playTtsAudio; return () => { if (playTtsAudioRef.current === playTtsAudio) { playTtsAudioRef.current = null; } }; }, [playTtsAudio]); const startVoiceCapture = useCallback(async () => { if (!liveSessionId || paused || sending || sessionEnded) return; if (!navigator.mediaDevices?.getUserMedia) { setVoiceStatus("error"); setVoiceDetail("이 브라우저는 마이크 녹음을 지원하지 않습니다."); pushSignal("warn", "마이크 미지원"); return; } setTurnError(null); shutdownVoice("requesting", "Chrome 마이크 권한을 요청합니다."); const attempt = ++voiceCaptureAttemptRef.current; const captureSessionId = liveSessionId; const attemptIsStale = () => !voiceCaptureMountedRef.current || attempt !== voiceCaptureAttemptRef.current || activeVoiceSessionRef.current !== captureSessionId; await primeVoicePlayback(); if (attemptIsStale()) return; let stream: MediaStream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }); } catch { if (attemptIsStale()) return; setVoiceStatus("error"); setVoiceDetail("마이크 권한이 거부됐습니다. Chrome 주소창 왼쪽 권한에서 마이크를 허용해 주세요."); pushSignal("warn", "마이크 권한 필요"); return; } if (attemptIsStale()) { stream.getTracks().forEach((track) => track.stop()); return; } micStreamRef.current = stream; setVoiceStatus("connecting"); setVoiceDetail("음성 연결을 준비하는 중입니다."); const ws = new WebSocket( apiWsUrl(`/voice/ws?session_id=${encodeURIComponent(captureSessionId)}`), ); ws.binaryType = "arraybuffer"; voiceSocketRef.current = ws; ttsChunksRef.current = []; setClientReplyPending(false); let pendingCapture: VoiceCaptureController | null = null; let replyReceived = false; let preserveNextIdleDetail = false; const capturePromise = (async () => { const ctx = ensureVoiceAudioContext(); if (supportsAudioWorkletCapture(ctx)) { try { pendingCapture = await createAudioWorkletCapture(stream, ws, ctx, stopMicStream); return pendingCapture; } catch { pendingCapture = null; } } pendingCapture = createMediaRecorderCapture(stream, ws, stopMicStream); return pendingCapture; })(); ws.onopen = async () => { const capture = await capturePromise; if (ws.readyState !== WebSocket.OPEN) { capture?.abort(); return; } if (!capture) { stopMicStream(); closeVoiceSocket(); setVoiceStatus("error"); setVoiceDetail("마이크 녹음기를 만들지 못했습니다. 브라우저 오디오 설정을 확인해 주세요."); return; } voiceCaptureRef.current = capture; sendVoiceControl(ws, capture.startControl); try { await capture.start(); } catch { voiceCaptureRef.current = null; capture.abort(); closeVoiceSocket(); setVoiceStatus("error"); setVoiceDetail("마이크 녹음기를 시작하지 못했습니다. 브라우저 오디오 설정을 확인해 주세요."); return; } 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 === "listening") { setAvatarState("listening"); if (voiceCaptureRef.current?.isRecording()) { setMicOn(true); setVoiceStatus("recording"); setVoiceDetail("계속 듣고 있습니다. 말한 뒤 발화를 보내세요."); } else { setMicOn(false); setVoiceStatus("idle"); setVoiceDetail("발화 종료를 확인하지 못했습니다. 다시 말하거나 텍스트로 입력하세요."); } } else if ( payload.state === "idle" && !voiceCaptureRef.current?.isRecording() && !ttsPlaybackActiveRef.current ) { setAvatarState("listening"); setVoiceStatus("idle"); if (preserveNextIdleDetail) { preserveNextIdleDetail = false; } else { setVoiceDetail("마이크를 다시 켜 발화하세요."); } } return; } if (payload.type === "eot") { if (payload.ready === false) { setVoiceDetail("말이 끝나지 않은 것으로 감지했습니다. 다시 말하거나 텍스트로 입력하세요."); } return; } if (payload.type === "transcript") { const transcriptText = (payload.text ?? "").trim(); const pendingId = pendingVoiceLearnerIdRef.current; if (!transcriptText) { if (payload.final) { if (pendingId != null) { setUtterances((prev) => prev.filter((utterance) => utterance.id !== pendingId)); } pendingVoiceLearnerIdRef.current = null; pendingVoiceLearnerTextRef.current = ""; setClientReplyPending(false); setAvatarState("listening"); setVoiceStatus("idle"); preserveNextIdleDetail = true; setVoiceDetail("음성을 인식하지 못했습니다. 다시 말하거나 텍스트로 입력하세요."); } return; } const id = pendingId ?? nextId(); pendingVoiceLearnerIdRef.current = id; pendingVoiceLearnerTextRef.current = transcriptText; setUtterances((prev) => { const existing = prev.some((utterance) => utterance.id === id); const nextUtterance: Utterance = { id, speaker: "learner", text: transcriptText, at: elapsed, partial: true, failed: false, voiceTranscriptState: payload.final ? "finalizing" : "interim", }; return existing ? prev.map((utterance) => utterance.id === id ? { ...utterance, text: transcriptText, partial: true, failed: false, voiceTranscriptState: nextUtterance.voiceTranscriptState, } : utterance, ) : [...prev, nextUtterance]; }); if (payload.final) { setClientReplyPending(true); setMicOn(false); setAvatarState("thinking"); setVoiceStatus("thinking"); setVoiceDetail("전사를 확정했습니다. 내담자 응답을 기다리는 중입니다."); } return; } if (payload.type === "reply") { replyReceived = true; setClientReplyPending(false); if (payload.stage) setStage(payload.stage); if (typeof payload.effective_openness === "number") { setOpenness(payload.effective_openness); } if (payload.progress) setProgress(payload.progress); if (payload.safety_flagged) { setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요."); } if (payload.conversation_stopped || payload.crisis_resource) { applyCrisisGate(payload.crisis_resource); } const conversationStopped = !!(payload.conversation_stopped || payload.crisis_resource); const pendingId = pendingVoiceLearnerIdRef.current; if (pendingId != null) { setUtterances((prev) => prev.map((u) => u.id === pendingId ? { ...u, partial: false, failed: false, turnSeq: payload.turn_seq, voiceTranscriptState: undefined, } : u, ), ); pendingVoiceLearnerIdRef.current = null; } appendServerClientReply(payload.text ?? null, elapsed, { suppressEmptyWarning: conversationStopped, }); const learnerText = pendingVoiceLearnerTextRef.current; pendingVoiceLearnerTextRef.current = ""; if (learnerText && !conversationStopped) { void requestLiveCoach({ learnerText, clientReply: payload.text ?? null, turnSeq: payload.turn_seq, }); } if (conversationStopped) { closeVoiceSocket(); } return; } if (payload.type === "tts_end") { void playTtsAudio(); return; } if (payload.type === "degraded") { setClientReplyPending(false); if (replyReceived) { const detail = "내담자 응답은 저장됐지만 음성 재생 연결이 끊겼습니다. 텍스트로 계속하거나 음성을 다시 연결해 주세요."; shutdownVoice("degraded", detail); pushSignal("warn", "음성 재생 연결 끊김"); return; } const hadPendingLearner = failPendingVoiceLearnerTurn(); const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다."; const providerReason = payload.reason?.trim() ?? ""; const reason = providerReason && !/(openai|stt|tts|provider|engine|failed|unavailable)/i.test(providerReason) ? providerReason : providerReason ? userFacingVoiceError({ ...payload, detail: providerReason }) : fallbackReason; if (hadPendingLearner) setTurnError(fallbackReason); shutdownVoice("degraded", reason); pushSignal("warn", "음성 기능 미설정"); return; } if (payload.type === "error") { setClientReplyPending(false); if (replyReceived) { const detail = "내담자 응답은 저장됐지만 음성 재생 연결이 끊겼습니다. 텍스트로 계속하거나 음성을 다시 연결해 주세요."; shutdownVoice("degraded", detail); pushSignal("warn", "음성 재생 연결 끊김"); return; } failPendingVoiceLearnerTurn(); const detail = userFacingVoiceError(payload); setTurnError(detail); shutdownVoice("error", detail); pushSignal("warn", "음성 오류"); } }; ws.onerror = () => { setClientReplyPending(false); if (replyReceived) { const detail = "내담자 응답은 저장됐지만 음성 재생 연결이 끊겼습니다. 텍스트로 계속하거나 음성을 다시 연결해 주세요."; shutdownVoice("degraded", detail); pushSignal("warn", "음성 재생 연결 끊김"); return; } failPendingVoiceLearnerTurn(); setTurnError(VOICE_CONNECTION_SAVE_FAILED); shutdownVoice("error", VOICE_CONNECTION_SAVE_FAILED); pushSignal("warn", "음성 연결 실패"); }; ws.onclose = () => { const hadPendingLearner = failPendingVoiceLearnerTurn(); if (voiceSocketRef.current === ws) voiceSocketRef.current = null; const capture = voiceCaptureRef.current; voiceCaptureRef.current = null; capture?.abort(); if (pendingCapture && pendingCapture !== capture) { pendingCapture.abort(); pendingCapture = null; } stopMicStream(); setMicOn(false); setClientReplyPending(false); if (replyReceived) { const detail = "내담자 응답은 저장됐지만 음성 재생 연결이 끊겼습니다. 텍스트로 계속하거나 음성을 다시 연결해 주세요."; setVoiceStatus("degraded"); setVoiceDetail(detail); pushSignal("warn", "음성 재생 연결 끊김"); return; } if (hadPendingLearner) { setTurnError(VOICE_CONNECTION_SAVE_FAILED); setVoiceStatus("error"); setVoiceDetail(VOICE_CONNECTION_SAVE_FAILED); return; } setTurnError(VOICE_CONNECTION_SAVE_FAILED); setVoiceStatus("error"); setVoiceDetail(VOICE_CONNECTION_SAVE_FAILED); pushSignal("warn", "음성 연결 종료"); }; }, [ clientName, appendServerClientReply, closeVoiceSocket, elapsed, ensureVoiceAudioContext, failPendingVoiceLearnerTurn, liveSessionId, paused, sessionEnded, playTtsAudio, primeVoicePlayback, pushSignal, requestLiveCoach, sending, shutdownVoice, stopMicStream, ]); const dismissVoiceConsent = useCallback(() => { if (voiceConsentSaving) return; setVoiceConsentDialogOpen(false); setVoiceConsentError(null); setVoiceDetail("음성 입력을 사용하지 않았습니다. 텍스트로 계속 진행할 수 있습니다."); pushSignal("neutral", "텍스트 입력으로 계속"); }, [pushSignal, voiceConsentSaving]); const requestVoiceCapture = useCallback(() => { if (!liveSessionId) return; if (voiceConsentSessionId !== liveSessionId) { voiceConsentPreviousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; setVoiceConsentError(null); setVoiceConsentDialogOpen(true); return; } void startVoiceCapture(); }, [liveSessionId, startVoiceCapture, voiceConsentSessionId]); const acceptVoiceConsent = useCallback(async () => { if (!liveSessionId || voiceConsentSaving) return; const consentSessionId = liveSessionId; if (voiceConsentSubmissionRef.current?.sessionId !== consentSessionId) { voiceConsentSubmissionRef.current = { sessionId: consentSessionId, body: { submission_id: randomUuid(), consent_status: "granted", retain_audio: false, retain_derived_features: true, transcript_retained: true, retention_days: 30, policy_version: "vignette.multimodal-consent.v1", reason_code: "learner_voice_session_opt_in", }, }; } const consentRequest = voiceConsentSubmissionRef.current.body; setVoiceConsentSaving(true); setVoiceConsentError(null); try { await multimodalAllianceApi.saveConsent( consentSessionId, consentRequest, ); } catch { if (activeVoiceSessionRef.current === consentSessionId) { setVoiceConsentError( "동의 원장을 기록하지 못해 마이크를 열지 않았습니다. 다시 시도하거나 텍스트로 계속해 주세요.", ); } return; } finally { if (voiceCaptureMountedRef.current) { setVoiceConsentSaving(false); } } if ( !voiceCaptureMountedRef.current || activeVoiceSessionRef.current !== consentSessionId ) { return; } voiceConsentSubmissionRef.current = null; setVoiceConsentSessionId(consentSessionId); setVoiceConsentDialogOpen(false); await startVoiceCapture(); }, [liveSessionId, startVoiceCapture, voiceConsentSaving]); 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 (alliancePreGateBlocked) { pushSignal("warn", "회기 전 자기점검 필요"); return; } if (voiceAvailable === false) { pushSignal("warn", "음성 미설정 — 텍스트로 진행"); return; } if (isVoiceStatusBusy(voiceStatus)) { return; } if (micOn || voiceStatus === "recording") { finishVoiceUtterance(); } else { requestVoiceCapture(); } }, [alliancePreGateBlocked, finishVoiceUtterance, micOn, paused, requestVoiceCapture, sending, sessionEnded, 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]); // ── 키보드 단축키: Alt+M=마이크, P=일시정지. 입력 중엔 무시 ── useEffect(() => { if (!started) return; const onKey = (e: KeyboardEvent) => { if (e.repeat) return; if (e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === "m") { e.preventDefault(); void toggleMic(); return; } const target = e.target instanceof HTMLElement ? e.target : null; const interactive = target?.closest( "button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='link']", ); if (interactive) return; if (e.key === "p" || e.key === "P") { togglePause(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [started, toggleMic, togglePause]); useEffect(() => { voiceCaptureMountedRef.current = true; return () => { voiceCaptureMountedRef.current = false; shutdownVoice(); audioContextRef.current?.close().catch(() => undefined); }; }, [shutdownVoice]); useEffect(() => { if (!voiceConsentDialogOpen) return; window.setTimeout(() => voiceConsentTextRef.current?.focus(), 0); const onKey = (event: KeyboardEvent) => { if (event.key === "Escape" && !voiceConsentSaving) { dismissVoiceConsent(); return; } if (event.key !== "Tab") return; const controls = [voiceConsentTextRef.current, voiceConsentAcceptRef.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); }, [dismissVoiceConsent, voiceConsentDialogOpen, voiceConsentSaving]); useEffect(() => { if (voiceConsentDialogOpen) return; voiceConsentPreviousFocusRef.current?.focus(); voiceConsentPreviousFocusRef.current = null; }, [voiceConsentDialogOpen]); 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); setTurnError(null); try { if (!liveSessionId) { throw new Error("회기 ID가 없습니다."); } await sessionApi.end(liveSessionId); } catch (err) { const detail = err instanceof ApiError ? err.detail : err instanceof Error ? err.message : "알 수 없는 오류"; setEndDialogOpen(false); setTurnError(`회기 종료에 실패했습니다. ${detail}`); pushSignal("warn", "회기 종료 실패"); setEnding(false); return; } setEndDialogOpen(false); setSessionEnded(true); setReviewReady(false); setMicOn(false); setPaused(true); setAvatarState("idle"); pushSignal("neutral", "회기 종료"); navigate(practiceReviewPath(liveSessionId), { replace: true }); }, [ending, liveSessionId, navigate, practiceReviewPath, 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 { micDisabled, micLabel, micButtonAriaLabel, sessionStatusLabel, transcriptLiveLabel, transcriptIsLive, textComposerDisabled, textTurnBlocked, voiceInputStatus, responseStatus, } = sessionVoiceStatusView({ voiceStatus, sessionEnded, paused, sending, clientReplyPending, clientName, utteranceCount: utterances.length, voiceAvailable, micOn, }); const voiceRecoveryAvailable = (voiceStatus === "error" || voiceStatus === "degraded") && voiceAvailable !== false && !paused && !sending && !sessionEnded; const elapsedLabel = formatTimecode(elapsed); const remainingLabel = formatTimecode(remainingSeconds); const limitMinutesLabel = Math.round(sessionLimitSeconds / 60); const warningMinutesLabel = Math.max(1, Math.round(sessionWarningSeconds / 60)); const selectedTheoryOption = THEORY_MODE_OPTIONS.find((option) => option.value === selectedTheoryMode) ?? THEORY_MODE_OPTIONS[0]; const selectedGoalSummary = SESSION_PHASES.filter((phase) => selectedGoals.includes(phase.key), ) .map((phase) => phase.key) .join(" · "); const turnCount = utterances.filter((utterance) => !utterance.partial && !utterance.failed).length; const latestClientUtterance = [...utterances] .reverse() .find((utterance) => utterance.speaker === "client" && utterance.text.trim()); const stageClientLine = latestClientUtterance?.text ?? primaryContext?.v ?? `${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`; const safetyStatusText = safety ? "확인 필요" : "안전"; const turnErrorTitle = turnError?.startsWith("회기 종료") ? "회기 종료" : turnError?.includes("음성") ? "음성 연결" : "엔진 연결"; const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false; const consentRequired = user?.role === "learner" && user.consentAt == null; const voicePracticeIsValid = !voicePracticeRequested || voicePracticeContext != null; const practiceLaunchIsValid = !practiceLaunchRequested || practiceLaunchIntent != null; const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired && voicePracticeIsValid && practiceLaunchIsValid; const prestartTitle = canStartSession ? `${clientName}님과의 회기를 시작할까요?` : !voicePracticeIsValid ? "음성 재연습의 출처를 다시 확인해 주세요." : !practiceLaunchIsValid ? "연습 처방의 출처를 다시 확인해 주세요." : 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 coachQuotaMax = Math.max(1, coachQuota.max ?? DEFAULT_COACH_QUOTA.max); const coachQuotaRemaining = Math.max( 0, Math.min(coachQuotaMax, coachQuota.remaining ?? DEFAULT_COACH_QUOTA.remaining), ); const coachQuotaSlots = Array.from({ length: coachQuotaMax }, (_, index) => index); const coachNudgeVisible = pendingCoachTurn != null && coachQuotaRemaining > 0 && feedbackMode !== "coached" && !sessionEnded && !paused; const coachCreditPulseText = coachCreditPulse == null ? null : coachCreditPulse.event_type === "recharge" ? `+1 충전 · ${coachCreditPulse.balance}/${coachQuotaMax}` : `-1 사용 · ${coachCreditPulse.balance}/${coachQuotaMax}`; const coachIsDegraded = coachSuggestion?.status === "degraded"; const coachDegradedNote = "AI 코칭 엔진 응답 대신 워크북 규칙과 현재 턴 신호로 만든 대체 제안입니다."; const coachStatusText = coachLoading ? "코치가 근거를 확인 중" : coachIsDegraded ? "AI 응답 대체" : 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" : ""; const isGoal = started && goalStages.includes(p.key); return (
{i > 0 ? ( ) : null}
{p.key} {isGoal ? ( 목표 ) : null} {i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"}
); })}
{started ? ( ) : null} {!started ? ( /* ── 시작 전: 준비 화면(한 화면 한 의도 = 세션 시작) ── */
{/* 아바타 카드는 인물 그림 + 이름 + 한 줄 소개만 맡는다. 호소·대상·난도 목록은 본문 .sx-prestart__facts 와 똑같은 내용이라 (3열·2열 모두에서 두 번 노출) 여기서는 제거했다. 이름 이니셜 원도 바로 위 초상과 이름을 중복하는 장식이라 함께 걷어냈다. */}
{personaUi.context.name} {/* D3 — 한 줄에 · 가 3개 몰리던 소개를 줄당 1개로 나눠 표시 */}

{clientMetaLines.map((line, i) => ( {line} ))}

{/* D4 — eyebrow "○○ 단계 준비" 는 바로 위 페이지 제목 "지금은 ○○ 단계입니다." 와 같은 말이라 제거했다(단계 정보는 상단 제목·단계 레일·진행 초점에 이미 있다). */}

{prestartTitle}

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

{voicePracticeRequested ? ( voicePracticeContext ? (

음성 장면 재연습

{voicePracticeContext.sceneType ? VOICE_SCENE_LABEL[voicePracticeContext.sceneType] : "선택한 장면"} {voicePracticeContext.sceneStartMs != null && voicePracticeContext.sceneEndMs != null ? ` · ${Math.floor(voicePracticeContext.sceneStartMs / 1000)}–${Math.ceil(voicePracticeContext.sceneEndMs / 1000)}초` : ""}

{PRACTICE_SOURCE_SESSION_LABEL} · 마이크는 시작 후 직접 켭니다.
) : (

음성 재연습 원본 정보를 확인할 수 없습니다. 일반 회기로 바꾸지 않았습니다. 리뷰에서 장면을 다시 선택해 주세요.

) ) : null} {practiceLaunchRequested ? ( practiceLaunchIntent ? (

{practiceLaunchIntent.kind === "transfer" ? "전이 검증" : "처방 연습"} · {PRACTICE_MODE_LABEL[practiceLaunchIntent.mode]}

{PRACTICE_NOVELTY_LABEL[practiceLaunchIntent.novelty]} · 성공 기준 {practiceCriterionLabel(practiceLaunchIntent.criterionId)}

{PRACTICE_SOURCE_SESSION_LABEL} {practiceLaunchIntent.kind === "transfer" ? " · 전이 과제 출처 확인됨" : ""} {" · "}이 출처는 회기 URL에 계속 보존됩니다.
) : (

연습 처방의 출처를 검증할 수 없습니다. 일반 회기로 축약하지 않았습니다. 리뷰에서 처방을 다시 선택해 주세요.

) ) : null}
선택한 회기 설정 {selectedTheoryOption.label}, {selectedGoalSummary || "목표 선택 필요"}
{personaUi.context.rows.map((row) => (
{row.l}
{row.v}
))}
{personaUi.context.chips.map((chip) => ( {chip.t} ))}
이론모드
{THEORY_MODE_OPTIONS.map((option) => ( ))}
이번 회기 목표 1~4개 선택
{SESSION_PHASES.map((phase) => { const selected = selectedGoals.includes(phase.key); return ( ); })}

실제 상담처럼 한 회기에 모든 단계를 이루지 않아도 됩니다. 회기는{" "} {Math.round((durationLimitSeconds > 0 ? durationLimitSeconds : 3600) / 60)}분 기준으로 진행되고, 목표를 이뤄도 시간이 남으면 계속 이어갈 수 있어요.

{personaStatusMessage ? (

{personaStatusMessage}

) : null} {startError ?

{startError}

: null} {consentRequired ? (
) : null}

{AI_VOICE_DISCLOSURE}

{selectedGoals.length === 0 ? "이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요." : "실시간에는 대화 흐름만 낮은 강도로 표시됩니다."}
진행 초점

선택한 회기 설정을 첫 발화에서 바로 쓸 수 있는 기준으로 정리했습니다.

시작 과업
{currentPhase.key} {currentPhase.desc}
선택 접근
{selectedTheoryOption.label} {selectedTheoryOption.focus}
이번 목표
{selectedGoals.length > 0 ? `${selectedGoals.length}개 선택` : "선택 필요"} {selectedGoalSummary || "목표를 1개 이상 선택하면 시작할 수 있습니다."}
운영 기준
{limitMinutesLabel}분 회기 종료 {warningMinutesLabel}분 전 알림 · 위험 신호 시 안전 확인 우선
) : ( <> {/* ── 시간 알람 바(회의 P1): 10분 전 경고 + 시간 만료 정리 유도 — 강제 노출 ── */} {(inWarningWindow || timeUp) && !sessionEnded ? (
) : null} {liveSessionId ? ( ) : 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 ? "확인 필요" : "안정"} 안전 점검