운영 화면과 회기 리뷰 UI 갱신

This commit is contained in:
Yun Chan 2026-06-28 20:13:21 +09:00
parent e7ebb38177
commit 3a9f70a97b
18 changed files with 2210 additions and 137 deletions

View file

@ -53,6 +53,7 @@ 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 =
@ -115,6 +116,20 @@ const SESSION_PHASES: PhaseInfo[] = [
{ 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",
@ -378,6 +393,7 @@ export default function Session() {
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>("라포");
@ -394,6 +410,7 @@ export default function Session() {
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(
"마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.",
@ -483,6 +500,7 @@ export default function Session() {
setSafety(null);
setCrisisResource(null);
setResumedSessionLoaded(false);
setClientReplyPending(false);
setStartError(null);
setCoachSuggestion(null);
setCoachError(null);
@ -524,6 +542,11 @@ export default function Session() {
};
}, [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];
@ -540,7 +563,7 @@ export default function Session() {
if (!autoScroll) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [utterances, autoScroll]);
}, [utterances, clientReplyPending, autoScroll]);
// ── 라이브 신호 6초 페이드(§5.5) ──
useEffect(() => {
@ -584,6 +607,7 @@ export default function Session() {
setSafety(next.message);
setPaused(true);
setMicOn(false);
setClientReplyPending(false);
setVoiceStatus("idle");
setVoiceDetail("위기 신호가 감지되어 연습을 중단했습니다.");
pushSignal("warn", "위기 안전게이트 작동");
@ -698,6 +722,7 @@ export default function Session() {
setCoachEvidenceOpen(false);
setCoachHistoryOpen(false);
setCoachHistoryTurnSeq(null);
setClientReplyPending(false);
pushSignal("neutral", ended ? "종료된 회기 기록" : "기존 회기 이어하기");
} catch {
if (!alive) return;
@ -725,15 +750,9 @@ export default function Session() {
setStarting(true);
setStartError(null);
setResumedSessionLoaded(false);
setClientReplyPending(false);
try {
// 회기 이론모드 = 페르소나 설계 이론(theory_target) 기준. CBT 페르소나는 cbt로 시작.
// (이전엔 'humanistic' 하드코딩 — 평가 이론부합이 항상 인간중심으로 고정됐다.)
const theoryMode =
personaSummary.theory_target.find(
(t): t is "humanistic" | "cbt" | "integrative" =>
t === "humanistic" || t === "cbt" || t === "integrative",
) ?? "humanistic";
const res = await sessionApi.start(personaCode, theoryMode);
const res = await sessionApi.start(personaCode, selectedTheoryMode);
setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브
setSessionEnded(false);
setReviewReady(false);
@ -778,7 +797,7 @@ export default function Session() {
} finally {
setStarting(false);
}
}, [navigate, personaCode, personaSummary, pushSignal]);
}, [navigate, personaCode, personaSummary, pushSignal, selectedTheoryMode]);
const handleAcceptConsent = useCallback(async () => {
if (!consentChecked) {
@ -799,6 +818,7 @@ export default function Session() {
const appendServerClientReply = useCallback(
(replyText: string | null, at = elapsed) => {
setClientReplyPending(false);
if (!replyText) {
setAvatarState("listening");
pushSignal("warn", "내담자 응답 없음");
@ -817,7 +837,7 @@ export default function Session() {
/* ── 학습자 발화 전송(텍스트 입력 폴백 = 음성 1턴 왕복 대역) ──────── */
const handleSend = useCallback(async () => {
const text = composeText.trim();
if (!text || sending || paused) return;
if (!text || sending || paused || voiceStatus === "thinking") return;
if (sessionEnded) {
setTurnError("종료된 회기에서는 새 발화를 보낼 수 없습니다. 리뷰에서 기록을 확인해 주세요.");
return;
@ -829,8 +849,14 @@ export default function Session() {
}
const at = elapsed;
const learnerId = nextId();
setSending(true);
setComposeText("");
setUtterances((prev) => [
...prev,
{ id: learnerId, speaker: "learner", text, at },
]);
setClientReplyPending(true);
setAvatarState("thinking");
setTurnError(null);
@ -841,6 +867,7 @@ export default function Session() {
onToken: (chunk) => {
if (!chunk) return;
clientReply += chunk;
setClientReplyPending(false);
setAvatarState("speaking");
if (clientId == null) {
clientId = nextId();
@ -878,25 +905,30 @@ export default function Session() {
},
});
const learnerUtterance: Utterance = {
id: nextId(),
speaker: "learner",
text,
turnSeq: done.turn_seq,
at,
};
if (clientId != null) {
const id = clientId;
setUtterances((prev) => {
const existingIndex = prev.findIndex((u) => u.id === id);
if (existingIndex === -1) return [...prev, learnerUtterance];
const next = [...prev];
next.splice(existingIndex, 0, learnerUtterance);
return next.map((u) => (u.id === id ? { ...u, partial: false } : u));
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 {
setUtterances((prev) => [...prev, learnerUtterance]);
setClientReplyPending(false);
setUtterances((prev) =>
prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
);
pushSignal("warn", "내담자 응답 없음");
}
setAvatarState("listening");
@ -907,9 +939,12 @@ export default function Session() {
});
} catch (err) {
setComposeText(text);
setClientReplyPending(false);
if (clientId != null) {
const id = clientId;
setUtterances((prev) => prev.filter((u) => u.id !== id));
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
@ -928,7 +963,17 @@ export default function Session() {
} finally {
setSending(false);
}
}, [composeText, sending, paused, sessionEnded, liveSessionId, elapsed, pushSignal, requestLiveCoach]);
}, [
composeText,
sending,
paused,
voiceStatus,
sessionEnded,
liveSessionId,
elapsed,
pushSignal,
requestLiveCoach,
]);
const onComposeKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
@ -1156,6 +1201,7 @@ export default function Session() {
return;
}
recorderRef.current = recorder;
setClientReplyPending(false);
recorder.ondataavailable = (event) => {
if (!event.data.size || ws.readyState !== WebSocket.OPEN) return;
@ -1218,6 +1264,7 @@ export default function Session() {
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 },
@ -1226,6 +1273,7 @@ export default function Session() {
}
if (payload.type === "reply") {
setClientReplyPending(false);
if (payload.stage) setStage(payload.stage);
if (typeof payload.effective_openness === "number") {
setOpenness(payload.effective_openness);
@ -1264,6 +1312,7 @@ export default function Session() {
}
if (payload.type === "degraded") {
setClientReplyPending(false);
const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다.";
const reason =
payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason;
@ -1273,6 +1322,7 @@ export default function Session() {
}
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));
@ -1285,6 +1335,7 @@ export default function Session() {
};
ws.onerror = () => {
setClientReplyPending(false);
shutdownVoice("error", "음성 연결을 확인하지 못했습니다.");
pushSignal("warn", "음성 연결 실패");
};
@ -1322,6 +1373,17 @@ export default function Session() {
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) {
@ -1348,6 +1410,7 @@ export default function Session() {
setPaused((p) => {
const next = !p;
if (next) {
if (voiceStatus !== "idle") setClientReplyPending(false);
shutdownVoice("idle", "일시정지 중입니다.");
setAvatarState("idle");
} else {
@ -1356,7 +1419,7 @@ export default function Session() {
}
return next;
});
}, [sessionEnded, shutdownVoice]);
}, [sessionEnded, shutdownVoice, voiceStatus]);
// ── 키보드 단축키 (§5.6: Space=마이크, P=일시정지). 입력 중엔 무시 ──
useEffect(() => {
@ -1546,6 +1609,7 @@ export default function Session() {
voiceStatus === "thinking" ||
voiceStatus === "speaking" ||
sending;
const textTurnBlocked = sending || voiceStatus === "thinking";
const elapsedLabel = formatElapsed(elapsed);
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
const consentRequired = user?.role === "learner" && user.consentAt == null;
@ -1748,6 +1812,23 @@ export default function Session() {
</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}
@ -1986,57 +2067,77 @@ export default function Session() {
</div>
<div className="sx-transcript__scroll" ref={scrollRef} onScroll={onScroll}>
{utterances.length > 0 ? (
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" : "")
}
>
{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">
{u.speaker === "client" ? clientName : "나 (학습자)"}
</span>
<span className="sx-utt__tc">{formatTimecode(u.at)}</span>
<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">
{u.text}
{u.partial ? <span className="sx-utt__caret" /> : null}
.
<span className="sx-utt__dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</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>
);
})
) : null}
</>
) : (
<div className="sx-transcript__empty">
<span className="sx-transcript__empty-dot" aria-hidden="true" />
@ -2078,13 +2179,13 @@ export default function Session() {
? "일시정지 중입니다."
: "학습자 발화를 입력하세요."
}
disabled={sessionEnded || paused || sending}
disabled={sessionEnded || paused || textTurnBlocked}
aria-label="학습자 발화 입력"
/>
</div>
<Button
onClick={() => void handleSend()}
disabled={!composeText.trim() || sending || paused || sessionEnded}
disabled={!composeText.trim() || textTurnBlocked || paused || sessionEnded}
trailing={<Icon name="arrow-up" size={15} />}
>
@ -2306,6 +2407,17 @@ export default function Session() {
<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" />