vignette/apps/web/src/pages/Session.tsx

2786 lines
105 KiB
TypeScript

/* =====================================================================
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<string, Partial<AvatarPersona>>;
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<AvatarPersona>).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<PersonaSummary | null>(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<string | null>(null);
const [sessionEnded, setSessionEnded] = useState(false);
const [reviewReady, setReviewReady] = useState(false);
const [starting, setStarting] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
const [consentChecked, setConsentChecked] = useState(false);
const [consentBusy, setConsentBusy] = useState(false);
const [selectedTheoryMode, setSelectedTheoryMode] = useState<TheoryMode>("humanistic");
// ── 회기/대화 상태 ──
const [stage, setStage] = useState<SessionStage>("라포");
const [utterances, setUtterances] = useState<Utterance[]>([]);
const [openness, setOpenness] = useState(0);
const [safety, setSafety] = useState<string | null>(null);
const [crisisResource, setCrisisResource] = useState<CrisisResource | null>(null);
const [turnError, setTurnError] = useState<string | null>(null);
// ── 음성/턴 UI 상태 ──
const [avatarState, setAvatarState] = useState<AvatarState>("idle");
const [micOn, setMicOn] = useState(false);
const [paused, setPaused] = useState(false);
const [feedbackMode, setFeedbackMode] = useState<FeedbackMode>("ambient");
const [composeText, setComposeText] = useState("");
const [sending, setSending] = useState(false);
const [clientReplyPending, setClientReplyPending] = useState(false);
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>("idle");
const [voiceDetail, setVoiceDetail] = useState(
"마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.",
);
// 음성 캐스케이드 가용성: null=미확인, true=가능, false=provider 키 미설정(버튼 사전 비활성).
const [voiceAvailable, setVoiceAvailable] = useState<boolean | null>(null);
const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false);
const [voiceAnalyser, setVoiceAnalyser] = useState<AnalyserNode | null>(null);
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [ending, setEnding] = useState(false);
const endCancelRef = useRef<HTMLButtonElement>(null);
const endConfirmRef = useRef<HTMLButtonElement>(null);
const endPreviousFocusRef = useRef<HTMLElement | null>(null);
// ── 라이브 신호(앰비언트 도트) ──
const [liveSignal, setLiveSignal] = useState<{ tone: SignalTone; text: string } | null>(null);
const [signalFaded, setSignalFaded] = useState(false);
const [signalSeq, setSignalSeq] = useState<SignalTone[]>([]);
const [coachSuggestion, setCoachSuggestion] = useState<LiveCoachSuggestion | null>(null);
const [coachLoading, setCoachLoading] = useState(false);
const [coachError, setCoachError] = useState<string | null>(null);
const [coachEvidenceOpen, setCoachEvidenceOpen] = useState(false);
const [coachHistory, setCoachHistory] = useState<LiveCoachEvent[]>([]);
const [coachHistoryOpen, setCoachHistoryOpen] = useState(false);
const [coachHistoryTurnSeq, setCoachHistoryTurnSeq] = useState<number | null>(null);
// ── 경과 타이머 ──
const [elapsed, setElapsed] = useState(0);
// ── 자동 스크롤 ──
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const fadeTimerRef = useRef<number | null>(null);
const voiceSocketRef = useRef<WebSocket | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const micStreamRef = useRef<MediaStream | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const ttsChunksRef = useRef<BlobPart[]>([]);
const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null);
const pendingVoiceLearnerIdRef = useRef<number | null>(null);
const pendingVoiceLearnerTextRef = useRef<string>("");
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
const coachHistoryCloseRef = useRef<HTMLButtonElement>(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<HTMLTextAreaElement>) => {
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<number, LiveCoachEvent[]>();
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<AvatarState, string> = {
idle: "잠시 시선을 내리고 말을 고르는 중",
listening: "당신의 말을 듣고 있어요",
thinking: "잠시 생각하는 중",
speaking: `${personaSummary ? clientName : "내담자"}이 이야기하는 중`,
};
return (
<AppShell
hideNav={started}
contextLabel="상담 세션"
navRole="learner"
bleed={started}
hideTopbar={started}
>
<div
className={
"sx-page " +
(started ? "sx-page--active" : "sx-page--prestart") +
(started ? ` sx-feedback-${feedbackMode}` : "")
}
data-avatar-expressions={avatarExpressionCount}
>
{/* ── 페이지 헤드라인 + 가로 단계 미니 ── */}
<header className="sx-head">
<div className="sx-head__lt">
<div className="sx-head__kicker">
<Kicker> · {personaCode}</Kicker>
</div>
<h1 className="sx-head__title">
<em>{stage} </em>.
</h1>
<div className="sx-head__sub">
{clientName} . .
</div>
</div>
<div className="sx-phases" aria-label="회기 진행 단계">
{SESSION_PHASES.map((p, i) => {
const state = i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : "";
return (
<div key={p.key} style={{ display: "flex", alignItems: "center" }}>
{i > 0 ? (
<span className={"sx-ph__link" + (i <= stageIdx ? " is-fill" : "")} />
) : null}
<div className={"sx-ph " + state}>
<span className="sx-ph__dot" />
<span className="sx-ph__meta">
<span className="sx-ph__name">{p.key}</span>
<span className="sx-ph__t">
{i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"}
</span>
</span>
</div>
</div>
);
})}
</div>
</header>
{started ? (
<nav className="sx-sessionbar" aria-label="세션 이동">
<button
type="button"
className="sx-sessionbar__back"
onClick={() => navigate("/learn/history")}
>
<Icon name="chevron-left" size={16} />
</button>
<div className="sx-sessionbar__meta">
<span className={"sx-sessionbar__dot" + (sessionEnded ? " is-ended" : "")} />
<span>
<b>{sessionStatusLabel}</b>
<small>
{clientName} · {stage} · {elapsedLabel}
</small>
</span>
</div>
<div className="sx-sessionbar__actions">
<button type="button" onClick={() => navigate("/learn")}>
<Icon name="home" size={15} />
</button>
{sessionEnded && liveSessionId ? (
<button
type="button"
className="sx-sessionbar__review"
onClick={() => navigate(`/learn/session/${liveSessionId}/review`)}
>
<Icon name="review" size={15} />
{reviewReady ? "리뷰 보기" : "리뷰 대기"}
</button>
) : null}
</div>
</nav>
) : null}
{!started ? (
/* ── 시작 전: 준비 화면(한 화면 한 의도 = 세션 시작) ── */
<div className="sx-prestart">
<div className="sx-prestart__visual">
<ClientAvatar
persona={personaUi.avatar}
state="idle"
affect={avatarAffect}
rapport={meters.rapport}
size={168}
animated={false}
showCaption={false}
showMeta={false}
/>
<div className="sx-prestart__case" aria-label="내담자 요약">
<span className="sx-prestart__case-initial" aria-hidden="true">
{personaUi.context.initial}
</span>
<div>
<b>{personaUi.context.name}</b>
<p>{personaUi.context.meta}</p>
</div>
<ul>
{personaUi.context.rows.slice(0, 3).map((row) => (
<li key={row.l}>
<span>{row.l}</span>
<em>{row.v}</em>
</li>
))}
</ul>
</div>
</div>
<div className="sx-prestart__main">
<span className="sx-prestart__eyebrow">
<span aria-hidden="true" />
{currentPhase.key}
</span>
<h2 className="sx-prestart__title">{prestartTitle}</h2>
<p className="sx-prestart__desc">
,
.
</p>
<dl className="sx-prestart__facts">
{personaUi.context.rows.map((row) => (
<div key={row.l}>
<dt>{row.l}</dt>
<dd>{row.v}</dd>
</div>
))}
</dl>
<div className="sx-prestart__chips" aria-label="내담자 태그">
{personaUi.context.chips.map((chip) => (
<span className={chip.clay ? "is-clay" : ""} key={chip.t}>
{chip.t}
</span>
))}
</div>
<fieldset className="sx-theory" aria-label="이론모드 선택">
<legend></legend>
<div className="sx-theory__seg">
{THEORY_MODE_OPTIONS.map((option) => (
<button
type="button"
key={option.value}
className={option.value === selectedTheoryMode ? "is-selected" : ""}
aria-pressed={option.value === selectedTheoryMode}
onClick={() => setSelectedTheoryMode(option.value)}
>
<span>{option.label}</span>
<small>{option.detail}</small>
</button>
))}
</div>
</fieldset>
{personaStatusMessage ? (
<p className="sx-prestart__note">{personaStatusMessage}</p>
) : null}
{startError ? <p className="sx-prestart__err">{startError}</p> : null}
{consentRequired ? (
<div className="sx-consent">
<label>
<input
type="checkbox"
checked={consentChecked}
onChange={(event) => setConsentChecked(event.currentTarget.checked)}
/>
<span>
, AI ,
.
</span>
</label>
<Button
size="sm"
onClick={handleAcceptConsent}
disabled={!consentChecked || consentBusy}
leading={<Icon name="check" size={15} />}
>
{consentBusy ? "저장 중" : "동의 저장"}
</Button>
</div>
) : null}
<div className="sx-prestart__actions">
<Button
size="lg"
onClick={handleStart}
disabled={starting || !canStartSession}
leading={<Icon name="play" size={16} />}
>
{starting ? "여는 중…" : "회기 시작"}
</Button>
<span> .</span>
</div>
</div>
<div className="sx-prestart__plan" aria-label="시작 전 초점">
<Kicker> </Kicker>
<ol>
<li>
<span aria-hidden="true" />
<p>{currentPhase.desc}</p>
</li>
<li>
<span aria-hidden="true" />
<p> .</p>
</li>
<li>
<span aria-hidden="true" />
<p> .</p>
</li>
</ol>
</div>
</div>
) : (
<>
{/* ── 3-region 그리드 ── */}
<div className="sx-grid">
<section className="sx-mobile-context" aria-label="현재 회기 요약">
<div className="sx-mobile-context__row">
<span>
<b>{sessionStatusLabel}</b>
<small>{elapsedLabel}</small>
</span>
<span>
<b>{clientName}</b>
<small></small>
</span>
<span>
<b>{micLabel}</b>
<small></small>
</span>
</div>
<div className="sx-mobile-context__brief">
<span>
<b>{currentPhase.desc}</b>
<small>{stage}</small>
</span>
{primaryContext ? (
<span>
<b>{primaryContext.v}</b>
<small>{primaryContext.l}</small>
</span>
) : null}
</div>
<div className="sx-mobile-context__row sx-mobile-context__row--meters">
<span>
<b>{meters.resistance > 0.55 ? "방어 신호" : "완화 신호"}</b>
<small> </small>
</span>
<span>
<b>{meters.anxiety > 0.5 ? "위축 신호" : "안정 신호"}</b>
<small> </small>
</span>
<span>
<b>{safety ? "확인 필요" : "안정"}</b>
<small> </small>
</span>
</div>
<div className="sx-mobile-context__row sx-mobile-context__row--live">
<span className="sx-mobile-context__signal">
<b aria-label={liveSignal?.text ?? stage}>
<i
className={
"sx-mobile-context__dot" +
(liveSignal?.tone === "pos"
? " is-pos"
: liveSignal?.tone === "warn"
? " is-warn"
: "")
}
aria-hidden="true"
/>
{feedbackMode === "coached" ? (liveSignal?.text ?? stage) : "신호"}
</b>
<small>{feedbackMode === "coached" ? "현재 신호" : "조용히 표시"}</small>
</span>
</div>
{resumedSessionLoaded ? (
<div className="sx-mobile-context__resume"> .</div>
) : null}
</section>
{/* ── LEFT ── */}
<div className="sx-col sx-col-left">
{/* 회기 단계 세로 트랙 */}
<section className="sx-panel sx-track">
<div className="sx-track__head">
<Kicker> </Kicker>
</div>
{SESSION_PHASES.map((p, i) => {
const cls =
i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : "";
return (
<div key={p.key} className={"sx-vstep " + cls}>
<div className="sx-vstep__rail">
<span className="sx-vstep__node" />
{i < SESSION_PHASES.length - 1 ? <span className="sx-vstep__line" /> : null}
</div>
<div className="sx-vstep__body">
<div className="sx-vstep__label">
{p.key}
<span className="sx-vstep__t">
{i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"}
</span>
</div>
<div className="sx-vstep__desc">{p.desc}</div>
</div>
</div>
);
})}
</section>
{/* 내담자 컨텍스트 카드 */}
<section className="sx-panel sx-ctx">
<div className="sx-ctx__head">
<Kicker> </Kicker>
</div>
<div className="sx-ctx__who">
<span className="sx-ctx__pf">{personaUi.context.initial}</span>
<span>
<span className="sx-ctx__nm">{personaUi.context.name}</span>
<div className="sx-ctx__mt">{personaUi.context.meta}</div>
</span>
</div>
<div className="sx-ctx__meta">
{personaUi.context.rows.map((r) => (
<div className="sx-ctx__row" key={r.l}>
<span className="sx-ctx__rl">{r.l}</span>
<span className="sx-ctx__rv">{r.v}</span>
</div>
))}
</div>
<div className="sx-ctx__chips">
{personaUi.context.chips.map((c) => (
<span key={c.t} className={"sx-chip" + (c.clay ? " is-clay" : "")}>
{c.t}
</span>
))}
</div>
</section>
</div>
{/* ── CENTER ── */}
<div className="sx-col sx-col-center">
{/* 어두운 STAGE */}
<section className={"sx-stage" + (paused ? " is-paused" : "")}>
<div className="sx-stage__top">
<span className="sx-stage__status">
<span
className={
"sx-stage__state-dot" +
(orbState === "learner"
? " is-learner"
: orbState === "thinking" || orbState === "idle"
? " is-think"
: "")
}
/>
{sessionStatusLabel}
</span>
<span className="sx-stage__timer">{elapsedLabel}</span>
</div>
<div className="sx-orb-wrap">
<span className="sx-orb" data-orb={orbState} aria-hidden="true" />
<ClientAvatar
persona={personaUi.avatar}
state={avatarState}
affect={avatarAffect}
analyser={voiceAnalyser}
rapport={meters.rapport}
size={220}
/>
</div>
<div className="sx-stage__now">
: {stageStateText[avatarState]} · {avatarExpressionLabel}
</div>
</section>
{/* 실시간 자막(대본 스타일) + 텍스트 입력 폴백 */}
<section className="sx-transcript">
<div className="sx-transcript__head">
<Kicker> </Kicker>
<span className="sx-transcript__live">
<span
className={"sx-transcript__live-dot" + (transcriptIsLive ? " is-live" : "")}
/>
{transcriptLiveLabel}
</span>
</div>
<div className="sx-transcript__scroll" ref={scrollRef} onScroll={onScroll}>
{utterances.length > 0 || clientReplyPending ? (
<>
{utterances.map((u) => {
const turnCoachEvents =
u.speaker === "learner" && u.turnSeq
? (coachHistoryByTurn.get(u.turnSeq) ?? [])
: [];
const latestCoach = turnCoachEvents[turnCoachEvents.length - 1];
return (
<div
key={u.id}
className={
"sx-utt " +
(u.speaker === "client" ? "is-client" : "is-learner") +
(u.partial ? " is-partial" : "")
}
>
<div className="sx-utt__head">
<span className="sx-utt__spk">
{u.speaker === "client" ? clientName : "나 (학습자)"}
</span>
<span className="sx-utt__tc">{formatTimecode(u.at)}</span>
</div>
<div className="sx-utt__body">
<div className="sx-utt__line">
{u.text}
{u.partial ? <span className="sx-utt__caret" /> : null}
</div>
{turnCoachEvents.length && u.turnSeq ? (
<button
type="button"
className={
"sx-utt__coach-mark" +
(latestCoach?.suggestion.tone === "pos"
? " is-pos"
: latestCoach?.suggestion.tone === "warn"
? " is-warn"
: "")
}
title={latestCoach?.suggestion.title ?? "코칭 이력"}
onClick={() => openCoachHistory(u.turnSeq ?? null)}
>
C
{turnCoachEvents.length > 1 ? (
<span>{turnCoachEvents.length}</span>
) : null}
</button>
) : null}
</div>
</div>
);
})}
{clientReplyPending ? (
<div className="sx-utt is-client is-thinking" aria-live="polite">
<div className="sx-utt__head">
<span className="sx-utt__spk">{clientName}</span>
<span className="sx-utt__tc">{formatTimecode(elapsed)}</span>
</div>
<div className="sx-utt__body">
<div className="sx-utt__line">
.
<span className="sx-utt__dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</div>
</div>
</div>
) : null}
</>
) : (
<div className="sx-transcript__empty">
<span className="sx-transcript__empty-dot" aria-hidden="true" />
<div>
<b>
{sessionEnded
? "종료된 회기 기록입니다."
: `${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`}
</b>
<span>
{sessionEnded
? "새 발화는 추가하지 않고 리뷰에서 회기를 확인하세요."
: "아래 입력창이나 마이크로 첫 발화를 시작하세요."}
</span>
</div>
</div>
)}
</div>
{!autoScroll ? (
<button type="button" className="sx-transcript__jump" onClick={jumpToLatest}>
<Icon name="arrow-down" size={13} />
</button>
) : null}
{/* 텍스트 입력 폴백 — 음성 1턴 왕복 대역(실제 STT 는 voice 트랙) */}
<div className="sx-compose">
<div className="sx-compose__field">
<textarea
rows={1}
value={composeText}
onChange={(e) => setComposeText(e.target.value)}
onKeyDown={onComposeKey}
placeholder={
sessionEnded
? "종료된 회기입니다."
: paused
? "일시정지 중입니다."
: "학습자 발화를 입력하세요."
}
disabled={sessionEnded || paused || textTurnBlocked}
aria-label="학습자 발화 입력"
/>
</div>
<Button
onClick={() => void handleSend()}
disabled={!composeText.trim() || textTurnBlocked || paused || sessionEnded}
trailing={<Icon name="arrow-up" size={15} />}
>
</Button>
</div>
{turnError ? (
<section className="sx-safety sx-safety--engine sx-turn-error" role="alert">
<span className="sx-safety__ico">
<Icon name="alert" size={17} />
</span>
<span className="sx-safety__text">
<b> </b> · {turnError}
</span>
</section>
) : null}
</section>
</div>
{/* ── RIGHT ── */}
<div className="sx-col sx-col-right">
{/* 라이브 신호 (몰입 모드면 숨김) */}
{feedbackMode !== "immersive" ? (
<section className="sx-panel sx-signal">
<div className="sx-signal__head">
<Kicker> </Kicker>
</div>
{liveSignal ? (
<div
className={"sx-signal__one" + (signalFaded ? " is-faded" : "")}
title={liveSignal.text}
>
<span
className={
"sx-signal__one-dot" +
(liveSignal.tone === "pos"
? " is-pos"
: liveSignal.tone === "warn"
? " is-warn"
: "")
}
/>
{feedbackMode === "coached" ? (
<>
<span className="sx-signal__one-text">{liveSignal.text}</span>
<span className="sx-signal__one-when"></span>
</>
) : (
<span className="sx-signal__one-when"></span>
)}
</div>
) : (
<p className="sx-signal__rest">
. .
</p>
)}
<div className="sx-signal__seq">
<span className="sx-signal__seq-label"> </span>
<span className="sx-signal__seq-dots">
{signalSeq.map((t, i) => (
<i
key={i}
className={
(t === "pos" ? "is-pos" : t === "warn" ? "is-warn" : "") +
(i === signalSeq.length - 1 ? " is-now" : "")
}
/>
))}
</span>
</div>
{feedbackMode === "coached" ? (
<div
className={
"sx-coach-card" +
(coachTone === "pos" ? " is-pos" : coachTone === "warn" ? " is-warn" : "") +
(coachLoading ? " is-loading" : "")
}
>
<div className="sx-coach-avatar" aria-hidden="true">
<span className="sx-coach-avatar__lens" />
<span className="sx-coach-avatar__face">
<i />
<i />
</span>
</div>
<div className="sx-coach-bubble">
<div className="sx-coach-bubble__meta">
<b>AI </b>
<span>{coachStatusText}</span>
</div>
{coachLoading ? (
<p> .</p>
) : coachSuggestion ? (
<>
<strong>{coachSuggestion.title}</strong>
<p>{coachSuggestion.message}</p>
{coachSuggestion.next_utterance ? (
<blockquote>{coachSuggestion.next_utterance}</blockquote>
) : null}
<div className="sx-coach-bubble__actions">
<button type="button" onClick={() => setCoachEvidenceOpen(true)}>
</button>
<button type="button" onClick={() => openCoachHistory(null)}>
</button>
</div>
</>
) : coachError ? (
<p>{coachError}</p>
) : (
<p> .</p>
)}
</div>
</div>
) : null}
<div className="sx-signal__defer">
{feedbackMode === "coached" ? (
<>
<b> </b>
. · .
</>
) : (
<>
<b> </b> . ·
.
</>
)}
</div>
</section>
) : (
<section className="sx-panel sx-signal">
<div className="sx-signal__head">
<Kicker> </Kicker>
</div>
<div className="sx-signal__defer">
.
.
</div>
</section>
)}
{/* 내담자 상태 미터 */}
<section className="sx-panel sx-meters">
<div className="sx-meters__head">
<Kicker> </Kicker>
</div>
<div className="sx-meter">
<div className="sx-meter__top">
<span className="sx-meter__label"> </span>
<span className="sx-meter__val is-clay">
{meters.resistance > 0.55 ? "아직 높음" : "완화되는 중"}
</span>
</div>
<Gauge value={meters.resistance} tone="clay" />
</div>
<div className="sx-meter">
<div className="sx-meter__top">
<span className="sx-meter__label"> </span>
<span className="sx-meter__val is-clay">
{meters.anxiety > 0.5 ? "중간" : "낮아지는 중"}
</span>
</div>
<Gauge value={meters.anxiety} tone="clay" />
</div>
<div className="sx-meter">
<div className="sx-meter__top">
<span className="sx-meter__label"> </span>
<span className="sx-meter__val is-accent"> </span>
</div>
<Gauge value={meters.rapport} tone="accent" />
</div>
<div className="sx-meters__note">
. .
</div>
</section>
<section className={"sx-safety" + (safety ? "" : " sx-safety--ok")}>
<span className="sx-safety__ico">
<Icon name={safety ? "alert" : "shield"} size={17} />
</span>
<span className="sx-safety__text">
<b> </b> · {safety ?? "현재 감지된 위기 신호 없음"}
{crisisResource ? (
<span className="sx-crisis-resource">
<strong>{crisisResource.title}</strong>
<a href={`tel:${crisisResource.number}`}>{crisisResource.number}</a>
</span>
) : null}
</span>
</section>
</div>
</div>
{/* ── 하단 컨트롤 바 ── */}
<div className="sx-controlbar">
<div className="sx-mic-block">
<button
type="button"
className={"sx-mic " + (micOn ? "is-on" : "is-off")}
onClick={toggleMic}
disabled={sessionEnded || paused || micBusy || voiceAvailable === false}
aria-pressed={micOn}
aria-label={micOn ? "발화 보내기" : "마이크 켜기"}
>
<Icon name={micOn ? "mic" : "mic-off"} size={22} />
</button>
<span className="sx-mic-block__ms">
<span className="sx-mic-block__l">{micLabel}</span>
<span className="sx-mic-block__h">{voiceDetail}</span>
</span>
{voiceStatus === "speaking" ? (
<button
type="button"
className="sx-voice-skip"
onClick={skipVoicePlayback}
aria-label="음성 건너뛰기"
>
<Icon name="x" size={14} />
<span></span>
</button>
) : null}
</div>
<span className="sx-cb-sep" />
<div className="sx-seg-block">
<span className="sx-seg-block__label"> </span>
<div className="sx-segmented" role="group" aria-label="피드백 모드">
<button
className={feedbackMode === "immersive" ? "is-on" : ""}
onClick={() => setFeedbackMode("immersive")}
>
</button>
<button
className={feedbackMode === "ambient" ? "is-on" : ""}
onClick={() => setFeedbackMode("ambient")}
>
</button>
<button
className={feedbackMode === "coached" ? "is-on" : ""}
onClick={() => setFeedbackMode("coached")}
>
</button>
</div>
</div>
<span className="sx-cb-spacer" />
<div className="sx-cb-actions">
{sessionEnded && liveSessionId ? (
<button
type="button"
className="sx-review-button"
onClick={() => navigate(`/learn/session/${liveSessionId}/review`)}
>
<Icon name="review" size={15} />
{reviewReady ? "리뷰 보기" : "리뷰 준비 중"}
</button>
) : (
<>
<button
type="button"
className={"sx-pause" + (paused ? " is-paused" : "")}
onClick={togglePause}
>
<Icon name={paused ? "play" : "pause"} size={15} />
{paused ? "이어가기" : "일시정지"}
</button>
<button
type="button"
className="sx-end-button"
onClick={() => setEndDialogOpen(true)}
disabled={ending}
>
<Icon name="x" size={15} />
</button>
</>
)}
</div>
</div>
{coachHistoryOpen ? (
<div
className="sx-coach-history"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setCoachHistoryOpen(false);
}}
>
<section
className="sx-coach-history__panel"
role="dialog"
aria-modal="true"
aria-labelledby="sx-coach-history-title"
aria-describedby="sx-coach-history-desc"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="sx-coach-history__head">
<div>
<h2 id="sx-coach-history-title">{coachHistoryTitle}</h2>
<p id="sx-coach-history-desc">
.
</p>
</div>
<button
ref={coachHistoryCloseRef}
type="button"
onClick={() => setCoachHistoryOpen(false)}
aria-label="코칭 이력 닫기"
>
<Icon name="x" size={16} />
</button>
</div>
<div className="sx-coach-history__body">
{activeCoachEvents.length ? (
activeCoachEvents.map((event) => (
<article
key={event.event_id}
className={
"sx-coach-history__item" +
(event.suggestion.tone === "pos"
? " is-pos"
: event.suggestion.tone === "warn"
? " is-warn"
: "")
}
>
<div className="sx-coach-history__item-top">
<span>{event.turn_seq} · {event.stage ?? "단계 미상"}</span>
<time dateTime={event.created_at}>
{formatCoachTimestamp(event.created_at)}
</time>
</div>
<h3>{event.suggestion.title}</h3>
<p>{event.suggestion.message}</p>
{event.learner_text_excerpt ? (
<blockquote className="sx-coach-history__trigger">
{event.learner_text_excerpt}
</blockquote>
) : null}
{event.suggestion.next_utterance ? (
<div className="sx-coach-history__next">
<b> </b>
<blockquote>{event.suggestion.next_utterance}</blockquote>
</div>
) : null}
{event.suggestion.rationale ? (
<p className="sx-coach-history__why">{event.suggestion.rationale}</p>
) : null}
{event.suggestion.sources?.length ? (
<ul className="sx-coach-source-list sx-coach-source-list--compact">
{event.suggestion.sources.map((source, index) => (
<li key={`${event.event_id}-${source.source_id}-${index}`}>
<span>{source.title}</span>
<small>
{[source.locator ?? source.source_id, source.kb_kind, source.version]
.filter(Boolean)
.join(" · ")}
</small>
{source.citation ? <small>{source.citation}</small> : null}
</li>
))}
</ul>
) : null}
</article>
))
) : (
<div className="sx-coach-history__empty">
.
</div>
)}
</div>
</section>
</div>
) : null}
{coachEvidenceOpen && coachSuggestion ? (
<div
className="sx-coach-modal"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setCoachEvidenceOpen(false);
}}
>
<section
className="sx-coach-modal__panel"
role="dialog"
aria-modal="true"
aria-labelledby="sx-coach-modal-title"
aria-describedby="sx-coach-modal-desc"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="sx-coach-modal__head">
<span className="sx-coach-modal__avatar" aria-hidden="true">
<span />
</span>
<div>
<h2 id="sx-coach-modal-title">{coachSuggestion.title}</h2>
<p id="sx-coach-modal-desc"> .</p>
</div>
</div>
<div className="sx-coach-modal__body">
<section className="sx-coach-evidence-block">
<b></b>
<p>{coachSuggestion.message}</p>
{coachSuggestion.rationale ? <p>{coachSuggestion.rationale}</p> : null}
{coachSuggestion.safety_note ? (
<p className="is-warn">{coachSuggestion.safety_note}</p>
) : null}
</section>
{coachSuggestion.next_utterance ? (
<section className="sx-coach-evidence-block">
<b> </b>
<blockquote>{coachSuggestion.next_utterance}</blockquote>
</section>
) : null}
<section className="sx-coach-evidence-block">
<b> </b>
{coachSources.length ? (
<ul className="sx-coach-source-list">
{coachSources.map((source, index) => (
<li key={`${source.source_id}-${source.locator ?? index}`}>
<span>{source.title}</span>
<small>
{[source.locator ?? source.source_id, source.kb_kind, source.version]
.filter(Boolean)
.join(" · ")}
</small>
{source.citation ? <small>{source.citation}</small> : null}
</li>
))}
</ul>
) : (
<p> , RAG .</p>
)}
</section>
</div>
<div className="sx-coach-modal__actions">
<button
type="button"
ref={coachEvidenceCloseRef}
onClick={() => setCoachEvidenceOpen(false)}
>
</button>
</div>
</section>
</div>
) : null}
{endDialogOpen ? (
<div
className="sx-end-dialog"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !ending) setEndDialogOpen(false);
}}
>
<section
className="sx-end-dialog__panel"
role="dialog"
aria-modal="true"
aria-labelledby="sx-end-dialog-title"
aria-describedby="sx-end-dialog-desc"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="sx-end-dialog__head">
<span className="sx-end-dialog__icon" aria-hidden="true">
<Icon name="review" size={18} />
</span>
<div>
<h2 id="sx-end-dialog-title"> ?</h2>
<p id="sx-end-dialog-desc">
.
</p>
</div>
</div>
<div className="sx-end-dialog__actions">
<button
ref={endCancelRef}
type="button"
className="sx-end-dialog__secondary"
onClick={() => setEndDialogOpen(false)}
disabled={ending}
>
</button>
<button
ref={endConfirmRef}
type="button"
className="sx-end-dialog__danger"
onClick={() => void handleEnd()}
disabled={ending}
>
{ending ? "종료 중" : "종료하고 리뷰 보기"}
</button>
</div>
</section>
</div>
) : null}
{/* 경과 시간(접근성 — 보조 표기. 화면 우상단 톱바는 셸 소관) */}
<span className="sr-only" aria-live="polite" style={{ position: "absolute", left: -9999 }}>
{formatElapsed(elapsed)}
</span>
</>
)}
</div>
</AppShell>
);
}
function formatCoachTimestamp(value: string | null | undefined) {
if (!value) return "방금";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "방금";
const now = new Date();
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
return new Intl.DateTimeFormat("ko-KR", {
month: sameDay ? undefined : "numeric",
day: sameDay ? undefined : "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
/* ── 로컬 게이지 (매우 천천히 채움, 2s — §5.5 산만함 방지) ──────────── */
function Gauge({ value, tone }: { value: number; tone: "clay" | "accent" }) {
const pct = Math.round(clamp01(value) * 100);
return (
<div
className="sx-gauge"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
style={{ height: 4, background: "var(--paper-2)", borderRadius: 2, overflow: "hidden" }}
>
<span
style={{
display: "block",
height: "100%",
width: `${pct}%`,
borderRadius: 2,
background: tone === "clay" ? "var(--clay)" : "var(--accent-bright)",
transition: "width 2s var(--ease-out)",
}}
/>
</div>
);
}