feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -225,6 +225,22 @@ function buildPersonaUi(
};
}
/* D3( ) "16-18 · 2 · · " ·
2 , · 1 .
personaMeta() . */
function splitMetaLines(meta: string): string[] {
const parts = meta
.split("·")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length <= 2) return [meta];
const lines: string[] = [];
for (let i = 0; i < parts.length; i += 2) {
lines.push(parts.slice(i, i + 2).join(" · "));
}
return lines;
}
/* ── 작은 표현 유틸 ─────────────────────────────────────────────────── */
// 백엔드가 반환한 effective_openness(0~1)를 실시간 관찰 신호로만 변환한다.
@ -317,6 +333,11 @@ export default function Session() {
);
const clientName = personaUi.context.name;
const primaryContext = personaUi.context.rows[0] ?? null;
// D3 — 내담자 한 줄 소개를 줄당 가운뎃점 1개 이하로 나눈 표시용 배열
const clientMetaLines = useMemo(
() => splitMetaLines(personaUi.context.meta),
[personaUi.context.meta],
);
// ── 세션 진행 상태 ──
const [started, setStarted] = useState(false);
@ -418,6 +439,7 @@ export default function Session() {
const ttsChunksRef = useRef<BlobPart[]>([]);
const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null);
const ttsPlaybackRequestRef = useRef(0);
const ttsRequestAbortRef = useRef<AbortController | null>(null);
const playTtsAudioRef = useRef<(() => Promise<void>) | null>(null);
const pendingVoiceLearnerIdRef = useRef<number | null>(null);
const pendingVoiceLearnerTextRef = useRef<string>("");
@ -464,6 +486,8 @@ export default function Session() {
const stopTtsPlayback = useCallback(() => {
ttsPlaybackRequestRef.current += 1;
ttsRequestAbortRef.current?.abort();
ttsRequestAbortRef.current = null;
const cleanup = ttsPlaybackCleanupRef.current;
ttsPlaybackCleanupRef.current = null;
if (cleanup) cleanup();
@ -1058,21 +1082,33 @@ export default function Session() {
async (sessionId: string, turnSeq: number) => {
const requestId = ttsPlaybackRequestRef.current + 1;
ttsPlaybackRequestRef.current = requestId;
const abortController = new AbortController();
ttsRequestAbortRef.current?.abort();
ttsRequestAbortRef.current = abortController;
setVoiceStatus("speaking");
setVoiceDetail("OpenAI 음성을 생성하고 있습니다.");
setVoiceDetail("내담자 음성을 준비하고 있습니다. 입력은 계속할 수 있습니다.");
try {
const audio = await sessionApi.speakClientTurn(sessionId, turnSeq);
const audio = await sessionApi.speakClientTurn(
sessionId,
turnSeq,
abortController.signal,
);
if (ttsPlaybackRequestRef.current !== requestId) return;
ttsChunksRef.current = [audio];
const play = playTtsAudioRef.current;
if (!play) throw new Error("voice playback is not ready");
await play();
} catch {
} catch (error) {
if (ttsPlaybackRequestRef.current !== requestId) return;
if (error instanceof DOMException && error.name === "AbortError") return;
setAvatarState("listening");
setVoiceStatus("degraded");
setVoiceDetail("OpenAI 음성을 재생하지 못했습니다. 자막 응답은 화면에 남겼습니다.");
setVoiceDetail("내담자 음성을 재생하지 못했습니다. 자막 응답은 화면에 남겼습니다.");
pushSignal("warn", "AI 음성 재생 실패");
} finally {
if (ttsRequestAbortRef.current === abortController) {
ttsRequestAbortRef.current = null;
}
}
},
[pushSignal],
@ -1908,6 +1944,7 @@ export default function Session() {
sessionStatusLabel,
transcriptLiveLabel,
transcriptIsLive,
textComposerDisabled,
textTurnBlocked,
voiceInputStatus,
responseStatus,
@ -2093,8 +2130,13 @@ export default function Session() {
<span className={"sx-sessionbar__dot" + (sessionEnded ? " is-ended" : "")} />
<span>
<b>{sessionStatusLabel}</b>
{/* D3 " · · 0:03" · 2
"서연 · 라포 단계" + "0:03" . */}
<small>
{clientName} · {stage} · {elapsedLabel}
<span>
{clientName} · {stage}
</span>
<span>{elapsedLabel}</span>
</small>
</span>
</div>
@ -2131,30 +2173,24 @@ export default function Session() {
showCaption={false}
showMeta={false}
/>
{/* + + .
·· .sx-prestart__facts
(3·2 ) .
. */}
<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 className={surfaceClassName(undefined, { variant: "inset", flat: true })} key={row.l}>
<span>{row.l}</span>
<em>{row.v}</em>
</li>
<b>{personaUi.context.name}</b>
{/* D3 — 한 줄에 · 가 3개 몰리던 소개를 줄당 1개로 나눠 표시 */}
<p>
{clientMetaLines.map((line, i) => (
<span key={`${i}-${line}`}>{line}</span>
))}
</ul>
</p>
</div>
</div>
<div className="sx-prestart__main">
<span className="sx-prestart__eyebrow">
<span aria-hidden="true" />
{currentPhase.key}
</span>
{/* D4 eyebrow " " " ."
( · · ). */}
<h2 className="sx-prestart__title">{prestartTitle}</h2>
<p className="sx-prestart__desc">
,
@ -2461,7 +2497,8 @@ export default function Session() {
aria-expanded={!ctxCollapsed}
onClick={() => setCtxCollapsed((prev) => !prev)}
>
<Kicker> </Kicker>
{/* D1 — 단순 섹션 라벨이라 accent dot 제거(상태를 나타내지 않음) */}
<Kicker dot={false}> </Kicker>
<span className="sx-panel-toggle__hint">
{ctxCollapsed ? `${personaUi.context.name} · 펼치기` : "접기"}
</span>
@ -2472,7 +2509,12 @@ export default function Session() {
<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>
{/* D3 — 줄당 가운뎃점 1개로 나눠 표시 */}
<div className="sx-ctx__mt">
{clientMetaLines.map((line, i) => (
<span key={`${i}-${line}`}>{line}</span>
))}
</div>
</span>
</div>
<div className="sx-ctx__meta">
@ -2496,7 +2538,8 @@ export default function Session() {
<section className={surfaceClassName("sx-panel sx-session-progress")}>
<div className="sx-session-progress__head">
<Kicker> </Kicker>
{/* D1 — 섹션 라벨(수치는 아래 셀이 전달) → dot 제거 */}
<Kicker dot={false}> </Kicker>
</div>
<div className="sx-session-progress__grid">
<span>
@ -2575,7 +2618,8 @@ export default function Session() {
{/* 실시간 자막(대본 스타일) + 텍스트 입력 폴백 */}
<section className={surfaceClassName("sx-transcript")}>
<div className="sx-transcript__head">
<Kicker> </Kicker>
{/* D1 — 라이브 여부는 우측 sx-transcript__live-dot 이 이미 전달 → 라벨 dot 제거 */}
<Kicker dot={false}> </Kicker>
<span className="sx-transcript__live">
<span
className={"sx-transcript__live-dot" + (transcriptIsLive ? " is-live" : "")}
@ -2708,7 +2752,7 @@ export default function Session() {
? "일시정지 중입니다."
: "학습자 발화를 입력하세요."
}
disabled={sessionEnded || paused || textTurnBlocked}
disabled={textComposerDisabled}
aria-label="학습자 발화 입력"
/>
</div>
@ -2739,7 +2783,8 @@ export default function Session() {
{feedbackMode !== "immersive" ? (
<section className={surfaceClassName("sx-panel sx-signal")}>
<div className="sx-signal__head">
<Kicker> </Kicker>
{/* D1 — 신호 상태는 아래 sx-signal__one-dot 이 색으로 전달 → 라벨 dot 제거 */}
<Kicker dot={false}> </Kicker>
</div>
{liveSignal ? (
@ -2925,7 +2970,8 @@ export default function Session() {
) : (
<section className={surfaceClassName("sx-panel sx-signal")}>
<div className="sx-signal__head">
<Kicker> </Kicker>
{/* D1 — 섹션 라벨 → dot 제거 */}
<Kicker dot={false}> </Kicker>
</div>
<div className="sx-signal__defer">
.
@ -2942,7 +2988,8 @@ export default function Session() {
aria-expanded={!metersCollapsed}
onClick={() => setMetersCollapsed((prev) => !prev)}
>
<Kicker> </Kicker>
{/* D1 — 수치는 아래 게이지가 전달하는 섹션 라벨 → dot 제거 */}
<Kicker dot={false}> </Kicker>
<span className="sx-panel-toggle__hint">
{metersCollapsed
? progress