세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등 - 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리 - 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치) - 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
This commit is contained in:
parent
7c41c3ce79
commit
778e8526d4
108 changed files with 6457 additions and 455 deletions
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
import type { ReactNode } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { AuthProvider, canAccessRole, useAuth, roleHomePath, type Role } from "./lib/auth";
|
||||
import { AuthProvider, canAccessRole, useAuth, roleHomePath, type AuthUser, type Role } from "./lib/auth";
|
||||
|
||||
import Login from "./pages/Login";
|
||||
import Onboarding from "./pages/Onboarding";
|
||||
|
|
@ -74,6 +74,17 @@ function RequireAuth({
|
|||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function isAdminWorkspacePath(path: string) {
|
||||
return path === "/admin" || path.startsWith("/admin/");
|
||||
}
|
||||
|
||||
function approvedHomePath(user: AuthUser) {
|
||||
if (user.onboardingCompletedAt == null) {
|
||||
return canAccessRole(user, "admin") ? "/admin" : "/onboarding";
|
||||
}
|
||||
return roleHomePath(user.role);
|
||||
}
|
||||
|
||||
function OnboardingGate({ children }: { children: ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
|
@ -82,6 +93,9 @@ function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
if (loading) return <BootScreen />;
|
||||
if (path === "/pending") return <>{children}</>;
|
||||
if (user && user.onboardingCompletedAt == null && path !== "/onboarding") {
|
||||
if (isAdminWorkspacePath(path) && canAccessRole(user, "admin")) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <Navigate to="/onboarding" replace state={{ from: path }} />;
|
||||
}
|
||||
if (user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding")) {
|
||||
|
|
@ -100,12 +114,7 @@ function PendingApprovalGate({ children }: { children: ReactNode }) {
|
|||
return <Navigate to="/pending" replace state={{ from: path }} />;
|
||||
}
|
||||
if (user && user.accountStatus === "approved" && path === "/pending") {
|
||||
return (
|
||||
<Navigate
|
||||
to={user.onboardingCompletedAt == null ? "/onboarding" : roleHomePath(user.role)}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
return <Navigate to={approvedHomePath(user)} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -118,7 +127,7 @@ function RootRedirect() {
|
|||
return <Navigate to="/pending" replace />;
|
||||
}
|
||||
if (user && user.onboardingCompletedAt == null) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
return <Navigate to={approvedHomePath(user)} replace />;
|
||||
}
|
||||
return <Navigate to={user ? roleHomePath(user.role) : "/login"} replace />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2439,6 +2439,33 @@ export interface components {
|
|||
source_note: string;
|
||||
terms: components["schemas"]["LegalDocument"];
|
||||
};
|
||||
/**
|
||||
* LiveCoachCreditEvent
|
||||
* @description 코칭 기회 사용/충전 학습 기록.
|
||||
*/
|
||||
LiveCoachCreditEvent: {
|
||||
/** Balance */
|
||||
balance: number;
|
||||
/** Created At */
|
||||
created_at: string;
|
||||
/** Delta */
|
||||
delta: number;
|
||||
/** Event Id */
|
||||
event_id: string;
|
||||
/**
|
||||
* Event Type
|
||||
* @enum {string}
|
||||
*/
|
||||
event_type: "use" | "recharge";
|
||||
/** Reason */
|
||||
reason: string;
|
||||
/** Session Id */
|
||||
session_id: string;
|
||||
/** Stage */
|
||||
stage?: ("라포" | "탐색" | "개입" | "정리") | null;
|
||||
/** Turn Seq */
|
||||
turn_seq: number;
|
||||
};
|
||||
/**
|
||||
* LiveCoachEvent
|
||||
* @description 회기 중 실제로 전달된 라이브 코칭 이력.
|
||||
|
|
@ -2462,8 +2489,11 @@ export interface components {
|
|||
};
|
||||
/** LiveCoachHistoryResponse */
|
||||
LiveCoachHistoryResponse: {
|
||||
/** Credit Events */
|
||||
credit_events?: components["schemas"]["LiveCoachCreditEvent"][];
|
||||
/** Events */
|
||||
events?: components["schemas"]["LiveCoachEvent"][];
|
||||
quota?: components["schemas"]["LiveCoachQuota"];
|
||||
/**
|
||||
* Source
|
||||
* @default runtime
|
||||
|
|
@ -2471,6 +2501,16 @@ export interface components {
|
|||
*/
|
||||
source: "database" | "runtime";
|
||||
};
|
||||
/**
|
||||
* LiveCoachQuota
|
||||
* @description 회기 중 즉시 코칭 사용 가능 횟수.
|
||||
*/
|
||||
LiveCoachQuota: {
|
||||
/** Max */
|
||||
max: number;
|
||||
/** Remaining */
|
||||
remaining: number;
|
||||
};
|
||||
/** LiveCoachRequest */
|
||||
LiveCoachRequest: {
|
||||
/** Client Reply */
|
||||
|
|
@ -2544,6 +2584,8 @@ export interface components {
|
|||
* @description 프론트가 그대로 표시하는 턴 직후 코칭 카드.
|
||||
*/
|
||||
LiveCoachSuggestion: {
|
||||
/** Credit Events */
|
||||
credit_events?: components["schemas"]["LiveCoachCreditEvent"][];
|
||||
/**
|
||||
* Focus
|
||||
* @default exploration
|
||||
|
|
@ -2559,6 +2601,13 @@ export interface components {
|
|||
message: string;
|
||||
/** Next Utterance */
|
||||
next_utterance?: string | null;
|
||||
/**
|
||||
* Persistence Source
|
||||
* @default database
|
||||
* @enum {string}
|
||||
*/
|
||||
persistence_source: "database" | "runtime";
|
||||
quota?: components["schemas"]["LiveCoachQuota"] | null;
|
||||
/** Rationale */
|
||||
rationale?: string | null;
|
||||
/** Safety Note */
|
||||
|
|
@ -3784,6 +3833,14 @@ export interface components {
|
|||
client_turn_count: number;
|
||||
/** Ended At */
|
||||
ended_at?: string | null;
|
||||
/** Evaluation Error */
|
||||
evaluation_error?: string | null;
|
||||
/**
|
||||
* Evaluation Status
|
||||
* @default pending
|
||||
* @enum {string}
|
||||
*/
|
||||
evaluation_status: "pending" | "ready" | "error";
|
||||
/** Learner Id */
|
||||
learner_id: string;
|
||||
/** Learner Label */
|
||||
|
|
@ -3796,6 +3853,11 @@ export interface components {
|
|||
persona_name: string;
|
||||
/** Review Note */
|
||||
review_note?: string | null;
|
||||
/**
|
||||
* Review Ready
|
||||
* @default false
|
||||
*/
|
||||
review_ready: boolean;
|
||||
/**
|
||||
* Review Status
|
||||
* @default pending
|
||||
|
|
@ -3817,6 +3879,12 @@ export interface components {
|
|||
started_at: string;
|
||||
/** Status */
|
||||
status: string;
|
||||
/**
|
||||
* Supervisor State
|
||||
* @default 기록 대기
|
||||
* @enum {string}
|
||||
*/
|
||||
supervisor_state: "기록 대기" | "평가 대기" | "평가 완료" | "평가 실패";
|
||||
/** Turn Count */
|
||||
turn_count: number;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -201,8 +201,10 @@ export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
|
|||
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
|
||||
export type TurnResponse = ApiSchema<"TurnResponse">;
|
||||
export type LiveCoachRequest = ApiSchema<"LiveCoachRequest">;
|
||||
export type LiveCoachCreditEvent = ApiSchema<"LiveCoachCreditEvent">;
|
||||
export type LiveCoachEvent = ApiSchema<"LiveCoachEvent">;
|
||||
export type LiveCoachHistoryResponse = ApiSchema<"LiveCoachHistoryResponse">;
|
||||
export type LiveCoachQuota = ApiSchema<"LiveCoachQuota">;
|
||||
export type LiveCoachSuggestion = ApiSchema<"LiveCoachSuggestion">;
|
||||
export type LiveCoachSource = ApiSchema<"LiveCoachSource">;
|
||||
export type ReevaluateRequest = ApiSchema<"ReevaluateRequest">;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,19 @@ import "./professor.css";
|
|||
type LoadState = "loading" | "ready" | "error";
|
||||
type ProfessorView = "console" | "analysis";
|
||||
type LearnerSort = "latest" | "sessions" | "score" | "delta";
|
||||
type LearnerAnalysisTab = "trend" | "sessions" | "stages";
|
||||
type LearnerAnalysisTab = "personas" | "trend" | "sessions" | "stages";
|
||||
type PersonaSessionGroup = {
|
||||
key: string;
|
||||
personaCode: string;
|
||||
personaName: string;
|
||||
sessions: TeacherSessionSummary[];
|
||||
latestAt: string | null;
|
||||
endedSessions: number;
|
||||
pendingReviews: number;
|
||||
closedReviews: number;
|
||||
avgScore: number | null;
|
||||
avgRapport: number | null;
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return "-";
|
||||
|
|
@ -51,6 +63,24 @@ function formatRapport(value: number | null | undefined): string {
|
|||
return `${Math.round(((value + 1) / 2) * 100)}%`;
|
||||
}
|
||||
|
||||
function average(values: Array<number | null | undefined>): number | null {
|
||||
const numbers = values.filter(
|
||||
(value): value is number => typeof value === "number" && !Number.isNaN(value),
|
||||
);
|
||||
if (numbers.length === 0) return null;
|
||||
return numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
|
||||
}
|
||||
|
||||
function timeValue(value: string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
}
|
||||
|
||||
function domToken(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9_-]+/g, "-") || "unknown";
|
||||
}
|
||||
|
||||
function trendLabel(value: string): string {
|
||||
if (value === "up") return "상승";
|
||||
if (value === "down") return "하락";
|
||||
|
|
@ -74,6 +104,16 @@ function sessionReviewTone(status: string | null | undefined): "accent" | "neutr
|
|||
return "neutral";
|
||||
}
|
||||
|
||||
function sessionSupervisorTone(status: string | null | undefined): "accent" | "neutral" | "warn" {
|
||||
if (status === "평가 완료") return "accent";
|
||||
if (status === "평가 실패" || status === "평가 대기") return "warn";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function sessionSupervisorLabel(session: TeacherSessionSummary): string {
|
||||
return session.supervisor_state ?? (session.review_ready ? "평가 완료" : "기록 대기");
|
||||
}
|
||||
|
||||
function personaReviewStatusLabel(status: PersonaReviewStatus): string {
|
||||
if (status === "review") return "검수 대기";
|
||||
if (status === "draft") return "수정 대기";
|
||||
|
|
@ -557,6 +597,9 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
|
|||
</div>
|
||||
<div className="pf-session__meta">
|
||||
<span>{formatDateTime(session.ended_at ?? null)}</span>
|
||||
<Badge tone={sessionSupervisorTone(sessionSupervisorLabel(session))}>
|
||||
{sessionSupervisorLabel(session)}
|
||||
</Badge>
|
||||
<Badge tone={sessionReviewTone(session.review_status)}>
|
||||
{sessionReviewStatusLabel(session.review_status)}
|
||||
</Badge>
|
||||
|
|
@ -773,6 +816,7 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
|
|||
</Badge>
|
||||
{session.status === "ended" ? (
|
||||
<small className="pf-recent__review-state">
|
||||
{sessionSupervisorLabel(session)} ·{" "}
|
||||
{sessionReviewStatusLabel(session.review_status)}
|
||||
</small>
|
||||
) : null}
|
||||
|
|
@ -1033,13 +1077,80 @@ function LearnerAnalysisPanel({
|
|||
analysis: TeacherLearnerAnalysisResponse;
|
||||
onOpenSession: (sessionId: string) => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<LearnerAnalysisTab>("trend");
|
||||
const [activeTab, setActiveTab] = useState<LearnerAnalysisTab>("personas");
|
||||
const [expandedPersonaKey, setExpandedPersonaKey] = useState<string | null>(null);
|
||||
const points = analysis.points ?? [];
|
||||
const sessions = analysis.sessions ?? [];
|
||||
const pointBySession = new Map(points.map((point) => [point.session_id, point]));
|
||||
const pointBySession = useMemo(
|
||||
() => new Map(points.map((point) => [point.session_id, point])),
|
||||
[points],
|
||||
);
|
||||
const stageBreakdown = analysis.stage_breakdown ?? [];
|
||||
const summary = analysis.summary;
|
||||
const personaGroups = useMemo<PersonaSessionGroup[]>(() => {
|
||||
const groups = new Map<string, PersonaSessionGroup>();
|
||||
sessions.forEach((session) => {
|
||||
const personaCode = session.persona_code || "페르소나";
|
||||
const personaName = session.persona_name || personaCode;
|
||||
const key = personaCode || personaName;
|
||||
const group =
|
||||
groups.get(key) ??
|
||||
{
|
||||
key,
|
||||
personaCode,
|
||||
personaName,
|
||||
sessions: [],
|
||||
latestAt: null,
|
||||
endedSessions: 0,
|
||||
pendingReviews: 0,
|
||||
closedReviews: 0,
|
||||
avgScore: null,
|
||||
avgRapport: null,
|
||||
};
|
||||
group.sessions.push(session);
|
||||
if (session.status === "ended") group.endedSessions += 1;
|
||||
if (session.review_status === "closed") {
|
||||
group.closedReviews += 1;
|
||||
} else {
|
||||
group.pendingReviews += 1;
|
||||
}
|
||||
const sessionTime = timeValue(session.ended_at ?? session.started_at);
|
||||
if (sessionTime >= timeValue(group.latestAt)) {
|
||||
group.latestAt = session.ended_at ?? session.started_at;
|
||||
}
|
||||
groups.set(key, group);
|
||||
});
|
||||
|
||||
return Array.from(groups.values())
|
||||
.map((group) => {
|
||||
const orderedSessions = [...group.sessions].sort(
|
||||
(left, right) =>
|
||||
left.session_no - right.session_no ||
|
||||
timeValue(left.started_at) - timeValue(right.started_at),
|
||||
);
|
||||
return {
|
||||
...group,
|
||||
sessions: orderedSessions,
|
||||
avgScore: average(
|
||||
orderedSessions.map((session) => pointBySession.get(session.session_id)?.score),
|
||||
),
|
||||
avgRapport: average(
|
||||
orderedSessions.map((session) => pointBySession.get(session.session_id)?.rapport),
|
||||
),
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
timeValue(right.latestAt) - timeValue(left.latestAt) ||
|
||||
right.sessions.length - left.sessions.length,
|
||||
);
|
||||
}, [pointBySession, sessions]);
|
||||
useEffect(() => {
|
||||
setActiveTab("personas");
|
||||
setExpandedPersonaKey(null);
|
||||
}, [analysis.learner_id]);
|
||||
const tabs: Array<{ key: LearnerAnalysisTab; label: string; count: string }> = [
|
||||
{ key: "personas", label: "페르소나별 회기", count: `${personaGroups.length}종` },
|
||||
{ key: "trend", label: "추이", count: `${points.length}점` },
|
||||
{ key: "sessions", label: "전체 회기", count: `${sessions.length}건` },
|
||||
{ key: "stages", label: "단계 분석", count: `${stageBreakdown.length}단계` },
|
||||
|
|
@ -1098,6 +1209,94 @@ function LearnerAnalysisPanel({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "personas" ? (
|
||||
<section className="pf-analysis__personas" aria-label="페르소나별 회기">
|
||||
<div className="pf-analysis__subhead">
|
||||
<b>페르소나별 회기</b>
|
||||
<span>
|
||||
{personaGroups.length}종 · {sessions.length}건
|
||||
</span>
|
||||
</div>
|
||||
{personaGroups.length > 0 ? (
|
||||
<div className="pf-persona-drill">
|
||||
{personaGroups.map((group) => {
|
||||
const isExpanded = expandedPersonaKey === group.key;
|
||||
const panelId = `persona-sessions-${domToken(group.key)}`;
|
||||
return (
|
||||
<article
|
||||
className={`pf-persona-group ${isExpanded ? "is-open" : ""}`}
|
||||
data-learner-persona-group="true"
|
||||
key={group.key}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pf-persona-group__summary"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={panelId}
|
||||
aria-label={`${group.personaCode} ${group.personaName} 회기 ${
|
||||
isExpanded ? "접기" : "펼치기"
|
||||
}`}
|
||||
onClick={() => setExpandedPersonaKey(isExpanded ? null : group.key)}
|
||||
>
|
||||
<span className="pf-persona-group__code">{group.personaCode}</span>
|
||||
<span className="pf-persona-group__main">
|
||||
<b>{group.personaName}</b>
|
||||
<small>
|
||||
최근 {formatDateTime(group.latestAt)} · 완료 {group.endedSessions}/
|
||||
{group.sessions.length} · 검토 대기 {group.pendingReviews}
|
||||
</small>
|
||||
</span>
|
||||
<span className="pf-persona-group__metrics">
|
||||
<span>
|
||||
<small>회기</small>
|
||||
<b>{group.sessions.length}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>적절성</small>
|
||||
<b>{formatScore(group.avgScore)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>라포</small>
|
||||
<b>{formatRapport(group.avgRapport)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>검토</small>
|
||||
<b>
|
||||
{group.closedReviews}/{group.sessions.length}
|
||||
</b>
|
||||
</span>
|
||||
</span>
|
||||
<span className="pf-persona-group__open">
|
||||
<Icon name={isExpanded ? "arrow-up" : "arrow-down"} size={13} />
|
||||
{isExpanded ? "회기 접기" : "회기 펼치기"}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div
|
||||
className="pf-persona-sessions"
|
||||
id={panelId}
|
||||
data-learner-persona-sessions="true"
|
||||
>
|
||||
{group.sessions.map((session) => (
|
||||
<LearnerSessionRow
|
||||
session={session}
|
||||
point={pointBySession.get(session.session_id)}
|
||||
key={session.session_id}
|
||||
onOpen={() => onOpenSession(session.session_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState title="페르소나 기록 없음" desc="이 학습자의 페르소나별 회기 기록이 없습니다." />
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeTab === "trend" ? (
|
||||
<section className="pf-analysis__chart" aria-label="회기별 추이">
|
||||
<div className="pf-analysis__subhead">
|
||||
|
|
@ -1197,6 +1396,12 @@ function LearnerSessionRow({
|
|||
<small>라포</small>
|
||||
<b>{formatRapport(point?.rapport)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<small>AI 평가</small>
|
||||
<Badge tone={sessionSupervisorTone(sessionSupervisorLabel(session))}>
|
||||
{sessionSupervisorLabel(session)}
|
||||
</Badge>
|
||||
</span>
|
||||
<span>
|
||||
<small>검토</small>
|
||||
<Badge tone={sessionReviewTone(session.review_status)}>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ import {
|
|||
personaApi,
|
||||
sessionApi,
|
||||
type CrisisResource,
|
||||
type LiveCoachCreditEvent,
|
||||
type LiveCoachEvent,
|
||||
type LiveCoachHistoryResponse,
|
||||
type LiveCoachQuota,
|
||||
type LiveCoachSuggestion,
|
||||
type PersonaSummary,
|
||||
type SessionDetailResponse,
|
||||
|
|
@ -70,6 +73,7 @@ type VoiceServerState = "idle" | "listening" | "thinking" | "speaking";
|
|||
|
||||
interface VoiceEvent {
|
||||
type?: string;
|
||||
code?: string;
|
||||
state?: VoiceServerState;
|
||||
text?: string;
|
||||
final?: boolean;
|
||||
|
|
@ -83,6 +87,22 @@ interface VoiceEvent {
|
|||
conversation_stopped?: boolean;
|
||||
}
|
||||
|
||||
const VOICE_TURN_SAVE_FAILED =
|
||||
"음성 발화를 저장하지 못했습니다. 방금 말한 내용을 확인한 뒤 다시 시도해 주세요.";
|
||||
const VOICE_CONNECTION_SAVE_FAILED =
|
||||
"음성 연결이 종료되어 발화를 저장하지 못했습니다. 방금 말한 내용을 확인한 뒤 다시 시도해 주세요.";
|
||||
|
||||
function userFacingVoiceError(payload: VoiceEvent): string {
|
||||
const detail = payload.detail ?? "";
|
||||
if (
|
||||
payload.code === "turn_persistence_unavailable" ||
|
||||
(detail.includes("turn append") && detail.includes("persistence unavailable"))
|
||||
) {
|
||||
return VOICE_TURN_SAVE_FAILED;
|
||||
}
|
||||
return detail || "음성 처리 중 오류가 발생했습니다.";
|
||||
}
|
||||
|
||||
type AudioContextWindow = Window & { webkitAudioContext?: typeof AudioContext };
|
||||
type VoiceCaptureMode = "audio-worklet" | "media-recorder";
|
||||
|
||||
|
|
@ -137,6 +157,8 @@ interface Utterance {
|
|||
at: number;
|
||||
/** 진행 중(스트리밍/타이핑) 발화 여부 */
|
||||
partial?: boolean;
|
||||
/** 음성 WebSocket이 reply 전에 실패해 DB 턴으로 확정되지 않은 발화 */
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
interface PhaseInfo {
|
||||
|
|
@ -164,6 +186,7 @@ const THEORY_MODE_OPTIONS: { value: TheoryMode; label: string; detail: string }[
|
|||
{ value: "cbt", label: "CBT", detail: "생각·행동" },
|
||||
{ value: "integrative", label: "통합", detail: "혼합 접근" },
|
||||
];
|
||||
const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 };
|
||||
const VOICE_WORKLET_MODULE_URL = "/worklets/voice-capture-worklet.js";
|
||||
const VOICE_WORKLET_PROCESSOR = "voice-capture-processor";
|
||||
|
||||
|
|
@ -839,8 +862,15 @@ export default function Session() {
|
|||
const [coachError, setCoachError] = useState<string | null>(null);
|
||||
const [coachEvidenceOpen, setCoachEvidenceOpen] = useState(false);
|
||||
const [coachHistory, setCoachHistory] = useState<LiveCoachEvent[]>([]);
|
||||
const [coachQuota, setCoachQuota] = useState<LiveCoachQuota>(DEFAULT_COACH_QUOTA);
|
||||
const [coachCreditEvents, setCoachCreditEvents] = useState<LiveCoachCreditEvent[]>([]);
|
||||
const [coachCreditPulse, setCoachCreditPulse] = useState<LiveCoachCreditEvent | null>(null);
|
||||
const [coachHistoryOpen, setCoachHistoryOpen] = useState(false);
|
||||
const [coachHistoryTurnSeq, setCoachHistoryTurnSeq] = useState<number | null>(null);
|
||||
const [coachHistoryLoading, setCoachHistoryLoading] = useState(false);
|
||||
const [coachHistoryError, setCoachHistoryError] = useState<string | null>(null);
|
||||
const [coachPersistenceSource, setCoachPersistenceSource] = useState<"database" | "runtime" | null>(null);
|
||||
const [coachSyncWarning, setCoachSyncWarning] = useState<string | null>(null);
|
||||
|
||||
// ── 경과 타이머 ──
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
|
@ -860,6 +890,23 @@ export default function Session() {
|
|||
const pendingVoiceLearnerTextRef = useRef<string>("");
|
||||
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
|
||||
const coachHistoryCloseRef = useRef<HTMLButtonElement>(null);
|
||||
const coachCreditSeenRef = useRef<Set<string>>(new Set());
|
||||
const coachCreditPulseTimerRef = useRef<number | null>(null);
|
||||
|
||||
const failPendingVoiceLearnerTurn = useCallback(() => {
|
||||
const pendingId = pendingVoiceLearnerIdRef.current;
|
||||
pendingVoiceLearnerIdRef.current = null;
|
||||
pendingVoiceLearnerTextRef.current = "";
|
||||
if (pendingId == null) return false;
|
||||
setUtterances((prev) =>
|
||||
prev.map((utterance) =>
|
||||
utterance.id === pendingId
|
||||
? { ...utterance, partial: false, failed: true, turnSeq: undefined }
|
||||
: utterance,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const ensureVoiceAudioContext = useCallback(() => {
|
||||
const existing = audioContextRef.current;
|
||||
|
|
@ -911,6 +958,12 @@ export default function Session() {
|
|||
setCoachError(null);
|
||||
setCoachEvidenceOpen(false);
|
||||
setCoachHistory([]);
|
||||
setCoachQuota(DEFAULT_COACH_QUOTA);
|
||||
setCoachCreditEvents([]);
|
||||
setCoachCreditPulse(null);
|
||||
setCoachPersistenceSource(null);
|
||||
setCoachSyncWarning(null);
|
||||
coachCreditSeenRef.current = new Set();
|
||||
setCoachHistoryOpen(false);
|
||||
setCoachHistoryTurnSeq(null);
|
||||
}, [navigate, routeIsSessionId, routeParam]);
|
||||
|
|
@ -1001,6 +1054,48 @@ export default function Session() {
|
|||
setSignalSeq((seq) => [...seq.slice(-4), tone]);
|
||||
}, []);
|
||||
|
||||
const showCoachCreditPulse = useCallback(
|
||||
(event: LiveCoachCreditEvent) => {
|
||||
setCoachCreditPulse(event);
|
||||
if (coachCreditPulseTimerRef.current) {
|
||||
window.clearTimeout(coachCreditPulseTimerRef.current);
|
||||
}
|
||||
coachCreditPulseTimerRef.current = window.setTimeout(() => {
|
||||
setCoachCreditPulse(null);
|
||||
coachCreditPulseTimerRef.current = null;
|
||||
}, 2600);
|
||||
pushSignal(
|
||||
event.event_type === "recharge" ? "pos" : "neutral",
|
||||
event.event_type === "recharge" ? "코칭 기회 충전" : "코칭 기회 사용",
|
||||
);
|
||||
},
|
||||
[pushSignal],
|
||||
);
|
||||
|
||||
const applyCoachCreditEvents = useCallback(
|
||||
(events: LiveCoachCreditEvent[], animate: boolean) => {
|
||||
setCoachCreditEvents(events);
|
||||
const fresh: LiveCoachCreditEvent[] = [];
|
||||
for (const event of events) {
|
||||
if (coachCreditSeenRef.current.has(event.event_id)) continue;
|
||||
coachCreditSeenRef.current.add(event.event_id);
|
||||
fresh.push(event);
|
||||
}
|
||||
if (animate && fresh.length > 0) {
|
||||
showCoachCreditPulse(fresh[fresh.length - 1]);
|
||||
}
|
||||
},
|
||||
[showCoachCreditPulse],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (coachCreditPulseTimerRef.current) {
|
||||
window.clearTimeout(coachCreditPulseTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const applyCrisisGate = useCallback((resource?: CrisisResource | null) => {
|
||||
const fallback: CrisisResource = {
|
||||
title: "자살예방상담전화 109",
|
||||
|
|
@ -1019,29 +1114,61 @@ export default function Session() {
|
|||
}, [pushSignal]);
|
||||
|
||||
const refreshCoachHistory = useCallback(
|
||||
async (sessionId = liveSessionId) => {
|
||||
async (
|
||||
sessionId = liveSessionId,
|
||||
options: { animateCredits?: boolean; surfaceErrors?: boolean; warnOnFailure?: boolean } = {},
|
||||
): Promise<LiveCoachHistoryResponse | null> => {
|
||||
if (options.surfaceErrors) {
|
||||
setCoachHistoryLoading(true);
|
||||
setCoachHistoryError(null);
|
||||
}
|
||||
if (!sessionId) {
|
||||
setCoachHistory([]);
|
||||
return;
|
||||
setCoachQuota(DEFAULT_COACH_QUOTA);
|
||||
setCoachCreditEvents([]);
|
||||
setCoachHistoryError(null);
|
||||
setCoachPersistenceSource(null);
|
||||
setCoachSyncWarning(null);
|
||||
if (options.surfaceErrors) setCoachHistoryLoading(false);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const history = await sessionApi.liveCoachHistory(sessionId);
|
||||
setCoachHistory(history.events ?? []);
|
||||
setCoachQuota(history.quota ?? DEFAULT_COACH_QUOTA);
|
||||
setCoachPersistenceSource(history.source);
|
||||
setCoachSyncWarning(
|
||||
history.source === "runtime"
|
||||
? "코칭 이력이 DB에 확정 저장되지 않아 임시 이력으로 표시됩니다."
|
||||
: null,
|
||||
);
|
||||
setCoachHistoryError(null);
|
||||
applyCoachCreditEvents(history.credit_events ?? [], !!options.animateCredits);
|
||||
return history;
|
||||
} catch {
|
||||
/* 코칭 이력 조회 실패는 회기 진행을 막지 않는다. */
|
||||
if (options.surfaceErrors) {
|
||||
setCoachHistoryError("코칭 이력을 불러오지 못했습니다. 잠시 뒤 다시 열어 주세요.");
|
||||
}
|
||||
if (options.warnOnFailure) {
|
||||
setCoachSyncWarning("코칭은 표시됐지만 이력 동기화를 확인하지 못했습니다. 이력 보기에서 다시 확인해 주세요.");
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (options.surfaceErrors) setCoachHistoryLoading(false);
|
||||
}
|
||||
},
|
||||
[liveSessionId],
|
||||
[applyCoachCreditEvents, liveSessionId],
|
||||
);
|
||||
|
||||
const openCoachHistory = useCallback(
|
||||
(turnSeq: number | null = null) => {
|
||||
setCoachHistoryTurnSeq(turnSeq);
|
||||
setCoachHistoryError(null);
|
||||
setCoachEvidenceOpen(false);
|
||||
setCoachHistoryOpen(true);
|
||||
void refreshCoachHistory();
|
||||
void refreshCoachHistory(liveSessionId, { surfaceErrors: true });
|
||||
},
|
||||
[refreshCoachHistory],
|
||||
[liveSessionId, refreshCoachHistory],
|
||||
);
|
||||
|
||||
const requestLiveCoach = useCallback(
|
||||
|
|
@ -1055,6 +1182,25 @@ export default function Session() {
|
|||
turnSeq?: number;
|
||||
}) => {
|
||||
if (!liveSessionId || feedbackMode !== "coached") return;
|
||||
let availableCoachCredits = coachQuota.remaining ?? 0;
|
||||
if (availableCoachCredits <= 0) {
|
||||
setCoachError(null);
|
||||
const refreshed = await refreshCoachHistory(liveSessionId, {
|
||||
animateCredits: true,
|
||||
warnOnFailure: true,
|
||||
});
|
||||
if (!refreshed) {
|
||||
setCoachError("코칭 기회를 확인하지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
|
||||
pushSignal("warn", "코칭 기회 확인 실패");
|
||||
return;
|
||||
}
|
||||
availableCoachCredits = refreshed.quota?.remaining ?? 0;
|
||||
}
|
||||
if (availableCoachCredits <= 0) {
|
||||
setCoachError("코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자의 변화 신호가 생기면 1개씩 다시 충전됩니다.");
|
||||
pushSignal("warn", "코칭 기회 소진");
|
||||
return;
|
||||
}
|
||||
setCoachLoading(true);
|
||||
setCoachError(null);
|
||||
try {
|
||||
|
|
@ -1064,16 +1210,41 @@ export default function Session() {
|
|||
turn_seq: turnSeq,
|
||||
});
|
||||
setCoachSuggestion(suggestion);
|
||||
setCoachPersistenceSource(suggestion.persistence_source ?? null);
|
||||
setCoachSyncWarning(
|
||||
suggestion.persistence_source === "runtime"
|
||||
? "코칭 이력이 DB에 확정 저장되지 않아 임시 이력으로 표시됩니다."
|
||||
: null,
|
||||
);
|
||||
if (suggestion.quota) {
|
||||
setCoachQuota(suggestion.quota);
|
||||
}
|
||||
if (suggestion.credit_events?.length) {
|
||||
applyCoachCreditEvents(suggestion.credit_events, true);
|
||||
}
|
||||
pushSignal(suggestion.tone, suggestion.title);
|
||||
void refreshCoachHistory();
|
||||
} catch {
|
||||
setCoachError("코칭 근거를 불러오지 못했습니다. 다음 턴에서 다시 시도합니다.");
|
||||
pushSignal("warn", "코칭 연결 실패");
|
||||
void refreshCoachHistory(liveSessionId, { animateCredits: false, warnOnFailure: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
setCoachQuota((current) => ({ ...current, remaining: 0 }));
|
||||
setCoachError("코칭 기회를 모두 사용했습니다. 좋은 발화가 실제 변화로 이어지면 다시 충전됩니다.");
|
||||
pushSignal("warn", "코칭 기회 소진");
|
||||
} else {
|
||||
setCoachError("코칭 근거를 불러오지 못했습니다. 다음 턴에서 다시 시도합니다.");
|
||||
pushSignal("warn", "코칭 연결 실패");
|
||||
}
|
||||
} finally {
|
||||
setCoachLoading(false);
|
||||
}
|
||||
},
|
||||
[feedbackMode, liveSessionId, pushSignal, refreshCoachHistory],
|
||||
[
|
||||
applyCoachCreditEvents,
|
||||
coachQuota.remaining,
|
||||
feedbackMode,
|
||||
liveSessionId,
|
||||
pushSignal,
|
||||
refreshCoachHistory,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1125,6 +1296,13 @@ export default function Session() {
|
|||
setCoachSuggestion(null);
|
||||
setCoachError(null);
|
||||
setCoachEvidenceOpen(false);
|
||||
setCoachHistory([]);
|
||||
setCoachQuota(DEFAULT_COACH_QUOTA);
|
||||
setCoachCreditEvents([]);
|
||||
setCoachCreditPulse(null);
|
||||
setCoachPersistenceSource(null);
|
||||
setCoachSyncWarning(null);
|
||||
coachCreditSeenRef.current = new Set();
|
||||
setCoachHistoryOpen(false);
|
||||
setCoachHistoryTurnSeq(null);
|
||||
setClientReplyPending(false);
|
||||
|
|
@ -1171,6 +1349,12 @@ export default function Session() {
|
|||
setCoachError(null);
|
||||
setCoachEvidenceOpen(false);
|
||||
setCoachHistory([]);
|
||||
setCoachQuota(DEFAULT_COACH_QUOTA);
|
||||
setCoachCreditEvents([]);
|
||||
setCoachCreditPulse(null);
|
||||
setCoachPersistenceSource(null);
|
||||
setCoachSyncWarning(null);
|
||||
coachCreditSeenRef.current = new Set();
|
||||
setCoachHistoryOpen(false);
|
||||
setCoachHistoryTurnSeq(null);
|
||||
if (res.degraded) {
|
||||
|
|
@ -1684,6 +1868,7 @@ export default function Session() {
|
|||
}
|
||||
|
||||
if (payload.type === "transcript" && payload.final && payload.text) {
|
||||
failPendingVoiceLearnerTurn();
|
||||
const id = nextId();
|
||||
pendingVoiceLearnerIdRef.current = id;
|
||||
pendingVoiceLearnerTextRef.current = payload.text;
|
||||
|
|
@ -1712,7 +1897,9 @@ export default function Session() {
|
|||
if (pendingId != null) {
|
||||
setUtterances((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === pendingId ? { ...u, partial: false, turnSeq: payload.turn_seq } : u,
|
||||
u.id === pendingId
|
||||
? { ...u, partial: false, failed: false, turnSeq: payload.turn_seq }
|
||||
: u,
|
||||
),
|
||||
);
|
||||
pendingVoiceLearnerIdRef.current = null;
|
||||
|
|
@ -1729,6 +1916,9 @@ export default function Session() {
|
|||
turnSeq: payload.turn_seq,
|
||||
});
|
||||
}
|
||||
if (conversationStopped) {
|
||||
closeVoiceSocket();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1739,9 +1929,11 @@ export default function Session() {
|
|||
|
||||
if (payload.type === "degraded") {
|
||||
setClientReplyPending(false);
|
||||
const hadPendingLearner = failPendingVoiceLearnerTurn();
|
||||
const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다.";
|
||||
const reason =
|
||||
payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason;
|
||||
if (hadPendingLearner) setTurnError(fallbackReason);
|
||||
shutdownVoice("degraded", reason);
|
||||
pushSignal("warn", "음성 기능 미설정");
|
||||
return;
|
||||
|
|
@ -1749,24 +1941,24 @@ export default function Session() {
|
|||
|
||||
if (payload.type === "error") {
|
||||
setClientReplyPending(false);
|
||||
if (pendingVoiceLearnerIdRef.current != null && payload.detail?.includes("engine unavailable")) {
|
||||
const pendingId = pendingVoiceLearnerIdRef.current;
|
||||
setUtterances((prev) => prev.filter((u) => u.id !== pendingId));
|
||||
pendingVoiceLearnerIdRef.current = null;
|
||||
pendingVoiceLearnerTextRef.current = "";
|
||||
}
|
||||
shutdownVoice("error", payload.detail || "음성 처리 중 오류가 발생했습니다.");
|
||||
failPendingVoiceLearnerTurn();
|
||||
const detail = userFacingVoiceError(payload);
|
||||
setTurnError(detail);
|
||||
shutdownVoice("error", detail);
|
||||
pushSignal("warn", "음성 오류");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setClientReplyPending(false);
|
||||
shutdownVoice("error", "음성 연결을 확인하지 못했습니다.");
|
||||
failPendingVoiceLearnerTurn();
|
||||
setTurnError(VOICE_CONNECTION_SAVE_FAILED);
|
||||
shutdownVoice("error", VOICE_CONNECTION_SAVE_FAILED);
|
||||
pushSignal("warn", "음성 연결 실패");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
const hadPendingLearner = failPendingVoiceLearnerTurn();
|
||||
const capture = voiceCaptureRef.current;
|
||||
voiceCaptureRef.current = null;
|
||||
capture?.abort();
|
||||
|
|
@ -1776,6 +1968,13 @@ export default function Session() {
|
|||
}
|
||||
stopMicStream();
|
||||
setMicOn(false);
|
||||
setClientReplyPending(false);
|
||||
if (hadPendingLearner) {
|
||||
setTurnError(VOICE_CONNECTION_SAVE_FAILED);
|
||||
setVoiceStatus("error");
|
||||
setVoiceDetail(VOICE_CONNECTION_SAVE_FAILED);
|
||||
return;
|
||||
}
|
||||
setVoiceStatus((current) => (current === "degraded" || current === "error" ? current : "idle"));
|
||||
setVoiceDetail((current) => {
|
||||
if (
|
||||
|
|
@ -1794,6 +1993,7 @@ export default function Session() {
|
|||
closeVoiceSocket,
|
||||
elapsed,
|
||||
ensureVoiceAudioContext,
|
||||
failPendingVoiceLearnerTurn,
|
||||
liveSessionId,
|
||||
paused,
|
||||
sessionEnded,
|
||||
|
|
@ -2019,7 +2219,7 @@ export default function Session() {
|
|||
const elapsedLabel = formatElapsed(elapsed);
|
||||
const recommendedSessionSeconds = 30 * 60;
|
||||
const remainingLabel = formatElapsed(Math.max(0, recommendedSessionSeconds - elapsed));
|
||||
const turnCount = utterances.filter((utterance) => !utterance.partial).length;
|
||||
const turnCount = utterances.filter((utterance) => !utterance.partial && !utterance.failed).length;
|
||||
const latestClientUtterance = [...utterances]
|
||||
.reverse()
|
||||
.find((utterance) => utterance.speaker === "client" && utterance.text.trim());
|
||||
|
|
@ -2051,10 +2251,25 @@ export default function Session() {
|
|||
: null;
|
||||
const coachSources = coachSuggestion?.sources ?? [];
|
||||
const coachTone = coachSuggestion?.tone ?? "neutral";
|
||||
const coachQuotaMax = Math.max(1, coachQuota.max ?? DEFAULT_COACH_QUOTA.max);
|
||||
const coachQuotaRemaining = Math.max(
|
||||
0,
|
||||
Math.min(coachQuotaMax, coachQuota.remaining ?? DEFAULT_COACH_QUOTA.remaining),
|
||||
);
|
||||
const coachQuotaSlots = Array.from({ length: coachQuotaMax }, (_, index) => index);
|
||||
const coachCreditPulseText =
|
||||
coachCreditPulse == null
|
||||
? null
|
||||
: coachCreditPulse.event_type === "recharge"
|
||||
? `+1 충전 · ${coachCreditPulse.balance}/${coachQuotaMax}`
|
||||
: `-1 사용 · ${coachCreditPulse.balance}/${coachQuotaMax}`;
|
||||
const coachIsDegraded = coachSuggestion?.status === "degraded";
|
||||
const coachDegradedNote =
|
||||
"AI 코칭 엔진 응답 대신 워크북 규칙과 현재 턴 신호로 만든 대체 제안입니다.";
|
||||
const coachStatusText = coachLoading
|
||||
? "코치가 근거를 확인 중"
|
||||
: coachSuggestion?.status === "degraded"
|
||||
? "규칙 기반 코칭"
|
||||
: coachIsDegraded
|
||||
? "AI 응답 대체"
|
||||
: coachSuggestion
|
||||
? "근거 확인 완료"
|
||||
: "턴 완료 후 개입";
|
||||
|
|
@ -2525,7 +2740,8 @@ export default function Session() {
|
|||
className={
|
||||
"sx-utt " +
|
||||
(u.speaker === "client" ? "is-client" : "is-learner") +
|
||||
(u.partial ? " is-partial" : "")
|
||||
(u.partial ? " is-partial" : "") +
|
||||
(u.failed ? " is-failed" : "")
|
||||
}
|
||||
>
|
||||
<div className="sx-utt__head">
|
||||
|
|
@ -2539,6 +2755,11 @@ export default function Session() {
|
|||
{u.text}
|
||||
{u.partial ? <span className="sx-utt__caret" /> : null}
|
||||
</div>
|
||||
{u.failed ? (
|
||||
<span className="sx-utt__status" role="note">
|
||||
저장 실패 · 다시 시도하세요.
|
||||
</span>
|
||||
) : null}
|
||||
{turnCoachEvents.length && u.turnSeq ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -2548,9 +2769,14 @@ export default function Session() {
|
|||
? " is-pos"
|
||||
: latestCoach?.suggestion.tone === "warn"
|
||||
? " is-warn"
|
||||
: "")
|
||||
: "") +
|
||||
(latestCoach?.suggestion.status === "degraded" ? " is-degraded" : "")
|
||||
}
|
||||
title={
|
||||
latestCoach?.suggestion.status === "degraded"
|
||||
? `AI 응답 대체: ${latestCoach.suggestion.title}`
|
||||
: latestCoach?.suggestion.title ?? "코칭 이력"
|
||||
}
|
||||
title={latestCoach?.suggestion.title ?? "코칭 이력"}
|
||||
onClick={() => openCoachHistory(u.turnSeq ?? null)}
|
||||
>
|
||||
C
|
||||
|
|
@ -2739,7 +2965,8 @@ export default function Session() {
|
|||
className={
|
||||
"sx-coach-card" +
|
||||
(coachTone === "pos" ? " is-pos" : coachTone === "warn" ? " is-warn" : "") +
|
||||
(coachLoading ? " is-loading" : "")
|
||||
(coachLoading ? " is-loading" : "") +
|
||||
(coachIsDegraded ? " is-degraded" : "")
|
||||
}
|
||||
>
|
||||
<div className="sx-coach-avatar" aria-hidden="true">
|
||||
|
|
@ -2751,11 +2978,52 @@ export default function Session() {
|
|||
</div>
|
||||
<div className="sx-coach-bubble">
|
||||
<div className="sx-coach-bubble__meta">
|
||||
<b>AI 코치</b>
|
||||
<b>{coachIsDegraded ? "대체 코칭" : "AI 코치"}</b>
|
||||
<span>{coachStatusText}</span>
|
||||
</div>
|
||||
{coachIsDegraded ? (
|
||||
<p className="sx-coach-degraded-note">{coachDegradedNote}</p>
|
||||
) : null}
|
||||
{coachSyncWarning ? (
|
||||
<p
|
||||
className={
|
||||
"sx-coach-persistence-note" +
|
||||
(coachPersistenceSource === "runtime" ? " is-runtime" : "")
|
||||
}
|
||||
role="status"
|
||||
>
|
||||
{coachSyncWarning}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="sx-coach-quota" aria-live="polite">
|
||||
<span>코칭 기회</span>
|
||||
<span className="sx-coach-quota__dots" aria-hidden="true">
|
||||
{coachQuotaSlots.map((slot) => (
|
||||
<i
|
||||
key={slot}
|
||||
className={slot < coachQuotaRemaining ? "is-filled" : ""}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<b>
|
||||
{coachQuotaRemaining}/{coachQuotaMax}
|
||||
</b>
|
||||
</div>
|
||||
{coachCreditPulseText ? (
|
||||
<div
|
||||
className={
|
||||
"sx-coach-credit-pulse" +
|
||||
(coachCreditPulse?.event_type === "recharge" ? " is-recharge" : "")
|
||||
}
|
||||
aria-live="polite"
|
||||
>
|
||||
{coachCreditPulseText}
|
||||
</div>
|
||||
) : null}
|
||||
{coachLoading ? (
|
||||
<p>방금 턴과 근거 자료를 대조하는 중입니다.</p>
|
||||
) : coachError ? (
|
||||
<p>{coachError}</p>
|
||||
) : coachSuggestion ? (
|
||||
<>
|
||||
<strong>{coachSuggestion.title}</strong>
|
||||
|
|
@ -2772,8 +3040,8 @@ export default function Session() {
|
|||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : coachError ? (
|
||||
<p>{coachError}</p>
|
||||
) : coachQuotaRemaining <= 0 ? (
|
||||
<p>코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자가 열리면 다시 1개가 충전됩니다.</p>
|
||||
) : (
|
||||
<p>코칭 모드에서는 방금 발화의 강점과 조정점을 근거와 함께 바로 짚습니다.</p>
|
||||
)}
|
||||
|
|
@ -2928,8 +3196,17 @@ export default function Session() {
|
|||
<button
|
||||
className={feedbackMode === "coached" ? "is-on" : ""}
|
||||
onClick={() => setFeedbackMode("coached")}
|
||||
aria-label={`코칭 모드, 남은 기회 ${coachQuotaRemaining}개`}
|
||||
>
|
||||
코칭
|
||||
<span>코칭</span>
|
||||
<span
|
||||
className={
|
||||
"sx-segmented__badge" + (coachQuotaRemaining <= 0 ? " is-empty" : "")
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{coachQuotaRemaining}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2992,6 +3269,24 @@ export default function Session() {
|
|||
<p id="sx-coach-history-desc">
|
||||
회기 중 실제로 받은 코칭과 근거를 시간순으로 확인합니다.
|
||||
</p>
|
||||
{coachPersistenceSource === "runtime" ? (
|
||||
<div className="sx-coach-history__source-alert" role="status">
|
||||
현재 코칭 이력은 임시 저장소 기준입니다.
|
||||
</div>
|
||||
) : null}
|
||||
{coachCreditEvents.length > 0 ? (
|
||||
<div className="sx-coach-credit-log" aria-label="코칭 기회 기록">
|
||||
{coachCreditEvents.slice(-4).map((event) => (
|
||||
<span
|
||||
key={event.event_id}
|
||||
className={event.event_type === "recharge" ? "is-recharge" : ""}
|
||||
>
|
||||
{event.event_type === "recharge" ? "+1 충전" : "-1 사용"} ·{" "}
|
||||
{event.balance}/{coachQuotaMax}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
ref={coachHistoryCloseRef}
|
||||
|
|
@ -3004,7 +3299,15 @@ export default function Session() {
|
|||
</div>
|
||||
|
||||
<div className="sx-coach-history__body">
|
||||
{activeCoachEvents.length ? (
|
||||
{coachHistoryLoading ? (
|
||||
<div className="sx-coach-history__empty">
|
||||
코칭 이력을 불러오는 중입니다.
|
||||
</div>
|
||||
) : coachHistoryError ? (
|
||||
<div className="sx-coach-history__empty is-error" role="alert">
|
||||
{coachHistoryError}
|
||||
</div>
|
||||
) : activeCoachEvents.length ? (
|
||||
activeCoachEvents.map((event) => (
|
||||
<article
|
||||
key={event.event_id}
|
||||
|
|
@ -3014,11 +3317,15 @@ export default function Session() {
|
|||
? " is-pos"
|
||||
: event.suggestion.tone === "warn"
|
||||
? " is-warn"
|
||||
: "")
|
||||
: "") +
|
||||
(event.suggestion.status === "degraded" ? " is-degraded" : "")
|
||||
}
|
||||
>
|
||||
<div className="sx-coach-history__item-top">
|
||||
<span>{event.turn_seq}번 턴 · {event.stage ?? "단계 미상"}</span>
|
||||
{event.suggestion.status === "degraded" ? (
|
||||
<b className="sx-coach-degraded-badge">AI 응답 대체</b>
|
||||
) : null}
|
||||
<time dateTime={event.created_at}>
|
||||
{formatCoachTimestamp(event.created_at)}
|
||||
</time>
|
||||
|
|
@ -3088,7 +3395,11 @@ export default function Session() {
|
|||
</span>
|
||||
<div>
|
||||
<h2 id="sx-coach-modal-title">{coachSuggestion.title}</h2>
|
||||
<p id="sx-coach-modal-desc">방금 코칭 판단에 사용한 근거와 다음 발화 제안입니다.</p>
|
||||
<p id="sx-coach-modal-desc">
|
||||
{coachIsDegraded
|
||||
? coachDegradedNote
|
||||
: "방금 코칭 판단에 사용한 근거와 다음 발화 제안입니다."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28";
|
|||
const PREPOST_SCORE_MIN = 1;
|
||||
const PREPOST_SCORE_MAX = 5;
|
||||
const REVIEW_READY_POLL_INTERVAL_MS = 1500;
|
||||
const REVIEW_READY_POLL_LIMIT = 8;
|
||||
const REVIEW_READY_POLL_LIMIT = 60;
|
||||
|
||||
const PREPOST_MEASURES: Array<{ key: PrepostMeasureName; label: string; hint: string }> = [
|
||||
{ key: "self_efficacy", label: "자기효능감", hint: "상담 수행 자신감" },
|
||||
|
|
@ -540,26 +540,32 @@ function PrepostMeasureCard() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const dirtyKeys = useMemo(
|
||||
const changedKeys = useMemo(
|
||||
() =>
|
||||
Object.keys(draft).filter((key) => {
|
||||
const split = splitPrepostDraftKey(key);
|
||||
if (!split) return false;
|
||||
const value = draft[key]?.trim() ?? "";
|
||||
return value !== (baseline[key] ?? "") && parsePrepostScore(value) !== null;
|
||||
return value !== (baseline[key] ?? "");
|
||||
}),
|
||||
[baseline, draft],
|
||||
);
|
||||
|
||||
const dirtyKeys = useMemo(
|
||||
() => changedKeys.filter((key) => parsePrepostScore(draft[key]?.trim() ?? "") !== null),
|
||||
[changedKeys, draft],
|
||||
);
|
||||
|
||||
const invalidKeys = useMemo(
|
||||
() =>
|
||||
Object.keys(draft).filter((key) => {
|
||||
const split = splitPrepostDraftKey(key);
|
||||
if (!split) return false;
|
||||
const value = draft[key]?.trim() ?? "";
|
||||
return value.length > 0 && parsePrepostScore(value) === null;
|
||||
const hadSavedValue = (baseline[key] ?? "").trim().length > 0;
|
||||
return (value.length === 0 && hadSavedValue) || (value.length > 0 && parsePrepostScore(value) === null);
|
||||
}),
|
||||
[draft],
|
||||
[baseline, draft],
|
||||
);
|
||||
|
||||
const saveDraft = async () => {
|
||||
|
|
@ -657,7 +663,7 @@ function PrepostMeasureCard() {
|
|||
</div>
|
||||
{invalidKeys.length > 0 ? (
|
||||
<p className="sr-prepost__error" role="alert">
|
||||
점수는 1 이상 5 이하로 입력해야 합니다.
|
||||
점수는 1 이상 5 이하로 입력해야 합니다. 기존 값을 비우려면 새 점수를 입력해 주세요.
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="sr-prepost__error" role="alert">
|
||||
|
|
@ -668,9 +674,11 @@ function PrepostMeasureCard() {
|
|||
<span>
|
||||
{loading
|
||||
? "불러오는 중"
|
||||
: dirtyKeys.length > 0
|
||||
? `${dirtyKeys.length}개 변경`
|
||||
: "저장된 값 기준"}
|
||||
: invalidKeys.length > 0
|
||||
? "저장되지 않은 입력 있음"
|
||||
: dirtyKeys.length > 0
|
||||
? `${dirtyKeys.length}개 변경`
|
||||
: "저장된 값 기준"}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
@ -973,9 +981,14 @@ export default function SessionReview() {
|
|||
const worksheetReviewStatus = (teacherReview?.worksheetStatus ?? "pending") as WorksheetReviewStatus;
|
||||
const worksheetReviewLabel = worksheetReviewStatusLabel(worksheetReviewStatus);
|
||||
const hasLearnerWorksheet = data.caseWorksheet?.status === "saved_by_learner";
|
||||
const canCloseTeacherReview = isSupervisorView && data.sessionSignal === "종료됨";
|
||||
const canDecideWorksheet = canCloseTeacherReview && hasLearnerWorksheet;
|
||||
const canRetryEvaluation = canCloseTeacherReview && hasTranscript && !data.reviewReady;
|
||||
const canReviewEndedSession = isSupervisorView && data.sessionSignal === "종료됨";
|
||||
const canCloseTeacherReview = canReviewEndedSession && data.reviewReady;
|
||||
const canDecideWorksheet = canReviewEndedSession && hasLearnerWorksheet;
|
||||
const canRetryEvaluation =
|
||||
canReviewEndedSession &&
|
||||
hasTranscript &&
|
||||
!data.reviewReady &&
|
||||
data.supervisorState === "평가 실패";
|
||||
const reviewReadiness = hasTranscript
|
||||
? `${turns.length}개 발화 기반`
|
||||
: "축어록 저장 후 생성";
|
||||
|
|
@ -1282,7 +1295,7 @@ export default function SessionReview() {
|
|||
<p className="sr-teacher-review__meta">
|
||||
완료 시각 {teacherReview.reviewedAt}
|
||||
</p>
|
||||
) : canCloseTeacherReview ? (
|
||||
) : canReviewEndedSession ? (
|
||||
<p className="sr-teacher-review__meta">
|
||||
종료 회기만 검토 완료로 닫을 수 있습니다.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -820,6 +820,7 @@
|
|||
}
|
||||
.pf-analysis__chart,
|
||||
.pf-analysis__stages,
|
||||
.pf-analysis__personas,
|
||||
.pf-analysis__timeline{
|
||||
min-width:0;
|
||||
border:1px solid var(--paper-2);
|
||||
|
|
@ -877,6 +878,135 @@
|
|||
font-size:var(--fs-xs);
|
||||
font-weight:700;
|
||||
}
|
||||
.pf-persona-drill{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
max-height:min(580px,62vh);
|
||||
overflow:auto;
|
||||
scrollbar-gutter:stable;
|
||||
}
|
||||
.pf-persona-group{
|
||||
min-width:0;
|
||||
border-top:1px solid var(--paper-2);
|
||||
}
|
||||
.pf-persona-group:first-child{
|
||||
border-top:0;
|
||||
}
|
||||
.pf-persona-group__summary{
|
||||
width:100%;
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:44px minmax(160px,1fr) minmax(260px,.92fr) auto;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
padding:12px;
|
||||
border:0;
|
||||
background:transparent;
|
||||
color:inherit;
|
||||
font:inherit;
|
||||
text-align:left;
|
||||
cursor:pointer;
|
||||
}
|
||||
.pf-persona-group__summary:hover,
|
||||
.pf-persona-group.is-open .pf-persona-group__summary{
|
||||
background:var(--bg-surface-2);
|
||||
}
|
||||
.pf-persona-group__summary:focus-visible{
|
||||
outline:2px solid var(--accent);
|
||||
outline-offset:-2px;
|
||||
}
|
||||
.pf-persona-group__code{
|
||||
width:38px;
|
||||
height:34px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
border-radius:var(--radius-sm);
|
||||
color:var(--accent-deep);
|
||||
background:var(--accent-tint);
|
||||
font-family:var(--font-num);
|
||||
font-size:12px;
|
||||
font-weight:830;
|
||||
}
|
||||
.pf-persona-group__main{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:4px;
|
||||
}
|
||||
.pf-persona-group__main b{
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
line-height:1.35;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-persona-group__main small{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
line-height:1.35;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-persona-group__metrics{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4,minmax(58px,1fr));
|
||||
gap:7px;
|
||||
}
|
||||
.pf-persona-group__metrics span{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:3px;
|
||||
}
|
||||
.pf-persona-group__metrics small{
|
||||
color:var(--text-muted);
|
||||
font-size:11px;
|
||||
line-height:1.2;
|
||||
}
|
||||
.pf-persona-group__metrics b{
|
||||
min-width:0;
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:15px;
|
||||
line-height:1.15;
|
||||
white-space:nowrap;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
}
|
||||
.pf-persona-group__open{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:4px;
|
||||
min-height:30px;
|
||||
padding:0 8px;
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius-sm);
|
||||
color:var(--accent-deep);
|
||||
background:var(--accent-tint);
|
||||
font-size:var(--fs-xs);
|
||||
font-weight:760;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.pf-persona-group__summary:hover .pf-persona-group__open{
|
||||
border-color:var(--accent);
|
||||
}
|
||||
.pf-persona-sessions{
|
||||
margin:0 12px 12px 56px;
|
||||
border:1px solid var(--paper-2);
|
||||
border-radius:var(--radius-sm);
|
||||
overflow:hidden;
|
||||
background:var(--bg-surface);
|
||||
}
|
||||
.pf-persona-sessions .pf-timeline-row{
|
||||
grid-template-columns:34px minmax(150px,1fr) minmax(200px,.82fr) minmax(104px,.42fr) auto;
|
||||
padding:10px;
|
||||
}
|
||||
.pf-persona-sessions .pf-timeline-row__step{
|
||||
width:28px;
|
||||
height:28px;
|
||||
}
|
||||
.pf-timeline{
|
||||
max-height:min(520px,58vh);
|
||||
overflow:auto;
|
||||
|
|
@ -1378,6 +1508,16 @@
|
|||
.pf-analysis__metrics span:nth-child(n+4){
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.pf-persona-group__summary{
|
||||
grid-template-columns:44px minmax(0,1fr) auto;
|
||||
}
|
||||
.pf-persona-group__metrics{
|
||||
grid-column:2 / 4;
|
||||
grid-row:2;
|
||||
}
|
||||
.pf-persona-sessions .pf-timeline-row{
|
||||
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
|
||||
}
|
||||
.pf-timeline-row{
|
||||
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
|
||||
}
|
||||
|
|
@ -1461,6 +1601,31 @@
|
|||
.pf-stage-item:nth-child(n+3){
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.pf-persona-drill{
|
||||
max-height:560px;
|
||||
}
|
||||
.pf-persona-group__summary{
|
||||
grid-template-columns:38px minmax(0,1fr);
|
||||
align-items:start;
|
||||
}
|
||||
.pf-persona-group__metrics,
|
||||
.pf-persona-group__open{
|
||||
grid-column:2;
|
||||
}
|
||||
.pf-persona-group__metrics{
|
||||
grid-row:auto;
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
}
|
||||
.pf-persona-group__open{
|
||||
justify-self:start;
|
||||
}
|
||||
.pf-persona-sessions{
|
||||
margin:0 10px 10px 48px;
|
||||
}
|
||||
.pf-persona-sessions .pf-timeline-row{
|
||||
grid-template-columns:34px minmax(0,1fr);
|
||||
align-items:start;
|
||||
}
|
||||
.pf-timeline{
|
||||
max-height:560px;
|
||||
}
|
||||
|
|
@ -1607,6 +1772,28 @@
|
|||
.pf-stage-item:nth-child(n+2){
|
||||
border-top:1px solid var(--hair);
|
||||
}
|
||||
.pf-persona-group__summary{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-persona-group__code{
|
||||
width:auto;
|
||||
min-width:38px;
|
||||
justify-self:start;
|
||||
padding:0 10px;
|
||||
}
|
||||
.pf-persona-group__metrics,
|
||||
.pf-persona-group__open{
|
||||
grid-column:1;
|
||||
}
|
||||
.pf-persona-group__metrics{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
.pf-persona-sessions{
|
||||
margin:0 8px 8px;
|
||||
}
|
||||
.pf-persona-sessions .pf-timeline-row__metrics{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
.pf-timeline-row__metrics{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1030,6 +1030,11 @@
|
|||
border-color: color-mix(in srgb, var(--warn-solid) 48%, transparent);
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.sx-utt__coach-mark.is-degraded {
|
||||
border-color: color-mix(in srgb, var(--clay-deep) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--clay-tint) 68%, var(--paper));
|
||||
color: var(--clay-deep);
|
||||
}
|
||||
.sx-utt__coach-mark:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 18px rgba(35, 42, 48, 0.16);
|
||||
|
|
@ -1052,6 +1057,25 @@
|
|||
.sx-utt.is-partial .sx-utt__line {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sx-utt.is-failed .sx-utt__line {
|
||||
background: color-mix(in srgb, var(--warn-tint) 74%, var(--paper));
|
||||
border: 1px dashed color-mix(in srgb, var(--warn-solid) 46%, transparent);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.sx-utt__status {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
|
||||
background: color-mix(in srgb, var(--warn-tint) 72%, var(--paper));
|
||||
color: var(--warn-text);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 4px 7px;
|
||||
}
|
||||
.sx-utt.is-thinking .sx-utt__line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -1427,6 +1451,19 @@
|
|||
.sx-coach-card.is-warn .sx-coach-bubble {
|
||||
border-color: color-mix(in srgb, var(--warn-solid) 42%, transparent);
|
||||
}
|
||||
.sx-coach-card.is-degraded .sx-coach-avatar {
|
||||
background: color-mix(in srgb, var(--clay-tint) 76%, var(--paper-2));
|
||||
border-color: color-mix(in srgb, var(--clay-deep) 40%, transparent);
|
||||
}
|
||||
.sx-coach-card.is-degraded .sx-coach-bubble {
|
||||
border-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--clay-tint) 22%, var(--paper-2));
|
||||
}
|
||||
.sx-coach-card.is-degraded .sx-coach-bubble::before {
|
||||
background: color-mix(in srgb, var(--clay-tint) 22%, var(--paper-2));
|
||||
border-left-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
|
||||
border-bottom-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
|
||||
}
|
||||
.sx-coach-bubble__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1439,6 +1476,78 @@
|
|||
color: var(--text-strong);
|
||||
font-size: 12px;
|
||||
}
|
||||
.sx-coach-degraded-note {
|
||||
padding: 8px 9px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--clay-deep) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--clay-tint) 38%, transparent);
|
||||
color: var(--clay-deep) !important;
|
||||
font-size: 11.5px !important;
|
||||
line-height: 1.45 !important;
|
||||
}
|
||||
.sx-coach-persistence-note {
|
||||
padding: 8px 9px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--warn-solid) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--warn-tint) 58%, transparent);
|
||||
color: var(--warn-text) !important;
|
||||
font-size: 11.5px !important;
|
||||
font-weight: 700;
|
||||
line-height: 1.45 !important;
|
||||
}
|
||||
.sx-coach-quota {
|
||||
min-height: 26px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--paper) 72%, var(--accent-tint));
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.sx-coach-quota b {
|
||||
color: var(--accent-deep);
|
||||
font-family: var(--font-num);
|
||||
font-size: 11.5px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.sx-coach-quota__dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.sx-coach-quota__dots i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--text-muted) 24%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-muted) 18%, transparent);
|
||||
}
|
||||
.sx-coach-quota__dots i.is-filled {
|
||||
background: var(--accent-deep);
|
||||
box-shadow: 0 0 0 3px var(--accent-tint);
|
||||
}
|
||||
.sx-coach-credit-pulse {
|
||||
justify-self: start;
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--paper);
|
||||
color: var(--text-body);
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
|
||||
font: 760 11px/1 var(--font-num);
|
||||
animation: sxCoachCreditPulse 900ms var(--ease-out) both;
|
||||
}
|
||||
.sx-coach-credit-pulse.is-recharge {
|
||||
color: var(--pos-solid);
|
||||
border-color: color-mix(in srgb, var(--pos-solid) 34%, transparent);
|
||||
background: color-mix(in srgb, var(--pos-solid) 11%, var(--paper));
|
||||
}
|
||||
.sx-coach-bubble strong {
|
||||
display: block;
|
||||
color: var(--text-strong);
|
||||
|
|
@ -1494,6 +1603,20 @@
|
|||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
@keyframes sxCoachCreditPulse {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.96);
|
||||
}
|
||||
35% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1.03);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.sx-coach-history {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -1535,6 +1658,26 @@
|
|||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.sx-coach-credit-log {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.sx-coach-credit-log span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--paper-2);
|
||||
color: var(--text-muted);
|
||||
font: 720 11px/1 var(--font-num);
|
||||
}
|
||||
.sx-coach-credit-log span.is-recharge {
|
||||
background: color-mix(in srgb, var(--pos-solid) 12%, var(--paper));
|
||||
color: var(--pos-solid);
|
||||
}
|
||||
.sx-coach-history__head button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
|
|
@ -1568,6 +1711,10 @@
|
|||
.sx-coach-history__item.is-warn {
|
||||
border-color: color-mix(in srgb, var(--warn-solid) 40%, transparent);
|
||||
}
|
||||
.sx-coach-history__item.is-degraded {
|
||||
border-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--clay-tint) 18%, var(--paper));
|
||||
}
|
||||
.sx-coach-history__item-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
|
@ -1576,6 +1723,18 @@
|
|||
color: var(--text-muted);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.sx-coach-degraded-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--clay-tint) 70%, transparent);
|
||||
color: var(--clay-deep);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sx-coach-history__item-top time {
|
||||
font-family: var(--font-num);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
|
@ -1623,6 +1782,23 @@
|
|||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.sx-coach-history__empty.is-error {
|
||||
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
|
||||
background: color-mix(in srgb, var(--warn-tint) 68%, var(--paper));
|
||||
color: var(--warn-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sx-coach-history__source-alert {
|
||||
margin-top: 8px;
|
||||
display: inline-flex;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--warn-solid) 28%, transparent);
|
||||
background: color-mix(in srgb, var(--warn-tint) 54%, var(--paper));
|
||||
color: var(--warn-text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sx-coach-source-list--compact {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
|
@ -2124,6 +2300,10 @@
|
|||
padding: 7px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
|
|
@ -2138,6 +2318,28 @@
|
|||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.sx-segmented__badge {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-deep);
|
||||
color: #fff;
|
||||
font: 800 10.5px/1 var(--font-num);
|
||||
font-variant-numeric: tabular-nums;
|
||||
box-shadow: 0 0 0 2px var(--accent-tint);
|
||||
}
|
||||
.sx-segmented button:not(.is-on) .sx-segmented__badge {
|
||||
background: color-mix(in srgb, var(--accent-deep) 72%, var(--text-muted));
|
||||
box-shadow: none;
|
||||
}
|
||||
.sx-segmented__badge.is-empty,
|
||||
.sx-segmented button:not(.is-on) .sx-segmented__badge.is-empty {
|
||||
background: color-mix(in srgb, var(--text-muted) 38%, transparent);
|
||||
color: var(--text-muted);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-muted) 25%, transparent);
|
||||
}
|
||||
|
||||
.sx-cb-spacer {
|
||||
flex: 1;
|
||||
|
|
@ -3722,6 +3924,8 @@
|
|||
.sx-mic.is-on::after,
|
||||
.sx-utt__caret,
|
||||
.sx-utt__dots i,
|
||||
.sx-coach-credit-pulse,
|
||||
.sx-coach-card.is-loading .sx-coach-avatar__lens,
|
||||
.sx-transcript__live-dot.is-live {
|
||||
animation: none !important;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue