feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

View file

@ -0,0 +1,980 @@
/* =====================================================================
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.turn(POST) + openSessionStream(SSE)
// ( ).
철칙: border-left···· .
weight/tint/kicker/dot. .
===================================================================== */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import { ClientAvatar } from "../components/avatar/ClientAvatar";
import type { AvatarState, AvatarAffect, AvatarPersona } from "../components/avatar/ClientAvatar";
import { Kicker, Button, Icon } from "../components/ui";
import {
sessionApi,
openSessionStream,
type SessionStage,
type SessionStreamHandle,
} from "../lib/api";
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
import { SlideToEnd } from "./session/SlideToEnd";
import "./session/session.css";
/* ── 도메인 상수/타입 ───────────────────────────────────────────────── */
type FeedbackMode = "immersive" | "ambient" | "coached";
type Speaker = "client" | "learner";
type SignalTone = "pos" | "warn" | "neutral";
interface Utterance {
id: number;
speaker: Speaker;
text: string;
/** 발화 시작 시점(세션 경과 초) */
at: number;
/** 진행 중(스트리밍/타이핑) 발화 여부 */
partial?: boolean;
}
interface PhaseInfo {
key: SessionStage;
desc: string;
}
/** 4단계 정의 (§5.4 라포→탐색→개입→정리) */
const PHASES: PhaseInfo[] = [
{ key: "라포", desc: "첫인사와 안전감 형성" },
{ key: "탐색", desc: "호소 문제와 일상을 함께 살펴봄" },
{ key: "개입", desc: "감정·생각을 다루는 기법 적용" },
{ key: "정리", desc: "오늘의 대화 정리와 다음 약속" },
];
/* ── mock 데이터 (화면을 살아있게. 실제 값은 백엔드 turn 응답으로 갱신) ── */
// mock: 내담자 페르소나 (서연). 백엔드 case 메타 연결 전까지 더미.
const MOCK_PERSONA: AvatarPersona = {
label: "서연 · 17세 · 고2",
ageBand: "teen",
skinTone: "#E8C9A8",
hair: { style: "long-straight", color: "#2B2723" },
realism: 0.4,
};
// mock: 내담자 컨텍스트 카드 내용
const MOCK_CTX = {
initial: "서",
name: "서연",
meta: "17세 · 고2 · 교육용 가상 내담자",
rows: [
{ l: "호소", v: "2주째 지속되는 우울감과 무기력, 등교 거부" },
{ l: "최근", v: "성적 하락과 또래 관계 위축, 자퇴를 입에 올림" },
{ l: "태도", v: "자기개방에 소극적·방어적. 라포가 쌓이면 점차 완화" },
],
chips: [
{ t: "teen", clay: false },
{ t: "방어적", clay: true },
{ t: "우울(저각성)", clay: false },
{ t: "난이도 중상", clay: false },
],
};
// mock: 시작 자막(데모 — 백엔드 recall_summary/turn 으로 대체 가능)
const MOCK_SEED: Utterance[] = [
{
id: 1,
speaker: "client",
at: 30,
text: "요즘은… 그냥 아침에 학교 가는 게 너무 버거워요. 일어나는 것부터가 힘들고.",
},
{
id: 2,
speaker: "learner",
at: 62,
text: "아침에 일어나는 것조차 버겁게 느껴지는구나. 그 마음을 조금 더 들려줄 수 있을까?",
},
{
id: 3,
speaker: "client",
at: 78,
text: "사실… 그냥 자퇴하면 안 되나 싶기도 해요. 가도 어차피 멍하니 앉아만 있거든요.",
},
];
// mock: 내담자 AI 응답 풀(백엔드 미가동/스트림 실패 시 폴백 데모용)
const MOCK_REPLIES = [
"네… 근데 막상 그만두면 또 뭘 해야 할지 모르겠어요. 그냥 다 멈춰 있는 느낌이에요.",
"그렇게 말해 주시니까… 조금은 마음이 놓이는 것 같기도 하고요.",
"사실 친구들한테도 이런 얘긴 못 했어요. 약해 보일까 봐.",
"잘 모르겠어요. 그냥 하루하루가 똑같이 무겁기만 해서.",
];
let _uid = 100;
const nextId = () => ++_uid;
/* ── 작은 표현 유틸 ─────────────────────────────────────────────────── */
// 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), // 불안·위축(완만히 감소)
};
}
/* ===================================================================== */
export default function Session() {
const { sessionId: routeId } = useParams<{ sessionId: string }>();
// 세션 식별자: 라우트 값이 있으면 그걸 사용(라우트는 항상 존재 — App.tsx).
const sessionId = routeId ?? "";
// ── 세션 진행 상태 ──
const [started, setStarted] = useState(false);
const [starting, setStarting] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
// ── 회기/대화 상태 ──
const [stage, setStage] = useState<SessionStage>("탐색");
const [utterances, setUtterances] = useState<Utterance[]>(MOCK_SEED);
const [openness, setOpenness] = useState(0.42); // effective_openness 미러
const [safety, setSafety] = useState<string | 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 [liveSignal, setLiveSignal] = useState<{ tone: SignalTone; text: string } | null>({
tone: "pos",
text: "공감적 반영",
});
const [signalFaded, setSignalFaded] = useState(false);
const [signalSeq, setSignalSeq] = useState<SignalTone[]>([
"neutral",
"pos",
"warn",
"pos",
"pos",
]);
// ── 경과 타이머 ──
const [elapsed, setElapsed] = useState(78);
// ── 자동 스크롤 ──
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
// SSE 핸들/타이머 ref (정리용)
const streamRef = useRef<SessionStreamHandle | null>(null);
const fadeTimerRef = useRef<number | null>(null);
// 현재 단계 인덱스
const stageIdx = useMemo(() => PHASES.findIndex((p) => p.key === stage), [stage]);
// ── 경과 타이머 진행(일시정지 시 멈춤) ──
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, 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]);
// ── 언마운트 시 스트림 정리 ──
useEffect(() => {
return () => {
streamRef.current?.close();
};
}, []);
// ── 키보드 단축키 (§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") {
if (paused) return; // 일시정지 중엔 마이크 토글 막음
e.preventDefault();
setMicOn((on) => {
const next = !on;
setAvatarState(next ? "listening" : "idle");
return next;
});
} else if (e.key === "p" || e.key === "P") {
setPaused((p) => {
const next = !p;
setAvatarState(next ? "idle" : "listening");
if (next) setMicOn(false);
return next;
});
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [started, paused]);
// 사용자가 위로 스크롤하면 자동스크롤 해제
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);
};
/*
sessionApi.start , ()
( ). started=true. */
const handleStart = useCallback(async () => {
setStarting(true);
setStartError(null);
try {
// persona_code 는 데모 고정. 백엔드 연결 시 케이스 라우팅으로 대체.
const res = await sessionApi.start("seoyeon", "humanistic");
setStage(res.stage);
} catch {
// 백엔드 미가동/스텁 → 데모 모드(에러로 막지 않음). 입력 가능한 데모 동작 유지.
setStartError(null);
} finally {
setStarted(true);
setStarting(false);
setAvatarState("listening");
setMicOn(true);
}
}, []);
// 라이브 신호 push + 시퀀스 누적 (최근 5개 유지)
const pushSignal = useCallback((tone: SignalTone, text: string) => {
setLiveSignal({ tone, text });
setSignalSeq((seq) => [...seq.slice(-4), tone]);
}, []);
// mock 응답 1개 선택 (백엔드 미가동/스트림 실패 시 폴백 데모)
const mockReply = useCallback(() => {
return MOCK_REPLIES[Math.floor(Math.random() * MOCK_REPLIES.length)];
}, []);
// 데모: 글자를 점진 타이핑(립싱크 없이도 partial 동작 보여줌)
const demoType = useCallback(
(full: string, id: number, done: (s: string) => void) => {
let i = 0;
const step = () => {
i += 2;
const slice = full.slice(0, i);
setUtterances((prev) => prev.map((u) => (u.id === id ? { ...u, text: slice } : u)));
if (i < full.length) {
window.setTimeout(step, 38);
} else {
done(full);
}
};
step();
},
[],
);
/*
turn POST SSE partial .
mock . */
const consumeClientReply = useCallback(
(seedText: string | null) => {
const at = elapsed;
const id = nextId();
// partial 자막 먼저 추가(빈 채로 시작 → 토큰으로 채움)
setUtterances((prev) => [
...prev,
{ id, speaker: "client", text: "", at, partial: true },
]);
setAvatarState("speaking");
let acc = "";
let gotToken = false;
const finalize = (full: string) => {
setUtterances((prev) =>
prev.map((u) => (u.id === id ? { ...u, text: full, partial: false } : u)),
);
setAvatarState("listening");
// 라이브 신호 갱신(데모 휴리스틱): 길게 답하면 pos, 짧으면 neutral
const tone: SignalTone = full.length > 24 ? "pos" : "neutral";
pushSignal(tone, full.length > 24 ? "자기개방 확장" : "짧은 응답");
// openness 소폭 상승(라포 누적 데모)
setOpenness((o) => clamp01(o + 0.04));
};
// SSE 시도
try {
streamRef.current?.close();
streamRef.current = openSessionStream(sessionId, {
onToken: (chunk) => {
gotToken = true;
acc += chunk;
setUtterances((prev) =>
prev.map((u) => (u.id === id ? { ...u, text: acc } : u)),
);
},
onSafety: () => {
setSafety("내담자 발화에서 안전 점검이 필요한 신호가 감지됐어요.");
},
onDone: () => {
finalize(acc || seedText || mockReply());
},
onError: () => {
// 스트림 실패 → mock 폴백(데모는 끊기지 않게)
if (!gotToken) demoType(seedText || mockReply(), id, finalize);
},
});
// 스트림이 일정 시간 토큰 0이면 데모 폴백(백엔드 스텁 대비)
window.setTimeout(() => {
if (!gotToken) {
streamRef.current?.close();
streamRef.current = null;
demoType(seedText || mockReply(), id, finalize);
}
}, 900);
} catch {
demoType(seedText || mockReply(), id, finalize);
}
},
[elapsed, sessionId, pushSignal, mockReply, demoType],
);
/* ── 학습자 발화 전송(텍스트 입력 폴백 = 음성 1턴 왕복 대역) ──────── */
const handleSend = useCallback(async () => {
const text = composeText.trim();
if (!text || sending || paused) return;
setSending(true);
setComposeText("");
// 학습자 자막 즉시 반영
setUtterances((prev) => [
...prev,
{ id: nextId(), speaker: "learner", text, at: elapsed },
]);
setAvatarState("thinking");
let clientReply: string | null = null;
try {
const res = await sessionApi.turn(sessionId, text);
setStage(res.stage);
setOpenness(res.effective_openness);
clientReply = res.client_reply;
if (res.safety_flagged) {
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
}
} catch {
// 백엔드 미가동 → 데모 응답(끊김 방지)
clientReply = null;
} finally {
setSending(false);
}
// 잠깐 'thinking' 후 내담자 응답 스트림
window.setTimeout(() => consumeClientReply(clientReply), 420);
}, [composeText, sending, paused, elapsed, sessionId, consumeClientReply]);
const onComposeKey = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
};
// 마이크 토글(실제 STT 는 voice 트랙. 여기선 상태 표시 + 텍스트 폴백 안내)
const toggleMic = () => {
setMicOn((on) => {
const next = !on;
setAvatarState(next ? "listening" : "idle");
return next;
});
};
const togglePause = () => {
setPaused((p) => {
const next = !p;
if (next) {
setAvatarState("idle");
setMicOn(false);
} else {
setAvatarState("listening");
}
return next;
});
};
// 회기 종료(밀어서 확인) — 데모: end 호출 후 안내. 실제 라우팅은 리뷰 페이지 소관.
const handleEnd = useCallback(async () => {
try {
await sessionApi.end(sessionId);
} catch {
/* 데모 — 무시 */
}
streamRef.current?.close();
setMicOn(false);
setPaused(true);
setAvatarState("idle");
pushSignal("neutral", "회기 종료");
}, [sessionId, pushSignal]);
// 단계 수동 보정(자각 촉진, §5.4)
const advanceStage = () => {
setStage((cur) => {
const idx = PHASES.findIndex((p) => p.key === cur);
const next = PHASES[Math.min(PHASES.length - 1, idx + 1)];
return next.key;
});
};
// 아바타 affect: 데모 — 라포(openness)가 낮으면 저항/우울, 높아지면 중립으로
const avatarAffect: AvatarAffect = useMemo(() => {
if (openness < 0.35) return "resistant";
if (openness < 0.6) return "depressed";
return "neutral";
}, [openness]);
// 음성 오브 상태(아바타 state → 오브 data-orb)
const orbState = paused
? "thinking"
: avatarState === "speaking"
? "client"
: avatarState === "listening"
? "learner"
: avatarState === "thinking"
? "thinking"
: "idle";
const meters = metersFromOpenness(openness);
// stage 상단 상태 라벨 텍스트
const stageStateText: Record<AvatarState, string> = {
idle: "잠시 시선을 내리고 말을 고르는 중",
listening: "당신의 말을 듣고 있어요",
thinking: "잠시 생각하는 중",
speaking: "서연이 이야기하는 중",
};
return (
<AppShell hideNav contextLabel="상담 세션" bleed>
<div className="sx-page">
{/* ── 페이지 헤드라인 + 가로 단계 미니 ── */}
<header className="sx-head">
<div className="sx-head__lt">
<div className="sx-head__kicker">
<Kicker> · · 2</Kicker>
</div>
<h1 className="sx-head__title">
<em>{stage} </em>.
</h1>
<div className="sx-head__sub">
. .
</div>
</div>
<div className="sx-phases" aria-label="회기 진행 단계">
{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 ? (
/* ── 시작 전: 준비 화면(한 화면 한 의도 = 세션 시작) ── */
<div className="sx-prestart">
<ClientAvatar
persona={MOCK_PERSONA}
state="idle"
affect="anxious"
analyser={null}
size={200}
/>
<h2 className="sx-prestart__title"> 2 ?</h2>
<p className="sx-prestart__desc">
. , .
, .
</p>
{startError ? <p className="sx-prestart__err">{startError}</p> : null}
<Button
size="lg"
onClick={handleStart}
disabled={starting}
leading={<Icon name="play" size={16} />}
>
{starting ? "여는 중…" : "회기 시작"}
</Button>
</div>
) : (
<>
{/* ── 3-region 그리드 ── */}
<div className="sx-grid">
{/* ── LEFT ── */}
<div className="sx-col sx-col-left">
{/* 회기 단계 세로 트랙 */}
<section className="sx-panel sx-track">
<div className="sx-track__head">
<Kicker> </Kicker>
</div>
{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 < 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>
);
})}
{stageIdx < PHASES.length - 1 ? (
<Button
variant="ghost"
size="sm"
className="sx-track__advance"
onClick={advanceStage}
trailing={<Icon name="chevron-right" size={14} />}
>
</Button>
) : null}
</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">{MOCK_CTX.initial}</span>
<span>
<span className="sx-ctx__nm">{MOCK_CTX.name}</span>
<div className="sx-ctx__mt">{MOCK_CTX.meta}</div>
</span>
</div>
<div className="sx-ctx__meta">
{MOCK_CTX.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">
{MOCK_CTX.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__state-dot" +
(orbState === "learner"
? " is-learner"
: orbState === "thinking" || orbState === "idle"
? " is-think"
: "")
}
/>
{avatarState === "speaking"
? "서연이 말하는 중"
: avatarState === "listening"
? "듣는 중"
: avatarState === "thinking"
? "응답을 고르는 중"
: "대기 중"}
</div>
<div className="sx-orb-wrap">
<span className="sx-orb" data-orb={orbState} aria-hidden="true" />
<ClientAvatar
persona={MOCK_PERSONA}
state={avatarState}
affect={avatarAffect}
analyser={null}
size={220}
/>
</div>
<div className="sx-stage__now">: {stageStateText[avatarState]}</div>
</section>
{/* 실시간 자막(대본 스타일) + 텍스트 입력 폴백 */}
<section className="sx-transcript">
<div className="sx-transcript__head">
<Kicker> </Kicker>
<span className="sx-transcript__live">
<span className="sx-transcript__live-dot" />
</span>
</div>
<div className="sx-transcript__scroll" ref={scrollRef} onScroll={onScroll}>
{utterances.map((u) => (
<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" ? "서연" : "나 (학습자)"}
</span>
<span className="sx-utt__tc">{formatTimecode(u.at)}</span>
</div>
<div className="sx-utt__line">
{u.text}
{u.partial ? <span className="sx-utt__caret" /> : null}
</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={
paused
? "일시정지 중입니다."
: "여기에 입력해 연습할 수도 있어요 (Enter 전송)"
}
disabled={paused || sending}
aria-label="학습자 발화 입력(텍스트 폴백)"
/>
</div>
<Button
onClick={() => void handleSend()}
disabled={!composeText.trim() || sending || paused}
trailing={<Icon name="arrow-up" size={15} />}
>
</Button>
</div>
</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"
: "")
}
/>
<span className="sx-signal__one-text">{liveSignal.text}</span>
<span className="sx-signal__one-when"></span>
</div>
) : null}
<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>
{/* 코칭 모드: 텍스트 힌트(한 번에 1개) */}
{feedbackMode === "coached" ? (
<div className="sx-coach">
<div className="sx-coach__head">
<Kicker> </Kicker>
</div>
<div className="sx-coach__body">
'예/아니오' .
?
</div>
<div className="sx-coach__ex">
: "그때 어떤 마음이 드셨는지 조금 더 들려주세요."
</div>
<button
type="button"
className="sx-coach__dismiss"
onClick={() => setFeedbackMode("ambient")}
>
</button>
</div>
) : (
<div className="sx-signal__defer">
<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>
{/* 안전 점검(있을 때만, warn 톤 — 빨강 아님) */}
{safety ? (
<section className="sx-safety">
<span className="sx-safety__ico">
<Icon name="alert" size={17} />
</span>
<span className="sx-safety__text">
<b> </b> · {safety}
</span>
</section>
) : null}
</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={paused}
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">
{paused ? "일시정지" : micOn ? "듣고 있어요" : "마이크 꺼짐"}
</span>
<span className="sx-mic-block__h"> · Space</span>
</span>
</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">
<button
type="button"
className={"sx-pause" + (paused ? " is-paused" : "")}
onClick={togglePause}
>
<Icon name={paused ? "play" : "pause"} size={15} />
{paused ? "이어가기" : "일시정지"}
</button>
<SlideToEnd onConfirm={() => void handleEnd()} label="밀어서 종료" />
</div>
</div>
{/* 경과 시간(접근성 — 보조 표기. 화면 우상단 톱바는 셸 소관) */}
<span className="sr-only" aria-live="polite" style={{ position: "absolute", left: -9999 }}>
{formatElapsed(elapsed)}
</span>
</>
)}
</div>
</AppShell>
);
}
/* ── 로컬 게이지 (매우 천천히 채움, 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>
);
}