운영 화면과 회기 리뷰 UI 갱신
This commit is contained in:
parent
e7ebb38177
commit
3a9f70a97b
18 changed files with 2210 additions and 137 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue