vignette/apps/web/src/pages/Session.tsx
2026-08-09 21:04:29 +09:00

4118 lines
165 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =====================================================================
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<string | null>(null);
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;
// 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<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 [alliancePreGateBlocked, setAlliancePreGateBlocked] = useState(false);
const [consentChecked, setConsentChecked] = useState(false);
const [consentBusy, setConsentBusy] = useState(false);
const [selectedTheoryMode, setSelectedTheoryMode] = useState<TheoryMode>("humanistic");
// 이번 회기 목표(2026-07-13 회의 P1): 4단계 전부가 아니라 1~2개를 고르고 시작한다.
const [selectedGoals, setSelectedGoals] = useState<SessionStage[]>(["라포", "탐색"]);
// ── 회기/대화 상태 ──
const [stage, setStage] = useState<SessionStage>("라포");
const [goalStages, setGoalStages] = useState<SessionStage[]>([]);
// P2 단계 누적 게이지·상세 수치 — 서버 결정론 파생값(턴/복원 시 갱신).
const [progress, setProgress] = useState<SessionProgress | null>(null);
// 시간 기반 회기(회의 P1): 서버 계약값. 0이면 기본 60분/10분으로 보정한다.
const [durationLimitSeconds, setDurationLimitSeconds] = useState(60 * 60);
const [warningBeforeEndSeconds, setWarningBeforeEndSeconds] = useState(10 * 60);
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);
// 음성 입력 선택은 서버 consent ledger와 분리된 현재 회기 메모리 상태다.
const [voiceConsentSessionId, setVoiceConsentSessionId] = useState<string | null>(null);
const [voiceConsentDialogOpen, setVoiceConsentDialogOpen] = useState(false);
const [voiceConsentSaving, setVoiceConsentSaving] = useState(false);
const [voiceConsentError, setVoiceConsentError] = useState<string | 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 voiceConsentTextRef = useRef<HTMLButtonElement>(null);
const voiceConsentAcceptRef = useRef<HTMLButtonElement>(null);
const voiceConsentPreviousFocusRef = useRef<HTMLElement | null>(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<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 [coachQuota, setCoachQuota] = useState<LiveCoachQuota>(DEFAULT_COACH_QUOTA);
const [coachCreditEvents, setCoachCreditEvents] = useState<LiveCoachCreditEvent[]>([]);
const [coachCreditPulse, setCoachCreditPulse] = useState<LiveCoachCreditEvent | null>(null);
const [coachHistoryOpen, setCoachHistoryOpen] = useState(false);
const [coachHistoryTurnSeq, setCoachHistoryTurnSeq] = useState<number | null>(null);
const [coachHistoryLoading, setCoachHistoryLoading] = useState(false);
const [coachHistoryError, setCoachHistoryError] = useState<string | null>(null);
const [coachPersistenceSource, setCoachPersistenceSource] = useState<"database" | "runtime" | null>(null);
const [coachSyncWarning, setCoachSyncWarning] = useState<string | null>(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<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const fadeTimerRef = useRef<number | null>(null);
const voiceSocketRef = useRef<WebSocket | null>(null);
const voiceCaptureRef = useRef<VoiceCaptureController | null>(null);
const voiceCaptureAttemptRef = useRef(0);
const voiceCaptureMountedRef = useRef(true);
const activeVoiceSessionRef = useRef<string | null>(liveSessionId);
const micStreamRef = useRef<MediaStream | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const ttsChunksRef = useRef<BlobPart[]>([]);
const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null);
const ttsPlaybackRequestRef = useRef(0);
const ttsPlaybackActiveRef = useRef(false);
const ttsRequestAbortRef = useRef<AbortController | null>(null);
const playTtsAudioRef = useRef<(() => Promise<void>) | null>(null);
const pendingVoiceLearnerIdRef = useRef<number | null>(null);
const pendingVoiceLearnerTextRef = useRef<string>("");
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
const coachHistoryCloseRef = useRef<HTMLButtonElement>(null);
const coachCreditSeenRef = useRef<Set<string>>(new Set());
const coachCreditPulseTimerRef = useRef<number | null>(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<LiveCoachHistoryResponse | null> => {
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<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 = "마이크 꺼짐") => {
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<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}` : "") +
(started && alliancePreGateBlocked ? " sx-alliance-gated" : "") +
(started && safety ? " sx-has-safety" : "")
}
data-avatar-expressions={avatarExpressionCount}
data-practice-mode={practiceLaunchIntent?.mode ?? voicePracticeContext?.mode ?? "standard"}
>
{/* ── 페이지 헤드라인 + 가로 단계 미니 ── */}
<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" : "";
const isGoal = started && goalStages.includes(p.key);
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 + (isGoal ? " is-goal" : "")}>
<span className="sx-ph__dot" />
<span className="sx-ph__meta">
<span className="sx-ph__name">
{p.key}
{isGoal ? (
<em className="sx-ph__goal" title="이번 회기 목표">
</em>
) : null}
</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>{resumedSessionLoaded ? "이전 회기 기록을 불러왔습니다." : sessionStatusLabel}</b>
{/* D3 — "서연 · 라포 단계 · 0:03" 처럼 · 가 2개였던 줄을
"서연 · 라포 단계" + "0:03" 두 칸으로 분리한다. */}
<small>
<span>
{clientName} · {stage}
</span>
<span>{elapsedLabel}</span>
</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(practiceReviewPath(liveSessionId))}
>
<Icon name="review" size={15} />
{reviewReady ? "리뷰 보기" : "리뷰 대기"}
</button>
) : null}
</div>
</nav>
) : null}
{!started ? (
/* ── 시작 전: 준비 화면(한 화면 한 의도 = 세션 시작) ── */
<div
className={surfaceClassName(
`sx-prestart${practiceLaunchIntent ? " sx-prestart--prescribed" : ""}`,
)}
>
<div className={surfaceClassName("sx-prestart__visual", { variant: "inset" })}>
<ClientAvatar
persona={personaUi.avatar}
state="idle"
affect={avatarAffect}
rapport={meters.rapport}
size={168}
animated={false}
showCaption={false}
showMeta={false}
/>
{/* 아바타 카드는 인물 그림 + 이름 + 한 줄 소개만 맡는다.
호소·대상·난도 목록은 본문 .sx-prestart__facts 와 똑같은 내용이라
(3열·2열 모두에서 두 번 노출) 여기서는 제거했다. 이름 이니셜 원도
바로 위 초상과 이름을 중복하는 장식이라 함께 걷어냈다. */}
<div className="sx-prestart__case" aria-label="내담자 요약">
<b>{personaUi.context.name}</b>
{/* D3 — 한 줄에 · 가 3개 몰리던 소개를 줄당 1개로 나눠 표시 */}
<p>
{clientMetaLines.map((line, i) => (
<span key={`${i}-${line}`}>{line}</span>
))}
</p>
</div>
</div>
<div className="sx-prestart__main">
{/* D4 — eyebrow "○○ 단계 준비" 는 바로 위 페이지 제목 "지금은 ○○ 단계입니다."
와 같은 말이라 제거했다(단계 정보는 상단 제목·단계 레일·진행 초점에 이미 있다). */}
<h2 className="sx-prestart__title">{prestartTitle}</h2>
<p className="sx-prestart__desc">
,
.
</p>
{voicePracticeRequested ? (
voicePracticeContext ? (
<section className="sx-voice-practice-context" aria-labelledby="sx-voice-practice-title">
<Icon name="mic" size={18} />
<div>
<h3 id="sx-voice-practice-title"> </h3>
<p>
{voicePracticeContext.sceneType
? VOICE_SCENE_LABEL[voicePracticeContext.sceneType]
: "선택한 장면"}
{voicePracticeContext.sceneStartMs != null && voicePracticeContext.sceneEndMs != null
? ` · ${Math.floor(voicePracticeContext.sceneStartMs / 1000)}${Math.ceil(voicePracticeContext.sceneEndMs / 1000)}`
: ""}
</p>
<small>{PRACTICE_SOURCE_SESSION_LABEL} · .</small>
</div>
</section>
) : (
<p className="sx-prestart__err" role="alert">
. . .
</p>
)
) : null}
{practiceLaunchRequested ? (
practiceLaunchIntent ? (
<section
className="sx-voice-practice-context sx-practice-launch-context"
aria-labelledby="sx-practice-launch-title"
>
<Icon name="review" size={18} />
<div>
<h3 id="sx-practice-launch-title">
{practiceLaunchIntent.kind === "transfer" ? "전이 검증" : "처방 연습"} · {PRACTICE_MODE_LABEL[practiceLaunchIntent.mode]}
</h3>
<p>
{PRACTICE_NOVELTY_LABEL[practiceLaunchIntent.novelty]} · {practiceCriterionLabel(practiceLaunchIntent.criterionId)}
</p>
<small>
{PRACTICE_SOURCE_SESSION_LABEL}
{practiceLaunchIntent.kind === "transfer"
? " · 전이 과제 출처 확인됨"
: ""}
{" · "} URL에 .
</small>
</div>
</section>
) : (
<p className="sx-prestart__err" role="alert">
. . .
</p>
)
) : null}
<details
className={`sx-prestart__settings${practiceLaunchIntent ? " is-prescribed" : ""}`}
open={practiceLaunchIntent ? undefined : true}
>
<summary>
<span> </span>
<b>
{selectedTheoryOption.label}, {selectedGoalSummary || "목표 선택 필요"}
</b>
<Icon name="chevron-right" size={16} />
</summary>
<div className="sx-prestart__settings-body">
<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>
<fieldset className="sx-goals" aria-label="이번 회기 목표 선택">
<legend>
<small>1~4 </small>
</legend>
<div className="sx-goals__grid">
{SESSION_PHASES.map((phase) => {
const selected = selectedGoals.includes(phase.key);
return (
<button
type="button"
key={phase.key}
className={selected ? "is-selected" : ""}
aria-pressed={selected}
onClick={() => toggleGoal(phase.key)}
>
<span>{phase.key}</span>
<small>{phase.desc}</small>
</button>
);
})}
</div>
<p className="sx-goals__hint">
. {" "}
{Math.round((durationLimitSeconds > 0 ? durationLimitSeconds : 3600) / 60)}
, .
</p>
</fieldset>
</div>
</details>
{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}
<p className="sx-prestart__voice-disclosure" role="note">
<Icon name="spark" size={14} />
<span>{AI_VOICE_DISCLOSURE}</span>
</p>
<div className="sx-prestart__actions">
<Button
size="lg"
onClick={handleStart}
disabled={starting || !canStartSession || selectedGoals.length === 0}
leading={<Icon name="play" size={16} />}
>
{starting ? "여는 중…" : "회기 시작"}
</Button>
<span>
{selectedGoals.length === 0
? "이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요."
: "실시간에는 대화 흐름만 낮은 강도로 표시됩니다."}
</span>
</div>
</div>
<div
className={surfaceClassName("sx-prestart__plan", { variant: "inset" })}
aria-label="시작 전 초점"
>
<Kicker> </Kicker>
<p className="sx-prestart__plan-summary">
.
</p>
<dl className="sx-prestart__plan-list">
<div>
<dt> </dt>
<dd>
<b>{currentPhase.key}</b>
{currentPhase.desc}
</dd>
</div>
<div>
<dt> </dt>
<dd>
<b>{selectedTheoryOption.label}</b>
{selectedTheoryOption.focus}
</dd>
</div>
<div>
<dt> </dt>
<dd>
<b>{selectedGoals.length > 0 ? `${selectedGoals.length}개 선택` : "선택 필요"}</b>
{selectedGoalSummary || "목표를 1개 이상 선택하면 시작할 수 있습니다."}
</dd>
</div>
<div>
<dt> </dt>
<dd>
<b>{limitMinutesLabel} </b>
{warningMinutesLabel} ·
</dd>
</div>
</dl>
</div>
</div>
) : (
<>
{/* ── 시간 알람 바(회의 P1): 10분 전 경고 + 시간 만료 정리 유도 — 강제 노출 ── */}
{(inWarningWindow || timeUp) && !sessionEnded ? (
<div className={"sx-timebar" + (timeUp ? " is-over" : "")} role="alert">
<span className="sx-timebar__dot" aria-hidden="true" />
<b>
{timeUp
? "회기 시간이 끝났어요"
: `종료 ${Math.max(1, Math.ceil(remainingSeconds / 60))}분 전`}
</b>
<span className="sx-timebar__desc">
{timeUp
? "마무리 인사를 나눈 뒤 종료하세요. 정리 시간이 지나면 새 발화가 제한됩니다."
: "남은 시간 동안 오늘 나눈 이야기를 정리해 보세요."}
</span>
{timeUp ? (
<button
type="button"
className="sx-timebar__end"
onClick={() => setEndDialogOpen(true)}
>
</button>
) : null}
</div>
) : null}
{liveSessionId ? (
<AllianceCheckpointPrompt
sessionId={liveSessionId}
turnCount={turnCount}
ended={sessionEnded}
onPreGateChange={handleAlliancePreGateChange}
/>
) : null}
{/* ── 3-region 그리드 ── */}
<div className="sx-grid">
<section className={surfaceClassName("sx-mobile-context", { variant: "inset" })} 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={surfaceClassName("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" : "";
const isGoal = goalStages.includes(p.key);
const goalReached = isGoal && goalStageAchieved(i);
const stageProgress = progress?.stages?.find(
(entry) => entry.stage === p.key,
);
return (
<div key={p.key} className={"sx-vstep " + cls + (isGoal ? " is-goal" : "")}>
<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}
{isGoal ? (
<span
className={
"sx-vstep__goal" + (goalReached ? " is-reached" : "")
}
>
{goalReached ? "목표 달성" : "이번 목표"}
</span>
) : null}
<span className="sx-vstep__t">
{i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"}
</span>
</div>
<div className="sx-vstep__desc">{p.desc}</div>
{stageProgress ? (
<div className="sx-vstep__gauge" aria-label={`${p.key} 누적 ${stageProgress.percent}%`}>
<Gauge value={stageProgress.percent / 100} tone="accent" />
<em>{stageProgress.percent}</em>
</div>
) : null}
</div>
</div>
);
})}
{progress ? (
<div className="sx-track__note">
. .
</div>
) : null}
</section>
{/* 내담자 컨텍스트 카드 — 시작 전 화면에서 이미 본 정보라 기본 접힘 */}
<section className={surfaceClassName("sx-panel sx-ctx" + (ctxCollapsed ? " is-collapsed" : ""))}>
<button
type="button"
className="sx-ctx__head sx-panel-toggle"
aria-expanded={!ctxCollapsed}
onClick={() => setCtxCollapsed((prev) => !prev)}
>
{/* D1 — 단순 섹션 라벨이라 accent dot 제거(상태를 나타내지 않음) */}
<Kicker dot={false}> </Kicker>
<span className="sx-panel-toggle__hint">
{ctxCollapsed ? `${personaUi.context.name} · 펼치기` : "접기"}
</span>
</button>
{!ctxCollapsed ? (
<>
<div className="sx-ctx__who">
<span className="sx-ctx__pf">{personaUi.context.initial}</span>
<span>
<span className="sx-ctx__nm">{personaUi.context.name}</span>
{/* D3 — 줄당 가운뎃점 1개로 나눠 표시 */}
<div className="sx-ctx__mt">
{clientMetaLines.map((line, i) => (
<span key={`${i}-${line}`}>{line}</span>
))}
</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>
</>
) : null}
</section>
<section className={surfaceClassName("sx-panel sx-session-progress")}>
<div className="sx-session-progress__head">
{/* D1 — 섹션 라벨(수치는 아래 셀이 전달) → dot 제거 */}
<Kicker dot={false}> </Kicker>
</div>
<div className="sx-session-progress__grid">
<span>
<b>{elapsedLabel}</b>
<small>
<Icon name="clock" size={18} />
</small>
</span>
<span className={inWarningWindow || timeUp ? "is-warning" : ""}>
<b>{timeUp ? "정리 시간" : remainingLabel}</b>
<small>
<Icon name="hourglass" size={18} />
</small>
</span>
<span>
<b>{turnCount}</b>
<small>
<Icon name="review" size={18} />
</small>
</span>
</div>
{goalsAchieved && !timeUp && !sessionEnded ? (
<p className="sx-session-progress__goalnote" role="status">
.
.
</p>
) : null}
</section>
</div>
{/* ── CENTER ── */}
<div className="sx-col sx-col-center">
{/* 어두운 STAGE */}
<section className={surfaceClassName("sx-stage" + (paused ? " is-paused" : ""), { variant: "inset" })}>
<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" + (inWarningWindow || timeUp ? " is-warning" : "")
}
title={timeUp ? "회기 시간이 끝났습니다" : `남은 시간 ${remainingLabel}`}
>
{timeUp ? "정리 시간" : inWarningWindow ? `-${remainingLabel}` : 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__client">
<span className="sx-stage__client-kicker">{clientName}</span>
<p>{stageClientLine}</p>
</div>
<div className="sx-stage__now">
<Icon name="users" size={20} />
<span>: {stageStateText[avatarState]}</span>
<small>{avatarExpressionLabel}</small>
</div>
</section>
{/* 실시간 자막(대본 스타일) + 텍스트 입력 폴백 */}
<section className={surfaceClassName("sx-transcript")}>
<div className="sx-transcript__head">
{/* D1 — 라이브 여부는 우측 sx-transcript__live-dot 이 이미 전달 → 라벨 dot 제거 */}
<Kicker dot={false}> </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}
role="log"
aria-label="실시간 상담 축어록"
aria-live="polite"
aria-relevant="additions text"
aria-busy={clientReplyPending}
>
{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" : "") +
(u.failed ? " is-failed" : "")
}
>
<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" aria-hidden="true" /> : null}
</div>
{u.failed ? (
<span className="sx-utt__status" role="note">
· .
</span>
) : u.voiceTranscriptState ? (
<span className="sx-utt__status is-live">
{u.voiceTranscriptState === "interim"
? "실시간 전사"
: "전사 확정, 응답 연결 중"}
</span>
) : null}
{turnCoachEvents.length && u.turnSeq ? (
<button
type="button"
className={
"sx-utt__coach-mark" +
(latestCoach?.suggestion.tone === "pos"
? " is-pos"
: latestCoach?.suggestion.tone === "warn"
? " is-warn"
: "") +
(latestCoach?.suggestion.status === "degraded" ? " is-degraded" : "")
}
title={
latestCoach?.suggestion.status === "degraded"
? `AI 응답 대체: ${latestCoach.suggestion.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">
<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>
)}
{turnError ? (
<section
className={surfaceClassName("sx-safety sx-safety--engine sx-turn-error", {
variant: "inset",
})}
role="alert"
>
<span className="sx-safety__ico">
<Icon name="alert" size={17} />
</span>
<span className="sx-safety__text">
<b>{turnErrorTitle}</b> · {turnError}
</span>
</section>
) : null}
</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={textComposerDisabled || alliancePreGateBlocked}
aria-label="학습자 발화 입력"
/>
</div>
<Button
onClick={() => void handleSend()}
disabled={!composeText.trim() || textTurnBlocked || paused || sessionEnded || alliancePreGateBlocked}
trailing={<Icon name="arrow-up" size={15} />}
>
</Button>
</div>
</section>
</div>
{/* ── RIGHT ── */}
<div className="sx-col sx-col-right">
{/* 라이브 신호 (몰입 모드면 숨김) */}
{feedbackMode !== "immersive" ? (
<section className={surfaceClassName("sx-panel sx-signal")}>
<div className="sx-signal__head">
<Kicker dot={false}> </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>
) : null}
<div className="sx-signal__status" aria-label="연결 상태">
<span>
<Icon name="mic" size={23} />
<b></b>
<small>{voiceInputStatus}</small>
</span>
<span>
<Icon name="spark" size={23} />
<b>AI </b>
<small>{responseStatus}</small>
</span>
</div>
{signalSeq.length > 0 ? (
<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>
) : null}
<div
className={
"sx-coach-card" +
(coachTone === "pos" ? " is-pos" : coachTone === "warn" ? " is-warn" : "") +
(coachLoading ? " is-loading" : "") +
(coachIsDegraded ? " is-degraded" : "")
}
>
<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-summary">
<div className="sx-coach-bubble__meta">
<b>{coachIsDegraded ? "대체 코칭" : "AI 코치"}</b>
<span>{coachStatusText}</span>
</div>
<div className="sx-coach-quota" aria-live="polite">
<span> </span>
<span className="sx-coach-quota__dots" aria-hidden="true">
{coachQuotaSlots.map((slot) => (
<i
key={slot}
className={slot < coachQuotaRemaining ? "is-filled" : ""}
/>
))}
</span>
<b>
{coachQuotaRemaining}/{coachQuotaMax}
</b>
</div>
</div>
<div
className={surfaceClassName("sx-coach-bubble", {
variant: "inset",
flat: true,
})}
>
{coachIsDegraded ? (
<p className="sx-coach-degraded-note">{coachDegradedNote}</p>
) : null}
{coachSyncWarning ? (
<p
className={
"sx-coach-persistence-note" +
(coachPersistenceSource === "runtime" ? " is-runtime" : "")
}
role="status"
>
{coachSyncWarning}
</p>
) : null}
{coachCreditPulseText ? (
<div
className={
"sx-coach-credit-pulse" +
(coachCreditPulse?.event_type === "recharge" ? " is-recharge" : "")
}
aria-live="polite"
>
{coachCreditPulseText}
</div>
) : null}
{coachLoading ? (
<p> .</p>
) : coachError ? (
<p>{coachError}</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>
</>
) : coachQuotaRemaining <= 0 ? (
<p>
. 1
.
</p>
) : (
<p> .</p>
)}
</div>
</div>
<div className="sx-signal__defer">
{feedbackMode === "coached" ? (
<>
<b> </b>
. · .
</>
) : (
<>
<b> </b> . ·
.
</>
)}
</div>
</section>
) : (
<section className={surfaceClassName("sx-panel sx-signal")}>
<div className="sx-signal__head">
{/* D1 — 섹션 라벨 → dot 제거 */}
<Kicker dot={false}> </Kicker>
</div>
<div className="sx-signal__defer">
.
.
</div>
</section>
)}
{/* 내담자 상태 미터 */}
<section className={surfaceClassName("sx-panel sx-meters" + (metersCollapsed ? " is-collapsed" : ""))}>
<button
type="button"
className="sx-meters__head sx-panel-toggle"
aria-expanded={!metersCollapsed}
onClick={() => setMetersCollapsed((prev) => !prev)}
>
{/* D1 — 수치는 아래 게이지가 전달하는 섹션 라벨 → dot 제거 */}
<Kicker dot={false}> </Kicker>
<span className="sx-panel-toggle__hint">
{metersCollapsed
? progress
? `라포 ${progress.rapport_percent}% · 펼치기`
: "펼치기"
: "접기"}
</span>
</button>
{metersCollapsed ? null : (
<>
<div className="sx-meter">
<div className="sx-meter__top">
<span className="sx-meter__label">() </span>
<span className="sx-meter__val is-clay">
{progress
? `${progress.resistance_percent}% · ${progress.resistance_percent > 55 ? "아직 높음" : "완화되는 중"}`
: meters.resistance > 0.55
? "아직 높음"
: "완화되는 중"}
</span>
</div>
<Gauge
value={progress ? progress.resistance_percent / 100 : 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-accent">
{progress
? `${progress.openness_percent}%`
: meters.anxiety > 0.5
? "중간"
: "낮아지는 중"}
</span>
</div>
<Gauge
value={progress ? progress.openness_percent / 100 : 1 - meters.anxiety}
tone="accent"
/>
</div>
<div className="sx-meter">
<div className="sx-meter__top">
<span className="sx-meter__label"> </span>
<span className="sx-meter__val is-accent">
{progress
? `${progress.rapport_percent}%` +
(progress.rapport_delta_percent > 0
? ` · 이번 회기 +${progress.rapport_delta_percent}%p`
: "")
: "조금씩 ↑"}
</span>
</div>
<Gauge
value={progress ? progress.rapport_percent / 100 : meters.rapport}
tone="accent"
/>
</div>
<div className="sx-meters__note">
.
.
</div>
</>
)}
</section>
<section
className={surfaceClassName(
"sx-panel sx-safety" +
(safety ? "" : " sx-safety--ok") +
(safetyCollapsed && !safety ? " is-collapsed" : ""),
)}
>
<button
type="button"
className="sx-safety__head sx-panel-toggle"
aria-expanded={!(safetyCollapsed && !safety)}
onClick={() => setSafetyCollapsed((prev) => !prev)}
>
<span className="sx-safety__ico">
<Icon name={safety ? "alert" : "shield"} size={17} />
</span>
<Kicker> </Kicker>
<span className="sx-safety__badge">{safetyStatusText}</span>
</button>
{safetyCollapsed && !safety ? null : (
<div className="sx-safety__rows">
<span>
<b> </b>
<small>{safety ?? "감지 없음"}</small>
</span>
<span>
<b> </b>
<small>{safety ? "즉시 확인" : "모니터링"}</small>
</span>
<span>
<b> </b>
<small>{crisisResource ? crisisResource.title : "대기"}</small>
</span>
</div>
)}
{crisisResource ? (
<span className="sx-crisis-resource">
<strong>{crisisResource.title}</strong>
<a href={`tel:${crisisResource.number}`}>{crisisResource.number}</a>
</span>
) : null}
</section>
</div>
</div>
{/* ── 하단 컨트롤 바 ── */}
<div className={surfaceClassName("sx-controlbar")}>
<div className={"sx-mic-block" + (voiceRecoveryAvailable ? " is-recovery" : "")}>
<button
type="button"
className={"sx-mic " + (micOn ? "is-on" : "is-off")}
onClick={toggleMic}
disabled={micDisabled || alliancePreGateBlocked}
aria-pressed={micOn}
aria-label={micButtonAriaLabel}
>
<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 className="sx-mic-block__shortcut" aria-label="마이크 단축키 Alt M">
<kbd>Alt</kbd><span aria-hidden="true">+</span><kbd>M</kbd>
</span>
<span className="sx-mic-block__disclosure" role="note">
<Icon name="spark" size={12} />
<span>{AI_VOICE_DISCLOSURE}</span>
</span>
</span>
{voiceStatus === "speaking" ? (
<button
type="button"
className="sx-voice-skip"
onClick={skipVoicePlayback}
aria-label="음성 건너뛰기"
>
<Icon name="x" size={14} />
<span></span>
</button>
) : null}
{voiceRecoveryAvailable ? (
<button
type="button"
className="sx-voice-retry"
onClick={requestVoiceCapture}
aria-describedby="sx-voice-recovery-detail"
>
<Icon name="refresh" size={14} />
<span> </span>
</button>
) : null}
{voiceRecoveryAvailable ? (
<span id="sx-voice-recovery-detail" className="sx-sr-only">
{voiceDetail}
</span>
) : null}
</div>
<p className="sx-controlbar__voice-disclosure" role="note">
<Icon name="spark" size={13} />
<span>{AI_VOICE_DISCLOSURE}</span>
</p>
<span className="sx-cb-sep" />
<div className="sx-seg-block">
<span className="sx-seg-block__label"> </span>
{coachNudgeVisible ? (
<button
type="button"
className="sx-coach-nudge"
onClick={() => setFeedbackMode("coached")}
>
<span className="sx-coach-nudge__dot" aria-hidden="true" />
<em> </em>
</button>
) : null}
<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")}
aria-label={`코칭 모드, 남은 기회 ${coachQuotaRemaining}`}
>
<span></span>
<span
className={
"sx-segmented__badge" +
(coachQuotaRemaining <= 0 ? " is-empty" : "") +
(coachNudgeVisible ? " is-pulsing" : "")
}
aria-hidden="true"
>
{coachQuotaRemaining}
</span>
</button>
</div>
</div>
<span className="sx-cb-spacer" />
<div className="sx-cb-actions">
{sessionEnded && liveSessionId ? (
<button
type="button"
className="sx-review-button"
onClick={() => navigate(practiceReviewPath(liveSessionId))}
>
<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>
{coachPersistenceSource === "runtime" ? (
<div className="sx-coach-history__source-alert" role="status">
.
</div>
) : null}
{coachCreditEvents.length > 0 ? (
<div className="sx-coach-credit-log" aria-label="코칭 기회 기록">
{coachCreditEvents.slice(-4).map((event) => (
<span
key={event.event_id}
className={event.event_type === "recharge" ? "is-recharge" : ""}
>
{event.event_type === "recharge" ? "+1 충전" : "-1 사용"} ·{" "}
{event.balance}/{coachQuotaMax}
</span>
))}
</div>
) : null}
</div>
<button
ref={coachHistoryCloseRef}
type="button"
onClick={() => setCoachHistoryOpen(false)}
aria-label="코칭 이력 닫기"
>
<Icon name="x" size={16} />
</button>
</div>
<div className="sx-coach-history__body">
{coachHistoryLoading ? (
<div className="sx-coach-history__empty">
.
</div>
) : coachHistoryError ? (
<div className="sx-coach-history__empty is-error" role="alert">
{coachHistoryError}
</div>
) : 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"
: "") +
(event.suggestion.status === "degraded" ? " is-degraded" : "")
}
>
<div className="sx-coach-history__item-top">
<span>{event.turn_seq} · {event.stage ?? "단계 미상"}</span>
{event.suggestion.status === "degraded" ? (
<b className="sx-coach-degraded-badge">AI </b>
) : null}
<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">
{coachIsDegraded
? coachDegradedNote
: "방금 코칭 판단에 사용한 근거와 다음 발화 제안입니다."}
</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}
{voiceConsentDialogOpen ? (
<div
className="sx-voice-consent"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) dismissVoiceConsent();
}}
>
<section
className="sx-voice-consent__panel"
role="dialog"
aria-modal="true"
aria-labelledby="sx-voice-consent-title"
aria-describedby="sx-voice-consent-desc sx-voice-consent-boundary"
aria-busy={voiceConsentSaving}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="sx-voice-consent__head">
<span className="sx-voice-consent__icon" aria-hidden="true">
<Icon name="mic" size={19} />
</span>
<div>
<h2 id="sx-voice-consent-title"> </h2>
<p id="sx-voice-consent-desc">
.
</p>
</div>
</div>
<div className="sx-voice-consent__body">
<dl className="sx-voice-consent__facts">
<div>
<dt> </dt>
<dd> AI .</dd>
</div>
<div>
<dt> </dt>
<dd> , , .</dd>
</div>
<div>
<dt> </dt>
<dd> . 30 .</dd>
</div>
<div>
<dt> </dt>
<dd> . .</dd>
</div>
</dl>
<p id="sx-voice-consent-boundary" className="sx-voice-consent__boundary">
. , .
</p>
{voiceConsentError ? (
<p className="sx-voice-consent__error" role="alert">
{voiceConsentError}
</p>
) : null}
</div>
<div className="sx-voice-consent__actions">
<button
ref={voiceConsentTextRef}
type="button"
className="sx-voice-consent__secondary"
onClick={dismissVoiceConsent}
disabled={voiceConsentSaving}
>
</button>
<button
ref={voiceConsentAcceptRef}
type="button"
className="sx-voice-consent__primary"
onClick={() => void acceptVoiceConsent()}
disabled={voiceConsentSaving}
>
{voiceConsentSaving ? "동의 기록 중…" : "동의하고 마이크 켜기"}
</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">
{timeUp ? "회기 시간이 끝났어요" : "회기를 종료할까요?"}
</h2>
<p id="sx-end-dialog-desc">
{timeUp
? "실제 상담처럼 시간이 회기를 마무리합니다. 마지막 인사를 나누고 싶다면 잠시 계속할 수 있어요. 종료하면 축어록을 저장하고 리뷰로 이동합니다."
: "종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동합니다."}
</p>
</div>
</div>
<div className="sx-end-dialog__actions">
<button
ref={endCancelRef}
type="button"
className="sx-end-dialog__secondary"
onClick={() => setEndDialogOpen(false)}
disabled={ending}
>
{timeUp ? "마무리 인사 나누기" : "계속 진행"}
</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>
);
}