현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -71,6 +71,38 @@
gap: var(--sp-2);
}
/* 운영 콘솔은 생성 시안처럼 어두운 크롬을 쓴다. 본문 컴포넌트 토큰은 그대로 유지한다. */
body[data-role="admin"] .vg-topbar {
background: #17211f;
border-bottom-color: rgba(255, 255, 255, 0.08);
color: #eef4f2;
}
body[data-role="admin"] .vg-topbar__wm,
body[data-role="admin"] .vg-topbar__role,
body[data-role="admin"] .vg-topbar__uname {
color: #eef4f2;
}
body[data-role="admin"] .vg-topbar__brand svg {
color: #7eb8ad;
}
body[data-role="admin"] .vg-topbar__user {
background: rgba(255, 255, 255, 0.08);
}
body[data-role="admin"] .vg-topbar__avatar {
background: rgba(126, 184, 173, 0.18);
color: #bfe0d9;
}
body[data-role="admin"] .vg-topbar__role {
border-left-color: rgba(255, 255, 255, 0.12);
}
body[data-role="admin"] .vg-iconbtn {
color: rgba(238, 244, 242, 0.78);
}
body[data-role="admin"] .vg-iconbtn:hover {
background: rgba(255, 255, 255, 0.09);
color: #ffffff;
}
/* 톱바 아이콘 버튼 (테마/로그아웃) */
.vg-iconbtn {
display: inline-flex;
@ -209,6 +241,40 @@
color: var(--text-muted);
}
body[data-role="admin"] .vg-shell__body {
background-image: linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08));
}
body[data-role="admin"] .vg-nav {
background:
linear-gradient(180deg, rgba(23, 33, 31, 0.98), rgba(26, 38, 42, 0.98)),
var(--asset-warm-elements) center / cover no-repeat;
color: #eef4f2;
}
body[data-role="admin"] .vg-nav__label,
body[data-role="admin"] .vg-nav__ethic {
color: rgba(238, 244, 242, 0.54);
}
body[data-role="admin"] .vg-nav__item {
color: rgba(238, 244, 242, 0.76);
}
body[data-role="admin"] .vg-nav__item:hover {
background: rgba(255, 255, 255, 0.08);
color: #ffffff;
}
body[data-role="admin"] .vg-nav__item .vg-nav__ic {
color: rgba(191, 224, 217, 0.72);
}
body[data-role="admin"] .vg-nav__item.is-active {
background: rgba(126, 184, 173, 0.22);
color: #ffffff;
}
body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic {
color: #91c8bd;
}
body[data-role="admin"] .vg-nav__foot {
border-top-color: rgba(255, 255, 255, 0.1);
}
/* ── 메인 콘텐츠 ── */
.vg-main {
min-width: 0; /* 그리드 자식 overflow 방지 */

3723
apps/web/src/lib/api.gen.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,10 @@
- SSE: POST /sessions/{id}/stream token/done/ping/error .
===================================================================== */
import type { components } from "./api.gen";
type ApiSchema<Name extends keyof components["schemas"]> = components["schemas"][Name];
// Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅).
const PUBLIC_API_ORIGIN_BY_HOST: Record<string, string> = {
"vignette.chanpaca.net": "https://api-vignette.chanpaca.net",
@ -209,6 +213,9 @@ export interface PersonaReviewSummary {
approved_at: string | null;
}
export type PersonaDraftPayload = ApiSchema<"PersonaDraftPayload">;
export type PersonaDraftDetail = ApiSchema<"PersonaDraftDetail">;
/** POST /sessions — sessions.py SessionStartResponse */
export interface SessionStartResponse {
session_id: string;
@ -221,19 +228,15 @@ export interface SessionStartResponse {
}
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
export interface TurnResponse {
turn_seq: number;
stage: SessionStage;
effective_openness: number;
client_reply: string | null;
safety_flagged: boolean;
}
export type TurnResponse = ApiSchema<"TurnResponse">;
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
export interface SessionEndResponse {
session_id: string;
session_no: number;
digest_pending: boolean;
export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
export interface CrisisResource {
title: string;
number: string;
message: string;
}
export interface LearnerSessionSummary {
@ -290,6 +293,12 @@ export interface ReviewTechnique {
label: string;
}
export interface ReviewNonverbalEvent {
kind: "audio" | "silence" | "pace" | "barge_in";
label: string;
detail: string;
}
export interface ReviewNote {
author: "ai" | "instructor" | string;
tone: "good" | "watch";
@ -305,6 +314,7 @@ export interface ReviewTurn {
who: string;
text: string;
techniques: ReviewTechnique[];
nonverbal: ReviewNonverbalEvent[];
note?: ReviewNote | null;
}
@ -333,6 +343,34 @@ export interface ReviewPoint {
jumpTo?: string | null;
}
export interface ReviewWorksheetEvidence {
turnId: string;
speaker: "learner" | "client";
quote: string;
}
export interface ReviewWorksheetItem {
key: string;
label: string;
value?: string | null;
evidence: ReviewWorksheetEvidence[];
confidence: "none" | "low" | "medium";
emptyReason?: string | null;
}
export interface ReviewWorksheetSection {
key: string;
title: string;
items: ReviewWorksheetItem[];
}
export interface ReviewCaseWorksheet {
status: "empty" | "draft_from_transcript";
generatedBy: string;
sections: ReviewWorksheetSection[];
limitations: string[];
}
export interface SessionReviewResponse {
session_id: string;
client: ReviewClient;
@ -353,6 +391,7 @@ export interface SessionReviewResponse {
rubric: ReviewRubricRow[];
goodMoments: ReviewPoint[];
growthPoints: ReviewPoint[];
caseWorksheet?: ReviewCaseWorksheet | null;
nextLine?: string | null;
clientFeedback?: string | null;
audioUrl?: string | null;
@ -388,6 +427,9 @@ export interface SessionStreamDone {
effective_openness?: number;
turn_seq?: number;
safety_flagged?: boolean;
crisis_kind?: string;
crisis_resource?: CrisisResource | null;
conversation_stopped?: boolean;
}
function safeParse(data: string): unknown {
@ -453,6 +495,9 @@ export async function openSessionStream(
effective_openness: parsed.effective_openness,
turn_seq: parsed.turn_seq,
safety_flagged: parsed.safety_flagged,
crisis_kind: parsed.crisis_kind,
crisis_resource: parsed.crisis_resource,
conversation_stopped: parsed.conversation_stopped,
};
handlers.onDone?.(donePayload);
return;
@ -520,6 +565,12 @@ export const personaReviewApi = {
api.post<PersonaReviewSummary>(`/personas/review/${encodeURIComponent(personaId)}`, {
action,
}),
createDraft: (payload: PersonaDraftPayload) =>
api.post<PersonaReviewSummary>("/personas/drafts", payload),
getDraft: (personaId: string) =>
api.get<PersonaDraftDetail>(`/personas/drafts/${encodeURIComponent(personaId)}`),
updateDraft: (personaId: string, payload: PersonaDraftPayload) =>
api.put<PersonaReviewSummary>(`/personas/drafts/${encodeURIComponent(personaId)}`, payload),
};
export const sessionApi = {
@ -568,8 +619,39 @@ export interface AdminHealthResponse {
services: AdminServiceHealth[];
}
export interface AdminUsageBreakdown {
provider: string;
model: string;
turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
}
export interface AdminUsageBudget {
limit_usd: number;
used_ratio: number;
remaining_usd: number | null;
status: "disabled" | "ok" | "warn" | "exceeded";
}
export interface AdminUsageResponse {
source: "database" | "server_session_registry";
durable: boolean;
window_days: number;
generated_at: number;
total_turns: number;
metered_turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
budget: AdminUsageBudget;
by_provider: AdminUsageBreakdown[];
}
export const adminApi = {
health: () => api.get<AdminHealthResponse>("/admin/health"),
usage: (windowDays = 7) => api.get<AdminUsageResponse>(`/admin/usage?window_days=${windowDays}`),
};
export interface AdminManagedUser {
@ -629,12 +711,58 @@ export interface TeacherSessionSummary {
ended_at: string | null;
}
export interface TeacherSafetyAlert {
id: string;
session_id: string;
learner_id: string;
learner_label: string;
persona_code: string;
session_no: number;
trigger_type: string;
ko_risk_level: number;
escalated: boolean;
created_at: string;
resource_title: string;
resource_number: string;
}
export interface TeacherGrowthPoint {
session_id: string;
session_no: number;
persona_code: string;
stage: string;
started_at: string;
ended_at: string | null;
score: number | null;
rapport: number | null;
technique_count: number;
watch_count: number;
}
export interface TeacherLearnerGrowth {
learner_id: string;
learner_label: string;
sessions: number;
ended_sessions: number;
latest_at: string;
first_score: number | null;
latest_score: number | null;
score_delta: number | null;
avg_score: number | null;
avg_rapport: number | null;
trend: "up" | "down" | "flat" | "insufficient" | string;
top_techniques: string[];
points: TeacherGrowthPoint[];
}
export interface TeacherDashboardResponse {
source: string;
cohort_label: string;
total_learners: number;
active_sessions: number;
ended_sessions: number;
safety_alerts: TeacherSafetyAlert[];
learner_growth: TeacherLearnerGrowth[];
pending_reviews: TeacherSessionSummary[];
recent_sessions: TeacherSessionSummary[];
message: string;

View file

@ -6,6 +6,7 @@ import {
adminUsersApi,
type AdminHealthResponse,
type AdminHealthStatus,
type AdminUsageResponse,
type AdminManagedUser,
type AdminUserCreateRequest,
type AdminUsersResponse,
@ -84,6 +85,38 @@ function dateTimeLabel(seconds: number): string {
});
}
function countLabel(value: number): string {
if (!Number.isFinite(value)) return "0";
return Math.round(value).toLocaleString("ko-KR");
}
function costLabel(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "$0";
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
}
function usageSourceLabel(data: AdminUsageResponse | null): string {
if (!data) return "대기 중";
return data.durable ? "DB 계량" : "비영구 런타임 계량";
}
function usageBudgetLabel(data: AdminUsageResponse): string {
const { budget } = data;
if (budget.status === "disabled") return "예산 경고 비활성";
if (budget.status === "exceeded") return "예산 초과";
if (budget.status === "warn") return "예산 주의";
return "예산 정상";
}
function usageBudgetDetail(data: AdminUsageResponse): string {
const { budget } = data;
if (budget.status === "disabled") return "ADMIN_USAGE_BUDGET_USD가 설정되지 않았습니다.";
const pct = Math.round(budget.used_ratio * 100);
const remaining =
budget.remaining_usd === null ? "" : ` · 잔여 ${costLabel(budget.remaining_usd)}`;
return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`;
}
function initialOf(user: AdminManagedUser): string {
const label = user.display_name.trim() || user.email;
return Array.from(label)[0]?.toUpperCase() ?? "?";
@ -114,6 +147,9 @@ export default function Admin() {
const [creatingUser, setCreatingUser] = useState(false);
const [deactivatingUserId, setDeactivatingUserId] = useState<string | null>(null);
const [userSearch, setUserSearch] = useState("");
const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
const [usageLoading, setUsageLoading] = useState(true);
const [usageError, setUsageError] = useState<string | null>(null);
const loadHealth = useCallback(async () => {
setLoading(true);
@ -155,14 +191,27 @@ export default function Admin() {
}
}, []);
const loadUsage = useCallback(async () => {
setUsageLoading(true);
setUsageError(null);
try {
setUsage(await adminApi.usage(7));
} catch (err) {
setUsageError(err instanceof Error ? err.message : "비용 사용량을 불러오지 못했습니다.");
} finally {
setUsageLoading(false);
}
}, []);
useEffect(() => {
void loadHealth();
void loadUsers();
}, [loadHealth, loadUsers]);
void loadUsage();
}, [loadHealth, loadUsage, loadUsers]);
const refreshAll = useCallback(async () => {
await Promise.all([loadHealth(), loadUsers()]);
}, [loadHealth, loadUsers]);
await Promise.all([loadHealth(), loadUsers(), loadUsage()]);
}, [loadHealth, loadUsage, loadUsers]);
const updateDraft = (userId: string, patch: Partial<UserDraft>) => {
setUserDrafts((current) => ({
@ -308,9 +357,9 @@ export default function Admin() {
variant="secondary"
leading={<Icon name="settings" size={16} />}
onClick={() => void refreshAll()}
disabled={loading || usersLoading}
disabled={loading || usersLoading || usageLoading}
>
{loading || usersLoading ? "확인 중" : "새로고침"}
{loading || usersLoading || usageLoading ? "확인 중" : "새로고침"}
</Button>
</header>
@ -355,6 +404,95 @@ export default function Admin() {
</section>
</section>
<section className="ad-section ad-usage-section" aria-label="AI 비용 관측">
<div className="ad-section__head">
<h2>AI </h2>
<span>
{usage
? `최근 ${usage.window_days}일 · ${usageSourceLabel(usage)}`
: usageSourceLabel(usage)}
</span>
</div>
{usageError ? (
<section className="ad-error" role="alert">
<Icon name="alert" size={18} />
<span>{usageError}</span>
</section>
) : null}
<div className="ad-usage-grid">
<div className="ad-usage-kpi">
<span> </span>
<b>{usage ? costLabel(usage.cost_usd) : "-"}</b>
<small>{usage ? `${countLabel(usage.metered_turns)}개 계량 턴` : "계산 중"}</small>
</div>
<div className="ad-usage-kpi">
<span> </span>
<b>{usage ? countLabel(usage.tokens_in) : "-"}</b>
<small>/</small>
</div>
<div className="ad-usage-kpi">
<span> </span>
<b>{usage ? countLabel(usage.tokens_out) : "-"}</b>
<small> </small>
</div>
<div className="ad-usage-kpi">
<span> </span>
<b>
{usage && usage.total_turns > 0
? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%`
: usage
? "0%"
: "-"}
</b>
<small>{usage ? `${countLabel(usage.total_turns)}개 내담자 턴` : "계산 중"}</small>
</div>
</div>
{usage ? (
<section
className={`ad-usage-budget ad-usage-budget--${usage.budget.status}`}
role={usage.budget.status === "warn" || usage.budget.status === "exceeded" ? "alert" : "status"}
>
<Icon
name={usage.budget.status === "warn" || usage.budget.status === "exceeded" ? "alert" : "check"}
size={18}
/>
<div>
<b>{usageBudgetLabel(usage)}</b>
<span>{usageBudgetDetail(usage)}</span>
</div>
</section>
) : null}
<div className="ad-usage-breakdown" aria-label="모델별 비용">
<div className="ad-usage-breakdown__head">
<span>Provider / Model</span>
<span></span>
<span></span>
<span></span>
</div>
{(usage?.by_provider ?? []).map((item) => (
<div className="ad-usage-row" key={`${item.provider}:${item.model}`}>
<span>
<b>{item.provider}</b>
<small>{item.model}</small>
</span>
<span>{countLabel(item.turns)}</span>
<span>{countLabel(item.tokens_in + item.tokens_out)}</span>
<span>{costLabel(item.cost_usd)}</span>
</div>
))}
{usageLoading && !usage ? (
<div className="ad-users-empty"> .</div>
) : null}
{!usageLoading && usage && usage.by_provider.length === 0 ? (
<div className="ad-users-empty"> AI .</div>
) : null}
</div>
</section>
{error ? (
<section className="ad-error" role="alert">
<Icon name="alert" size={18} />
@ -698,9 +836,25 @@ export default function Admin() {
const ADMIN_CSS = `
.ad-root{
width:min(100%,1180px);
margin:0 auto;
position:relative;
isolation:isolate;
display:flex;
flex-direction:column;
gap:18px;
gap:16px;
}
.ad-root::before{
content:"";
position:absolute;
z-index:-1;
inset:-80px -180px auto auto;
width:min(560px,52vw);
height:420px;
background:var(--asset-warm-elements) center / cover no-repeat;
opacity:.045;
filter:saturate(.75);
pointer-events:none;
}
.ad-head{
display:flex;
@ -708,7 +862,7 @@ const ADMIN_CSS = `
justify-content:space-between;
gap:var(--sp-4);
flex-wrap:wrap;
padding-bottom:2px;
padding:0 2px 2px;
}
.ad-head h1{
margin:6px 0 0;
@ -726,7 +880,7 @@ const ADMIN_CSS = `
}
.ad-ops{
display:grid;
grid-template-columns:minmax(0,max-content) minmax(320px,1fr);
grid-template-columns:minmax(320px,.64fr) minmax(0,1fr);
gap:12px;
}
.ad-status{
@ -738,7 +892,8 @@ const ADMIN_CSS = `
padding:14px 16px;
border:1px solid var(--hair);
border-radius:var(--radius);
background:var(--bg-surface);
background:
linear-gradient(180deg,color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface)),var(--bg-surface));
box-shadow:var(--shadow-sm);
}
.ad-status__dot{
@ -779,7 +934,7 @@ const ADMIN_CSS = `
}
.ad-kpis{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
grid-template-columns:repeat(4,minmax(0,1fr));
border:1px solid var(--hair);
border-radius:var(--radius);
overflow:hidden;
@ -793,7 +948,8 @@ const ADMIN_CSS = `
gap:4px;
}
.ad-kpi:nth-child(even){border-left:1px solid var(--hair);}
.ad-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.ad-kpi + .ad-kpi{border-left:1px solid var(--hair);}
.ad-kpi:nth-child(n+3){border-top:0;}
.ad-kpi__lab{
display:block;
color:var(--text-muted);
@ -812,6 +968,138 @@ const ADMIN_CSS = `
font-size:12px;
line-height:1.35;
}
.ad-usage-section{
padding:14px;
border:1px solid var(--hair);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.ad-usage-grid{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:10px;
}
.ad-usage-kpi{
min-width:0;
display:grid;
gap:5px;
padding:12px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.ad-usage-kpi span{
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
}
.ad-usage-kpi b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:22px;
line-height:1;
overflow-wrap:anywhere;
}
.ad-usage-kpi small{
color:var(--text-muted);
font-size:12px;
line-height:1.35;
}
.ad-usage-budget{
display:flex;
align-items:flex-start;
gap:10px;
min-width:0;
padding:10px 12px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
color:var(--text-muted);
}
.ad-usage-budget svg{
flex:0 0 auto;
margin-top:1px;
}
.ad-usage-budget div{
display:grid;
gap:2px;
min-width:0;
}
.ad-usage-budget b{
color:var(--text-strong);
font-size:var(--fs-xs);
line-height:1.3;
}
.ad-usage-budget span{
font-size:12px;
line-height:1.4;
overflow-wrap:anywhere;
}
.ad-usage-budget--warn{
border-color:color-mix(in srgb,var(--warn-solid) 42%,var(--hair));
background:color-mix(in srgb,var(--warn-tint) 72%,var(--bg-surface));
color:var(--warn-text);
}
.ad-usage-budget--exceeded{
border-color:color-mix(in srgb,var(--crit-solid) 42%,var(--hair));
background:color-mix(in srgb,var(--crit-tint) 72%,var(--bg-surface));
color:var(--crit-text);
}
.ad-usage-budget--ok{
border-color:color-mix(in srgb,var(--pos-solid) 36%,var(--hair));
background:color-mix(in srgb,var(--pos-tint) 72%,var(--bg-surface));
color:var(--pos-text);
}
.ad-usage-breakdown{
display:flex;
flex-direction:column;
min-width:0;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
overflow:hidden;
background:var(--bg-surface-2);
}
.ad-usage-breakdown__head,
.ad-usage-row{
display:grid;
grid-template-columns:minmax(170px,1fr) 72px 110px 90px;
align-items:center;
gap:10px;
padding:9px 12px;
}
.ad-usage-breakdown__head{
background:var(--bg-app);
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:800;
}
.ad-usage-row{
border-top:1px solid var(--hair);
color:var(--text-body);
font-family:var(--font-num);
font-size:var(--fs-sm);
}
.ad-usage-row > span{
min-width:0;
}
.ad-usage-row > span:first-child{
display:grid;
gap:2px;
font-family:var(--font-sans);
}
.ad-usage-row b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.25;
overflow-wrap:anywhere;
}
.ad-usage-row small{
color:var(--text-muted);
font-size:12px;
line-height:1.25;
overflow-wrap:anywhere;
}
.ad-section{
display:flex;
flex-direction:column;
@ -996,12 +1284,13 @@ const ADMIN_CSS = `
}
.ad-user-workspace{
display:grid;
grid-template-columns:minmax(250px,292px) minmax(0,1fr);
grid-template-columns:minmax(0,1fr) minmax(258px,300px);
align-items:stretch;
gap:14px;
min-width:0;
}
.ad-user-sidecar{
order:2;
position:sticky;
top:calc(var(--topbar-h) + 16px);
align-self:start;
@ -1011,6 +1300,7 @@ const ADMIN_CSS = `
min-width:0;
}
.ad-user-listpane{
order:1;
display:flex;
flex-direction:column;
min-width:0;
@ -1073,10 +1363,10 @@ const ADMIN_CSS = `
.ad-user{
min-width:0;
display:grid;
grid-template-columns:minmax(150px,.75fr) minmax(260px,1.35fr) minmax(104px,.45fr) max-content;
grid-template-columns:minmax(190px,.75fr) minmax(420px,1.4fr) minmax(138px,.42fr) max-content;
align-items:center;
gap:10px 12px;
padding:11px 12px;
padding:10px 12px;
border-top:1px solid var(--paper-2);
background:var(--bg-surface);
}
@ -1123,7 +1413,7 @@ const ADMIN_CSS = `
}
.ad-user__fields{
display:grid;
grid-template-columns:minmax(0,1fr) 104px;
grid-template-columns:minmax(130px,1.1fr) 96px minmax(118px,.95fr) minmax(110px,.85fr);
gap:8px;
min-width:0;
}
@ -1134,10 +1424,17 @@ const ADMIN_CSS = `
gap:6px;
}
.ad-user__fields span{
display:none;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:650;
}
.ad-user__fields input,
.ad-user__fields select{
height:32px;
font-size:12.5px;
padding-inline:9px;
}
.ad-user__meta{
display:grid;
gap:4px;
@ -1180,12 +1477,20 @@ const ADMIN_CSS = `
grid-template-columns:1fr;
}
.ad-user-sidecar{
order:0;
position:static;
}
.ad-user-listpane{order:1;}
.ad-user-create{grid-template-columns:repeat(2,minmax(0,1fr));}
.ad-user{
grid-template-columns:minmax(190px,.8fr) minmax(0,1.2fr) max-content;
}
.ad-user__fields{
grid-template-columns:minmax(0,1fr) 104px;
}
.ad-user__fields span{
display:block;
}
.ad-user__meta{
grid-column:1 / -1;
display:flex;
@ -1203,6 +1508,9 @@ const ADMIN_CSS = `
.ad-kpi:nth-child(even),
.ad-kpi + .ad-kpi{border-left:1px solid var(--hair);}
.ad-kpi:nth-child(n+3){border-top:0;}
.ad-usage-grid{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.ad-service{
grid-template-columns:minmax(160px,.85fr) minmax(0,1fr);
}
@ -1218,6 +1526,7 @@ const ADMIN_CSS = `
}
}
@media (max-width:700px){
.ad-root::before{display:none;}
.ad-head{
align-items:flex-start;
flex-direction:column;
@ -1231,6 +1540,16 @@ const ADMIN_CSS = `
.ad-kpi:nth-child(even){border-left:1px solid var(--hair);}
.ad-kpi:nth-child(odd){border-left:0;}
.ad-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.ad-usage-breakdown__head{
display:none;
}
.ad-usage-row{
grid-template-columns:minmax(0,1fr) auto;
gap:8px 12px;
}
.ad-usage-row > span:first-child{
grid-row:1 / span 3;
}
.ad-service{
grid-template-columns:1fr;
}
@ -1259,6 +1578,13 @@ const ADMIN_CSS = `
border-left:0;
}
.ad-kpi + .ad-kpi{border-top:1px solid var(--hair);}
.ad-usage-grid{grid-template-columns:1fr;}
.ad-usage-row{
grid-template-columns:1fr;
}
.ad-usage-row > span:first-child{
grid-row:auto;
}
.ad-service__meter{grid-template-columns:1fr;}
.ad-service__meter span{text-align:left;}
.ad-user__top{align-items:stretch;flex-direction:column;}

View file

@ -430,6 +430,8 @@ const LH_CSS = `
width:min(100%,1480px);
min-height:calc(100dvh - var(--topbar-h));
margin:0 auto;
position:relative;
isolation:isolate;
display:grid;
grid-template-rows:auto minmax(0,1fr);
align-content:start;
@ -438,6 +440,18 @@ const LH_CSS = `
background:var(--bg-app);
overflow:visible;
}
.lh-root::before{
content:"";
position:absolute;
z-index:-1;
inset:-120px -220px auto auto;
width:min(720px,58vw);
height:520px;
background:var(--asset-warm-elements) center / cover no-repeat;
opacity:.07;
filter:saturate(.8);
pointer-events:none;
}
.lh-head{
display:flex;
align-items:center;
@ -500,6 +514,16 @@ const LH_CSS = `
box-shadow:var(--shadow-sm);
overflow:hidden;
}
.lh-list-pane::after{
content:"";
position:absolute;
inset:auto -48px -68px auto;
width:180px;
height:180px;
background:var(--asset-warm-elements) center / cover no-repeat;
opacity:.05;
pointer-events:none;
}
.lh-pane-head{
display:flex;
align-items:center;
@ -637,6 +661,7 @@ const LH_CSS = `
}
.lh-preview__main{
min-width:0;
position:relative;
display:grid;
gap:var(--sp-4);
align-content:start;
@ -645,6 +670,22 @@ const LH_CSS = `
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
overflow:hidden;
}
.lh-preview__main::after{
content:"";
position:absolute;
inset:auto -180px -210px auto;
width:520px;
height:360px;
background:var(--asset-warm-elements) center / cover no-repeat;
opacity:.08;
filter:saturate(.82);
pointer-events:none;
}
.lh-preview__main > *{
position:relative;
z-index:1;
}
.lh-preview__hero{
min-width:0;
@ -721,7 +762,8 @@ const LH_CSS = `
min-width:0;
padding:var(--sp-4);
border-radius:var(--radius);
background:var(--bg-surface-2);
background:
linear-gradient(180deg,color-mix(in srgb,var(--clay-tint) 48%,var(--bg-surface-2)),var(--bg-surface-2));
}
.lh-summary p{
margin:8px 0 0;
@ -971,6 +1013,11 @@ const LH_CSS = `
max-height:none;
overflow:visible;
}
.lh-root::before{
width:560px;
height:420px;
opacity:.055;
}
.lh-personas{
grid-template-columns:repeat(2,minmax(0,1fr));
grid-auto-rows:auto;
@ -989,6 +1036,11 @@ const LH_CSS = `
padding:12px;
gap:12px;
}
.lh-root::before,
.lh-preview__main::after,
.lh-list-pane::after{
display:none;
}
.lh-head{
align-items:flex-start;
}

View file

@ -10,16 +10,50 @@ const OAUTH_FAILED_MESSAGE =
"Google 로그인 흐름을 완료하지 못했습니다. 다시 시도하거나 관리자에게 설정 확인을 요청하세요.";
const LOCAL_OAUTH_UNAVAILABLE_MESSAGE =
"로컬 개발 주소에서는 Google OAuth 콜백이 공개 API로 돌아가므로 로컬 테스트 계정으로 로그인하세요.";
const OAUTH_STATE_FAILED_MESSAGE =
"로그인 세션 확인에 실패했습니다. 브라우저 쿠키를 허용한 뒤 다시 시도하세요.";
const OAUTH_TOKEN_FAILED_MESSAGE =
"Google 인증 코드를 서버에서 교환하지 못했습니다. 관리자에게 OAuth 클라이언트 secret과 redirect URI 확인을 요청하세요.";
const OAUTH_IDENTITY_FAILED_MESSAGE =
"Google 계정 정보를 확인하지 못했습니다. 다시 시도하거나 관리자에게 OAuth 클라이언트 설정 확인을 요청하세요.";
const OAUTH_PROVIDER_DENIED_MESSAGE =
"Google 로그인이 취소되었거나 계정 선택이 거부되었습니다. 다시 시도하세요.";
const OAUTH_PROVIDER_FAILED_MESSAGE =
"Google이 인증 코드를 발급하지 못했습니다. 관리자에게 OAuth 동의 화면과 클라이언트 설정 확인을 요청하세요.";
const OAUTH_UNSUPPORTED_PROVIDER_MESSAGE =
"지원하지 않는 로그인 공급자입니다. Google 로그인 버튼으로 다시 시작하세요.";
const SAML_NOT_CONFIGURED_MESSAGE =
"학교 SSO가 아직 연결되지 않았습니다. 현재는 승인된 Google 계정으로 로그인하세요.";
const SAML_FAILED_MESSAGE =
"학교 SSO 로그인 흐름을 완료하지 못했습니다. 관리자에게 SSO 설정 확인을 요청하세요.";
function oauthMessage(reason: string | null): string | null {
if (!reason) return null;
if (reason === "not_configured") return OAUTH_NOT_CONFIGURED_MESSAGE;
if (reason === "local_oauth_unavailable") return LOCAL_OAUTH_UNAVAILABLE_MESSAGE;
if (reason === "invalid_state" || reason === "missing_callback") {
return OAUTH_STATE_FAILED_MESSAGE;
}
if (reason === "token_exchange_failed") return OAUTH_TOKEN_FAILED_MESSAGE;
if (
reason === "id_token_missing" ||
reason === "id_token_invalid" ||
reason === "audience_mismatch" ||
reason === "issuer_mismatch"
) {
return OAUTH_IDENTITY_FAILED_MESSAGE;
}
if (reason === "domain_not_allowed") {
return "승인된 이메일 도메인의 Google 계정만 사용할 수 있습니다.";
}
if (reason === "inactive_user") {
return "비활성화된 계정입니다. 관리자에게 계정 상태 확인을 요청하세요.";
}
if (reason === "access_denied") return OAUTH_PROVIDER_DENIED_MESSAGE;
if (reason === "provider_error") return OAUTH_PROVIDER_FAILED_MESSAGE;
if (reason === "unsupported_provider") return OAUTH_UNSUPPORTED_PROVIDER_MESSAGE;
if (reason === "saml_not_configured") return SAML_NOT_CONFIGURED_MESSAGE;
if (reason.startsWith("saml_")) return SAML_FAILED_MESSAGE;
return OAUTH_FAILED_MESSAGE;
}
@ -49,6 +83,7 @@ export default function Login() {
const [selected, setSelected] = useState<Role>("learner");
const [pending, setPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [loginErrorReason, setLoginErrorReason] = useState<string | null>(null);
const [authConfig, setAuthConfig] = useState<AuthConfigResponse | null>(null);
const [authConfigError, setAuthConfigError] = useState<string | null>(null);
@ -63,6 +98,7 @@ export default function Login() {
useEffect(() => {
const oauthState = new URLSearchParams(location.search).get("oauth");
setLoginError(oauthMessage(oauthState));
setLoginErrorReason(oauthState);
}, [location.search]);
useEffect(() => {
@ -86,24 +122,21 @@ export default function Login() {
const oauthChecking = authConfig === null && authConfigError === null;
const devLoginReady = import.meta.env.DEV && authConfig?.dev_login_enabled === true;
const localOrigin =
typeof window !== "undefined" && isLocalHostname(window.location.hostname);
const localOAuthUnavailable =
localOrigin &&
const devOAuthUnavailable =
devLoginReady &&
authConfig?.google_oauth_configured === true &&
!isLocalRedirectUri(authConfig.redirect_uri);
const oauthReady =
authConfig?.google_oauth_configured === true && !localOAuthUnavailable;
authConfig?.google_oauth_configured === true && !devOAuthUnavailable;
const allowedDomains = authConfig?.allowed_email_domains ?? [];
const primaryDomainLabel = localOAuthUnavailable
const primaryDomainLabel = devOAuthUnavailable
? "로컬은 테스트 계정 사용"
: allowedDomains[0]
? `@${allowedDomains[0]}`
: oauthChecking
? "도메인 확인 중"
: "승인 도메인 계정";
const secondaryDomainLabel = localOAuthUnavailable
const secondaryDomainLabel = devOAuthUnavailable
? "공개 주소에서 사용"
: allowedDomains[1]
? `@${allowedDomains[1]}`
@ -113,8 +146,9 @@ export default function Login() {
const startOAuth = () => {
if (!oauthReady) {
setLoginErrorReason(devOAuthUnavailable ? "local_oauth_unavailable" : "not_configured");
setLoginError(
localOAuthUnavailable
devOAuthUnavailable
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
: (authConfigError ?? OAUTH_NOT_CONFIGURED_MESSAGE),
);
@ -128,6 +162,7 @@ export default function Login() {
const enterDev = async (role: Role) => {
setPending(true);
setLoginError(null);
setLoginErrorReason(null);
try {
const signedIn = await login(role);
navigate(roleHomePath(signedIn.role), { replace: true });
@ -233,7 +268,7 @@ export default function Login() {
<div className="lg-config" role="status">
<Icon name={authConfigError ? "alert" : "info"} size={17} />
<span>
{localOAuthUnavailable
{devOAuthUnavailable
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
: oauthChecking
? "Google 로그인 설정을 확인하는 중입니다."
@ -275,11 +310,21 @@ export default function Login() {
>
{pending ? "테스트 계정 확인 중" : "로컬 테스트 계정으로 계속"}
</button>
{loginError ? <p className="lg-error">{loginError}</p> : null}
{loginError ? (
<p className="lg-error">
{loginError}
{loginErrorReason ? <small> : {loginErrorReason}</small> : null}
</p>
) : null}
</div>
) : null}
{!devLoginReady && loginError ? <p className="lg-error">{loginError}</p> : null}
{!devLoginReady && loginError ? (
<p className="lg-error">
{loginError}
{loginErrorReason ? <small> : {loginErrorReason}</small> : null}
</p>
) : null}
<p className="lg-note">
. , , .
@ -293,22 +338,58 @@ export default function Login() {
const LOGIN_CSS = `
.lg-root{
min-height:100dvh;
position:relative;
display:grid;
grid-template-columns:minmax(0,1fr) minmax(360px,480px);
background:var(--bg-app);
color:var(--text-strong);
overflow:hidden;
}
.lg-root::before{
content:"";
position:absolute;
inset:auto -12vw -22vw 38vw;
height:42vw;
min-height:360px;
background:var(--asset-warm-elements) center / cover no-repeat;
opacity:.08;
filter:saturate(.82);
pointer-events:none;
}
.lg-brand,
.lg-enter{
position:relative;
z-index:1;
}
.lg-brand{
min-width:0;
position:relative;
display:flex;
flex-direction:column;
justify-content:space-between;
gap:var(--sp-7);
padding:var(--sp-7);
background:var(--bg-stage);
background:
linear-gradient(115deg,rgba(14,22,20,.94),rgba(30,39,36,.84) 54%,rgba(30,39,36,.68)),
var(--asset-warm-elements) center / cover no-repeat;
color:#edf4f2;
overflow:hidden;
}
.lg-brand::after{
content:"";
position:absolute;
left:var(--sp-7);
bottom:var(--sp-7);
width:min(360px,42vw);
height:120px;
border:1px solid rgba(255,255,255,.12);
border-radius:var(--radius-lg);
background:rgba(251,250,248,.06);
pointer-events:none;
}
.lg-wordmark{
position:relative;
z-index:1;
display:flex;
align-items:center;
gap:10px;
@ -319,6 +400,11 @@ const LOGIN_CSS = `
}
.lg-mark{display:grid;place-items:center;color:var(--accent-bright);}
.lg-copy{max-width:620px;}
.lg-copy,
.lg-policy{
position:relative;
z-index:1;
}
.lg-kicker{
display:inline-flex;
align-items:center;
@ -373,6 +459,8 @@ const LOGIN_CSS = `
align-items:center;
justify-content:center;
padding:var(--sp-6);
background:
linear-gradient(180deg,rgba(251,250,248,.9),rgba(244,242,238,.76));
}
.lg-panel{
width:100%;
@ -517,6 +605,14 @@ const LOGIN_CSS = `
font-size:13px;
line-height:1.5;
}
.lg-error small{
display:block;
margin-top:4px;
color:var(--text-muted);
font-family:var(--font-num);
font-size:11.5px;
overflow-wrap:anywhere;
}
.lg-note{
margin:var(--sp-5) 0 0;
color:var(--text-muted);
@ -525,7 +621,9 @@ const LOGIN_CSS = `
}
@media (max-width:880px){
.lg-root{grid-template-columns:1fr;}
.lg-root::before{display:none;}
.lg-brand{padding:var(--sp-6) var(--sp-5);gap:var(--sp-6);}
.lg-brand::after{display:none;}
.lg-copy h1{font-size:36px;}
.lg-enter{padding:var(--sp-5);}
.lg-panel{max-width:560px;}

View file

@ -6,8 +6,13 @@ import {
personaReviewApi,
teacherApi,
type PersonaReviewAction,
type PersonaDraftDetail,
type PersonaDraftPayload,
type PersonaReviewStatus,
type PersonaReviewSummary,
type TeacherLearnerGrowth,
type TeacherGrowthPoint,
type TeacherSafetyAlert,
type TeacherDashboardResponse,
} from "../lib/api";
@ -25,6 +30,24 @@ function formatDateTime(value: string | null): string {
});
}
function formatScore(value: number | null | undefined): string {
if (typeof value !== "number" || Number.isNaN(value)) return "평가 부족";
return `${Math.round(value * 100)}%`;
}
function formatDelta(value: number | null | undefined): string {
if (typeof value !== "number" || Number.isNaN(value)) return "변화 부족";
const sign = value > 0 ? "+" : "";
return `${sign}${Math.round(value * 100)}%p`;
}
function trendLabel(value: string): string {
if (value === "up") return "상승";
if (value === "down") return "하락";
if (value === "flat") return "유지";
return "평가 부족";
}
function personaReviewStatusLabel(status: PersonaReviewStatus): string {
if (status === "review") return "검수 대기";
if (status === "draft") return "수정 대기";
@ -38,6 +61,79 @@ function personaReviewTone(status: PersonaReviewStatus): "accent" | "neutral" |
return "neutral";
}
const EMPTY_PERSONA_DRAFT: PersonaDraftPayload = {
code: "P4",
display_name: "새 페르소나",
difficulty: "moderate",
theory_target: ["humanistic"],
demographics: {
age_band: "F-20s",
},
presenting: {
complaint: "",
},
history: {},
big5: {
O: 0.5,
C: 0.5,
E: 0.5,
A: 0.5,
N: 0.5,
},
resistance: {
base_resistance: 0.5,
unlock_rate: 0.1,
decay_floor: 0.05,
silence_prob: 0.15,
deflection_prob: 0.25,
},
speech_style: {
register: "polite",
avg_sentence_len: "medium",
fillers: [],
honorific: true,
verbal_tics: [],
},
affect_baseline: {
negative_affect: 0.45,
hopelessness: 0.2,
anhedonia: 0.2,
sleep: 0.2,
anxiety: 0.35,
suicide_ideation_stage: 1,
},
ccd: {},
dsm5_dimensional: {},
source_provenance: "clinical draft",
is_synthetic: true,
submit_for_review: false,
};
function stringifyDraft(payload: PersonaDraftPayload): string {
return JSON.stringify(payload, null, 2);
}
function draftDetailToPayload(detail: PersonaDraftDetail): PersonaDraftPayload {
return {
code: detail.code,
display_name: detail.display_name,
difficulty: detail.difficulty === "easy" || detail.difficulty === "hard" ? detail.difficulty : "moderate",
theory_target: detail.theory_target,
demographics: detail.demographics,
presenting: detail.presenting,
history: detail.history,
big5: detail.big5,
resistance: detail.resistance,
speech_style: detail.speech_style,
affect_baseline: detail.affect_baseline,
ccd: detail.ccd,
dsm5_dimensional: detail.dsm5_dimensional,
source_provenance: detail.source_provenance,
is_synthetic: detail.is_synthetic,
submit_for_review: detail.status === "review",
};
}
function EmptyState({ title, desc }: { title: string; desc: string }) {
return (
<div className="pf-empty">
@ -55,6 +151,11 @@ export default function Professor() {
const [personaReviewLoading, setPersonaReviewLoading] = useState(true);
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
const [personaReviewBusy, setPersonaReviewBusy] = useState<string | null>(null);
const [draftJson, setDraftJson] = useState(() => stringifyDraft(EMPTY_PERSONA_DRAFT));
const [draftEditingId, setDraftEditingId] = useState<string | null>(null);
const [draftBusy, setDraftBusy] = useState<"load" | "save" | "submit" | null>(null);
const [draftError, setDraftError] = useState<string | null>(null);
const [draftMessage, setDraftMessage] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const loadDashboard = useCallback(async () => {
@ -119,6 +220,73 @@ export default function Professor() {
[],
);
const resetPersonaDraft = useCallback(() => {
setDraftJson(stringifyDraft(EMPTY_PERSONA_DRAFT));
setDraftEditingId(null);
setDraftError(null);
setDraftMessage(null);
}, []);
const parsePersonaDraft = useCallback(
(submitForReview: boolean): PersonaDraftPayload => {
const parsed = JSON.parse(draftJson) as PersonaDraftPayload;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("JSON 객체가 필요합니다.");
}
return {
...parsed,
submit_for_review: submitForReview,
};
},
[draftJson],
);
const savePersonaDraft = useCallback(
async (submitForReview: boolean) => {
setDraftBusy(submitForReview ? "submit" : "save");
setDraftError(null);
setDraftMessage(null);
try {
const payload = parsePersonaDraft(submitForReview);
const updated = draftEditingId
? await personaReviewApi.updateDraft(draftEditingId, payload)
: await personaReviewApi.createDraft(payload);
setDraftEditingId(updated.persona_id);
setDraftMessage(
updated.status === "review"
? `${updated.code} v${updated.version} 검수 요청을 올렸습니다.`
: `${updated.code} v${updated.version} 초안을 저장했습니다.`,
);
await loadPersonaReviews();
} catch (err) {
if (err instanceof SyntaxError) {
setDraftError("JSON 형식이 올바르지 않습니다.");
} else {
setDraftError(err instanceof Error ? err.message : "페르소나 초안을 저장하지 못했습니다.");
}
} finally {
setDraftBusy(null);
}
},
[draftEditingId, loadPersonaReviews, parsePersonaDraft],
);
const loadPersonaDraft = useCallback(async (personaId: string) => {
setDraftBusy("load");
setDraftError(null);
setDraftMessage(null);
try {
const detail = await personaReviewApi.getDraft(personaId);
setDraftEditingId(detail.persona_id);
setDraftJson(stringifyDraft(draftDetailToPayload(detail)));
setDraftMessage(`${detail.code} v${detail.version} 초안을 불러왔습니다.`);
} catch (err) {
setDraftError(err instanceof Error ? err.message : "페르소나 초안을 불러오지 못했습니다.");
} finally {
setDraftBusy(null);
}
}, []);
const kpis = useMemo(
() => [
{
@ -139,6 +307,12 @@ export default function Professor() {
hint: "저장 완료",
icon: "check" as const,
},
{
label: "위기 알림",
value: dashboard?.safety_alerts.length ?? 0,
hint: "109 확인",
icon: "alert" as const,
},
{
label: "리뷰 대기",
value: dashboard?.pending_reviews.length ?? 0,
@ -152,6 +326,8 @@ export default function Professor() {
const hasPending = pendingCount > 0;
const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0);
const personaReviewCount = personaReviews.length;
const safetyAlerts = dashboard?.safety_alerts ?? [];
const learnerGrowth = dashboard?.learner_growth ?? [];
return (
<AppShell>
@ -225,8 +401,99 @@ export default function Professor() {
</section>
) : null}
<section className="pf-section pf-section--growth">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2>· </h2>
</div>
<Badge tone={learnerGrowth.length > 0 ? "accent" : "neutral"}>
{learnerGrowth.length}
</Badge>
</div>
<Card className="pf-panel pf-growth-panel">
{learnerGrowth.length > 0 ? (
<div className="pf-growth-list">
{learnerGrowth.map((learner) => (
<GrowthCard learner={learner} key={learner.learner_id} />
))}
</div>
) : (
<EmptyState
title={loadState === "loading" ? "성장 지표 계산 중" : "표시할 성장 이력 없음"}
desc="학습자 회기와 턴별 평가가 쌓이면 적절성·라포·기법 사용 추이를 표시합니다."
/>
)}
</Card>
</section>
<section className="pf-workspace">
<div className="pf-queue-stack">
<section className="pf-section">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone={draftEditingId ? "warn" : "neutral"}>
{draftEditingId ? "편집 중" : "새 초안"}
</Badge>
</div>
<Card className="pf-panel pf-draft-panel">
<div className="pf-draft-toolbar">
<span>{draftEditingId ? "기존 초안 수정" : "새 페르소나 버전"}</span>
<Button
size="sm"
variant="secondary"
leading={<Icon name="x" size={14} />}
onClick={resetPersonaDraft}
disabled={draftBusy !== null}
>
</Button>
</div>
<textarea
className="pf-draft-json"
value={draftJson}
onChange={(event) => {
setDraftJson(event.target.value);
setDraftError(null);
setDraftMessage(null);
}}
spellCheck={false}
aria-label="페르소나 JSON 초안"
/>
{draftError ? (
<p className="pf-draft-status is-error" role="alert">
{draftError}
</p>
) : draftMessage ? (
<p className="pf-draft-status">{draftMessage}</p>
) : null}
<div className="pf-draft-actions">
<Button
size="sm"
variant="secondary"
leading={<Icon name="check" size={14} />}
onClick={() => void savePersonaDraft(false)}
disabled={draftBusy !== null}
>
{draftBusy === "save" ? "저장 중" : "초안 저장"}
</Button>
<Button
size="sm"
leading={<Icon name="review" size={14} />}
onClick={() => void savePersonaDraft(true)}
disabled={draftBusy !== null}
>
{draftBusy === "submit" ? "요청 중" : "검수 요청"}
</Button>
</div>
</Card>
</section>
<section className="pf-section">
<div className="pf-section__head">
<div>
@ -284,6 +551,15 @@ export default function Professor() {
<span>{formatDateTime(persona.created_at)}</span>
</div>
<div className="pf-persona__actions">
<Button
size="sm"
variant="secondary"
leading={<Icon name="review" size={14} />}
onClick={() => void loadPersonaDraft(persona.persona_id)}
disabled={busy || draftBusy !== null}
>
{draftBusy === "load" && draftEditingId === persona.persona_id ? "불러오는 중" : "편집"}
</Button>
<Button
size="sm"
variant="secondary"
@ -315,6 +591,33 @@ export default function Professor() {
</Card>
</section>
<section className="pf-section">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2>109 </h2>
</div>
<Badge tone={safetyAlerts.length > 0 ? "warn" : "neutral"}>
{safetyAlerts.length}
</Badge>
</div>
<Card className="pf-panel">
{safetyAlerts.length > 0 ? (
<div className="pf-alerts">
{safetyAlerts.map((alert) => (
<SafetyAlertRow alert={alert} key={alert.id} />
))}
</div>
) : (
<EmptyState
title="현재 위기 알림 없음"
desc="실제 위기 신호가 감지되면 이 목록에 109 확인 큐로 표시됩니다."
/>
)}
</Card>
</section>
<section className="pf-section">
<div className="pf-section__head">
<div>
@ -422,6 +725,110 @@ export default function Professor() {
);
}
function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
const recentPoints = learner.points.slice(-3).reverse();
return (
<article className={`pf-growth-card trend-${learner.trend}`}>
<div className="pf-growth-card__top">
<div className="pf-growth-card__id">
<b>{learner.learner_label}</b>
<span>
{learner.ended_sessions}/{learner.sessions} · {formatDateTime(learner.latest_at)}
</span>
</div>
<Badge tone={learner.trend === "down" ? "warn" : learner.trend === "up" ? "accent" : "neutral"}>
{trendLabel(learner.trend)}
</Badge>
</div>
<div className="pf-growth-card__metrics" aria-label="학습자 성장 요약">
<span>
<small> </small>
<b>{formatScore(learner.latest_score)}</b>
</span>
<span>
<small></small>
<b>{formatDelta(learner.score_delta)}</b>
</span>
<span>
<small> </small>
<b>{formatScore(learner.avg_rapport == null ? null : (learner.avg_rapport + 1) / 2)}</b>
</span>
</div>
<div className="pf-growth-bars" aria-label="회기별 적절성 추이">
{learner.points.map((point) => (
<GrowthBar point={point} key={point.session_id} />
))}
</div>
<div className="pf-growth-card__tags">
{learner.top_techniques.length > 0 ? (
learner.top_techniques.map((tag) => <span key={tag}>{tag}</span>)
) : (
<span> </span>
)}
</div>
<div className="pf-growth-card__points">
{recentPoints.length > 0 ? (
recentPoints.map((point) => (
<div className="pf-growth-point" key={point.session_id}>
<b>
{point.persona_code} · {point.session_no}
</b>
<span>
{formatScore(point.score)} · {point.technique_count} · {point.watch_count}
</span>
</div>
))
) : (
<div className="pf-growth-point">
<b> </b>
<span> .</span>
</div>
)}
</div>
</article>
);
}
function GrowthBar({ point }: { point: TeacherGrowthPoint }) {
const hasScore = typeof point.score === "number" && !Number.isNaN(point.score);
const height = hasScore ? Math.max(10, Math.round((point.score ?? 0) * 100)) : 10;
return (
<span
className={`pf-growth-bar ${hasScore ? "" : "is-empty"}`}
title={`${point.persona_code} ${point.session_no}회기 · ${formatScore(point.score)}`}
>
<i style={{ height: `${height}%` }} />
<small>{point.session_no}</small>
</span>
);
}
function SafetyAlertRow({ alert }: { alert: TeacherSafetyAlert }) {
return (
<article className="pf-alert">
<span className="pf-alert__ic" aria-hidden="true">
<Icon name="alert" size={16} />
</span>
<div className="pf-alert__main">
<b>{alert.learner_label}</b>
<span>
{alert.persona_code || "세션"} · {alert.ko_risk_level} ·{" "}
{formatDateTime(alert.created_at)}
</span>
<code>{alert.session_id}</code>
</div>
<div className="pf-alert__resource">
<span>{alert.resource_title}</span>
<b>{alert.resource_number}</b>
</div>
</article>
);
}
const PF_CSS = `
.pf-root{
max-width:var(--maxw);
@ -526,7 +933,7 @@ const PF_CSS = `
}
.pf-kpis{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
grid-template-columns:repeat(3,minmax(0,1fr));
border:1px solid var(--hair);
border-radius:var(--radius);
overflow:hidden;
@ -541,8 +948,8 @@ const PF_CSS = `
grid-template-columns:minmax(0,1fr) auto;
gap:4px 10px;
}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:1px solid var(--hair);}
.pf-kpi__ic{
grid-column:2;
grid-row:1 / span 3;
@ -609,6 +1016,213 @@ const PF_CSS = `
padding:0;
overflow:hidden;
}
.pf-draft-panel{
display:flex;
flex-direction:column;
gap:10px;
padding:12px;
}
.pf-draft-toolbar{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
min-width:0;
}
.pf-draft-toolbar span{
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
}
.pf-draft-json{
width:100%;
min-height:240px;
max-height:min(420px,48vh);
resize:vertical;
overflow:auto;
padding:11px 12px;
border:1px solid var(--line-strong);
border-radius:var(--radius);
background:var(--bg);
color:var(--text-strong);
font-family:var(--font-num);
font-size:11px;
line-height:1.55;
outline:none;
}
.pf-draft-json:focus{
border-color:var(--accent);
box-shadow:0 0 0 3px var(--accent-tint);
}
.pf-draft-status{
margin:0;
color:var(--accent-deep);
font-size:var(--fs-xs);
font-weight:700;
}
.pf-draft-status.is-error{
color:var(--crit-text);
}
.pf-draft-actions{
display:flex;
justify-content:flex-end;
gap:8px;
min-width:0;
}
.pf-draft-actions .vg-btn{
min-width:84px;
}
.pf-section--growth{
min-width:0;
}
.pf-growth-panel{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
}
.pf-growth-list{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:12px;
padding:12px;
}
.pf-growth-card{
min-width:0;
display:flex;
flex-direction:column;
gap:12px;
padding:13px;
border:1px solid var(--paper-2);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.pf-growth-card__top{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:10px;
min-width:0;
}
.pf-growth-card__id{
min-width:0;
display:grid;
gap:3px;
}
.pf-growth-card__id b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-card__id span,
.pf-growth-card__metrics small,
.pf-growth-point span{
color:var(--text-muted);
font-size:12px;
line-height:1.4;
}
.pf-growth-card__metrics{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:8px;
}
.pf-growth-card__metrics span{
min-width:0;
display:grid;
gap:3px;
padding:9px 10px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.pf-growth-card__metrics b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:15px;
line-height:1.15;
white-space:nowrap;
}
.pf-growth-bars{
height:82px;
display:flex;
align-items:flex-end;
gap:6px;
padding:8px 8px 6px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface-2) 82%,transparent);
}
.pf-growth-bar{
flex:1 1 0;
min-width:14px;
height:100%;
display:grid;
grid-template-rows:minmax(0,1fr) 14px;
gap:4px;
align-items:end;
}
.pf-growth-bar i{
display:block;
width:100%;
min-height:6px;
border-radius:6px 6px 3px 3px;
background:linear-gradient(180deg,var(--accent),var(--accent-deep));
}
.pf-growth-bar.is-empty i{
background:repeating-linear-gradient(135deg,var(--paper-2),var(--paper-2) 3px,var(--hair) 3px,var(--hair) 6px);
}
.pf-growth-bar small{
color:var(--text-muted);
font-family:var(--font-num);
font-size:10px;
text-align:center;
line-height:1;
}
.pf-growth-card__tags{
min-height:26px;
display:flex;
flex-wrap:wrap;
gap:6px;
align-content:flex-start;
}
.pf-growth-card__tags span{
max-width:100%;
padding:4px 7px;
border:1px solid var(--paper-2);
border-radius:999px;
color:var(--text-body);
background:var(--bg-surface-2);
font-size:11px;
line-height:1.2;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-card__points{
display:grid;
gap:7px;
}
.pf-growth-point{
min-width:0;
display:grid;
grid-template-columns:minmax(86px,.5fr) minmax(0,1fr);
gap:8px;
align-items:center;
}
.pf-growth-point b{
color:var(--text-strong);
font-size:12px;
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-point span{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-empty{
min-height:118px;
display:grid;
@ -707,6 +1321,63 @@ const PF_CSS = `
min-width:72px;
padding-inline:10px;
}
.pf-alerts{
max-height:min(300px,38vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-alert{
display:grid;
grid-template-columns:auto minmax(0,1fr) auto;
gap:10px;
align-items:center;
padding:12px;
border-top:1px solid var(--paper-2);
background:color-mix(in srgb,var(--warn-tint) 34%,transparent);
}
.pf-alert:first-child{border-top:0;}
.pf-alert__ic{
width:30px;
height:30px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--warn-text);
background:var(--warn-tint);
}
.pf-alert__main{
min-width:0;
display:grid;
gap:3px;
}
.pf-alert__main b{
color:var(--text-strong);
font-size:var(--fs-sm);
}
.pf-alert__main span,
.pf-alert__main code,
.pf-alert__resource span{
color:var(--text-muted);
font-size:12px;
}
.pf-alert__main code{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-alert__resource{
display:grid;
gap:2px;
justify-items:end;
min-width:86px;
}
.pf-alert__resource b{
color:var(--warn-text);
font-family:var(--font-num);
font-size:18px;
}
.pf-session{
display:grid;
grid-template-columns:8px minmax(0,1fr);
@ -831,9 +1502,11 @@ const PF_CSS = `
.pf-kpis{
grid-template-columns:repeat(4,minmax(0,1fr));
}
.pf-kpi:nth-child(even),
.pf-kpi + .pf-kpi{border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:0;}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:0;}
.pf-growth-list{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-list,
.pf-personas{
max-height:360px;
@ -851,8 +1524,12 @@ const PF_CSS = `
width:100%;
}
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi:nth-child(odd){border-left:0;}
.pf-kpi:nth-child(n+2){border-left:0;}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-growth-list{
grid-template-columns:1fr;
}
.pf-recent-list{
display:flex;
flex-direction:column;
@ -911,6 +1588,27 @@ const PF_CSS = `
.pf-persona__actions .vg-btn{
width:100%;
}
.pf-growth-list{
padding:10px;
}
.pf-growth-card__metrics{
grid-template-columns:1fr;
}
.pf-growth-point{
grid-template-columns:1fr;
gap:2px;
}
.pf-growth-point b,
.pf-growth-point span{
white-space:normal;
}
.pf-alert{
grid-template-columns:auto minmax(0,1fr);
}
.pf-alert__resource{
grid-column:2;
justify-items:start;
}
.pf-recent-list{
padding:8px;
}

View file

@ -30,6 +30,7 @@ import {
apiWsUrl,
personaApi,
sessionApi,
type CrisisResource,
type PersonaSummary,
type SessionDetailResponse,
type SessionStage,
@ -65,6 +66,8 @@ interface VoiceEvent {
stage?: SessionStage;
effective_openness?: number;
safety_flagged?: boolean;
crisis_resource?: CrisisResource | null;
conversation_stopped?: boolean;
}
interface Utterance {
@ -403,6 +406,7 @@ export default function Session() {
const [utterances, setUtterances] = useState<Utterance[]>([]);
const [openness, setOpenness] = useState(0);
const [safety, setSafety] = useState<string | null>(null);
const [crisisResource, setCrisisResource] = useState<CrisisResource | null>(null);
const [turnError, setTurnError] = useState<string | null>(null);
// ── 음성/턴 UI 상태 ──
@ -452,6 +456,8 @@ export default function Session() {
setStarted(false);
setUtterances([]);
setElapsed(0);
setSafety(null);
setCrisisResource(null);
setResumedSessionLoaded(false);
setStartError(null);
}, [navigate, routeIsSessionId, routeParam]);
@ -537,6 +543,22 @@ export default function Session() {
setSignalSeq((seq) => [...seq.slice(-4), tone]);
}, []);
const applyCrisisGate = useCallback((resource?: CrisisResource | null) => {
const fallback: CrisisResource = {
title: "자살예방상담전화 109",
number: "109",
message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.",
};
const next = resource ?? fallback;
setCrisisResource(next);
setSafety(next.message);
setPaused(true);
setMicOn(false);
setVoiceStatus("idle");
setVoiceDetail("위기 신호가 감지되어 연습을 중단했습니다.");
pushSignal("warn", "위기 안전게이트 작동");
}, [pushSignal]);
useEffect(() => {
if (!routeIsSessionId) return;
let alive = true;
@ -567,6 +589,8 @@ export default function Session() {
setElapsed(elapsedFromSession(detail));
setStarted(true);
setPaused(false);
setSafety(null);
setCrisisResource(null);
setAvatarState("idle");
setMicOn(false);
setVoiceStatus("idle");
@ -610,6 +634,8 @@ export default function Session() {
setStage(res.stage);
setOpenness(res.effective_openness);
setUtterances([]);
setSafety(null);
setCrisisResource(null);
setElapsed(0);
if (res.degraded) {
pushSignal("neutral", "서버 기록 제한");
@ -700,9 +726,17 @@ export default function Session() {
if (data.safety_flagged) {
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
}
if (data.conversation_stopped || data.crisis_resource) {
applyCrisisGate(data.crisis_resource);
}
},
onSafety: () => {
onSafety: (payload) => {
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
if (payload && typeof payload === "object") {
const resource = (payload as { crisis_resource?: CrisisResource }).crisis_resource;
const stopped = (payload as { conversation_stopped?: boolean }).conversation_stopped;
if (resource || stopped) applyCrisisGate(resource);
}
},
});
@ -994,6 +1028,9 @@ export default function Session() {
if (payload.safety_flagged) {
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
}
if (payload.conversation_stopped || payload.crisis_resource) {
applyCrisisGate(payload.crisis_resource);
}
const pendingId = pendingVoiceLearnerIdRef.current;
if (pendingId != null) {
setUtterances((prev) =>
@ -1748,6 +1785,12 @@ export default function Session() {
</span>
<span className="sx-safety__text">
<b> </b> · {safety}
{crisisResource ? (
<span className="sx-crisis-resource">
<strong>{crisisResource.title}</strong>
<a href={`tel:${crisisResource.number}`}>{crisisResource.number}</a>
</span>
) : null}
</span>
</section>
) : null}

View file

@ -12,6 +12,8 @@ import {
} from "../components/ui";
import {
sessionApi,
type ReviewCaseWorksheet,
type ReviewNonverbalEvent,
type ReviewNote,
type ReviewPoint,
type ReviewTechnique,
@ -56,6 +58,16 @@ function TechniqueChip({ tech }: { tech: ReviewTechnique }) {
);
}
function NonverbalChip({ event }: { event: ReviewNonverbalEvent }) {
return (
<span className={`sr-nonverbal sr-nonverbal--${event.kind}`} title={event.detail}>
<span className="sr-nonverbal__dot" aria-hidden="true" />
{event.label}
<span className="sr-nonverbal__detail">{event.detail}</span>
</span>
);
}
function SupervisorCallout({ note }: { note: ReviewNote }) {
const toneCls = note.tone === "good" ? "sr-note--ai" : "sr-note--warn";
const iconName = note.tone === "good" ? "check" : "info";
@ -112,6 +124,73 @@ function JumpablePoint({
);
}
function confidenceLabel(confidence: "none" | "low" | "medium") {
if (confidence === "medium") return "근거 있음";
if (confidence === "low") return "초안";
return "빈칸";
}
function CaseWorksheetCard({
worksheet,
onJump,
}: {
worksheet: ReviewCaseWorksheet | null | undefined;
onJump: (id: string) => void;
}) {
const sections = worksheet?.sections ?? [];
return (
<Card className="sr-card sr-card--side sr-card--worksheet">
<Kicker> </Kicker>
<div className="sr-worksheet">
{sections.length > 0 ? (
sections.map((section) => (
<section className="sr-ws-section" key={section.key}>
<h3>{section.title}</h3>
<div className="sr-ws-items">
{section.items.map((item) => {
const evidence = item.evidence[0];
return (
<div className="sr-ws-item" key={item.key}>
<div className="sr-ws-item__head">
<b>{item.label}</b>
<span className={`sr-ws-badge sr-ws-badge--${item.confidence}`}>
{confidenceLabel(item.confidence)}
</span>
</div>
<p>{item.value || item.emptyReason || "근거 대기"}</p>
{evidence ? (
<button
type="button"
className="sr-ws-evidence"
onClick={() => onJump(evidence.turnId)}
>
{evidence.speaker === "learner" ? "학습자" : "내담자"} · {evidence.quote}
</button>
) : null}
</div>
);
})}
</div>
</section>
))
) : (
<EmptyBlock
title="워크시트 대기"
desc="저장된 축어록이 생기면 사례개념화 초안이 표시됩니다."
/>
)}
</div>
{(worksheet?.limitations ?? []).length > 0 ? (
<div className="sr-ws-limitations">
{worksheet!.limitations.slice(0, 2).map((item) => (
<span key={item}>{item}</span>
))}
</div>
) : null}
</Card>
);
}
export default function SessionReview() {
const { sessionId } = useParams<{ sessionId: string }>();
const [data, setData] = useState<SessionReviewResponse | null>(null);
@ -407,6 +486,12 @@ export default function SessionReview() {
{turn.techniques.map((tech, i) => (
<TechniqueChip key={`${tech.label}-${i}`} tech={tech} />
))}
{(turn.nonverbal ?? []).map((event, i) => (
<NonverbalChip
key={`${event.kind}-${event.detail}-${i}`}
event={event}
/>
))}
</div>
<p
className={
@ -517,6 +602,8 @@ export default function SessionReview() {
) : null}
</Card>
<CaseWorksheetCard worksheet={data.caseWorksheet} onJump={jumpToTurn} />
<div className={`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`}>
<div className="sr-feedback__kicker">
<span className="sr-technique__dot" aria-hidden="true" />

View file

@ -3,9 +3,23 @@
.sr-root {
width: min(100%, 1360px);
margin: 0 auto;
position: relative;
isolation: isolate;
display: grid;
gap: var(--sp-5);
}
.sr-root::before {
content: "";
position: absolute;
z-index: -1;
inset: -90px -170px auto auto;
width: min(620px, 56vw);
height: 440px;
background: var(--asset-warm-elements) center / cover no-repeat;
opacity: .05;
filter: saturate(.8);
pointer-events: none;
}
.sr-root--empty {
--sr-empty-tone: var(--neutral-sig);
}
@ -16,6 +30,11 @@
align-items: center;
gap: var(--sp-5);
padding: var(--sp-4) var(--sp-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background:
linear-gradient(180deg, color-mix(in srgb, var(--bg-surface) 86%, var(--accent-tint)), var(--bg-surface));
box-shadow: var(--shadow-sm);
}
.sr-head__id {
min-width: 0;
@ -109,12 +128,13 @@
.sr-cols {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1.38fr) minmax(320px, 0.72fr);
grid-template-columns: minmax(0, 1.34fr) minmax(320px, 0.66fr);
grid-template-areas:
"overview rubric"
"chart rubric"
"flow good"
"transcript growth"
"transcript worksheet"
"transcript feedback"
"transcript session";
gap: var(--sp-5);
@ -149,6 +169,9 @@
.sr-card--growth {
grid-area: growth;
}
.sr-card--worksheet {
grid-area: worksheet;
}
.sr-card--transcript {
grid-area: transcript;
}
@ -157,6 +180,7 @@
}
.sr-card {
min-width: 0;
box-shadow: var(--shadow-sm);
}
.sr-card--side {
align-self: start;
@ -349,6 +373,7 @@
.sr-card--transcript {
padding: 0;
overflow: hidden;
border-color: color-mix(in srgb, var(--accent) 14%, var(--border-subtle));
}
.sr-tx__head {
display: flex;
@ -525,6 +550,48 @@
background: var(--warn-solid);
}
.sr-nonverbal {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 2px 8px;
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
background: var(--paper-1);
color: var(--text-body);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 650;
line-height: 1.5;
white-space: nowrap;
}
.sr-nonverbal__dot {
width: 5px;
height: 5px;
border-radius: 50%;
flex: none;
background: var(--text-muted);
}
.sr-nonverbal__detail {
color: var(--text-muted);
font-family: var(--font-num);
font-size: 10px;
font-weight: 650;
}
.sr-nonverbal--silence .sr-nonverbal__dot {
background: var(--warn-solid);
}
.sr-nonverbal--pace .sr-nonverbal__dot {
background: var(--info-solid);
}
.sr-nonverbal--barge_in .sr-nonverbal__dot {
background: var(--clay);
}
.sr-nonverbal--audio .sr-nonverbal__dot {
background: var(--accent);
}
.sr-note {
max-width: 68ch;
margin-top: var(--sp-3);
@ -535,7 +602,7 @@
transform var(--dur-base) var(--ease-out);
}
.sr-note--ai {
background: var(--accent-tint);
background: color-mix(in srgb, var(--info-tint) 72%, var(--accent-tint));
}
.sr-note--warn {
background: var(--warn-tint);
@ -632,6 +699,109 @@
font-size: 11px;
}
.sr-worksheet {
display: grid;
gap: var(--sp-4);
margin-top: var(--sp-4);
}
.sr-ws-section {
min-width: 0;
display: grid;
gap: var(--sp-3);
}
.sr-ws-section h3 {
margin: 0;
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 680;
line-height: 1.35;
}
.sr-ws-items {
display: grid;
gap: 8px;
}
.sr-ws-item {
min-width: 0;
display: grid;
gap: 6px;
padding: 10px 11px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
}
.sr-ws-item__head {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sr-ws-item__head b {
min-width: 0;
color: var(--text-strong);
font-size: var(--fs-xs);
font-weight: 680;
line-height: 1.35;
}
.sr-ws-badge {
flex: none;
padding: 2px 7px;
border-radius: var(--radius-sm);
font-size: 10.5px;
font-weight: 680;
line-height: 1.45;
white-space: nowrap;
}
.sr-ws-badge--medium {
color: var(--pos-text);
background: var(--pos-tint);
}
.sr-ws-badge--low {
color: var(--accent-deep);
background: var(--accent-tint);
}
.sr-ws-badge--none {
color: var(--text-muted);
background: var(--paper-2);
}
.sr-ws-item p {
margin: 0;
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.55;
overflow-wrap: anywhere;
}
.sr-ws-evidence {
min-width: 0;
width: 100%;
padding: 0;
border: none;
background: none;
color: var(--accent-deep);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 650;
line-height: 1.5;
text-align: left;
cursor: pointer;
overflow-wrap: anywhere;
}
.sr-ws-evidence:hover {
text-decoration: underline;
}
.sr-ws-limitations {
display: grid;
gap: 5px;
margin-top: var(--sp-4);
padding-top: var(--sp-3);
border-top: 1px solid var(--hair);
}
.sr-ws-limitations span {
color: var(--text-muted);
font-size: 11px;
line-height: 1.45;
}
/* Empty state: keep the card as low-contrast as the other placeholders so an
unanswered feedback block never outweighs the page heading or actions. */
.sr-feedback {
@ -644,7 +814,9 @@
/* Filled state: only a real client quote earns the high-contrast stage card. */
.sr-feedback--filled {
border-color: transparent;
background: var(--bg-stage);
background:
linear-gradient(135deg, rgba(30, 39, 36, .95), rgba(30, 39, 36, .82)),
var(--asset-warm-elements) center / cover no-repeat;
box-shadow: var(--shadow-sm);
}
.sr-feedback__kicker {
@ -749,7 +921,7 @@
margin-top: var(--sp-4);
padding: var(--sp-4);
border-radius: var(--radius);
background: var(--accent-tint);
background: color-mix(in srgb, var(--warn-tint) 58%, var(--bg-surface-2));
}
.sr-nextline__lab {
margin-bottom: 7px;
@ -789,6 +961,7 @@
"chart flow"
"rubric rubric"
"good growth"
"worksheet worksheet"
"feedback feedback"
"transcript transcript"
"session session";
@ -799,6 +972,9 @@
.sr-root {
gap: var(--sp-4);
}
.sr-root::before {
display: none;
}
.sr-head {
grid-template-columns: 1fr;
gap: var(--sp-4);
@ -816,6 +992,7 @@
"rubric"
"good"
"growth"
"worksheet"
"feedback"
"transcript"
"session";

View file

@ -18,6 +18,13 @@
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--sp-4);
position: relative;
isolation: isolate;
word-break: keep-all;
overflow-wrap: break-word;
background:
linear-gradient(135deg, rgba(251, 250, 248, 0.94), rgba(244, 242, 238, 0.9)),
var(--asset-warm-elements) center / cover no-repeat;
}
.sx-page,
.sx-page * {
@ -26,6 +33,12 @@
.sx-page--prestart {
grid-template-rows: auto minmax(0, 1fr);
overflow: auto;
color: #eef4f2;
background:
radial-gradient(circle at 14% 18%, rgba(95, 150, 139, 0.2), transparent 26%),
radial-gradient(circle at 86% 8%, rgba(176, 115, 92, 0.18), transparent 24%),
linear-gradient(135deg, rgba(13, 24, 22, 0.96), rgba(28, 39, 36, 0.93)),
var(--asset-warm-elements) center / cover no-repeat;
}
.sx-page--active {
height: 100vh;
@ -33,9 +46,16 @@
grid-template-rows: minmax(0, 1fr) auto;
padding: 14px;
gap: 12px;
background:
radial-gradient(circle at 16% 14%, rgba(95, 150, 139, 0.2), transparent 27%),
radial-gradient(circle at 82% 8%, rgba(176, 115, 92, 0.18), transparent 24%),
linear-gradient(135deg, #111c1a, #1d2926 52%, #15211f);
color: #eaf0f1;
}
.sx-page--active .sx-grid {
height: auto;
width: min(100%, 1460px);
margin: 0 auto;
}
.sx-page--active .sx-head {
display: none;
@ -216,6 +236,9 @@
gap: 12px;
min-height: 0;
}
.sx-page--active .sx-col-center {
grid-template-rows: minmax(230px, 0.42fr) minmax(0, 1fr);
}
/* ── LEFT: 세로 단계 트랙 ── */
.sx-track {
@ -419,6 +442,24 @@
column-gap: 18px;
row-gap: 8px;
}
.sx-page--active .sx-stage {
background:
radial-gradient(circle at 28% 50%, rgba(145, 200, 189, 0.18), transparent 42%),
linear-gradient(135deg, rgba(11, 22, 20, 0.96), rgba(31, 43, 39, 0.93)),
var(--asset-warm-elements) center / cover no-repeat;
border-color: rgba(255, 255, 255, 0.11);
box-shadow: 0 18px 44px rgba(7, 16, 14, 0.28);
grid-template-columns: minmax(184px, 256px) minmax(0, 1fr);
padding: 20px 24px;
}
.sx-page--active .sx-stage::before {
content: "";
position: absolute;
inset: 12px;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: calc(var(--radius-lg) - 2px);
pointer-events: none;
}
/* stage 상단 좌측 상태 라벨 (어두운 배경 위 옅은 텍스트) */
.sx-stage__top {
width: 100%;
@ -526,17 +567,17 @@
min-width: 0;
}
.sx-page--active .sx-stage .vg-avatar {
width: clamp(136px, 12vw, 184px) !important;
width: clamp(176px, 15vw, 238px) !important;
}
.sx-page--active .sx-stage .vg-avatar__label {
display: none;
}
.sx-page--active .sx-stage .vg-avatar__stage {
height: clamp(136px, 12vw, 184px) !important;
height: clamp(176px, 15vw, 238px) !important;
}
.sx-page--active .sx-stage .vg-avatar__svg {
width: clamp(136px, 12vw, 184px) !important;
height: clamp(136px, 12vw, 184px) !important;
width: clamp(176px, 15vw, 238px) !important;
height: clamp(176px, 15vw, 238px) !important;
}
.sx-page--active .sx-stage .vg-avatar__meta {
display: none;
@ -554,6 +595,11 @@
min-height: 0;
overflow: hidden;
}
.sx-page--active .sx-transcript {
background: rgba(251, 250, 248, 0.97);
border-color: rgba(255, 255, 255, 0.16);
box-shadow: 0 14px 34px rgba(7, 16, 14, 0.16);
}
.sx-transcript__head {
display: flex;
align-items: center;
@ -987,6 +1033,25 @@
.sx-safety__text b {
font-weight: 600;
}
.sx-crisis-resource {
display: grid;
gap: 4px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid color-mix(in srgb, var(--warn-text) 18%, transparent);
}
.sx-crisis-resource strong {
font-size: 12px;
color: var(--warn-text);
}
.sx-crisis-resource a {
width: fit-content;
color: var(--warn-text);
font-family: var(--font-num);
font-size: 18px;
font-weight: 800;
text-decoration: none;
}
/* ── 하단 컨트롤 바 (80px) ── */
.sx-controlbar {
@ -1003,6 +1068,47 @@
gap: 14px;
min-width: 0;
}
.sx-page--active .sx-controlbar {
width: min(100%, 1460px);
margin: 0 auto;
background: rgba(16, 28, 26, 0.94);
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 14px 36px rgba(7, 16, 14, 0.24);
color: rgba(238, 244, 242, 0.86);
}
.sx-page--active .sx-mic-block__l,
.sx-page--active .sx-seg-block__label {
color: #eef4f2;
}
.sx-page--active .sx-mic-block__h {
color: rgba(238, 244, 242, 0.58);
}
.sx-page--active .sx-segmented {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.1);
}
.sx-page--active .sx-segmented button {
color: rgba(238, 244, 242, 0.68);
}
.sx-page--active .sx-segmented button:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
}
.sx-page--active .sx-segmented button.is-on {
background: rgba(145, 200, 189, 0.2);
color: #ffffff;
}
.sx-page--active .sx-cb-sep {
background: rgba(255, 255, 255, 0.12);
}
.sx-page--active .sx-pause {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
color: #eef4f2;
}
.sx-page--active .sx-pause:hover {
background: rgba(255, 255, 255, 0.12);
}
/* 마이크 (주 컨트롤, 음성 호흡 펄스) — 원형 예외 허용 */
.sx-mic-block {
display: flex;
@ -1226,28 +1332,80 @@
align-self: start;
justify-self: center;
text-align: left;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
position: relative;
background:
linear-gradient(132deg, rgba(15, 28, 26, 0.96), rgba(31, 44, 40, 0.92) 56%, rgba(46, 55, 49, 0.86)),
var(--asset-warm-elements) center / cover no-repeat;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
box-shadow: 0 18px 52px rgba(6, 14, 13, 0.34);
color: #eef4f2;
overflow: hidden;
}
.sx-page--prestart .sx-head {
width: min(1180px, 100%);
margin: 0 auto;
}
.sx-page--prestart .sx-head__title,
.sx-page--prestart .sx-ph.is-cur .sx-ph__name {
color: #f3f8f6;
}
.sx-page--prestart .sx-head__title em {
color: #91c8bd;
}
.sx-page--prestart .sx-head__sub,
.sx-page--prestart .sx-ph__name,
.sx-page--prestart .sx-ph__t {
color: rgba(238, 244, 242, 0.62);
}
.sx-page--prestart .sx-ph__dot {
background: rgba(255, 255, 255, 0.09);
border-color: rgba(255, 255, 255, 0.22);
}
.sx-page--prestart .sx-ph__link {
background: rgba(255, 255, 255, 0.18);
}
.sx-page--prestart .sx-ph__link.is-fill {
background: rgba(145, 200, 189, 0.74);
}
.sx-page--prestart .sx-prestart::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.08), transparent 38%),
radial-gradient(circle at 20% 22%, rgba(126, 184, 173, 0.22), transparent 32%);
pointer-events: none;
}
.sx-prestart__visual {
min-width: 0;
display: grid;
justify-items: center;
gap: var(--sp-3);
position: relative;
z-index: 1;
align-self: stretch;
align-content: center;
padding: var(--sp-4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius);
background:
radial-gradient(circle at 50% 45%, rgba(145, 200, 189, 0.2), transparent 52%),
rgba(251, 250, 248, 0.06);
}
.sx-prestart__visual .vg-avatar {
justify-self: center;
}
.sx-prestart__main {
min-width: 0;
position: relative;
z-index: 1;
}
.sx-prestart__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--accent-deep);
color: #a8d4ca;
font-family: var(--font-num);
font-size: var(--fs-kicker);
font-weight: 700;
@ -1264,13 +1422,13 @@
font-size: var(--fs-h2);
font-weight: 700;
letter-spacing: 0;
color: var(--text-strong);
color: #f4f8f7;
line-height: 1.3;
}
.sx-prestart__desc {
margin-top: var(--sp-3);
font-size: var(--fs-sm);
color: var(--text-body);
color: rgba(238, 244, 242, 0.76);
line-height: 1.6;
max-width: 58ch;
}
@ -1283,17 +1441,20 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-4);
}
.sx-page--prestart .sx-prestart__facts {
border-color: rgba(255, 255, 255, 0.13);
}
.sx-prestart__facts div {
min-width: 0;
}
.sx-prestart__facts dt {
color: var(--text-muted);
color: rgba(238, 244, 242, 0.52);
font-size: 11.5px;
font-weight: 700;
}
.sx-prestart__facts dd {
margin: 6px 0 0;
color: var(--text-strong);
color: #f4f8f7;
font-size: 13.5px;
font-weight: 650;
line-height: 1.4;
@ -1314,20 +1475,20 @@
min-height: 26px;
padding: 0 10px;
border-radius: var(--radius-sm);
background: var(--accent-tint);
color: var(--accent-deep);
background: rgba(145, 200, 189, 0.16);
color: #bfe0d9;
font-family: var(--font-num);
font-size: 11.5px;
font-weight: 700;
}
.sx-prestart__chips span.is-clay {
background: var(--clay-tint);
color: var(--clay-deep);
background: rgba(204, 143, 119, 0.17);
color: #f0bda9;
}
.sx-prestart__note {
max-width: 460px;
font-size: 12.5px;
color: var(--text-muted);
color: rgba(238, 244, 242, 0.6);
line-height: 1.55;
margin-top: calc(var(--sp-3) * -1);
}
@ -1343,7 +1504,7 @@
margin-top: var(--sp-5);
}
.sx-prestart__actions span {
color: var(--text-muted);
color: rgba(238, 244, 242, 0.58);
font-size: var(--fs-xs);
line-height: 1.45;
}
@ -1353,7 +1514,12 @@
display: grid;
align-content: center;
gap: var(--sp-4);
padding: var(--sp-3) 0;
position: relative;
z-index: 1;
padding: var(--sp-4);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius);
background: rgba(251, 250, 248, 0.07);
}
.sx-prestart__plan ol {
list-style: none;
@ -1378,7 +1544,7 @@
}
.sx-prestart__plan p {
margin: 0;
color: var(--text-body);
color: rgba(238, 244, 242, 0.74);
font-size: var(--fs-xs);
line-height: 1.6;
}

View file

@ -111,6 +111,9 @@
--shadow-sm: 0 1px 2px rgba(28, 42, 42, 0.05);
--shadow-md: 0 4px 14px rgba(28, 42, 42, 0.07); /* 모달·팝오버만 */
/* ── 래스터 디자인 요소 ── */
--asset-warm-elements: url("/design-elements/clinical-paper-ambient.png");
/* ── 모션 §3.9 ── */
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-in-out: cubic-bezier(0.45, 0.05, 0.55, 0.95);