2786 lines
105 KiB
TypeScript
2786 lines
105 KiB
TypeScript
/* =====================================================================
|
|
Session — 상담 세션 메인 화면 (/learn/session/:sessionId)
|
|
DESIGN_CONCEPT §5: 3-column 상담실 골격.
|
|
· 좌(296): 회기단계 세로 트랙커 + 내담자 컨텍스트 카드
|
|
· 중앙: 어두운 STAGE(bg-stage) — ClientAvatar + 음성 오브(4상태) + 실시간 자막(대본)
|
|
· 우(300): 라이브 신호(앰비언트 도트 1개, 6초 페이드) + 내담자 상태 미터 + 안전 점검
|
|
· 하단(80): 마이크 + 일시정지 + [몰입|은은|코칭] segmented + 종료 확인
|
|
|
|
실제 STT/TTS 는 voice 트랙 소관. 여기선 UI + 텍스트 입력 대체 경로로 1턴 왕복:
|
|
lib/api 의 sessionApi.stream(POST SSE)으로
|
|
내담자 응답을 받아 자막/오브/아바타 상태를 구동한다.
|
|
|
|
철칙: border-left·이모지·카드덤프·순흑백·과한 라운드 금지.
|
|
강조는 weight/tint/kicker/dot. 빨강은 종료에만.
|
|
===================================================================== */
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { AppShell } from "../components/shell/AppShell";
|
|
import {
|
|
AVATAR_EXPRESSION_LIBRARY,
|
|
ClientAvatar,
|
|
expressionLabelFor,
|
|
} from "../components/avatar/ClientAvatar";
|
|
import type { AvatarState, AvatarAffect, AvatarPersona } from "../components/avatar/ClientAvatar";
|
|
import { Kicker, Button, Icon } from "../components/ui";
|
|
import {
|
|
ApiError,
|
|
apiWsUrl,
|
|
personaApi,
|
|
sessionApi,
|
|
type CrisisResource,
|
|
type LiveCoachEvent,
|
|
type LiveCoachSuggestion,
|
|
type PersonaSummary,
|
|
type SessionDetailResponse,
|
|
type SessionStage,
|
|
} from "../lib/api";
|
|
import { useAuth } from "../lib/auth";
|
|
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
|
|
import {
|
|
DIFFICULTY_LABEL,
|
|
demographicText,
|
|
isUsablePersona,
|
|
personaAgeBand,
|
|
personaMeta,
|
|
shortPersonaName,
|
|
unavailablePersonaMessage,
|
|
} from "../lib/personaViewModel";
|
|
import "./session/session.css";
|
|
|
|
/* ── 도메인 상수/타입 ───────────────────────────────────────────────── */
|
|
|
|
type FeedbackMode = "immersive" | "ambient" | "coached";
|
|
type TheoryMode = "humanistic" | "cbt" | "integrative";
|
|
type Speaker = "client" | "learner";
|
|
type SignalTone = "pos" | "warn" | "neutral";
|
|
type VoiceStatus =
|
|
| "idle"
|
|
| "requesting"
|
|
| "connecting"
|
|
| "recording"
|
|
| "thinking"
|
|
| "speaking"
|
|
| "degraded"
|
|
| "error";
|
|
|
|
type VoiceServerState = "idle" | "listening" | "thinking" | "speaking";
|
|
|
|
interface VoiceEvent {
|
|
type?: string;
|
|
state?: VoiceServerState;
|
|
text?: string;
|
|
final?: boolean;
|
|
reason?: string;
|
|
detail?: string;
|
|
stage?: SessionStage;
|
|
effective_openness?: number;
|
|
turn_seq?: number;
|
|
safety_flagged?: boolean;
|
|
crisis_resource?: CrisisResource | null;
|
|
conversation_stopped?: boolean;
|
|
}
|
|
|
|
type AudioContextWindow = Window & { webkitAudioContext?: typeof AudioContext };
|
|
|
|
interface Utterance {
|
|
id: number;
|
|
speaker: Speaker;
|
|
text: string;
|
|
turnSeq?: number;
|
|
/** 발화 시작 시점(세션 경과 초) */
|
|
at: number;
|
|
/** 진행 중(스트리밍/타이핑) 발화 여부 */
|
|
partial?: boolean;
|
|
}
|
|
|
|
interface PhaseInfo {
|
|
key: SessionStage;
|
|
desc: string;
|
|
}
|
|
|
|
interface ClientContext {
|
|
initial: string;
|
|
name: string;
|
|
meta: string;
|
|
rows: { l: string; v: string }[];
|
|
chips: { t: string; clay: boolean }[];
|
|
}
|
|
|
|
const SESSION_PHASES: PhaseInfo[] = [
|
|
{ key: "라포", desc: "첫 인사와 안전감 형성" },
|
|
{ key: "탐색", desc: "호소 문제와 일상을 함께 이해" },
|
|
{ key: "개입", desc: "감정과 생각을 다루는 기법 적용" },
|
|
{ key: "정리", desc: "오늘의 대화 정리와 다음 약속" },
|
|
];
|
|
|
|
const THEORY_MODE_OPTIONS: { value: TheoryMode; label: string; detail: string }[] = [
|
|
{ value: "humanistic", label: "인간중심", detail: "공감·반영" },
|
|
{ value: "cbt", label: "CBT", detail: "생각·행동" },
|
|
{ value: "integrative", label: "통합", detail: "혼합 접근" },
|
|
];
|
|
|
|
function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
|
|
const firstSupported = summary?.theory_target.find(
|
|
(target): target is TheoryMode =>
|
|
target === "humanistic" || target === "cbt" || target === "integrative",
|
|
);
|
|
return firstSupported ?? "humanistic";
|
|
}
|
|
|
|
const PERSONA_AVATAR_LOOKS = {
|
|
P1: {
|
|
skinTone: "#F0DDC4",
|
|
hair: { style: "long-straight", color: "#5A4030" },
|
|
outfitColor: "#7C8A92",
|
|
eyeColor: "#7A5A3C",
|
|
accentColor: "#6B5E7D",
|
|
rasterArtSet: "seoyeon-live2d-psd-v2",
|
|
expressionBias: "sad",
|
|
},
|
|
P4: {
|
|
skinTone: "#E5C0A0",
|
|
hair: { style: "long-straight", color: "#35261F" },
|
|
outfitColor: "#5F6F88",
|
|
eyeColor: "#2F3438",
|
|
accentColor: "#3B6E8F",
|
|
rasterArtSet: "p4-live2d-generated",
|
|
expressionBias: "anxious",
|
|
},
|
|
P5: {
|
|
skinTone: "#D7AD8D",
|
|
hair: { style: "messy", color: "#28231F" },
|
|
outfitColor: "#6F766F",
|
|
eyeColor: "#24292A",
|
|
accentColor: "#7D8784",
|
|
rasterArtSet: "p5-live2d-generated",
|
|
expressionBias: "guarded",
|
|
},
|
|
P6: {
|
|
skinTone: "#E6BE9A",
|
|
hair: { style: "soft-wave", color: "#2D241F" },
|
|
outfitColor: "#776F85",
|
|
eyeColor: "#2E2B35",
|
|
accentColor: "#6B5E7D",
|
|
rasterArtSet: "p6-live2d-generated",
|
|
expressionBias: "conflicted",
|
|
},
|
|
P7: {
|
|
skinTone: "#D5AA88",
|
|
hair: { style: "side-part", color: "#23211E" },
|
|
outfitColor: "#596367",
|
|
eyeColor: "#25292B",
|
|
accentColor: "#6F7777",
|
|
rasterArtSet: "p7-live2d-generated",
|
|
expressionBias: "tired",
|
|
},
|
|
} satisfies Record<string, Partial<AvatarPersona>>;
|
|
|
|
let _uid = 100;
|
|
const nextId = () => ++_uid;
|
|
|
|
function normalizePersonaCode(raw?: string): string {
|
|
return (raw ?? "").trim().toUpperCase();
|
|
}
|
|
|
|
function looksLikeSessionId(raw?: string): boolean {
|
|
const value = (raw ?? "").trim();
|
|
return (
|
|
/^[0-9a-f]{32}$/i.test(value) ||
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
);
|
|
}
|
|
|
|
function secondsBetween(startedAt: string, createdAt: string): number {
|
|
const start = Date.parse(startedAt);
|
|
const created = Date.parse(createdAt);
|
|
if (!Number.isFinite(start) || !Number.isFinite(created)) return 0;
|
|
return Math.max(0, Math.round((created - start) / 1000));
|
|
}
|
|
|
|
function elapsedFromSession(detail: SessionDetailResponse): number {
|
|
const start = Date.parse(detail.started_at);
|
|
const end = detail.ended_at ? Date.parse(detail.ended_at) : Date.now();
|
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
|
|
return Math.max(0, Math.round((end - start) / 1000));
|
|
}
|
|
|
|
function buildPersonaUi(
|
|
summary: PersonaSummary | null,
|
|
code: string,
|
|
): { avatar: AvatarPersona; context: ClientContext } {
|
|
if (!summary) {
|
|
return {
|
|
avatar: {
|
|
label: "내담자 정보 확인 중",
|
|
ageBand: "adult",
|
|
skinTone: "#D7CCC3",
|
|
hair: { style: "short", color: "#7A7068" },
|
|
realism: 0.3,
|
|
},
|
|
context: {
|
|
initial: "?",
|
|
name: "내담자 확인 중",
|
|
meta: code ? `${code} · DB 카탈로그 확인 중` : "DB 카탈로그 확인 중",
|
|
rows: [
|
|
{ l: "상태", v: "실제 연습 대상 정보를 확인하고 있습니다." },
|
|
],
|
|
chips: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
const name = shortPersonaName(summary.display_name);
|
|
const codeKey = summary.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS;
|
|
const look = PERSONA_AVATAR_LOOKS[codeKey] ?? {};
|
|
const sex = demographicText(summary.demographics.sex);
|
|
const isMale = sex === "male" || sex === "남성";
|
|
const ageBand = personaAgeBand(summary);
|
|
const difficulty = DIFFICULTY_LABEL[summary.difficulty] ?? summary.difficulty;
|
|
const theories = summary.theory_target.length ? summary.theory_target.join(", ") : "공통";
|
|
const chips = [
|
|
{ t: summary.code, clay: false },
|
|
{ t: difficulty, clay: summary.difficulty === "hard" },
|
|
...summary.theory_target.slice(0, 2).map((target) => ({ t: target, clay: false })),
|
|
];
|
|
|
|
return {
|
|
avatar: {
|
|
code: summary.code,
|
|
label: `${name} · ${personaMeta(summary)}`,
|
|
ageBand,
|
|
skinTone: look.skinTone ?? (isMale ? "#D8B18F" : "#E3BE9B"),
|
|
hair: look.hair ?? {
|
|
style: ageBand === "teen" ? "long-straight" : isMale ? "short" : "bob",
|
|
color: isMale ? "#25221F" : "#31251E",
|
|
},
|
|
outfitColor: look.outfitColor ?? (isMale ? "#5E676A" : "#6A6874"),
|
|
eyeColor: look.eyeColor ?? "#2B2B2B",
|
|
accentColor: look.accentColor,
|
|
rasterArtSet: (look as Partial<AvatarPersona>).rasterArtSet,
|
|
realism: 0.4,
|
|
resistance: summary.difficulty === "hard" ? 0.68 : summary.difficulty === "moderate" ? 0.45 : 0.3,
|
|
expressionBias: look.expressionBias,
|
|
},
|
|
context: {
|
|
initial: name.slice(0, 1) || summary.code.slice(0, 1),
|
|
name,
|
|
meta: personaMeta(summary),
|
|
rows: [
|
|
{ l: "호소", v: summary.presenting_summary || "요약 정보 없음" },
|
|
{ l: "대상", v: theories },
|
|
{ l: "난도", v: difficulty },
|
|
],
|
|
chips,
|
|
},
|
|
};
|
|
}
|
|
|
|
function recorderMimeType(): string {
|
|
if (typeof MediaRecorder === "undefined") return "";
|
|
const supported = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"];
|
|
return supported.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? "";
|
|
}
|
|
|
|
function voiceFormatFromMime(mime: string): string {
|
|
if (mime.includes("ogg")) return "ogg";
|
|
if (mime.includes("mp4")) return "mp4";
|
|
return "webm";
|
|
}
|
|
|
|
/* ── 작은 표현 유틸 ─────────────────────────────────────────────────── */
|
|
|
|
// 백엔드가 반환한 effective_openness(0~1)를 실시간 관찰 신호로만 변환한다.
|
|
// 점수/정밀 평가는 회기 종료 후 리뷰 화면에서 다룬다.
|
|
function metersFromOpenness(openness: number) {
|
|
const o = clamp01(openness);
|
|
return {
|
|
rapport: o, // 마음 열림(라포)
|
|
resistance: clamp01(1 - o * 0.9), // 저항감(반비례)
|
|
anxiety: clamp01(0.7 - o * 0.4), // 불안·위축(완만히 감소)
|
|
};
|
|
}
|
|
|
|
function baselineExpressionFor(summary: PersonaSummary | null): AvatarAffect {
|
|
if (!summary) return "neutral";
|
|
const codeKey = summary.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS;
|
|
return PERSONA_AVATAR_LOOKS[codeKey]?.expressionBias ?? "neutral";
|
|
}
|
|
|
|
function expressionForSession({
|
|
state,
|
|
openness,
|
|
paused,
|
|
safety,
|
|
summary,
|
|
stage,
|
|
}: {
|
|
state: AvatarState;
|
|
openness: number;
|
|
paused: boolean;
|
|
safety: string | null;
|
|
summary: PersonaSummary | null;
|
|
stage: SessionStage;
|
|
}): AvatarAffect {
|
|
const o = clamp01(openness);
|
|
const code = summary?.code.toUpperCase() ?? "";
|
|
const baseline = baselineExpressionFor(summary);
|
|
|
|
if (safety) return "startled";
|
|
if (paused) return "tired";
|
|
|
|
if (state === "thinking") {
|
|
if (code === "P4" && o < 0.38) return "anxious";
|
|
if (code === "P6") return o < 0.55 ? "conflicted" : "determined";
|
|
if (code === "P7") return o < 0.45 ? "overwhelmed" : "tired";
|
|
if (o < 0.32) return "guarded";
|
|
return "confused";
|
|
}
|
|
|
|
if (state === "listening") {
|
|
if (o < 0.28) return code === "P4" ? "panic" : "guarded";
|
|
if (o < 0.48) return baseline === "tired" ? "bored" : baseline;
|
|
if (o > 0.72) return "warm";
|
|
return "calm";
|
|
}
|
|
|
|
if (state === "speaking") {
|
|
if (o < 0.22) return code === "P7" ? "bored" : "resistant";
|
|
if (o < 0.38) return code === "P5" ? "skeptical" : baseline;
|
|
if (stage === "정리") return "relief";
|
|
if (o > 0.78) return "hopeful";
|
|
if (o > 0.62) return "warm";
|
|
return "neutral";
|
|
}
|
|
|
|
if (o < 0.3) return baseline;
|
|
if (o > 0.76) return "relief";
|
|
return "neutral";
|
|
}
|
|
|
|
/* ===================================================================== */
|
|
|
|
export default function Session() {
|
|
const { sessionId: routeId } = useParams<{ sessionId: string }>();
|
|
const navigate = useNavigate();
|
|
const { user, acceptConsent } = useAuth();
|
|
|
|
const routeParam = routeId ?? "";
|
|
const routeIsSessionId = useMemo(() => looksLikeSessionId(routeParam), [routeParam]);
|
|
const [personaCode, setPersonaCode] = useState(() =>
|
|
looksLikeSessionId(routeId) ? "" : normalizePersonaCode(routeId),
|
|
);
|
|
const [personaSummary, setPersonaSummary] = useState<PersonaSummary | null>(null);
|
|
const [personaLoadState, setPersonaLoadState] = useState<"loading" | "ready" | "missing" | "error">(
|
|
"loading",
|
|
);
|
|
const personaUi = useMemo(
|
|
() => buildPersonaUi(personaSummary, personaCode),
|
|
[personaSummary, personaCode],
|
|
);
|
|
const clientName = personaUi.context.name;
|
|
const primaryContext = personaUi.context.rows[0] ?? null;
|
|
|
|
// ── 세션 진행 상태 ──
|
|
const [started, setStarted] = useState(false);
|
|
// start 가 백엔드에서 발급한 진짜 세션 id (turn/stream 은 이걸 써야 함. URL :sessionId 는 라우팅용)
|
|
const [liveSessionId, setLiveSessionId] = useState<string | null>(null);
|
|
const [sessionEnded, setSessionEnded] = useState(false);
|
|
const [reviewReady, setReviewReady] = useState(false);
|
|
const [starting, setStarting] = useState(false);
|
|
const [startError, setStartError] = useState<string | null>(null);
|
|
const [consentChecked, setConsentChecked] = useState(false);
|
|
const [consentBusy, setConsentBusy] = useState(false);
|
|
const [selectedTheoryMode, setSelectedTheoryMode] = useState<TheoryMode>("humanistic");
|
|
|
|
// ── 회기/대화 상태 ──
|
|
const [stage, setStage] = useState<SessionStage>("라포");
|
|
const [utterances, setUtterances] = useState<Utterance[]>([]);
|
|
const [openness, setOpenness] = useState(0);
|
|
const [safety, setSafety] = useState<string | null>(null);
|
|
const [crisisResource, setCrisisResource] = useState<CrisisResource | null>(null);
|
|
const [turnError, setTurnError] = useState<string | null>(null);
|
|
|
|
// ── 음성/턴 UI 상태 ──
|
|
const [avatarState, setAvatarState] = useState<AvatarState>("idle");
|
|
const [micOn, setMicOn] = useState(false);
|
|
const [paused, setPaused] = useState(false);
|
|
const [feedbackMode, setFeedbackMode] = useState<FeedbackMode>("ambient");
|
|
const [composeText, setComposeText] = useState("");
|
|
const [sending, setSending] = useState(false);
|
|
const [clientReplyPending, setClientReplyPending] = useState(false);
|
|
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>("idle");
|
|
const [voiceDetail, setVoiceDetail] = useState(
|
|
"마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.",
|
|
);
|
|
// 음성 캐스케이드 가용성: null=미확인, true=가능, false=provider 키 미설정(버튼 사전 비활성).
|
|
const [voiceAvailable, setVoiceAvailable] = useState<boolean | null>(null);
|
|
const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false);
|
|
const [voiceAnalyser, setVoiceAnalyser] = useState<AnalyserNode | null>(null);
|
|
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
|
const [ending, setEnding] = useState(false);
|
|
const endCancelRef = useRef<HTMLButtonElement>(null);
|
|
const endConfirmRef = useRef<HTMLButtonElement>(null);
|
|
const endPreviousFocusRef = useRef<HTMLElement | null>(null);
|
|
|
|
// ── 라이브 신호(앰비언트 도트) ──
|
|
const [liveSignal, setLiveSignal] = useState<{ tone: SignalTone; text: string } | null>(null);
|
|
const [signalFaded, setSignalFaded] = useState(false);
|
|
const [signalSeq, setSignalSeq] = useState<SignalTone[]>([]);
|
|
const [coachSuggestion, setCoachSuggestion] = useState<LiveCoachSuggestion | null>(null);
|
|
const [coachLoading, setCoachLoading] = useState(false);
|
|
const [coachError, setCoachError] = useState<string | null>(null);
|
|
const [coachEvidenceOpen, setCoachEvidenceOpen] = useState(false);
|
|
const [coachHistory, setCoachHistory] = useState<LiveCoachEvent[]>([]);
|
|
const [coachHistoryOpen, setCoachHistoryOpen] = useState(false);
|
|
const [coachHistoryTurnSeq, setCoachHistoryTurnSeq] = useState<number | null>(null);
|
|
|
|
// ── 경과 타이머 ──
|
|
const [elapsed, setElapsed] = useState(0);
|
|
|
|
// ── 자동 스크롤 ──
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
|
|
const fadeTimerRef = useRef<number | null>(null);
|
|
const voiceSocketRef = useRef<WebSocket | null>(null);
|
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
const micStreamRef = useRef<MediaStream | null>(null);
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
const ttsChunksRef = useRef<BlobPart[]>([]);
|
|
const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null);
|
|
const pendingVoiceLearnerIdRef = useRef<number | null>(null);
|
|
const pendingVoiceLearnerTextRef = useRef<string>("");
|
|
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
|
|
const coachHistoryCloseRef = useRef<HTMLButtonElement>(null);
|
|
|
|
const ensureVoiceAudioContext = useCallback(() => {
|
|
const existing = audioContextRef.current;
|
|
if (existing && existing.state !== "closed") return existing;
|
|
const AudioContextCtor =
|
|
window.AudioContext ?? (window as AudioContextWindow).webkitAudioContext;
|
|
if (!AudioContextCtor) return null;
|
|
const ctx = new AudioContextCtor();
|
|
audioContextRef.current = ctx;
|
|
return ctx;
|
|
}, []);
|
|
|
|
const primeVoicePlayback = useCallback(async () => {
|
|
const ctx = ensureVoiceAudioContext();
|
|
if (!ctx || ctx.state === "closed") return;
|
|
try {
|
|
await ctx.resume();
|
|
} catch {
|
|
/* 브라우저가 아직 unlock을 거부하면 실제 재생 시 fallback한다. */
|
|
}
|
|
}, [ensureVoiceAudioContext]);
|
|
|
|
const stopTtsPlayback = useCallback(() => {
|
|
const cleanup = ttsPlaybackCleanupRef.current;
|
|
ttsPlaybackCleanupRef.current = null;
|
|
if (cleanup) cleanup();
|
|
setVoiceAnalyser(null);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (routeIsSessionId) return;
|
|
if (!routeParam.trim()) {
|
|
navigate("/learn", { replace: true });
|
|
return;
|
|
}
|
|
setPersonaCode(normalizePersonaCode(routeParam));
|
|
setLiveSessionId(null);
|
|
setSessionEnded(false);
|
|
setReviewReady(false);
|
|
setStarted(false);
|
|
setUtterances([]);
|
|
setElapsed(0);
|
|
setSafety(null);
|
|
setCrisisResource(null);
|
|
setResumedSessionLoaded(false);
|
|
setClientReplyPending(false);
|
|
setStartError(null);
|
|
setCoachSuggestion(null);
|
|
setCoachError(null);
|
|
setCoachEvidenceOpen(false);
|
|
setCoachHistory([]);
|
|
setCoachHistoryOpen(false);
|
|
setCoachHistoryTurnSeq(null);
|
|
}, [navigate, routeIsSessionId, routeParam]);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
if (routeIsSessionId && !personaCode) {
|
|
setPersonaLoadState("loading");
|
|
setPersonaSummary(null);
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}
|
|
setPersonaLoadState("loading");
|
|
setPersonaSummary(null);
|
|
|
|
(async () => {
|
|
try {
|
|
const personas = await personaApi.list();
|
|
if (!alive) return;
|
|
const matched =
|
|
personas.find((persona) => persona.code.toUpperCase() === personaCode) ?? null;
|
|
setPersonaSummary(matched);
|
|
setPersonaLoadState(matched ? "ready" : "missing");
|
|
} catch {
|
|
if (!alive) return;
|
|
setPersonaSummary(null);
|
|
setPersonaLoadState("error");
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [personaCode]);
|
|
|
|
useEffect(() => {
|
|
if (started) return;
|
|
setSelectedTheoryMode(preferredTheoryMode(personaSummary));
|
|
}, [personaSummary, started]);
|
|
|
|
// 현재 단계 인덱스
|
|
const stageIdx = useMemo(() => SESSION_PHASES.findIndex((p) => p.key === stage), [stage]);
|
|
const currentPhase = SESSION_PHASES[Math.max(0, stageIdx)] ?? SESSION_PHASES[0];
|
|
|
|
// ── 경과 타이머 진행(일시정지 시 멈춤) ──
|
|
useEffect(() => {
|
|
if (!started || paused) return;
|
|
const t = window.setInterval(() => setElapsed((s) => s + 1), 1000);
|
|
return () => window.clearInterval(t);
|
|
}, [started, paused]);
|
|
|
|
// ── 자막 자동 스크롤(아래로) ──
|
|
useEffect(() => {
|
|
if (!autoScroll) return;
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
}, [utterances, clientReplyPending, autoScroll]);
|
|
|
|
// ── 라이브 신호 6초 페이드(§5.5) ──
|
|
useEffect(() => {
|
|
if (!liveSignal) return;
|
|
setSignalFaded(false);
|
|
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current);
|
|
fadeTimerRef.current = window.setTimeout(() => setSignalFaded(true), 6000);
|
|
return () => {
|
|
if (fadeTimerRef.current) window.clearTimeout(fadeTimerRef.current);
|
|
};
|
|
}, [liveSignal]);
|
|
|
|
// 사용자가 위로 스크롤하면 자동스크롤 해제
|
|
const onScroll = useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
|
setAutoScroll(atBottom);
|
|
}, []);
|
|
|
|
const jumpToLatest = () => {
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
setAutoScroll(true);
|
|
};
|
|
|
|
// 라이브 신호 push + 시퀀스 누적 (최근 5개 유지)
|
|
const pushSignal = useCallback((tone: SignalTone, text: string) => {
|
|
setLiveSignal({ tone, text });
|
|
setSignalSeq((seq) => [...seq.slice(-4), tone]);
|
|
}, []);
|
|
|
|
const applyCrisisGate = useCallback((resource?: CrisisResource | null) => {
|
|
const fallback: CrisisResource = {
|
|
title: "자살예방상담전화 109",
|
|
number: "109",
|
|
message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.",
|
|
};
|
|
const next = resource ?? fallback;
|
|
setCrisisResource(next);
|
|
setSafety(next.message);
|
|
setPaused(true);
|
|
setMicOn(false);
|
|
setClientReplyPending(false);
|
|
setVoiceStatus("idle");
|
|
setVoiceDetail("위기 신호가 감지되어 연습을 중단했습니다.");
|
|
pushSignal("warn", "위기 안전게이트 작동");
|
|
}, [pushSignal]);
|
|
|
|
const refreshCoachHistory = useCallback(
|
|
async (sessionId = liveSessionId) => {
|
|
if (!sessionId) {
|
|
setCoachHistory([]);
|
|
return;
|
|
}
|
|
try {
|
|
const history = await sessionApi.liveCoachHistory(sessionId);
|
|
setCoachHistory(history.events ?? []);
|
|
} catch {
|
|
/* 코칭 이력 조회 실패는 회기 진행을 막지 않는다. */
|
|
}
|
|
},
|
|
[liveSessionId],
|
|
);
|
|
|
|
const openCoachHistory = useCallback(
|
|
(turnSeq: number | null = null) => {
|
|
setCoachHistoryTurnSeq(turnSeq);
|
|
setCoachEvidenceOpen(false);
|
|
setCoachHistoryOpen(true);
|
|
void refreshCoachHistory();
|
|
},
|
|
[refreshCoachHistory],
|
|
);
|
|
|
|
const requestLiveCoach = useCallback(
|
|
async ({
|
|
learnerText,
|
|
clientReply,
|
|
turnSeq,
|
|
}: {
|
|
learnerText: string;
|
|
clientReply?: string | null;
|
|
turnSeq?: number;
|
|
}) => {
|
|
if (!liveSessionId || feedbackMode !== "coached") return;
|
|
setCoachLoading(true);
|
|
setCoachError(null);
|
|
try {
|
|
const suggestion = await sessionApi.liveCoach(liveSessionId, {
|
|
learner_text: learnerText,
|
|
client_reply: clientReply ?? null,
|
|
turn_seq: turnSeq,
|
|
});
|
|
setCoachSuggestion(suggestion);
|
|
pushSignal(suggestion.tone, suggestion.title);
|
|
void refreshCoachHistory();
|
|
} catch {
|
|
setCoachError("코칭 근거를 불러오지 못했습니다. 다음 턴에서 다시 시도합니다.");
|
|
pushSignal("warn", "코칭 연결 실패");
|
|
} finally {
|
|
setCoachLoading(false);
|
|
}
|
|
},
|
|
[feedbackMode, liveSessionId, pushSignal, refreshCoachHistory],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!started || !liveSessionId) return;
|
|
void refreshCoachHistory(liveSessionId);
|
|
}, [liveSessionId, refreshCoachHistory, started]);
|
|
|
|
useEffect(() => {
|
|
if (!routeIsSessionId) return;
|
|
let alive = true;
|
|
setStarting(true);
|
|
setStartError(null);
|
|
setResumedSessionLoaded(false);
|
|
|
|
(async () => {
|
|
try {
|
|
const detail = await sessionApi.get(routeParam);
|
|
if (!alive) return;
|
|
const ended = detail.status === "ended";
|
|
setPersonaCode(detail.persona_code);
|
|
setLiveSessionId(detail.session_id);
|
|
setSessionEnded(ended);
|
|
setReviewReady(detail.review_ready);
|
|
setStage(detail.stage);
|
|
setOpenness(detail.effective_openness);
|
|
setUtterances(
|
|
(detail.turns ?? []).map((turn) => ({
|
|
id: nextId(),
|
|
speaker: turn.speaker === "client" ? "client" : "learner",
|
|
text: turn.text,
|
|
turnSeq: turn.turn_seq,
|
|
at: secondsBetween(detail.started_at, turn.created_at),
|
|
})),
|
|
);
|
|
setElapsed(elapsedFromSession(detail));
|
|
setStarted(true);
|
|
setPaused(ended);
|
|
setSafety(null);
|
|
setCrisisResource(null);
|
|
setAvatarState("idle");
|
|
setMicOn(false);
|
|
setVoiceStatus(ended ? "degraded" : "idle");
|
|
setVoiceDetail(
|
|
ended
|
|
? "종료된 회기입니다. 대화 기록은 이 화면에 남기고 리뷰로 이동할 수 있습니다."
|
|
: "이전 회기 기록을 불러왔습니다. 이어서 말하거나 회기를 종료할 수 있습니다.",
|
|
);
|
|
setResumedSessionLoaded(true);
|
|
setCoachSuggestion(null);
|
|
setCoachError(null);
|
|
setCoachEvidenceOpen(false);
|
|
setCoachHistoryOpen(false);
|
|
setCoachHistoryTurnSeq(null);
|
|
setClientReplyPending(false);
|
|
pushSignal("neutral", ended ? "종료된 회기 기록" : "기존 회기 이어하기");
|
|
} catch {
|
|
if (!alive) return;
|
|
setStarted(false);
|
|
setSessionEnded(false);
|
|
setReviewReady(false);
|
|
setResumedSessionLoaded(false);
|
|
setStartError("기존 회기를 불러오지 못했습니다. 학습자 홈에서 다시 선택해 주세요.");
|
|
} finally {
|
|
if (alive) setStarting(false);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [navigate, pushSignal, routeIsSessionId, routeParam]);
|
|
|
|
/* ── 세션 시작 ───────────────────────────────────────────────────── */
|
|
const handleStart = useCallback(async () => {
|
|
if (!personaSummary) {
|
|
setStartError("페르소나 정보를 확인한 뒤 회기를 시작할 수 있습니다.");
|
|
return;
|
|
}
|
|
setStarting(true);
|
|
setStartError(null);
|
|
setResumedSessionLoaded(false);
|
|
setClientReplyPending(false);
|
|
try {
|
|
const res = await sessionApi.start(personaCode, selectedTheoryMode);
|
|
setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브
|
|
setSessionEnded(false);
|
|
setReviewReady(false);
|
|
setStage(res.stage);
|
|
setOpenness(res.effective_openness);
|
|
setUtterances([]);
|
|
setSafety(null);
|
|
setCrisisResource(null);
|
|
setElapsed(0);
|
|
setCoachSuggestion(null);
|
|
setCoachError(null);
|
|
setCoachEvidenceOpen(false);
|
|
setCoachHistory([]);
|
|
setCoachHistoryOpen(false);
|
|
setCoachHistoryTurnSeq(null);
|
|
if (res.degraded) {
|
|
pushSignal("neutral", "서버 기록 제한");
|
|
}
|
|
setStarted(true);
|
|
setAvatarState("idle");
|
|
setMicOn(false);
|
|
setVoiceStatus("idle");
|
|
setVoiceDetail("마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.");
|
|
navigate(`/learn/session/${res.session_id}`, { replace: true });
|
|
// 음성 가용성 사전 확인 — provider 키 미설정이면 마이크 버튼을 비활성화하고 명확히 안내한다.
|
|
// (클릭→권한요청→마이크 깜빡→WS degraded 종료의 혼란스러운 "바로 꺼짐"을 방지.)
|
|
void sessionApi.voiceHealth().then(({ available }) => {
|
|
setVoiceAvailable(available);
|
|
if (!available) {
|
|
setVoiceStatus("degraded");
|
|
setVoiceDetail(
|
|
"음성 기능이 설정되지 않았습니다(STT/TTS provider 키 필요). 아래 텍스트 입력으로 회기를 진행하세요.",
|
|
);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.detail === "consent_required") {
|
|
setStartError("개인정보 및 연습 기록 처리 동의 후 회기를 시작할 수 있습니다.");
|
|
} else {
|
|
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
|
|
}
|
|
} finally {
|
|
setStarting(false);
|
|
}
|
|
}, [navigate, personaCode, personaSummary, pushSignal, selectedTheoryMode]);
|
|
|
|
const handleAcceptConsent = useCallback(async () => {
|
|
if (!consentChecked) {
|
|
setStartError("동의 항목을 확인해야 회기를 시작할 수 있습니다.");
|
|
return;
|
|
}
|
|
setConsentBusy(true);
|
|
setStartError(null);
|
|
try {
|
|
await acceptConsent();
|
|
pushSignal("pos", "동의 확인");
|
|
} catch {
|
|
setStartError("동의 상태를 저장하지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
|
|
} finally {
|
|
setConsentBusy(false);
|
|
}
|
|
}, [acceptConsent, consentChecked, pushSignal]);
|
|
|
|
const appendServerClientReply = useCallback(
|
|
(replyText: string | null, at = elapsed) => {
|
|
setClientReplyPending(false);
|
|
if (!replyText) {
|
|
setAvatarState("listening");
|
|
pushSignal("warn", "내담자 응답 없음");
|
|
return;
|
|
}
|
|
setUtterances((prev) => [
|
|
...prev,
|
|
{ id: nextId(), speaker: "client", text: replyText, at },
|
|
]);
|
|
setAvatarState("speaking");
|
|
pushSignal("neutral", "내담자 응답 수신");
|
|
},
|
|
[elapsed, pushSignal],
|
|
);
|
|
|
|
/* ── 학습자 발화 전송(텍스트 입력 폴백 = 음성 1턴 왕복 대역) ──────── */
|
|
const handleSend = useCallback(async () => {
|
|
const text = composeText.trim();
|
|
if (!text || sending || paused || voiceStatus === "thinking") return;
|
|
if (sessionEnded) {
|
|
setTurnError("종료된 회기에서는 새 발화를 보낼 수 없습니다. 리뷰에서 기록을 확인해 주세요.");
|
|
return;
|
|
}
|
|
if (!liveSessionId) {
|
|
setTurnError("회기가 아직 열리지 않았습니다. 회기를 다시 시작해 주세요.");
|
|
pushSignal("warn", "회기 연결 없음");
|
|
return;
|
|
}
|
|
|
|
const at = elapsed;
|
|
const learnerId = nextId();
|
|
setSending(true);
|
|
setComposeText("");
|
|
setUtterances((prev) => [
|
|
...prev,
|
|
{ id: learnerId, speaker: "learner", text, at },
|
|
]);
|
|
setClientReplyPending(true);
|
|
setAvatarState("thinking");
|
|
setTurnError(null);
|
|
|
|
let clientId: number | null = null;
|
|
let clientReply = "";
|
|
try {
|
|
const done = await sessionApi.stream(liveSessionId, text, {
|
|
onToken: (chunk) => {
|
|
if (!chunk) return;
|
|
clientReply += chunk;
|
|
setClientReplyPending(false);
|
|
setAvatarState("speaking");
|
|
if (clientId == null) {
|
|
clientId = nextId();
|
|
const id = clientId;
|
|
setUtterances((prev) => [
|
|
...prev,
|
|
{ id, speaker: "client", text: clientReply, at, partial: true },
|
|
]);
|
|
return;
|
|
}
|
|
const id = clientId;
|
|
setUtterances((prev) =>
|
|
prev.map((u) => (u.id === id ? { ...u, text: clientReply } : u)),
|
|
);
|
|
},
|
|
onDone: (data) => {
|
|
if (data.stage) setStage(data.stage);
|
|
if (typeof data.effective_openness === "number") {
|
|
setOpenness(data.effective_openness);
|
|
}
|
|
if (data.safety_flagged) {
|
|
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
|
|
}
|
|
if (data.conversation_stopped || data.crisis_resource) {
|
|
applyCrisisGate(data.crisis_resource);
|
|
}
|
|
},
|
|
onSafety: (payload) => {
|
|
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
|
|
if (payload && typeof payload === "object") {
|
|
const resource = (payload as { crisis_resource?: CrisisResource }).crisis_resource;
|
|
const stopped = (payload as { conversation_stopped?: boolean }).conversation_stopped;
|
|
if (resource || stopped) applyCrisisGate(resource);
|
|
}
|
|
},
|
|
});
|
|
|
|
if (clientId != null) {
|
|
const id = clientId;
|
|
setUtterances((prev) => {
|
|
const existingIndex = prev.findIndex((u) => u.id === id);
|
|
if (existingIndex === -1) {
|
|
return [
|
|
...prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
|
|
{ id, speaker: "client", text: clientReply, at },
|
|
];
|
|
}
|
|
return prev.map((u) =>
|
|
u.id === learnerId
|
|
? { ...u, turnSeq: done.turn_seq }
|
|
: u.id === id
|
|
? { ...u, partial: false }
|
|
: u,
|
|
);
|
|
});
|
|
pushSignal("neutral", "내담자 응답 수신");
|
|
} else {
|
|
setClientReplyPending(false);
|
|
setUtterances((prev) =>
|
|
prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
|
|
);
|
|
pushSignal("warn", "내담자 응답 없음");
|
|
}
|
|
setAvatarState("listening");
|
|
void requestLiveCoach({
|
|
learnerText: text,
|
|
clientReply,
|
|
turnSeq: done.turn_seq,
|
|
});
|
|
} catch (err) {
|
|
setComposeText(text);
|
|
setClientReplyPending(false);
|
|
if (clientId != null) {
|
|
const id = clientId;
|
|
setUtterances((prev) => prev.filter((u) => u.id !== id && u.id !== learnerId));
|
|
} else {
|
|
setUtterances((prev) => prev.filter((u) => u.id !== learnerId));
|
|
}
|
|
const detail =
|
|
err instanceof ApiError
|
|
? err.detail
|
|
: err instanceof Error
|
|
? err.message
|
|
: "AI 엔진 연결에 실패했습니다.";
|
|
const normalized = detail.includes("Not logged in")
|
|
? "AI 엔진 로그인이 필요합니다. 관리자에게 엔진 상태 확인을 요청하세요."
|
|
: detail.includes("engine unavailable")
|
|
? "AI 엔진이 응답하지 않습니다. 잠시 뒤 다시 시도하거나 관리자에게 알려 주세요."
|
|
: "내담자 응답을 생성하지 못했습니다. 잠시 뒤 다시 시도해 주세요.";
|
|
setTurnError(normalized);
|
|
pushSignal("warn", "AI 엔진 연결 실패");
|
|
setAvatarState("listening");
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}, [
|
|
composeText,
|
|
sending,
|
|
paused,
|
|
voiceStatus,
|
|
sessionEnded,
|
|
liveSessionId,
|
|
elapsed,
|
|
pushSignal,
|
|
requestLiveCoach,
|
|
]);
|
|
|
|
const onComposeKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void handleSend();
|
|
}
|
|
};
|
|
|
|
const stopMicStream = useCallback(() => {
|
|
micStreamRef.current?.getTracks().forEach((track) => track.stop());
|
|
micStreamRef.current = null;
|
|
}, []);
|
|
|
|
const closeVoiceSocket = useCallback(() => {
|
|
const ws = voiceSocketRef.current;
|
|
voiceSocketRef.current = null;
|
|
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) return;
|
|
try {
|
|
ws.onclose = null;
|
|
ws.onerror = null;
|
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "close" }));
|
|
ws.close();
|
|
} catch {
|
|
/* 이미 닫힌 소켓은 무시 */
|
|
}
|
|
}, []);
|
|
|
|
const shutdownVoice = useCallback(
|
|
(status: VoiceStatus = "idle", detail = "마이크 꺼짐") => {
|
|
stopTtsPlayback();
|
|
const recorder = recorderRef.current;
|
|
recorderRef.current = null;
|
|
if (recorder && recorder.state !== "inactive") {
|
|
try {
|
|
recorder.stop();
|
|
} catch {
|
|
/* recorder stop race 무시 */
|
|
}
|
|
}
|
|
stopMicStream();
|
|
closeVoiceSocket();
|
|
setMicOn(false);
|
|
setVoiceStatus(status);
|
|
setVoiceDetail(detail);
|
|
setVoiceAnalyser(null);
|
|
},
|
|
[closeVoiceSocket, stopMicStream, stopTtsPlayback],
|
|
);
|
|
|
|
const finishVoiceUtterance = useCallback(() => {
|
|
const recorder = recorderRef.current;
|
|
if (recorder && recorder.state !== "inactive") {
|
|
try {
|
|
recorder.stop();
|
|
} catch {
|
|
stopMicStream();
|
|
}
|
|
} else {
|
|
stopMicStream();
|
|
}
|
|
setMicOn(false);
|
|
setAvatarState("thinking");
|
|
setVoiceStatus("thinking");
|
|
setVoiceDetail("녹음을 마쳤습니다. 전사 중입니다.");
|
|
}, [stopMicStream]);
|
|
|
|
const playTtsAudio = useCallback(async () => {
|
|
const chunks = ttsChunksRef.current.splice(0);
|
|
if (!chunks.length) return;
|
|
|
|
const blob = new Blob(chunks, { type: "audio/mpeg" });
|
|
stopTtsPlayback();
|
|
|
|
const finishPlayback = () => {
|
|
ttsPlaybackCleanupRef.current = null;
|
|
setVoiceAnalyser(null);
|
|
setAvatarState("listening");
|
|
setVoiceStatus("idle");
|
|
setVoiceDetail("응답이 끝났습니다. 마이크를 다시 켜 발화하세요.");
|
|
closeVoiceSocket();
|
|
};
|
|
|
|
const failPlayback = (detail: string, status: VoiceStatus = "error") => {
|
|
ttsPlaybackCleanupRef.current = null;
|
|
setVoiceAnalyser(null);
|
|
setAvatarState("listening");
|
|
setVoiceStatus(status);
|
|
setVoiceDetail(detail);
|
|
closeVoiceSocket();
|
|
};
|
|
|
|
const ctx = ensureVoiceAudioContext();
|
|
if (ctx && ctx.state !== "closed") {
|
|
try {
|
|
if (ctx.state === "suspended") await ctx.resume();
|
|
const arrayBuffer = await blob.arrayBuffer();
|
|
const decoded = await ctx.decodeAudioData(arrayBuffer.slice(0));
|
|
const analyser = ctx.createAnalyser();
|
|
analyser.fftSize = 1024;
|
|
const source = ctx.createBufferSource();
|
|
source.buffer = decoded;
|
|
source.connect(analyser);
|
|
analyser.connect(ctx.destination);
|
|
|
|
let completed = false;
|
|
const cleanup = () => {
|
|
if (completed) return;
|
|
completed = true;
|
|
source.onended = null;
|
|
try {
|
|
source.stop();
|
|
} catch {
|
|
/* 이미 종료된 source는 무시 */
|
|
}
|
|
try {
|
|
source.disconnect();
|
|
analyser.disconnect();
|
|
} catch {
|
|
/* disconnect race 무시 */
|
|
}
|
|
};
|
|
ttsPlaybackCleanupRef.current = cleanup;
|
|
|
|
source.onended = () => {
|
|
if (completed) return;
|
|
completed = true;
|
|
try {
|
|
source.disconnect();
|
|
analyser.disconnect();
|
|
} catch {
|
|
/* disconnect race 무시 */
|
|
}
|
|
finishPlayback();
|
|
};
|
|
|
|
setVoiceAnalyser(analyser);
|
|
setAvatarState("speaking");
|
|
setVoiceStatus("speaking");
|
|
setVoiceDetail(`${clientName} 음성을 재생 중입니다.`);
|
|
source.start();
|
|
return;
|
|
} catch {
|
|
setVoiceAnalyser(null);
|
|
}
|
|
}
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
const audio = new Audio(url);
|
|
let cleaned = false;
|
|
const cleanupElement = () => {
|
|
if (cleaned) return;
|
|
cleaned = true;
|
|
audio.pause();
|
|
audio.removeAttribute("src");
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
ttsPlaybackCleanupRef.current = cleanupElement;
|
|
|
|
setAvatarState("speaking");
|
|
setVoiceStatus("speaking");
|
|
setVoiceDetail(`${clientName} 음성을 재생 중입니다.`);
|
|
|
|
audio.onended = () => {
|
|
cleanupElement();
|
|
finishPlayback();
|
|
};
|
|
audio.onerror = () => {
|
|
cleanupElement();
|
|
failPlayback("음성 재생에 실패했습니다. 자막 응답은 화면에 남겼습니다.");
|
|
};
|
|
|
|
try {
|
|
await audio.play();
|
|
} catch {
|
|
cleanupElement();
|
|
failPlayback("브라우저가 자동 재생을 막았습니다. 자막 응답은 화면에 남겼습니다.", "degraded");
|
|
}
|
|
}, [clientName, closeVoiceSocket, ensureVoiceAudioContext, stopTtsPlayback]);
|
|
|
|
const startVoiceCapture = useCallback(async () => {
|
|
if (!liveSessionId || paused || sending || sessionEnded) return;
|
|
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
|
|
setVoiceStatus("error");
|
|
setVoiceDetail("이 브라우저는 마이크 녹음을 지원하지 않습니다.");
|
|
pushSignal("warn", "마이크 미지원");
|
|
return;
|
|
}
|
|
|
|
shutdownVoice("requesting", "Chrome 마이크 권한을 요청합니다.");
|
|
await primeVoicePlayback();
|
|
|
|
let stream: MediaStream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
});
|
|
} catch {
|
|
setVoiceStatus("error");
|
|
setVoiceDetail("마이크 권한이 거부됐습니다. Chrome 주소창 왼쪽 권한에서 마이크를 허용해 주세요.");
|
|
pushSignal("warn", "마이크 권한 필요");
|
|
return;
|
|
}
|
|
|
|
micStreamRef.current = stream;
|
|
setVoiceStatus("connecting");
|
|
setVoiceDetail("음성 연결을 준비하는 중입니다.");
|
|
|
|
const mimeType = recorderMimeType();
|
|
const format = voiceFormatFromMime(mimeType);
|
|
const ws = new WebSocket(
|
|
apiWsUrl(`/voice/ws?session_id=${encodeURIComponent(liveSessionId)}`),
|
|
);
|
|
ws.binaryType = "arraybuffer";
|
|
voiceSocketRef.current = ws;
|
|
ttsChunksRef.current = [];
|
|
|
|
let recorder: MediaRecorder;
|
|
try {
|
|
recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
|
|
} catch {
|
|
stopMicStream();
|
|
closeVoiceSocket();
|
|
setVoiceStatus("error");
|
|
setVoiceDetail("마이크 녹음기를 만들지 못했습니다. 브라우저 오디오 설정을 확인해 주세요.");
|
|
return;
|
|
}
|
|
recorderRef.current = recorder;
|
|
setClientReplyPending(false);
|
|
|
|
recorder.ondataavailable = (event) => {
|
|
if (!event.data.size || ws.readyState !== WebSocket.OPEN) return;
|
|
ws.send(event.data);
|
|
};
|
|
recorder.onstop = () => {
|
|
stopMicStream();
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "audio_end", format }));
|
|
}
|
|
};
|
|
|
|
ws.onopen = () => {
|
|
ws.send(JSON.stringify({ type: "audio_start", format }));
|
|
recorder.start(500);
|
|
setMicOn(true);
|
|
setAvatarState("listening");
|
|
setVoiceStatus("recording");
|
|
setVoiceDetail("녹음 중입니다. 다시 누르면 발화를 보냅니다.");
|
|
pushSignal("neutral", "마이크 연결됨");
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
if (typeof event.data !== "string") {
|
|
ttsChunksRef.current.push(event.data as BlobPart);
|
|
return;
|
|
}
|
|
|
|
let payload: VoiceEvent;
|
|
try {
|
|
payload = JSON.parse(event.data) as VoiceEvent;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "ready") {
|
|
setVoiceDetail("음성 연결됨. 말한 뒤 버튼을 다시 눌러 보내세요.");
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "state") {
|
|
if (payload.state === "thinking") {
|
|
setMicOn(false);
|
|
setAvatarState("thinking");
|
|
setVoiceStatus("thinking");
|
|
setVoiceDetail("전사와 응답 생성을 기다리는 중입니다.");
|
|
} else if (payload.state === "speaking") {
|
|
setAvatarState("speaking");
|
|
setVoiceStatus("speaking");
|
|
setVoiceDetail(`${clientName} 음성을 받는 중입니다.`);
|
|
} else if (payload.state === "idle" && recorderRef.current?.state !== "recording") {
|
|
setAvatarState("listening");
|
|
setVoiceStatus("idle");
|
|
setVoiceDetail("마이크를 다시 켜 발화하세요.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "transcript" && payload.final && payload.text) {
|
|
const id = nextId();
|
|
pendingVoiceLearnerIdRef.current = id;
|
|
pendingVoiceLearnerTextRef.current = payload.text;
|
|
setClientReplyPending(true);
|
|
setUtterances((prev) => [
|
|
...prev,
|
|
{ id, speaker: "learner", text: payload.text ?? "", at: elapsed, partial: true },
|
|
]);
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "reply") {
|
|
setClientReplyPending(false);
|
|
if (payload.stage) setStage(payload.stage);
|
|
if (typeof payload.effective_openness === "number") {
|
|
setOpenness(payload.effective_openness);
|
|
}
|
|
if (payload.safety_flagged) {
|
|
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
|
|
}
|
|
if (payload.conversation_stopped || payload.crisis_resource) {
|
|
applyCrisisGate(payload.crisis_resource);
|
|
}
|
|
const pendingId = pendingVoiceLearnerIdRef.current;
|
|
if (pendingId != null) {
|
|
setUtterances((prev) =>
|
|
prev.map((u) =>
|
|
u.id === pendingId ? { ...u, partial: false, turnSeq: payload.turn_seq } : u,
|
|
),
|
|
);
|
|
pendingVoiceLearnerIdRef.current = null;
|
|
}
|
|
appendServerClientReply(payload.text ?? null);
|
|
const learnerText = pendingVoiceLearnerTextRef.current;
|
|
pendingVoiceLearnerTextRef.current = "";
|
|
if (learnerText) {
|
|
void requestLiveCoach({
|
|
learnerText,
|
|
clientReply: payload.text ?? null,
|
|
turnSeq: payload.turn_seq,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "tts_end") {
|
|
void playTtsAudio();
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "degraded") {
|
|
setClientReplyPending(false);
|
|
const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다.";
|
|
const reason =
|
|
payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason;
|
|
shutdownVoice("degraded", reason);
|
|
pushSignal("warn", "음성 기능 미설정");
|
|
return;
|
|
}
|
|
|
|
if (payload.type === "error") {
|
|
setClientReplyPending(false);
|
|
if (pendingVoiceLearnerIdRef.current != null && payload.detail?.includes("engine unavailable")) {
|
|
const pendingId = pendingVoiceLearnerIdRef.current;
|
|
setUtterances((prev) => prev.filter((u) => u.id !== pendingId));
|
|
pendingVoiceLearnerIdRef.current = null;
|
|
pendingVoiceLearnerTextRef.current = "";
|
|
}
|
|
shutdownVoice("error", payload.detail || "음성 처리 중 오류가 발생했습니다.");
|
|
pushSignal("warn", "음성 오류");
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setClientReplyPending(false);
|
|
shutdownVoice("error", "음성 연결을 확인하지 못했습니다.");
|
|
pushSignal("warn", "음성 연결 실패");
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
recorderRef.current = null;
|
|
stopMicStream();
|
|
setMicOn(false);
|
|
setVoiceStatus((current) => (current === "degraded" || current === "error" ? current : "idle"));
|
|
setVoiceDetail((current) => {
|
|
if (
|
|
current.includes("비활성화") ||
|
|
current.includes("오류") ||
|
|
current.includes("권한")
|
|
) {
|
|
return current;
|
|
}
|
|
return "마이크 꺼짐";
|
|
});
|
|
};
|
|
}, [
|
|
clientName,
|
|
appendServerClientReply,
|
|
closeVoiceSocket,
|
|
elapsed,
|
|
liveSessionId,
|
|
paused,
|
|
sessionEnded,
|
|
playTtsAudio,
|
|
primeVoicePlayback,
|
|
pushSignal,
|
|
requestLiveCoach,
|
|
sending,
|
|
shutdownVoice,
|
|
stopMicStream,
|
|
]);
|
|
|
|
const skipVoicePlayback = useCallback(() => {
|
|
ttsChunksRef.current = [];
|
|
stopTtsPlayback();
|
|
closeVoiceSocket();
|
|
setVoiceAnalyser(null);
|
|
setAvatarState("listening");
|
|
setVoiceStatus("idle");
|
|
setVoiceDetail("음성을 건너뛰었습니다. 다음 발화를 입력하거나 마이크를 켜세요.");
|
|
pushSignal("neutral", "음성 건너뜀");
|
|
}, [closeVoiceSocket, pushSignal, stopTtsPlayback]);
|
|
|
|
const toggleMic = useCallback(() => {
|
|
if (paused || sending || sessionEnded) return;
|
|
if (voiceAvailable === false) {
|
|
pushSignal("warn", "음성 미설정 — 텍스트로 진행");
|
|
return;
|
|
}
|
|
if (
|
|
voiceStatus === "requesting" ||
|
|
voiceStatus === "connecting" ||
|
|
voiceStatus === "thinking" ||
|
|
voiceStatus === "speaking"
|
|
) {
|
|
return;
|
|
}
|
|
if (micOn || voiceStatus === "recording") {
|
|
finishVoiceUtterance();
|
|
} else {
|
|
void startVoiceCapture();
|
|
}
|
|
}, [finishVoiceUtterance, micOn, paused, sending, sessionEnded, startVoiceCapture, voiceAvailable, voiceStatus, pushSignal]);
|
|
|
|
const togglePause = useCallback(() => {
|
|
if (sessionEnded) return;
|
|
setPaused((p) => {
|
|
const next = !p;
|
|
if (next) {
|
|
if (voiceStatus !== "idle") setClientReplyPending(false);
|
|
shutdownVoice("idle", "일시정지 중입니다.");
|
|
setAvatarState("idle");
|
|
} else {
|
|
setAvatarState("listening");
|
|
setVoiceDetail("마이크를 켜면 Chrome 권한 요청 후 음성으로 회기를 진행합니다.");
|
|
}
|
|
return next;
|
|
});
|
|
}, [sessionEnded, shutdownVoice, voiceStatus]);
|
|
|
|
// ── 키보드 단축키 (§5.6: Space=마이크, P=일시정지). 입력 중엔 무시 ──
|
|
useEffect(() => {
|
|
if (!started) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const tag = (e.target as HTMLElement | null)?.tagName;
|
|
const typing = tag === "TEXTAREA" || tag === "INPUT";
|
|
if (typing) return;
|
|
if (e.code === "Space") {
|
|
e.preventDefault();
|
|
void toggleMic();
|
|
} else if (e.key === "p" || e.key === "P") {
|
|
togglePause();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [started, toggleMic, togglePause]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
shutdownVoice();
|
|
audioContextRef.current?.close().catch(() => undefined);
|
|
};
|
|
}, [shutdownVoice]);
|
|
|
|
useEffect(() => {
|
|
if (!coachEvidenceOpen) return;
|
|
window.setTimeout(() => coachEvidenceCloseRef.current?.focus(), 0);
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
setCoachEvidenceOpen(false);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [coachEvidenceOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!coachHistoryOpen) return;
|
|
window.setTimeout(() => coachHistoryCloseRef.current?.focus(), 0);
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
setCoachHistoryOpen(false);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [coachHistoryOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!endDialogOpen) return;
|
|
endPreviousFocusRef.current =
|
|
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
window.setTimeout(() => endCancelRef.current?.focus(), 0);
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape" && !ending) {
|
|
setEndDialogOpen(false);
|
|
return;
|
|
}
|
|
if (event.key === "Tab") {
|
|
const controls = [endCancelRef.current, endConfirmRef.current].filter(
|
|
(control): control is HTMLButtonElement => control !== null && !control.disabled,
|
|
);
|
|
if (controls.length === 0) return;
|
|
const first = controls[0];
|
|
const last = controls[controls.length - 1];
|
|
if (event.shiftKey && document.activeElement === first) {
|
|
event.preventDefault();
|
|
last.focus();
|
|
} else if (!event.shiftKey && document.activeElement === last) {
|
|
event.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [endDialogOpen, ending]);
|
|
|
|
useEffect(() => {
|
|
if (endDialogOpen || ending) return;
|
|
endPreviousFocusRef.current?.focus();
|
|
endPreviousFocusRef.current = null;
|
|
}, [endDialogOpen, ending]);
|
|
|
|
// 회기 종료 — 명시 확인 후 end 호출, 현재 화면을 정지 상태로 둔다.
|
|
const handleEnd = useCallback(async () => {
|
|
if (ending || sessionEnded) return;
|
|
setEnding(true);
|
|
try {
|
|
if (liveSessionId) await sessionApi.end(liveSessionId);
|
|
} catch {
|
|
/* 종료 실패는 UI 종료 처리를 막지 않는다. */
|
|
}
|
|
setEndDialogOpen(false);
|
|
setSessionEnded(true);
|
|
setReviewReady(true);
|
|
setMicOn(false);
|
|
setPaused(true);
|
|
setAvatarState("idle");
|
|
pushSignal("neutral", "회기 종료");
|
|
if (liveSessionId) {
|
|
navigate(`/learn/session/${liveSessionId}/review`, { replace: true });
|
|
} else {
|
|
setEnding(false);
|
|
}
|
|
}, [ending, liveSessionId, navigate, pushSignal, sessionEnded]);
|
|
|
|
const meters = metersFromOpenness(openness);
|
|
|
|
const avatarAffect: AvatarAffect = useMemo(
|
|
() =>
|
|
expressionForSession({
|
|
state: avatarState,
|
|
openness,
|
|
paused,
|
|
safety,
|
|
summary: personaSummary,
|
|
stage,
|
|
}),
|
|
[avatarState, openness, paused, personaSummary, safety, stage],
|
|
);
|
|
const avatarExpressionLabel = expressionLabelFor(avatarAffect);
|
|
const avatarExpressionCount = AVATAR_EXPRESSION_LIBRARY.length;
|
|
|
|
// 음성 오브 상태(아바타 state → 오브 data-orb)
|
|
const orbState = paused
|
|
? "thinking"
|
|
: avatarState === "speaking"
|
|
? "client"
|
|
: avatarState === "listening"
|
|
? "learner"
|
|
: avatarState === "thinking"
|
|
? "thinking"
|
|
: "idle";
|
|
|
|
const micBusy =
|
|
voiceStatus === "requesting" ||
|
|
voiceStatus === "connecting" ||
|
|
voiceStatus === "thinking" ||
|
|
voiceStatus === "speaking";
|
|
const micLabel = sessionEnded
|
|
? "종료됨"
|
|
: paused
|
|
? "일시정지"
|
|
: voiceStatus === "requesting"
|
|
? "권한 요청 중"
|
|
: voiceStatus === "connecting"
|
|
? "연결 중"
|
|
: voiceStatus === "recording"
|
|
? "녹음 중"
|
|
: voiceStatus === "thinking"
|
|
? "전사 중"
|
|
: voiceStatus === "speaking"
|
|
? "재생 중"
|
|
: voiceStatus === "degraded"
|
|
? "음성 미설정"
|
|
: voiceStatus === "error"
|
|
? "마이크 오류"
|
|
: "마이크 꺼짐";
|
|
const sessionStatusLabel = sessionEnded
|
|
? "회기 종료됨"
|
|
: paused
|
|
? "일시정지"
|
|
: voiceStatus === "recording"
|
|
? "학습자 발화 수신 중"
|
|
: voiceStatus === "thinking"
|
|
? "내담자 응답 준비 중"
|
|
: voiceStatus === "speaking"
|
|
? `${clientName} 응답 중`
|
|
: "회기 진행 중";
|
|
const transcriptLiveLabel =
|
|
voiceStatus === "recording"
|
|
? "받아쓰는 중"
|
|
: voiceStatus === "thinking"
|
|
? "전사 중"
|
|
: voiceStatus === "speaking"
|
|
? "응답 표시 중"
|
|
: sending
|
|
? "응답 대기"
|
|
: utterances.length > 0
|
|
? "기록 중"
|
|
: "준비됨";
|
|
const transcriptIsLive =
|
|
voiceStatus === "recording" ||
|
|
voiceStatus === "thinking" ||
|
|
voiceStatus === "speaking" ||
|
|
sending;
|
|
const textTurnBlocked = sending || voiceStatus === "thinking";
|
|
const elapsedLabel = formatElapsed(elapsed);
|
|
const recommendedSessionSeconds = 30 * 60;
|
|
const remainingLabel = formatElapsed(Math.max(0, recommendedSessionSeconds - elapsed));
|
|
const turnCount = utterances.filter((utterance) => !utterance.partial).length;
|
|
const latestClientUtterance = [...utterances]
|
|
.reverse()
|
|
.find((utterance) => utterance.speaker === "client" && utterance.text.trim());
|
|
const stageClientLine =
|
|
latestClientUtterance?.text ??
|
|
primaryContext?.v ??
|
|
`${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`;
|
|
const voiceInputStatus =
|
|
voiceAvailable === false
|
|
? "음성 미설정"
|
|
: micOn
|
|
? "수신 중"
|
|
: voiceStatus === "requesting"
|
|
? "권한 요청"
|
|
: "대기";
|
|
const responseStatus =
|
|
sending || clientReplyPending
|
|
? "응답 대기"
|
|
: voiceStatus === "thinking"
|
|
? "전사 중"
|
|
: voiceStatus === "speaking"
|
|
? "재생 중"
|
|
: "안정";
|
|
const safetyStatusText = safety ? "확인 필요" : "안전";
|
|
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
|
|
const consentRequired = user?.role === "learner" && user.consentAt == null;
|
|
const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired;
|
|
const prestartTitle = canStartSession
|
|
? `${clientName}님과의 회기를 시작할까요?`
|
|
: consentRequired
|
|
? "동의 확인 후 회기를 시작할 수 있습니다."
|
|
: personaLoadState === "ready" && personaSummary
|
|
? "이 내담자는 현재 연습에 사용할 수 없습니다."
|
|
: "연습 대상 정보를 확인하고 있습니다.";
|
|
const personaStatusMessage =
|
|
personaLoadState === "loading"
|
|
? "페르소나 정보를 불러오는 중입니다."
|
|
: personaLoadState === "missing"
|
|
? `${personaCode} 페르소나는 현재 연습 목록에 없습니다. 학습자 홈에서 사용 가능한 페르소나를 선택해 주세요.`
|
|
: personaLoadState === "error"
|
|
? "연습 목록을 불러오지 못했습니다. 잠시 뒤 다시 시도해 주세요."
|
|
: personaSummary && !personaIsUsable
|
|
? unavailablePersonaMessage(personaSummary)
|
|
: null;
|
|
const coachSources = coachSuggestion?.sources ?? [];
|
|
const coachTone = coachSuggestion?.tone ?? "neutral";
|
|
const coachStatusText = coachLoading
|
|
? "코치가 근거를 확인 중"
|
|
: coachSuggestion?.status === "degraded"
|
|
? "규칙 기반 코칭"
|
|
: coachSuggestion
|
|
? "근거 확인 완료"
|
|
: "턴 완료 후 개입";
|
|
const coachHistoryByTurn = useMemo(() => {
|
|
const grouped = new Map<number, LiveCoachEvent[]>();
|
|
for (const event of coachHistory) {
|
|
const turnSeq = event.turn_seq;
|
|
grouped.set(turnSeq, [...(grouped.get(turnSeq) ?? []), event]);
|
|
}
|
|
return grouped;
|
|
}, [coachHistory]);
|
|
const activeCoachEvents = useMemo(() => {
|
|
if (coachHistoryTurnSeq == null) return coachHistory;
|
|
return coachHistoryByTurn.get(coachHistoryTurnSeq) ?? [];
|
|
}, [coachHistory, coachHistoryByTurn, coachHistoryTurnSeq]);
|
|
const coachHistoryTitle =
|
|
coachHistoryTurnSeq == null ? "라이브 코칭 이력" : `${coachHistoryTurnSeq}번 턴 코칭`;
|
|
|
|
// stage 상단 상태 라벨 텍스트
|
|
const stageStateText: Record<AvatarState, string> = {
|
|
idle: "잠시 시선을 내리고 말을 고르는 중",
|
|
listening: "당신의 말을 듣고 있어요",
|
|
thinking: "잠시 생각하는 중",
|
|
speaking: `${personaSummary ? clientName : "내담자"}이 이야기하는 중`,
|
|
};
|
|
|
|
return (
|
|
<AppShell
|
|
hideNav={started}
|
|
contextLabel="상담 세션"
|
|
navRole="learner"
|
|
bleed={started}
|
|
hideTopbar={started}
|
|
>
|
|
<div
|
|
className={
|
|
"sx-page " +
|
|
(started ? "sx-page--active" : "sx-page--prestart") +
|
|
(started ? ` sx-feedback-${feedbackMode}` : "")
|
|
}
|
|
data-avatar-expressions={avatarExpressionCount}
|
|
>
|
|
{/* ── 페이지 헤드라인 + 가로 단계 미니 ── */}
|
|
<header className="sx-head">
|
|
<div className="sx-head__lt">
|
|
<div className="sx-head__kicker">
|
|
<Kicker>상담 연습 · {personaCode}</Kicker>
|
|
</div>
|
|
<h1 className="sx-head__title">
|
|
지금은 <em>{stage} 단계</em>입니다.
|
|
</h1>
|
|
<div className="sx-head__sub">
|
|
{clientName}의 말에 귀를 기울이세요. 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 봅니다.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sx-phases" aria-label="회기 진행 단계">
|
|
{SESSION_PHASES.map((p, i) => {
|
|
const state = i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : "";
|
|
return (
|
|
<div key={p.key} style={{ display: "flex", alignItems: "center" }}>
|
|
{i > 0 ? (
|
|
<span className={"sx-ph__link" + (i <= stageIdx ? " is-fill" : "")} />
|
|
) : null}
|
|
<div className={"sx-ph " + state}>
|
|
<span className="sx-ph__dot" />
|
|
<span className="sx-ph__meta">
|
|
<span className="sx-ph__name">{p.key}</span>
|
|
<span className="sx-ph__t">
|
|
{i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</header>
|
|
|
|
{started ? (
|
|
<nav className="sx-sessionbar" aria-label="세션 이동">
|
|
<button
|
|
type="button"
|
|
className="sx-sessionbar__back"
|
|
onClick={() => navigate("/learn/history")}
|
|
>
|
|
<Icon name="chevron-left" size={16} />
|
|
기록으로
|
|
</button>
|
|
<div className="sx-sessionbar__meta">
|
|
<span className={"sx-sessionbar__dot" + (sessionEnded ? " is-ended" : "")} />
|
|
<span>
|
|
<b>{sessionStatusLabel}</b>
|
|
<small>
|
|
{clientName} · {stage} 단계 · {elapsedLabel}
|
|
</small>
|
|
</span>
|
|
</div>
|
|
<div className="sx-sessionbar__actions">
|
|
<button type="button" onClick={() => navigate("/learn")}>
|
|
<Icon name="home" size={15} />
|
|
학습 홈
|
|
</button>
|
|
{sessionEnded && liveSessionId ? (
|
|
<button
|
|
type="button"
|
|
className="sx-sessionbar__review"
|
|
onClick={() => navigate(`/learn/session/${liveSessionId}/review`)}
|
|
>
|
|
<Icon name="review" size={15} />
|
|
{reviewReady ? "리뷰 보기" : "리뷰 대기"}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</nav>
|
|
) : null}
|
|
|
|
{!started ? (
|
|
/* ── 시작 전: 준비 화면(한 화면 한 의도 = 세션 시작) ── */
|
|
<div className="sx-prestart">
|
|
<div className="sx-prestart__visual">
|
|
<ClientAvatar
|
|
persona={personaUi.avatar}
|
|
state="idle"
|
|
affect={avatarAffect}
|
|
rapport={meters.rapport}
|
|
size={168}
|
|
animated={false}
|
|
showCaption={false}
|
|
showMeta={false}
|
|
/>
|
|
<div className="sx-prestart__case" aria-label="내담자 요약">
|
|
<span className="sx-prestart__case-initial" aria-hidden="true">
|
|
{personaUi.context.initial}
|
|
</span>
|
|
<div>
|
|
<b>{personaUi.context.name}</b>
|
|
<p>{personaUi.context.meta}</p>
|
|
</div>
|
|
<ul>
|
|
{personaUi.context.rows.slice(0, 3).map((row) => (
|
|
<li key={row.l}>
|
|
<span>{row.l}</span>
|
|
<em>{row.v}</em>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sx-prestart__main">
|
|
<span className="sx-prestart__eyebrow">
|
|
<span aria-hidden="true" />
|
|
{currentPhase.key} 단계 준비
|
|
</span>
|
|
<h2 className="sx-prestart__title">{prestartTitle}</h2>
|
|
<p className="sx-prestart__desc">
|
|
짧은 첫 인사로 안전감을 만들고, 정밀 평가는 회기가 끝난 뒤 리뷰에서 함께
|
|
확인합니다.
|
|
</p>
|
|
<dl className="sx-prestart__facts">
|
|
{personaUi.context.rows.map((row) => (
|
|
<div key={row.l}>
|
|
<dt>{row.l}</dt>
|
|
<dd>{row.v}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
<div className="sx-prestart__chips" aria-label="내담자 태그">
|
|
{personaUi.context.chips.map((chip) => (
|
|
<span className={chip.clay ? "is-clay" : ""} key={chip.t}>
|
|
{chip.t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<fieldset className="sx-theory" aria-label="이론모드 선택">
|
|
<legend>이론모드</legend>
|
|
<div className="sx-theory__seg">
|
|
{THEORY_MODE_OPTIONS.map((option) => (
|
|
<button
|
|
type="button"
|
|
key={option.value}
|
|
className={option.value === selectedTheoryMode ? "is-selected" : ""}
|
|
aria-pressed={option.value === selectedTheoryMode}
|
|
onClick={() => setSelectedTheoryMode(option.value)}
|
|
>
|
|
<span>{option.label}</span>
|
|
<small>{option.detail}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
{personaStatusMessage ? (
|
|
<p className="sx-prestart__note">{personaStatusMessage}</p>
|
|
) : null}
|
|
{startError ? <p className="sx-prestart__err">{startError}</p> : null}
|
|
{consentRequired ? (
|
|
<div className="sx-consent">
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={consentChecked}
|
|
onChange={(event) => setConsentChecked(event.currentTarget.checked)}
|
|
/>
|
|
<span>
|
|
상담 연습 기록 저장, 개인정보 마스킹 후 평가 AI 처리, 회기 리뷰 생성을
|
|
확인합니다.
|
|
</span>
|
|
</label>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleAcceptConsent}
|
|
disabled={!consentChecked || consentBusy}
|
|
leading={<Icon name="check" size={15} />}
|
|
>
|
|
{consentBusy ? "저장 중" : "동의 저장"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
<div className="sx-prestart__actions">
|
|
<Button
|
|
size="lg"
|
|
onClick={handleStart}
|
|
disabled={starting || !canStartSession}
|
|
leading={<Icon name="play" size={16} />}
|
|
>
|
|
{starting ? "여는 중…" : "회기 시작"}
|
|
</Button>
|
|
<span>실시간에는 대화 흐름만 낮은 강도로 표시됩니다.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sx-prestart__plan" aria-label="시작 전 초점">
|
|
<Kicker>진행 초점</Kicker>
|
|
<ol>
|
|
<li>
|
|
<span aria-hidden="true" />
|
|
<p>{currentPhase.desc}</p>
|
|
</li>
|
|
<li>
|
|
<span aria-hidden="true" />
|
|
<p>질문은 짧게 두고 표현을 먼저 반영합니다.</p>
|
|
</li>
|
|
<li>
|
|
<span aria-hidden="true" />
|
|
<p>위험 신호가 나오면 안전 확인을 우선합니다.</p>
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* ── 3-region 그리드 ── */}
|
|
<div className="sx-grid">
|
|
<section className="sx-mobile-context" aria-label="현재 회기 요약">
|
|
<div className="sx-mobile-context__row">
|
|
<span>
|
|
<b>{sessionStatusLabel}</b>
|
|
<small>{elapsedLabel}</small>
|
|
</span>
|
|
<span>
|
|
<b>{clientName}</b>
|
|
<small>내담자</small>
|
|
</span>
|
|
<span>
|
|
<b>{micLabel}</b>
|
|
<small>마이크</small>
|
|
</span>
|
|
</div>
|
|
<div className="sx-mobile-context__brief">
|
|
<span>
|
|
<b>{currentPhase.desc}</b>
|
|
<small>{stage}</small>
|
|
</span>
|
|
{primaryContext ? (
|
|
<span>
|
|
<b>{primaryContext.v}</b>
|
|
<small>{primaryContext.l}</small>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="sx-mobile-context__row sx-mobile-context__row--meters">
|
|
<span>
|
|
<b>{meters.resistance > 0.55 ? "방어 신호" : "완화 신호"}</b>
|
|
<small>관찰 신호</small>
|
|
</span>
|
|
<span>
|
|
<b>{meters.anxiety > 0.5 ? "위축 신호" : "안정 신호"}</b>
|
|
<small>관찰 신호</small>
|
|
</span>
|
|
<span>
|
|
<b>{safety ? "확인 필요" : "안정"}</b>
|
|
<small>안전 점검</small>
|
|
</span>
|
|
</div>
|
|
<div className="sx-mobile-context__row sx-mobile-context__row--live">
|
|
<span className="sx-mobile-context__signal">
|
|
<b aria-label={liveSignal?.text ?? stage}>
|
|
<i
|
|
className={
|
|
"sx-mobile-context__dot" +
|
|
(liveSignal?.tone === "pos"
|
|
? " is-pos"
|
|
: liveSignal?.tone === "warn"
|
|
? " is-warn"
|
|
: "")
|
|
}
|
|
aria-hidden="true"
|
|
/>
|
|
{feedbackMode === "coached" ? (liveSignal?.text ?? stage) : "신호"}
|
|
</b>
|
|
<small>{feedbackMode === "coached" ? "현재 신호" : "조용히 표시"}</small>
|
|
</span>
|
|
</div>
|
|
{resumedSessionLoaded ? (
|
|
<div className="sx-mobile-context__resume">이전 회기 기록을 불러왔습니다.</div>
|
|
) : null}
|
|
</section>
|
|
|
|
{/* ── LEFT ── */}
|
|
<div className="sx-col sx-col-left">
|
|
{/* 회기 단계 세로 트랙 */}
|
|
<section className="sx-panel sx-track">
|
|
<div className="sx-track__head">
|
|
<Kicker>회기 진행</Kicker>
|
|
</div>
|
|
{SESSION_PHASES.map((p, i) => {
|
|
const cls =
|
|
i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : "";
|
|
return (
|
|
<div key={p.key} className={"sx-vstep " + cls}>
|
|
<div className="sx-vstep__rail">
|
|
<span className="sx-vstep__node" />
|
|
{i < SESSION_PHASES.length - 1 ? <span className="sx-vstep__line" /> : null}
|
|
</div>
|
|
<div className="sx-vstep__body">
|
|
<div className="sx-vstep__label">
|
|
{p.key}
|
|
<span className="sx-vstep__t">
|
|
{i < stageIdx ? "완료" : i === stageIdx ? "진행 중" : "예정"}
|
|
</span>
|
|
</div>
|
|
<div className="sx-vstep__desc">{p.desc}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</section>
|
|
|
|
{/* 내담자 컨텍스트 카드 */}
|
|
<section className="sx-panel sx-ctx">
|
|
<div className="sx-ctx__head">
|
|
<Kicker>내담자 컨텍스트</Kicker>
|
|
</div>
|
|
<div className="sx-ctx__who">
|
|
<span className="sx-ctx__pf">{personaUi.context.initial}</span>
|
|
<span>
|
|
<span className="sx-ctx__nm">{personaUi.context.name}</span>
|
|
<div className="sx-ctx__mt">{personaUi.context.meta}</div>
|
|
</span>
|
|
</div>
|
|
<div className="sx-ctx__meta">
|
|
{personaUi.context.rows.map((r) => (
|
|
<div className="sx-ctx__row" key={r.l}>
|
|
<span className="sx-ctx__rl">{r.l}</span>
|
|
<span className="sx-ctx__rv">{r.v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="sx-ctx__chips">
|
|
{personaUi.context.chips.map((c) => (
|
|
<span key={c.t} className={"sx-chip" + (c.clay ? " is-clay" : "")}>
|
|
{c.t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{/* ── CENTER ── */}
|
|
<div className="sx-col sx-col-center">
|
|
{/* 어두운 STAGE */}
|
|
<section className={"sx-stage" + (paused ? " is-paused" : "")}>
|
|
<div className="sx-stage__top">
|
|
<span className="sx-stage__status">
|
|
<span
|
|
className={
|
|
"sx-stage__state-dot" +
|
|
(orbState === "learner"
|
|
? " is-learner"
|
|
: orbState === "thinking" || orbState === "idle"
|
|
? " is-think"
|
|
: "")
|
|
}
|
|
/>
|
|
{sessionStatusLabel}
|
|
</span>
|
|
<span className="sx-stage__timer">{elapsedLabel}</span>
|
|
</div>
|
|
|
|
<div className="sx-orb-wrap">
|
|
<span className="sx-orb" data-orb={orbState} aria-hidden="true" />
|
|
<ClientAvatar
|
|
persona={personaUi.avatar}
|
|
state={avatarState}
|
|
affect={avatarAffect}
|
|
analyser={voiceAnalyser}
|
|
rapport={meters.rapport}
|
|
size={220}
|
|
/>
|
|
</div>
|
|
|
|
<div className="sx-stage__now">
|
|
지금: {stageStateText[avatarState]} · {avatarExpressionLabel}
|
|
</div>
|
|
</section>
|
|
|
|
{/* 실시간 자막(대본 스타일) + 텍스트 입력 폴백 */}
|
|
<section className="sx-transcript">
|
|
<div className="sx-transcript__head">
|
|
<Kicker>실시간 자막</Kicker>
|
|
<span className="sx-transcript__live">
|
|
<span
|
|
className={"sx-transcript__live-dot" + (transcriptIsLive ? " is-live" : "")}
|
|
/>
|
|
{transcriptLiveLabel}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="sx-transcript__scroll" ref={scrollRef} onScroll={onScroll}>
|
|
{utterances.length > 0 || clientReplyPending ? (
|
|
<>
|
|
{utterances.map((u) => {
|
|
const turnCoachEvents =
|
|
u.speaker === "learner" && u.turnSeq
|
|
? (coachHistoryByTurn.get(u.turnSeq) ?? [])
|
|
: [];
|
|
const latestCoach = turnCoachEvents[turnCoachEvents.length - 1];
|
|
return (
|
|
<div
|
|
key={u.id}
|
|
className={
|
|
"sx-utt " +
|
|
(u.speaker === "client" ? "is-client" : "is-learner") +
|
|
(u.partial ? " is-partial" : "")
|
|
}
|
|
>
|
|
<div className="sx-utt__head">
|
|
<span className="sx-utt__spk">
|
|
{u.speaker === "client" ? clientName : "나 (학습자)"}
|
|
</span>
|
|
<span className="sx-utt__tc">{formatTimecode(u.at)}</span>
|
|
</div>
|
|
<div className="sx-utt__body">
|
|
<div className="sx-utt__line">
|
|
{u.text}
|
|
{u.partial ? <span className="sx-utt__caret" /> : null}
|
|
</div>
|
|
{turnCoachEvents.length && u.turnSeq ? (
|
|
<button
|
|
type="button"
|
|
className={
|
|
"sx-utt__coach-mark" +
|
|
(latestCoach?.suggestion.tone === "pos"
|
|
? " is-pos"
|
|
: latestCoach?.suggestion.tone === "warn"
|
|
? " is-warn"
|
|
: "")
|
|
}
|
|
title={latestCoach?.suggestion.title ?? "코칭 이력"}
|
|
onClick={() => openCoachHistory(u.turnSeq ?? null)}
|
|
>
|
|
C
|
|
{turnCoachEvents.length > 1 ? (
|
|
<span>{turnCoachEvents.length}</span>
|
|
) : null}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{clientReplyPending ? (
|
|
<div className="sx-utt is-client is-thinking" aria-live="polite">
|
|
<div className="sx-utt__head">
|
|
<span className="sx-utt__spk">{clientName}</span>
|
|
<span className="sx-utt__tc">{formatTimecode(elapsed)}</span>
|
|
</div>
|
|
<div className="sx-utt__body">
|
|
<div className="sx-utt__line">
|
|
답변을 준비 중입니다.
|
|
<span className="sx-utt__dots" aria-hidden="true">
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<div className="sx-transcript__empty">
|
|
<span className="sx-transcript__empty-dot" aria-hidden="true" />
|
|
<div>
|
|
<b>
|
|
{sessionEnded
|
|
? "종료된 회기 기록입니다."
|
|
: `${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`}
|
|
</b>
|
|
<span>
|
|
{sessionEnded
|
|
? "새 발화는 추가하지 않고 리뷰에서 회기를 확인하세요."
|
|
: "아래 입력창이나 마이크로 첫 발화를 시작하세요."}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!autoScroll ? (
|
|
<button type="button" className="sx-transcript__jump" onClick={jumpToLatest}>
|
|
<Icon name="arrow-down" size={13} />
|
|
최신으로
|
|
</button>
|
|
) : null}
|
|
|
|
{/* 텍스트 입력 폴백 — 음성 1턴 왕복 대역(실제 STT 는 voice 트랙) */}
|
|
<div className="sx-compose">
|
|
<div className="sx-compose__field">
|
|
<textarea
|
|
rows={1}
|
|
value={composeText}
|
|
onChange={(e) => setComposeText(e.target.value)}
|
|
onKeyDown={onComposeKey}
|
|
placeholder={
|
|
sessionEnded
|
|
? "종료된 회기입니다."
|
|
: paused
|
|
? "일시정지 중입니다."
|
|
: "학습자 발화를 입력하세요."
|
|
}
|
|
disabled={sessionEnded || paused || textTurnBlocked}
|
|
aria-label="학습자 발화 입력"
|
|
/>
|
|
</div>
|
|
<Button
|
|
onClick={() => void handleSend()}
|
|
disabled={!composeText.trim() || textTurnBlocked || paused || sessionEnded}
|
|
trailing={<Icon name="arrow-up" size={15} />}
|
|
>
|
|
보내기
|
|
</Button>
|
|
</div>
|
|
{turnError ? (
|
|
<section className="sx-safety sx-safety--engine sx-turn-error" role="alert">
|
|
<span className="sx-safety__ico">
|
|
<Icon name="alert" size={17} />
|
|
</span>
|
|
<span className="sx-safety__text">
|
|
<b>엔진 연결</b> · {turnError}
|
|
</span>
|
|
</section>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
|
|
{/* ── RIGHT ── */}
|
|
<div className="sx-col sx-col-right">
|
|
{/* 라이브 신호 (몰입 모드면 숨김) */}
|
|
{feedbackMode !== "immersive" ? (
|
|
<section className="sx-panel sx-signal">
|
|
<div className="sx-signal__head">
|
|
<Kicker>라이브 신호</Kicker>
|
|
</div>
|
|
|
|
{liveSignal ? (
|
|
<div
|
|
className={"sx-signal__one" + (signalFaded ? " is-faded" : "")}
|
|
title={liveSignal.text}
|
|
>
|
|
<span
|
|
className={
|
|
"sx-signal__one-dot" +
|
|
(liveSignal.tone === "pos"
|
|
? " is-pos"
|
|
: liveSignal.tone === "warn"
|
|
? " is-warn"
|
|
: "")
|
|
}
|
|
/>
|
|
{feedbackMode === "coached" ? (
|
|
<>
|
|
<span className="sx-signal__one-text">{liveSignal.text}</span>
|
|
<span className="sx-signal__one-when">방금</span>
|
|
</>
|
|
) : (
|
|
<span className="sx-signal__one-when">방금</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="sx-signal__rest">
|
|
아직 표시할 신호가 없어요. 대화가 이어지면 여기에 짧게 비춰 드릴게요.
|
|
</p>
|
|
)}
|
|
|
|
<div className="sx-signal__seq">
|
|
<span className="sx-signal__seq-label">최근 흐름</span>
|
|
<span className="sx-signal__seq-dots">
|
|
{signalSeq.map((t, i) => (
|
|
<i
|
|
key={i}
|
|
className={
|
|
(t === "pos" ? "is-pos" : t === "warn" ? "is-warn" : "") +
|
|
(i === signalSeq.length - 1 ? " is-now" : "")
|
|
}
|
|
/>
|
|
))}
|
|
</span>
|
|
</div>
|
|
|
|
{feedbackMode === "coached" ? (
|
|
<div
|
|
className={
|
|
"sx-coach-card" +
|
|
(coachTone === "pos" ? " is-pos" : coachTone === "warn" ? " is-warn" : "") +
|
|
(coachLoading ? " is-loading" : "")
|
|
}
|
|
>
|
|
<div className="sx-coach-avatar" aria-hidden="true">
|
|
<span className="sx-coach-avatar__lens" />
|
|
<span className="sx-coach-avatar__face">
|
|
<i />
|
|
<i />
|
|
</span>
|
|
</div>
|
|
<div className="sx-coach-bubble">
|
|
<div className="sx-coach-bubble__meta">
|
|
<b>AI 코치</b>
|
|
<span>{coachStatusText}</span>
|
|
</div>
|
|
{coachLoading ? (
|
|
<p>방금 턴과 근거 자료를 대조하는 중입니다.</p>
|
|
) : coachSuggestion ? (
|
|
<>
|
|
<strong>{coachSuggestion.title}</strong>
|
|
<p>{coachSuggestion.message}</p>
|
|
{coachSuggestion.next_utterance ? (
|
|
<blockquote>{coachSuggestion.next_utterance}</blockquote>
|
|
) : null}
|
|
<div className="sx-coach-bubble__actions">
|
|
<button type="button" onClick={() => setCoachEvidenceOpen(true)}>
|
|
근거 보기
|
|
</button>
|
|
<button type="button" onClick={() => openCoachHistory(null)}>
|
|
이력 보기
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : coachError ? (
|
|
<p>{coachError}</p>
|
|
) : (
|
|
<p>코칭 모드에서는 방금 발화의 강점과 조정점을 근거와 함께 바로 짚습니다.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="sx-signal__defer">
|
|
{feedbackMode === "coached" ? (
|
|
<>
|
|
코칭 모드에서는 <b>턴 완료 직후</b> 방금 발화의 강점과 조정점을
|
|
보여줍니다. 점수·정밀 평가는 회기가 끝난 뒤 리뷰에서 함께 봐요.
|
|
</>
|
|
) : (
|
|
<>
|
|
실시간엔 <b>수신 상태</b>만 비춥니다. 점수·정밀 평가는 회기가 끝난 뒤
|
|
리뷰에서 함께 봐요.
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
) : (
|
|
<section className="sx-panel sx-signal">
|
|
<div className="sx-signal__head">
|
|
<Kicker>몰입 모드</Kicker>
|
|
</div>
|
|
<div className="sx-signal__defer">
|
|
실시간 신호 없이 대화에만 집중합니다. 모든 피드백은 회기가 끝난 뒤 리뷰에서
|
|
함께 봅니다.
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* 내담자 상태 미터 */}
|
|
<section className="sx-panel sx-meters">
|
|
<div className="sx-meters__head">
|
|
<Kicker>관찰 신호</Kicker>
|
|
</div>
|
|
|
|
<div className="sx-meter">
|
|
<div className="sx-meter__top">
|
|
<span className="sx-meter__label">방어 신호</span>
|
|
<span className="sx-meter__val is-clay">
|
|
{meters.resistance > 0.55 ? "아직 높음" : "완화되는 중"}
|
|
</span>
|
|
</div>
|
|
<Gauge value={meters.resistance} tone="clay" />
|
|
</div>
|
|
|
|
<div className="sx-meter">
|
|
<div className="sx-meter__top">
|
|
<span className="sx-meter__label">위축 신호</span>
|
|
<span className="sx-meter__val is-clay">
|
|
{meters.anxiety > 0.5 ? "중간" : "낮아지는 중"}
|
|
</span>
|
|
</div>
|
|
<Gauge value={meters.anxiety} tone="clay" />
|
|
</div>
|
|
|
|
<div className="sx-meter">
|
|
<div className="sx-meter__top">
|
|
<span className="sx-meter__label">관계 신호</span>
|
|
<span className="sx-meter__val is-accent">조금씩 ↑</span>
|
|
</div>
|
|
<Gauge value={meters.rapport} tone="accent" />
|
|
</div>
|
|
|
|
<div className="sx-meters__note">
|
|
실시간에는 방향 신호만 표시합니다. 정밀 평가는 회기 종료 후 리뷰에서 확인합니다.
|
|
</div>
|
|
</section>
|
|
|
|
<section className={"sx-safety" + (safety ? "" : " sx-safety--ok")}>
|
|
<span className="sx-safety__ico">
|
|
<Icon name={safety ? "alert" : "shield"} size={17} />
|
|
</span>
|
|
<span className="sx-safety__text">
|
|
<b>안전 점검</b> · {safety ?? "현재 감지된 위기 신호 없음"}
|
|
{crisisResource ? (
|
|
<span className="sx-crisis-resource">
|
|
<strong>{crisisResource.title}</strong>
|
|
<a href={`tel:${crisisResource.number}`}>{crisisResource.number}</a>
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 하단 컨트롤 바 ── */}
|
|
<div className="sx-controlbar">
|
|
<div className="sx-mic-block">
|
|
<button
|
|
type="button"
|
|
className={"sx-mic " + (micOn ? "is-on" : "is-off")}
|
|
onClick={toggleMic}
|
|
disabled={sessionEnded || paused || micBusy || voiceAvailable === false}
|
|
aria-pressed={micOn}
|
|
aria-label={micOn ? "발화 보내기" : "마이크 켜기"}
|
|
>
|
|
<Icon name={micOn ? "mic" : "mic-off"} size={22} />
|
|
</button>
|
|
<span className="sx-mic-block__ms">
|
|
<span className="sx-mic-block__l">{micLabel}</span>
|
|
<span className="sx-mic-block__h">{voiceDetail}</span>
|
|
</span>
|
|
{voiceStatus === "speaking" ? (
|
|
<button
|
|
type="button"
|
|
className="sx-voice-skip"
|
|
onClick={skipVoicePlayback}
|
|
aria-label="음성 건너뛰기"
|
|
>
|
|
<Icon name="x" size={14} />
|
|
<span>건너뛰기</span>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
<span className="sx-cb-sep" />
|
|
|
|
<div className="sx-seg-block">
|
|
<span className="sx-seg-block__label">화면 모드</span>
|
|
<div className="sx-segmented" role="group" aria-label="피드백 모드">
|
|
<button
|
|
className={feedbackMode === "immersive" ? "is-on" : ""}
|
|
onClick={() => setFeedbackMode("immersive")}
|
|
>
|
|
몰입
|
|
</button>
|
|
<button
|
|
className={feedbackMode === "ambient" ? "is-on" : ""}
|
|
onClick={() => setFeedbackMode("ambient")}
|
|
>
|
|
상태 신호
|
|
</button>
|
|
<button
|
|
className={feedbackMode === "coached" ? "is-on" : ""}
|
|
onClick={() => setFeedbackMode("coached")}
|
|
>
|
|
코칭
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<span className="sx-cb-spacer" />
|
|
|
|
<div className="sx-cb-actions">
|
|
{sessionEnded && liveSessionId ? (
|
|
<button
|
|
type="button"
|
|
className="sx-review-button"
|
|
onClick={() => navigate(`/learn/session/${liveSessionId}/review`)}
|
|
>
|
|
<Icon name="review" size={15} />
|
|
{reviewReady ? "리뷰 보기" : "리뷰 준비 중"}
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className={"sx-pause" + (paused ? " is-paused" : "")}
|
|
onClick={togglePause}
|
|
>
|
|
<Icon name={paused ? "play" : "pause"} size={15} />
|
|
{paused ? "이어가기" : "일시정지"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="sx-end-button"
|
|
onClick={() => setEndDialogOpen(true)}
|
|
disabled={ending}
|
|
>
|
|
<Icon name="x" size={15} />
|
|
회기 종료
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{coachHistoryOpen ? (
|
|
<div
|
|
className="sx-coach-history"
|
|
role="presentation"
|
|
onMouseDown={(event) => {
|
|
if (event.target === event.currentTarget) setCoachHistoryOpen(false);
|
|
}}
|
|
>
|
|
<section
|
|
className="sx-coach-history__panel"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="sx-coach-history-title"
|
|
aria-describedby="sx-coach-history-desc"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="sx-coach-history__head">
|
|
<div>
|
|
<h2 id="sx-coach-history-title">{coachHistoryTitle}</h2>
|
|
<p id="sx-coach-history-desc">
|
|
회기 중 실제로 받은 코칭과 근거를 시간순으로 확인합니다.
|
|
</p>
|
|
</div>
|
|
<button
|
|
ref={coachHistoryCloseRef}
|
|
type="button"
|
|
onClick={() => setCoachHistoryOpen(false)}
|
|
aria-label="코칭 이력 닫기"
|
|
>
|
|
<Icon name="x" size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="sx-coach-history__body">
|
|
{activeCoachEvents.length ? (
|
|
activeCoachEvents.map((event) => (
|
|
<article
|
|
key={event.event_id}
|
|
className={
|
|
"sx-coach-history__item" +
|
|
(event.suggestion.tone === "pos"
|
|
? " is-pos"
|
|
: event.suggestion.tone === "warn"
|
|
? " is-warn"
|
|
: "")
|
|
}
|
|
>
|
|
<div className="sx-coach-history__item-top">
|
|
<span>{event.turn_seq}번 턴 · {event.stage ?? "단계 미상"}</span>
|
|
<time dateTime={event.created_at}>
|
|
{formatCoachTimestamp(event.created_at)}
|
|
</time>
|
|
</div>
|
|
<h3>{event.suggestion.title}</h3>
|
|
<p>{event.suggestion.message}</p>
|
|
{event.learner_text_excerpt ? (
|
|
<blockquote className="sx-coach-history__trigger">
|
|
{event.learner_text_excerpt}
|
|
</blockquote>
|
|
) : null}
|
|
{event.suggestion.next_utterance ? (
|
|
<div className="sx-coach-history__next">
|
|
<b>다음 한 문장</b>
|
|
<blockquote>{event.suggestion.next_utterance}</blockquote>
|
|
</div>
|
|
) : null}
|
|
{event.suggestion.rationale ? (
|
|
<p className="sx-coach-history__why">{event.suggestion.rationale}</p>
|
|
) : null}
|
|
{event.suggestion.sources?.length ? (
|
|
<ul className="sx-coach-source-list sx-coach-source-list--compact">
|
|
{event.suggestion.sources.map((source, index) => (
|
|
<li key={`${event.event_id}-${source.source_id}-${index}`}>
|
|
<span>{source.title}</span>
|
|
<small>
|
|
{[source.locator ?? source.source_id, source.kb_kind, source.version]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
</small>
|
|
{source.citation ? <small>{source.citation}</small> : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</article>
|
|
))
|
|
) : (
|
|
<div className="sx-coach-history__empty">
|
|
아직 이 턴에 저장된 코칭 이력이 없습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
|
|
{coachEvidenceOpen && coachSuggestion ? (
|
|
<div
|
|
className="sx-coach-modal"
|
|
role="presentation"
|
|
onMouseDown={(event) => {
|
|
if (event.target === event.currentTarget) setCoachEvidenceOpen(false);
|
|
}}
|
|
>
|
|
<section
|
|
className="sx-coach-modal__panel"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="sx-coach-modal-title"
|
|
aria-describedby="sx-coach-modal-desc"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="sx-coach-modal__head">
|
|
<span className="sx-coach-modal__avatar" aria-hidden="true">
|
|
<span />
|
|
</span>
|
|
<div>
|
|
<h2 id="sx-coach-modal-title">{coachSuggestion.title}</h2>
|
|
<p id="sx-coach-modal-desc">방금 코칭 판단에 사용한 근거와 다음 발화 제안입니다.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="sx-coach-modal__body">
|
|
<section className="sx-coach-evidence-block">
|
|
<b>판단</b>
|
|
<p>{coachSuggestion.message}</p>
|
|
{coachSuggestion.rationale ? <p>{coachSuggestion.rationale}</p> : null}
|
|
{coachSuggestion.safety_note ? (
|
|
<p className="is-warn">{coachSuggestion.safety_note}</p>
|
|
) : null}
|
|
</section>
|
|
|
|
{coachSuggestion.next_utterance ? (
|
|
<section className="sx-coach-evidence-block">
|
|
<b>다음 한 문장</b>
|
|
<blockquote>{coachSuggestion.next_utterance}</blockquote>
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="sx-coach-evidence-block">
|
|
<b>근거 자료</b>
|
|
{coachSources.length ? (
|
|
<ul className="sx-coach-source-list">
|
|
{coachSources.map((source, index) => (
|
|
<li key={`${source.source_id}-${source.locator ?? index}`}>
|
|
<span>{source.title}</span>
|
|
<small>
|
|
{[source.locator ?? source.source_id, source.kb_kind, source.version]
|
|
.filter(Boolean)
|
|
.join(" · ")}
|
|
</small>
|
|
{source.citation ? <small>{source.citation}</small> : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p>이번 코칭은 규칙 기반 신호로 생성됐고, 연결된 RAG 근거는 없습니다.</p>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
<div className="sx-coach-modal__actions">
|
|
<button
|
|
type="button"
|
|
ref={coachEvidenceCloseRef}
|
|
onClick={() => setCoachEvidenceOpen(false)}
|
|
>
|
|
닫기
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
|
|
{endDialogOpen ? (
|
|
<div
|
|
className="sx-end-dialog"
|
|
role="presentation"
|
|
onMouseDown={(event) => {
|
|
if (event.target === event.currentTarget && !ending) setEndDialogOpen(false);
|
|
}}
|
|
>
|
|
<section
|
|
className="sx-end-dialog__panel"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="sx-end-dialog-title"
|
|
aria-describedby="sx-end-dialog-desc"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="sx-end-dialog__head">
|
|
<span className="sx-end-dialog__icon" aria-hidden="true">
|
|
<Icon name="review" size={18} />
|
|
</span>
|
|
<div>
|
|
<h2 id="sx-end-dialog-title">회기를 종료할까?</h2>
|
|
<p id="sx-end-dialog-desc">
|
|
종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동한다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="sx-end-dialog__actions">
|
|
<button
|
|
ref={endCancelRef}
|
|
type="button"
|
|
className="sx-end-dialog__secondary"
|
|
onClick={() => setEndDialogOpen(false)}
|
|
disabled={ending}
|
|
>
|
|
계속 진행
|
|
</button>
|
|
<button
|
|
ref={endConfirmRef}
|
|
type="button"
|
|
className="sx-end-dialog__danger"
|
|
onClick={() => void handleEnd()}
|
|
disabled={ending}
|
|
>
|
|
{ending ? "종료 중" : "종료하고 리뷰 보기"}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* 경과 시간(접근성 — 보조 표기. 화면 우상단 톱바는 셸 소관) */}
|
|
<span className="sr-only" aria-live="polite" style={{ position: "absolute", left: -9999 }}>
|
|
경과 {formatElapsed(elapsed)}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
function formatCoachTimestamp(value: string | null | undefined) {
|
|
if (!value) return "방금";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return "방금";
|
|
const now = new Date();
|
|
const sameDay =
|
|
date.getFullYear() === now.getFullYear() &&
|
|
date.getMonth() === now.getMonth() &&
|
|
date.getDate() === now.getDate();
|
|
return new Intl.DateTimeFormat("ko-KR", {
|
|
month: sameDay ? undefined : "numeric",
|
|
day: sameDay ? undefined : "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}).format(date);
|
|
}
|
|
|
|
/* ── 로컬 게이지 (매우 천천히 채움, 2s — §5.5 산만함 방지) ──────────── */
|
|
function Gauge({ value, tone }: { value: number; tone: "clay" | "accent" }) {
|
|
const pct = Math.round(clamp01(value) * 100);
|
|
return (
|
|
<div
|
|
className="sx-gauge"
|
|
role="progressbar"
|
|
aria-valuenow={pct}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
style={{ height: 4, background: "var(--paper-2)", borderRadius: 2, overflow: "hidden" }}
|
|
>
|
|
<span
|
|
style={{
|
|
display: "block",
|
|
height: "100%",
|
|
width: `${pct}%`,
|
|
borderRadius: 2,
|
|
background: tone === "clay" ? "var(--clay)" : "var(--accent-bright)",
|
|
transition: "width 2s var(--ease-out)",
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|