feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue