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:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
980
apps/web/src/pages/Session.tsx
Normal file
980
apps/web/src/pages/Session.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue