런타임 계약과 학습자 흐름 보강

This commit is contained in:
Yun Chan 2026-06-29 08:12:14 +09:00
parent f456b8997a
commit 206018b088
56 changed files with 4306 additions and 1008 deletions

View file

@ -30,6 +30,7 @@ function defaultApiBase(): string {
const configuredApiBase = (import.meta.env.VITE_API_BASE as string | undefined)?.trim();
const API_BASE: string = configuredApiBase || defaultApiBase();
export const AUTH_EXPIRED_EVENT = "vignette:auth-expired";
export class ApiError extends Error {
readonly status: number;
@ -96,6 +97,15 @@ async function parseError(res: Response): Promise<ApiError> {
return new ApiError(res.status, detail, body);
}
function notifyAuthExpired(path: string, error: ApiError): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(AUTH_EXPIRED_EVENT, {
detail: { path, status: error.status, detail: error.detail },
}),
);
}
/**
* JSON API . 2xx ApiError throw.
* 204/ undefined .
@ -126,7 +136,9 @@ export async function apiFetch<T = unknown>(
const res = await fetch(joinUrl(path), init);
if (!res.ok) {
throw await parseError(res);
const error = await parseError(res);
if (error.status === 401) notifyAuthExpired(path, error);
throw error;
}
if (res.status === 204) return undefined as T;

View file

@ -7,7 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import { api, apiUrl, authApi, type MeResponse } from "./api";
import { AUTH_EXPIRED_EVENT, api, apiUrl, authApi, type MeResponse } from "./api";
export type Role = "learner" | "teacher" | "admin";
export type AccountStatus = "pending" | "approved" | "suspended";
@ -113,6 +113,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const handleAuthExpired = () => {
setUser(null);
setLoading(false);
};
window.addEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
return () => {
window.removeEventListener(AUTH_EXPIRED_EVENT, handleAuthExpired);
};
}, []);
useEffect(() => {
let alive = true;
(async () => {

View file

@ -935,116 +935,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
</div>
</header>
<section className="lh-dashboard-hero" aria-label="AI 코칭 요약">
<article className="lh-session-focus">
<div className="lh-session-focus__head">
<div>
<Kicker>{spotlightSession ? "오늘 이어갈 회기" : "첫 회기 준비"}</Kicker>
<h2>{recapSessionTitle}</h2>
<p>{recapPersonaSubtitle}</p>
</div>
<span className="lh-session-focus__status">
{spotlightSession ? sessionStatusLabel(spotlightSession) : "대기"}
</span>
</div>
<section className="lh-recap" aria-label="마지막 세션 리캡">
<div className="lh-recap__avatar">
<ClientAvatar
persona={spotlightAvatar}
state={recapAvatarState(spotlightSession)}
affect={spotlightAffect}
rapport={spotlightSession?.stage === "정리" ? 0.72 : 0.38}
size={132}
showCaption={false}
showMeta={false}
animated={false}
/>
</div>
<div className="lh-recap__body">
<div className="lh-recap__meta-row">
<span>{spotlightSession?.persona_code ?? selected?.code ?? "P-"}</span>
<span>{spotlightSession ? `${spotlightSession.turn_count}` : "선택 전"}</span>
<span>{spotlightSession ? sessionDateLabel(spotlightSession.started_at) : "새 연습"}</span>
</div>
<dl className="lh-recap__facts">
<div>
<dt></dt>
<dd>{spotlightSession?.stage ?? "선택 전"}</dd>
</div>
<div>
<dt> </dt>
<dd>{spotlightAffectLabel}</dd>
</div>
<div>
<dt> </dt>
<dd>{spotlightSession ? `${spotlightSession.learner_turn_count}회 응답` : "-"}</dd>
</div>
</dl>
<div
className="lh-recap__progress"
style={{ "--lh-stage-progress": `${recapProgress}%` } as CSSProperties}
>
<div>
<span> </span>
<b>{spotlightSession ? `${recapProgress}%` : "0%"}</b>
</div>
<i aria-hidden="true" />
</div>
<div className="lh-recap__quote">
<span> </span>
<p>
{lastClientLine
? `"${truncateText(lastClientLine)}"`
: recapLoadState === "loading"
? "상세 발화를 확인하고 있습니다."
: "아직 표시할 내담자 발화가 없습니다."}
</p>
</div>
<div className="lh-recap__learner">
<span> </span>
<p>{lastLearnerLine ? truncateText(lastLearnerLine, 118) : "아직 기록된 학습자 발화가 없습니다."}</p>
</div>
</div>
</section>
</article>
<aside className="lh-coach-card" aria-label="AI 코치">
<div>
<Kicker>AI </Kicker>
<h2>{coach.title}</h2>
<p>{coach.body}</p>
</div>
<div className="lh-coach-hints" aria-label="코칭 힌트">
{coachHints.map((hint) => (
<span key={hint}>
<Icon name="check" size={14} />
{hint}
</span>
))}
</div>
<p className="lh-recap__coach">{recapCoachLine(spotlightSession)}</p>
<Button
className="lh-coach-card__cta"
size="lg"
block
onClick={goPrimaryAction}
trailing={<Icon name="chevron-right" size={18} />}
>
{coach.action}
</Button>
</aside>
</section>
<section className="lh-metric-grid" aria-label="학습 현황">
<section className="lh-dashboard-status" aria-label="학습 상태 바로가기">
{dashboardMetrics.map((metric) => (
<button
type="button"
@ -1059,103 +950,247 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
))}
</section>
<section className="lh-dashboard-columns">
<article className="lh-panel">
<div className="lh-panel__head">
<section className="lh-dashboard-grid" aria-label="오늘 학습 대시보드">
<section className="lh-work-cluster" aria-label="오늘의 회기 작업">
<div className="lh-work-cluster__head">
<div>
<Kicker> </Kicker>
<h2> .</h2>
<Kicker> </Kicker>
<h2>, , .</h2>
</div>
<button type="button" onClick={() => navigate("/learn/history")}>
</button>
</div>
{renderCompactSessionRows(reviewQueue, "대기 중인 리뷰가 없습니다.", "review")}
<div className="lh-feedback-mini" aria-label="최근 피드백">
<div className="lh-feedback-mini__head">
<Kicker> </Kicker>
<span>
{dashboardReady
? `${recentFeedback.length}`
: dashboardLoadState === "error"
? "불러오기 실패"
: "확인 중"}
</span>
</div>
{dashboardLoadState === "loading" ? (
<p> .</p>
) : dashboardLoadState === "error" ? (
<p> .</p>
) : recentFeedback.length > 0 ? (
<button
type="button"
className="lh-feedback-mini__row"
onClick={() => navigate(`/learn/session/${recentFeedback[0].session_id}/review`)}
>
<b>
{recentFeedback[0].persona_code} · {recentFeedback[0].stage} ·{" "}
{scoreLabel(recentFeedback[0].score)}
</b>
<span>{truncateText(recentFeedback[0].note, 86)}</span>
</button>
) : (
<p> .</p>
)}
</div>
</article>
<article className="lh-panel">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2>{reviewCount > 0 ? "리뷰 확인을 우선합니다." : "연습 목표를 좁힙니다."}</h2>
</div>
<span className="lh-panel__badge"> </span>
</div>
<div className="lh-recommend-card">
<span>{reviewCount > 0 ? "AI 피드백 먼저" : "개인 연습"}</span>
<b>
{reviewCount > 0
? `${reviewCount}건의 회기 피드백 확인`
: recentAverageTurns != null && recentAverageTurns < 4
? "탐색 질문 늘리기"
: "난도 확장 검토"}
</b>
<p>
{reviewCount > 0
? "새 회기를 시작하기 전에 이미 끝난 대화의 반응과 대안 발화를 확인하세요."
: "다음 회기에서는 감정 반영 뒤 무엇을 더 물을지 한 문장으로 정하고 들어갑니다."}
</p>
<button
type="button"
onClick={() => navigate(reviewCount > 0 ? "/learn/history" : "/learn/practice")}
<Button
variant="secondary"
size="sm"
onClick={() => navigate("/learn/practice")}
trailing={<Icon name="chevron-right" size={15} />}
>
{reviewCount > 0 ? "리뷰 확인하기" : "연습 시작하기"}
<Icon name="chevron-right" size={15} />
</button>
</Button>
</div>
<div className="lh-insight-list">
<div>
<span> </span>
<b>{topPersona?.total ? `${topPersona.name} · ${topPersona.total}` : "아직 없음"}</b>
<p> .</p>
</div>
</div>
</article>
<div className="lh-work-cluster__primary">
<article className="lh-session-focus">
<div className="lh-session-focus__head">
<div>
<Kicker>{spotlightSession ? "오늘 이어갈 회기" : "첫 회기 준비"}</Kicker>
<h2>{recapSessionTitle}</h2>
<p>{recapPersonaSubtitle}</p>
</div>
<span className="lh-session-focus__status">
{spotlightSession ? sessionStatusLabel(spotlightSession) : "대기"}
</span>
</div>
<article className="lh-panel">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2> .</h2>
</div>
<button type="button" onClick={() => navigate("/learn/history")}>
</button>
<section className="lh-recap" aria-label="마지막 세션 리캡">
<div className="lh-recap__avatar">
<ClientAvatar
persona={spotlightAvatar}
state={recapAvatarState(spotlightSession)}
affect={spotlightAffect}
rapport={spotlightSession?.stage === "정리" ? 0.72 : 0.38}
size={132}
showCaption={false}
showMeta={false}
animated={false}
/>
</div>
<div className="lh-recap__body">
<div className="lh-recap__meta-row">
<span>{spotlightSession?.persona_code ?? selected?.code ?? "P-"}</span>
<span>{spotlightSession ? `${spotlightSession.turn_count}` : "선택 전"}</span>
<span>{spotlightSession ? sessionDateLabel(spotlightSession.started_at) : "새 연습"}</span>
</div>
<dl className="lh-recap__facts">
<div>
<dt></dt>
<dd>{spotlightSession?.stage ?? "선택 전"}</dd>
</div>
<div>
<dt> </dt>
<dd>{spotlightAffectLabel}</dd>
</div>
<div>
<dt> </dt>
<dd>{spotlightSession ? `${spotlightSession.learner_turn_count}회 응답` : "-"}</dd>
</div>
</dl>
<div
className="lh-recap__progress"
style={{ "--lh-stage-progress": `${recapProgress}%` } as CSSProperties}
>
<div>
<span> </span>
<b>{spotlightSession ? `${recapProgress}%` : "0%"}</b>
</div>
<i aria-hidden="true" />
</div>
<div className="lh-recap__quote">
<span> </span>
<p>
{lastClientLine
? `"${truncateText(lastClientLine)}"`
: recapLoadState === "loading"
? "상세 발화를 확인하고 있습니다."
: "아직 표시할 내담자 발화가 없습니다."}
</p>
</div>
<div className="lh-recap__learner">
<span> </span>
<p>{lastLearnerLine ? truncateText(lastLearnerLine, 118) : "아직 기록된 학습자 발화가 없습니다."}</p>
</div>
</div>
</section>
</article>
<aside className="lh-coach-card" aria-label="AI 코치">
<div>
<Kicker>AI </Kicker>
<h2>{coach.title}</h2>
<p>{coach.body}</p>
</div>
<div className="lh-coach-hints" aria-label="코칭 힌트">
{coachHints.map((hint) => (
<span key={hint}>
<Icon name="check" size={14} />
{hint}
</span>
))}
</div>
<p className="lh-recap__coach">{recapCoachLine(spotlightSession)}</p>
<Button
className="lh-coach-card__cta"
size="lg"
block
onClick={goPrimaryAction}
trailing={<Icon name="chevron-right" size={18} />}
>
{coach.action}
</Button>
</aside>
</div>
{renderCompactSessionRows(recentRecordRows, "최근 기록이 없습니다.", "recent")}
</article>
<div className="lh-work-cluster__secondary">
<article className="lh-panel lh-dashboard-recommend">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2>{reviewCount > 0 ? "리뷰 확인을 우선합니다." : "연습 목표를 좁힙니다."}</h2>
</div>
<span className="lh-panel__badge"> </span>
</div>
<div className="lh-recommend-card">
<span>{reviewCount > 0 ? "AI 피드백 먼저" : "개인 연습"}</span>
<b>
{reviewCount > 0
? `${reviewCount}건의 회기 피드백 확인`
: recentAverageTurns != null && recentAverageTurns < 4
? "탐색 질문 늘리기"
: "난도 확장 검토"}
</b>
<p>
{reviewCount > 0
? "새 회기를 시작하기 전에 이미 끝난 대화의 반응과 대안 발화를 확인하세요."
: "다음 회기에서는 감정 반영 뒤 무엇을 더 물을지 한 문장으로 정하고 들어갑니다."}
</p>
<button
type="button"
onClick={() => navigate(reviewCount > 0 ? "/learn/history" : "/learn/practice")}
>
{reviewCount > 0 ? "리뷰 확인하기" : "연습 시작하기"}
<Icon name="chevron-right" size={15} />
</button>
</div>
</article>
<article className="lh-panel lh-dashboard-feedback">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2> .</h2>
</div>
<span className="lh-panel__badge">
{dashboardReady
? `${recentFeedback.length}`
: dashboardLoadState === "error"
? "불러오기 실패"
: "확인 중"}
</span>
</div>
<div className="lh-feedback-mini" aria-label="최근 피드백">
{dashboardLoadState === "loading" ? (
<p> .</p>
) : dashboardLoadState === "error" ? (
<p> .</p>
) : recentFeedback.length > 0 ? (
<button
type="button"
className="lh-feedback-mini__row"
onClick={() => navigate(`/learn/session/${recentFeedback[0].session_id}/review`)}
>
<b>
{recentFeedback[0].persona_code} · {recentFeedback[0].stage} ·{" "}
{scoreLabel(recentFeedback[0].score)}
</b>
<span>{truncateText(recentFeedback[0].note, 86)}</span>
</button>
) : (
<p> .</p>
)}
</div>
</article>
</div>
</section>
<aside className="lh-dashboard-side" aria-label="최근 기록 패널">
<article className="lh-panel">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2> .</h2>
</div>
<button type="button" onClick={() => navigate("/learn/history")}>
</button>
</div>
{renderCompactSessionRows(recentRecordRows, "최근 기록이 없습니다.", "recent")}
</article>
<article className="lh-panel">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2> .</h2>
</div>
<button type="button" onClick={() => navigate("/learn/history")}>
</button>
</div>
{renderCompactSessionRows(reviewQueue, "대기 중인 리뷰가 없습니다.", "review")}
</article>
<article className="lh-panel">
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
<h2>{topPersona?.total ? `${topPersona.name} · ${topPersona.total}` : "아직 없음"}</h2>
</div>
</div>
<div className="lh-insight-list">
<div>
<span> </span>
<b>{topPersona?.total ? "주호소 범위를 넓힐 차례" : "첫 회기 이후 표시"}</b>
<p> .</p>
</div>
</div>
</article>
</aside>
</section>
</>
) : null}

View file

@ -84,6 +84,29 @@ interface VoiceEvent {
}
type AudioContextWindow = Window & { webkitAudioContext?: typeof AudioContext };
type VoiceCaptureMode = "audio-worklet" | "media-recorder";
interface VoiceCaptureControl {
type: "audio_start" | "audio_end";
format: string;
sample_rate?: number;
channels?: number;
sample_width?: number;
}
interface VoiceCaptureController {
mode: VoiceCaptureMode;
startControl: VoiceCaptureControl;
start: () => Promise<void> | void;
finish: () => void;
abort: () => void;
isRecording: () => boolean;
}
interface VoiceWorkletMessage {
type?: string;
pcm?: ArrayBuffer;
}
interface Utterance {
id: number;
@ -121,6 +144,8 @@ const THEORY_MODE_OPTIONS: { value: TheoryMode; label: string; detail: string }[
{ value: "cbt", label: "CBT", detail: "생각·행동" },
{ value: "integrative", label: "통합", detail: "혼합 접근" },
];
const VOICE_WORKLET_MODULE_URL = "/worklets/voice-capture-worklet.js";
const VOICE_WORKLET_PROCESSOR = "voice-capture-processor";
function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
const firstSupported = summary?.theory_target.find(
@ -130,6 +155,131 @@ function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
return firstSupported ?? "humanistic";
}
interface SessionVoiceStatusInput {
voiceStatus: VoiceStatus;
sessionEnded: boolean;
paused: boolean;
sending: boolean;
clientReplyPending: boolean;
clientName: string;
utteranceCount: number;
voiceAvailable: boolean | null;
micOn: boolean;
}
interface SessionVoiceStatusView {
micDisabled: boolean;
micLabel: string;
micButtonAriaLabel: string;
sessionStatusLabel: string;
transcriptLiveLabel: string;
transcriptIsLive: boolean;
textTurnBlocked: boolean;
voiceInputStatus: string;
responseStatus: string;
}
function isVoiceStatusBusy(voiceStatus: VoiceStatus): boolean {
return (
voiceStatus === "requesting" ||
voiceStatus === "connecting" ||
voiceStatus === "thinking" ||
voiceStatus === "speaking"
);
}
function sessionVoiceStatusView(input: SessionVoiceStatusInput): SessionVoiceStatusView {
const {
voiceStatus,
sessionEnded,
paused,
sending,
clientReplyPending,
clientName,
utteranceCount,
voiceAvailable,
micOn,
} = input;
const micBusy = isVoiceStatusBusy(voiceStatus);
const micDisabled = sessionEnded || paused || micBusy || voiceAvailable === false;
const micButtonAriaLabel = micOn ? "발화 보내기" : "마이크 켜기";
const micLabel = sessionEnded
? "종료됨"
: paused
? "일시정지"
: voiceStatus === "requesting"
? "권한 요청 중"
: voiceStatus === "connecting"
? "연결 중"
: voiceStatus === "recording"
? "녹음 중"
: voiceStatus === "thinking"
? "전사 중"
: voiceStatus === "speaking"
? "재생 중"
: voiceStatus === "degraded"
? "음성 미설정"
: voiceStatus === "error"
? "마이크 오류"
: "마이크 꺼짐";
const sessionStatusLabel = sessionEnded
? "회기 종료됨"
: paused
? "일시정지"
: voiceStatus === "recording"
? "학습자 발화 수신 중"
: voiceStatus === "thinking"
? "내담자 응답 준비 중"
: voiceStatus === "speaking"
? `${clientName} 응답 중`
: "회기 진행 중";
const transcriptLiveLabel =
voiceStatus === "recording"
? "받아쓰는 중"
: voiceStatus === "thinking"
? "전사 중"
: voiceStatus === "speaking"
? "응답 표시 중"
: sending
? "응답 대기"
: utteranceCount > 0
? "기록 중"
: "준비됨";
const transcriptIsLive =
voiceStatus === "recording" ||
voiceStatus === "thinking" ||
voiceStatus === "speaking" ||
sending;
const textTurnBlocked = sending || voiceStatus === "thinking";
const voiceInputStatus =
voiceAvailable === false
? "음성 미설정"
: micOn
? "수신 중"
: voiceStatus === "requesting"
? "권한 요청"
: "대기";
const responseStatus =
sending || clientReplyPending
? "응답 대기"
: voiceStatus === "thinking"
? "전사 중"
: voiceStatus === "speaking"
? "재생 중"
: "안정";
return {
micDisabled,
micLabel,
micButtonAriaLabel,
sessionStatusLabel,
transcriptLiveLabel,
transcriptIsLive,
textTurnBlocked,
voiceInputStatus,
responseStatus,
};
}
const PERSONA_AVATAR_LOOKS = {
P1: {
skinTone: "#F0DDC4",
@ -290,6 +440,174 @@ function voiceFormatFromMime(mime: string): string {
return "webm";
}
function sendVoiceControl(ws: WebSocket, payload: VoiceCaptureControl): void {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function supportsAudioWorkletCapture(ctx: AudioContext | null): ctx is AudioContext & {
audioWorklet: AudioWorklet;
} {
return Boolean(
ctx &&
ctx.state !== "closed" &&
ctx.audioWorklet &&
typeof AudioWorkletNode !== "undefined" &&
typeof ctx.createMediaStreamSource === "function",
);
}
function createMediaRecorderCapture(
stream: MediaStream,
ws: WebSocket,
onRelease: () => void,
): VoiceCaptureController | null {
if (typeof MediaRecorder === "undefined") return null;
const mimeType = recorderMimeType();
const format = voiceFormatFromMime(mimeType);
const startControl: VoiceCaptureControl = { type: "audio_start", format };
const endControl: VoiceCaptureControl = { type: "audio_end", format };
let recorder: MediaRecorder;
try {
recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
} catch {
return null;
}
let finishRequested = false;
let released = false;
const release = () => {
if (released) return;
released = true;
onRelease();
};
recorder.ondataavailable = (event) => {
if (!event.data.size || ws.readyState !== WebSocket.OPEN) return;
ws.send(event.data);
};
recorder.onstop = () => {
release();
if (finishRequested) sendVoiceControl(ws, endControl);
};
return {
mode: "media-recorder",
startControl,
start: () => {
recorder.start(500);
},
finish: () => {
finishRequested = true;
if (recorder.state !== "inactive") {
try {
recorder.stop();
return;
} catch {
/* recorder stop race 무시 */
}
}
release();
sendVoiceControl(ws, endControl);
},
abort: () => {
finishRequested = false;
if (recorder.state !== "inactive") {
try {
recorder.stop();
} catch {
/* recorder stop race 무시 */
}
}
release();
},
isRecording: () => recorder.state === "recording",
};
}
async function createAudioWorkletCapture(
stream: MediaStream,
ws: WebSocket,
ctx: AudioContext & { audioWorklet: AudioWorklet },
onRelease: () => void,
): Promise<VoiceCaptureController> {
await ctx.audioWorklet.addModule(VOICE_WORKLET_MODULE_URL);
const source = ctx.createMediaStreamSource(stream);
const node = new AudioWorkletNode(ctx, VOICE_WORKLET_PROCESSOR, {
numberOfInputs: 1,
numberOfOutputs: 0,
});
const startControl: VoiceCaptureControl = {
type: "audio_start",
format: "pcm",
sample_rate: Math.round(ctx.sampleRate || 48000),
channels: 1,
sample_width: 2,
};
const endControl: VoiceCaptureControl = { ...startControl, type: "audio_end" };
let recording = false;
let released = false;
let finishTimer: number | null = null;
const release = () => {
if (released) return;
released = true;
if (finishTimer !== null) {
window.clearTimeout(finishTimer);
finishTimer = null;
}
try {
source.disconnect();
node.disconnect();
} catch {
/* disconnect race 무시 */
}
node.port.onmessage = null;
try {
node.port.close();
} catch {
/* 일부 브라우저/테스트 double은 close가 없을 수 있다. */
}
onRelease();
};
node.port.onmessage = (event: MessageEvent<VoiceWorkletMessage>) => {
if (!recording || ws.readyState !== WebSocket.OPEN) return;
const payload = event.data;
if (payload?.type !== "chunk" || !payload.pcm?.byteLength) return;
ws.send(payload.pcm);
};
return {
mode: "audio-worklet",
startControl,
start: () => {
recording = true;
source.connect(node);
},
finish: () => {
if (!recording || finishTimer !== null) return;
try {
node.port.postMessage({ type: "flush" });
} catch {
/* flush 실패 시 이미 보낸 chunk만 사용한다. */
}
finishTimer = window.setTimeout(() => {
finishTimer = null;
recording = false;
release();
sendVoiceControl(ws, endControl);
}, 20);
},
abort: () => {
recording = false;
release();
},
isRecording: () => recording,
};
}
/* ── 작은 표현 유틸 ─────────────────────────────────────────────────── */
// 백엔드가 반환한 effective_openness(0~1)를 실시간 관찰 신호로만 변환한다.
@ -446,7 +764,7 @@ export default function Session() {
const fadeTimerRef = useRef<number | null>(null);
const voiceSocketRef = useRef<WebSocket | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const voiceCaptureRef = useRef<VoiceCaptureController | null>(null);
const micStreamRef = useRef<MediaStream | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const ttsChunksRef = useRef<BlobPart[]>([]);
@ -1004,15 +1322,9 @@ export default function Session() {
const shutdownVoice = useCallback(
(status: VoiceStatus = "idle", detail = "마이크 꺼짐") => {
stopTtsPlayback();
const recorder = recorderRef.current;
recorderRef.current = null;
if (recorder && recorder.state !== "inactive") {
try {
recorder.stop();
} catch {
/* recorder stop race 무시 */
}
}
const capture = voiceCaptureRef.current;
voiceCaptureRef.current = null;
capture?.abort();
stopMicStream();
closeVoiceSocket();
setMicOn(false);
@ -1024,13 +1336,10 @@ export default function Session() {
);
const finishVoiceUtterance = useCallback(() => {
const recorder = recorderRef.current;
if (recorder && recorder.state !== "inactive") {
try {
recorder.stop();
} catch {
stopMicStream();
}
const capture = voiceCaptureRef.current;
voiceCaptureRef.current = null;
if (capture) {
capture.finish();
} else {
stopMicStream();
}
@ -1155,7 +1464,7 @@ export default function Session() {
const startVoiceCapture = useCallback(async () => {
if (!liveSessionId || paused || sending || sessionEnded) return;
if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
if (!navigator.mediaDevices?.getUserMedia) {
setVoiceStatus("error");
setVoiceDetail("이 브라우저는 마이크 녹음을 지원하지 않습니다.");
pushSignal("warn", "마이크 미지원");
@ -1181,8 +1490,6 @@ export default function Session() {
setVoiceStatus("connecting");
setVoiceDetail("음성 연결을 준비하는 중입니다.");
const mimeType = recorderMimeType();
const format = voiceFormatFromMime(mimeType);
const ws = new WebSocket(
apiWsUrl(`/voice/ws?session_id=${encodeURIComponent(liveSessionId)}`),
);
@ -1190,33 +1497,48 @@ export default function Session() {
voiceSocketRef.current = ws;
ttsChunksRef.current = [];
let recorder: MediaRecorder;
try {
recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
} catch {
stopMicStream();
closeVoiceSocket();
setVoiceStatus("error");
setVoiceDetail("마이크 녹음기를 만들지 못했습니다. 브라우저 오디오 설정을 확인해 주세요.");
return;
}
recorderRef.current = recorder;
setClientReplyPending(false);
recorder.ondataavailable = (event) => {
if (!event.data.size || ws.readyState !== WebSocket.OPEN) return;
ws.send(event.data);
};
recorder.onstop = () => {
stopMicStream();
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "audio_end", format }));
let pendingCapture: VoiceCaptureController | null = null;
const capturePromise = (async () => {
const ctx = ensureVoiceAudioContext();
if (supportsAudioWorkletCapture(ctx)) {
try {
pendingCapture = await createAudioWorkletCapture(stream, ws, ctx, stopMicStream);
return pendingCapture;
} catch {
pendingCapture = null;
}
}
};
pendingCapture = createMediaRecorderCapture(stream, ws, stopMicStream);
return pendingCapture;
})();
ws.onopen = () => {
ws.send(JSON.stringify({ type: "audio_start", format }));
recorder.start(500);
ws.onopen = async () => {
const capture = await capturePromise;
if (ws.readyState !== WebSocket.OPEN) {
capture?.abort();
return;
}
if (!capture) {
stopMicStream();
closeVoiceSocket();
setVoiceStatus("error");
setVoiceDetail("마이크 녹음기를 만들지 못했습니다. 브라우저 오디오 설정을 확인해 주세요.");
return;
}
voiceCaptureRef.current = capture;
sendVoiceControl(ws, capture.startControl);
try {
await capture.start();
} catch {
voiceCaptureRef.current = null;
capture.abort();
closeVoiceSocket();
setVoiceStatus("error");
setVoiceDetail("마이크 녹음기를 시작하지 못했습니다. 브라우저 오디오 설정을 확인해 주세요.");
return;
}
setMicOn(true);
setAvatarState("listening");
setVoiceStatus("recording");
@ -1252,7 +1574,7 @@ export default function Session() {
setAvatarState("speaking");
setVoiceStatus("speaking");
setVoiceDetail(`${clientName} 음성을 받는 중입니다.`);
} else if (payload.state === "idle" && recorderRef.current?.state !== "recording") {
} else if (payload.state === "idle" && !voiceCaptureRef.current?.isRecording()) {
setAvatarState("listening");
setVoiceStatus("idle");
setVoiceDetail("마이크를 다시 켜 발화하세요.");
@ -1341,7 +1663,13 @@ export default function Session() {
};
ws.onclose = () => {
recorderRef.current = null;
const capture = voiceCaptureRef.current;
voiceCaptureRef.current = null;
capture?.abort();
if (pendingCapture && pendingCapture !== capture) {
pendingCapture.abort();
pendingCapture = null;
}
stopMicStream();
setMicOn(false);
setVoiceStatus((current) => (current === "degraded" || current === "error" ? current : "idle"));
@ -1361,6 +1689,7 @@ export default function Session() {
appendServerClientReply,
closeVoiceSocket,
elapsed,
ensureVoiceAudioContext,
liveSessionId,
paused,
sessionEnded,
@ -1390,12 +1719,7 @@ export default function Session() {
pushSignal("warn", "음성 미설정 — 텍스트로 진행");
return;
}
if (
voiceStatus === "requesting" ||
voiceStatus === "connecting" ||
voiceStatus === "thinking" ||
voiceStatus === "speaking"
) {
if (isVoiceStatusBusy(voiceStatus)) {
return;
}
if (micOn || voiceStatus === "recording") {
@ -1557,59 +1881,27 @@ export default function Session() {
? "thinking"
: "idle";
const micBusy =
voiceStatus === "requesting" ||
voiceStatus === "connecting" ||
voiceStatus === "thinking" ||
voiceStatus === "speaking";
const micLabel = sessionEnded
? "종료됨"
: paused
? "일시정지"
: voiceStatus === "requesting"
? "권한 요청 중"
: voiceStatus === "connecting"
? "연결 중"
: voiceStatus === "recording"
? "녹음 중"
: voiceStatus === "thinking"
? "전사 중"
: voiceStatus === "speaking"
? "재생 중"
: voiceStatus === "degraded"
? "음성 미설정"
: voiceStatus === "error"
? "마이크 오류"
: "마이크 꺼짐";
const sessionStatusLabel = sessionEnded
? "회기 종료됨"
: paused
? "일시정지"
: voiceStatus === "recording"
? "학습자 발화 수신 중"
: voiceStatus === "thinking"
? "내담자 응답 준비 중"
: voiceStatus === "speaking"
? `${clientName} 응답 중`
: "회기 진행 중";
const transcriptLiveLabel =
voiceStatus === "recording"
? "받아쓰는 중"
: voiceStatus === "thinking"
? "전사 중"
: voiceStatus === "speaking"
? "응답 표시 중"
: sending
? "응답 대기"
: utterances.length > 0
? "기록 중"
: "준비됨";
const transcriptIsLive =
voiceStatus === "recording" ||
voiceStatus === "thinking" ||
voiceStatus === "speaking" ||
sending;
const textTurnBlocked = sending || voiceStatus === "thinking";
const {
micDisabled,
micLabel,
micButtonAriaLabel,
sessionStatusLabel,
transcriptLiveLabel,
transcriptIsLive,
textTurnBlocked,
voiceInputStatus,
responseStatus,
} = sessionVoiceStatusView({
voiceStatus,
sessionEnded,
paused,
sending,
clientReplyPending,
clientName,
utteranceCount: utterances.length,
voiceAvailable,
micOn,
});
const elapsedLabel = formatElapsed(elapsed);
const recommendedSessionSeconds = 30 * 60;
const remainingLabel = formatElapsed(Math.max(0, recommendedSessionSeconds - elapsed));
@ -1621,22 +1913,6 @@ export default function Session() {
latestClientUtterance?.text ??
primaryContext?.v ??
`${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`;
const voiceInputStatus =
voiceAvailable === false
? "음성 미설정"
: micOn
? "수신 중"
: voiceStatus === "requesting"
? "권한 요청"
: "대기";
const responseStatus =
sending || clientReplyPending
? "응답 대기"
: voiceStatus === "thinking"
? "전사 중"
: voiceStatus === "speaking"
? "재생 중"
: "안정";
const safetyStatusText = safety ? "확인 필요" : "안전";
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
const consentRequired = user?.role === "learner" && user.consentAt == null;
@ -2494,9 +2770,9 @@ export default function Session() {
type="button"
className={"sx-mic " + (micOn ? "is-on" : "is-off")}
onClick={toggleMic}
disabled={sessionEnded || paused || micBusy || voiceAvailable === false}
disabled={micDisabled}
aria-pressed={micOn}
aria-label={micOn ? "발화 보내기" : "마이크 켜기"}
aria-label={micButtonAriaLabel}
>
<Icon name={micOn ? "mic" : "mic-off"} size={22} />
</button>

View file

@ -305,6 +305,7 @@ function JumpablePoint({
<button
type="button"
className="sr-point__at tabular"
aria-label={`${targetTs} 발화로 이동`}
onClick={() => onJump(point.jumpTo!)}
>
{targetTs}
@ -446,6 +447,9 @@ function CaseWorksheetCard({
</div>
<textarea
className={`sr-ws-input ${readOnly ? "is-readonly" : ""}`}
name={`worksheet-${section.key}-${item.key}`}
aria-label={`${section.title} ${item.label}`}
autoComplete="off"
value={item.value ?? ""}
placeholder={item.emptyReason || "근거 대기"}
rows={3}
@ -458,6 +462,7 @@ function CaseWorksheetCard({
<button
type="button"
className="sr-ws-evidence"
aria-label={`${evidence.speaker === "learner" ? "학습자" : "내담자"} 근거 발화로 이동`}
onClick={() => onJump(evidence.turnId)}
>
{evidence.speaker === "learner" ? "학습자" : "내담자"} · {evidence.quote}
@ -610,11 +615,13 @@ function PrepostMeasureCard() {
<label className="sr-prepost__field" key={key}>
<span>{measure.label} {timepoint.label}</span>
<input
name={`prepost-${key}`}
type="number"
min={PREPOST_SCORE_MIN}
max={PREPOST_SCORE_MAX}
step="0.1"
inputMode="decimal"
autoComplete="off"
value={draft[key] ?? ""}
placeholder="-"
aria-invalid={invalid}
@ -1179,6 +1186,8 @@ export default function SessionReview() {
<label className="sr-teacher-review__note">
<span> </span>
<textarea
name="teacher-review-note"
autoComplete="off"
value={teacherNote}
rows={4}
placeholder="다음 지도에서 확인할 점을 남깁니다."

View file

@ -60,6 +60,70 @@
gap:var(--sp-4);
align-items:start;
}
.lh-dashboard-grid{
min-width:0;
display:grid;
grid-template-columns:minmax(0,1fr) minmax(300px,360px);
gap:var(--sp-4);
align-items:start;
}
.lh-dashboard-status{
min-width:0;
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:10px;
}
.lh-dashboard-side{
min-width:0;
display:grid;
align-content:start;
gap:var(--sp-4);
}
.lh-dashboard-feedback .lh-feedback-mini{
padding-top:0;
border-top:0;
}
.lh-work-cluster{
min-width:0;
display:grid;
gap:var(--sp-4);
padding:clamp(14px,1.6vw,20px);
border:1px solid color-mix(in srgb,var(--accent) 15%,var(--border-subtle));
border-radius:var(--radius);
background:
linear-gradient(135deg,color-mix(in srgb,var(--accent) 8%,transparent),transparent 44%),
color-mix(in srgb,var(--bg-surface) 88%,var(--bg-surface-2));
box-shadow:var(--shadow-sm);
}
.lh-work-cluster__head{
min-width:0;
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:var(--sp-3);
padding-bottom:var(--sp-3);
border-bottom:1px solid var(--border-subtle);
}
.lh-work-cluster__head h2{
margin:7px 0 0;
color:var(--text-strong);
font-size:clamp(19px,1.6vw,24px);
line-height:1.18;
letter-spacing:0;
}
.lh-work-cluster__primary,
.lh-work-cluster__secondary{
min-width:0;
display:grid;
gap:var(--sp-4);
align-items:stretch;
}
.lh-work-cluster__primary{
grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);
}
.lh-work-cluster__secondary{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.lh-session-focus,
.lh-coach-card,
.lh-panel,
@ -362,6 +426,20 @@
border-color:var(--border-strong);
background:var(--bg-surface-2);
}
.lh-metric-card:focus-visible,
.lh-history-task:focus-visible,
.lh-panel__head button:focus-visible,
.lh-recommend-card button:focus-visible,
.lh-compact-list__action:focus-visible,
.lh-feedback-mini__row:focus-visible,
.lh-pane-head button:focus-visible,
.lh-persona:focus-visible,
.lh-review-link:focus-visible,
.lh-persona-progress__row:focus-visible{
outline:2px solid var(--border-focus);
outline-offset:2px;
box-shadow:0 0 0 3px var(--focus-ring);
}
.lh-metric-card span,
.lh-insight-list span{
color:var(--text-muted);
@ -782,6 +860,22 @@
overflow:hidden;
}
.lh-pane-head{margin-bottom:var(--sp-3);}
.lh-pane-head button{
min-height:30px;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
color:var(--accent-deep);
padding:0 9px;
font-size:12px;
font-weight:740;
white-space:nowrap;
cursor:pointer;
}
.lh-pane-head button:hover{
border-color:var(--border-strong);
background:var(--accent-tint);
}
.lh-pane-head span,
.lh-activity__head span,
.lh-persona-progress__head span{
@ -848,10 +942,11 @@
color:var(--text-strong);
font-size:14.5px;
font-weight:760;
line-height:1.25;
line-height:1.32;
word-break:keep-all;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:1;
-webkit-line-clamp:2;
-webkit-box-orient:vertical;
}
.lh-persona__meta{
@ -869,7 +964,7 @@
line-height:1.45;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:2;
-webkit-line-clamp:3;
-webkit-box-orient:vertical;
}
.lh-persona__check{
@ -1363,8 +1458,30 @@
}
.lh-error b{display:block;}
.lh-error p{margin:4px 0 0;font-size:var(--fs-sm);line-height:1.5;}
[data-theme="dark"] .lh-coach-hints span,
[data-theme="dark"] .lh-insight-list div,
[data-theme="dark"] .lh-compact-list li,
[data-theme="dark"] .lh-persona,
[data-theme="dark"] .lh-persona-progress__row{
border-color:rgba(203,227,220,.11);
background:rgba(255,255,255,.045);
}
[data-theme="dark"] .lh-recommend-card{
border-color:rgba(111,179,164,.22);
background:
linear-gradient(135deg,rgba(111,179,164,.12),transparent 62%),
rgba(255,255,255,.052);
}
[data-theme="dark"] .lh-persona.is-selected,
[data-theme="dark"] .lh-persona-progress__row.is-selected{
border-color:rgba(111,179,164,.66);
background:rgba(111,179,164,.13);
}
@keyframes lhSkel{0%{background-position:120% 0}100%{background-position:-80% 0}}
@media (max-width:1180px){
.lh-dashboard-grid{
grid-template-columns:1fr;
}
.lh-dashboard-hero,
.lh-dashboard-columns,
.lh-history-layout,
@ -1377,6 +1494,12 @@
max-height:none;
overflow:visible;
}
.lh-work-cluster__primary{
grid-template-columns:1fr;
}
.lh-dashboard-status{
grid-template-columns:repeat(4,minmax(0,1fr));
}
.lh-personas{
grid-template-columns:repeat(2,minmax(0,1fr));
overflow:visible;
@ -1384,9 +1507,13 @@
}
}
@media (max-width:860px){
.lh-dashboard-status,
.lh-metric-grid{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.lh-work-cluster__secondary{
grid-template-columns:1fr;
}
.lh-history-overview{
grid-template-columns:repeat(2,minmax(0,1fr));
}
@ -1430,6 +1557,7 @@
.lh-session-focus,
.lh-coach-card,
.lh-panel,
.lh-work-cluster,
.lh-list-pane,
.lh-preview__main,
.lh-activity,

View file

@ -140,24 +140,21 @@
"session session session";
}
.sr-cols--learner {
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
grid-template-columns: minmax(232px, 300px) minmax(380px, 1fr) minmax(264px, 328px);
grid-template-areas:
"transcript overview"
"transcript chart"
"transcript flow"
"transcript rubric"
"transcript good"
"transcript growth"
"transcript prepost"
"transcript worksheet"
"transcript feedback"
"session session";
"overview transcript rubric"
"chart transcript good"
"flow transcript growth"
"worksheet worksheet prepost"
"worksheet worksheet feedback"
"session session session";
}
.sr-cols--learner .sr-overview {
position: static;
max-height: none;
overflow: visible;
scrollbar-gutter: auto;
position: sticky;
top: calc(var(--topbar-h) + var(--sp-4));
max-height: calc(100vh - var(--topbar-h) - var(--sp-5) - var(--sp-5));
overflow: auto;
scrollbar-gutter: stable;
}
.sr-root--empty .sr-cols {
grid-template-columns: minmax(236px, 300px) minmax(0, 1fr);
@ -293,10 +290,11 @@
font-size: var(--fs-sm);
line-height: 1.5;
}
.sr-teacher-review__note textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: var(--accent);
.sr-teacher-review__note textarea:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.sr-teacher-review__meta,
.sr-teacher-review__error {
@ -525,9 +523,11 @@
font-weight: 650;
text-align: center;
}
.sr-prepost__field input:focus {
outline: 2px solid var(--accent-tint);
border-color: var(--accent);
.sr-prepost__field input:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.sr-prepost__field input[aria-invalid="true"] {
border-color: color-mix(in srgb, var(--crit-text) 44%, var(--border-subtle));
@ -718,6 +718,13 @@
color: var(--text-strong);
border-color: var(--border-strong);
}
.sr-chip-toggle:focus-visible,
.sr-ws-evidence:focus-visible,
.sr-point__at:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
box-shadow: 0 0 0 3px var(--focus-ring);
}
.sr-chip-toggle[aria-pressed="true"] {
color: var(--accent-deep);
background: var(--accent-tint);
@ -1180,9 +1187,11 @@
line-height: 1.55;
overflow-wrap: anywhere;
}
.sr-ws-input:focus {
outline: 2px solid var(--accent-tint);
border-color: var(--accent);
.sr-ws-input:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.sr-ws-input.is-readonly {
resize: none;
@ -1190,9 +1199,10 @@
color: var(--text-body);
background: color-mix(in srgb, var(--bg-surface) 82%, var(--bg-surface-2));
}
.sr-ws-input.is-readonly:focus {
.sr-ws-input.is-readonly:focus-visible {
outline: none;
border-color: var(--border-subtle);
box-shadow: none;
}
.sr-ws-input::placeholder {
color: var(--text-muted);
@ -1395,6 +1405,17 @@
[data-theme="dark"] .sr-card--transcript {
background: linear-gradient(180deg, rgba(13, 24, 22, 0.98), rgba(11, 19, 17, 0.98));
}
[data-theme="dark"] .sr-overview {
border-color: rgba(111, 179, 164, 0.2);
background: linear-gradient(180deg, rgba(32, 50, 45, 0.94), rgba(18, 31, 28, 0.98));
}
[data-theme="dark"] .sr-card--worksheet {
background: linear-gradient(180deg, rgba(25, 39, 44, 0.94), rgba(16, 27, 31, 0.98));
}
[data-theme="dark"] .sr-card--prepost,
[data-theme="dark"] .sr-card--rubric {
background: linear-gradient(180deg, rgba(29, 43, 38, 0.92), rgba(18, 30, 27, 0.96));
}
[data-theme="dark"] .sr-stat,
[data-theme="dark"] .sr-readiness span,
[data-theme="dark"] .sr-empty,