런타임 계약과 학습자 흐름 보강
This commit is contained in:
parent
f456b8997a
commit
206018b088
56 changed files with 4306 additions and 1008 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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="다음 지도에서 확인할 점을 남깁니다."
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue