4118 lines
165 KiB
TypeScript
4118 lines
165 KiB
TypeScript
/* =====================================================================
|
||
Session — 상담 세션 메인 화면 (/learn/session/:sessionId)
|
||
DESIGN_CONCEPT §5: 3-column 상담실 골격.
|
||
· 좌(296): 회기단계 세로 트랙커 + 내담자 컨텍스트 카드
|
||
· 중앙: 어두운 STAGE(bg-stage) — ClientAvatar + 음성 오브(4상태) + 실시간 자막(대본)
|
||
· 우(300): 라이브 신호(앰비언트 도트 1개, 6초 페이드) + 내담자 상태 미터 + 안전 점검
|
||
· 하단(80): 마이크 + 일시정지 + [몰입|은은|코칭] segmented + 종료 확인
|
||
|
||
실제 STT/TTS 는 voice 트랙 소관. 여기선 UI + 텍스트 입력 대체 경로로 1턴 왕복:
|
||
lib/api 의 sessionApi.stream(POST SSE)으로
|
||
내담자 응답을 받아 자막/오브/아바타 상태를 구동한다.
|
||
|
||
철칙: border-left·이모지·카드덤프·순흑백·과한 라운드 금지.
|
||
강조는 weight/tint/kicker/dot. 빨강은 종료에만.
|
||
===================================================================== */
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||
import { useNavigate, useParams, 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>
|
||
);
|
||
}
|