feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

File diff suppressed because it is too large Load diff

View file

@ -7,16 +7,26 @@ import {
type AdminEngineConfigResponse,
type AdminHealthResponse,
type AdminUsageResponse,
type EngineCapabilitiesResponse,
} from "../lib/api";
import "./admin/admin-ai.css";
const WINDOW_OPTIONS = [7, 30, 90] as const;
const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const;
const ENGINE_MODES = [
"claude_cli",
"claude_api",
"codex_cli",
"agy_cli",
"openai",
"solar",
] as const;
const ENGINE_MODE_LABEL: Record<string, string> = {
claude_cli: "Claude CLI 게이트웨이",
messages_api: "클라우드 연결",
claude_api: "Anthropic API",
codex_cli: "Codex CLI 게이트웨이",
agy_cli: "Agy CLI 게이트웨이",
openai: "OpenAI 호환",
solar: "Solar",
};
@ -25,9 +35,21 @@ function countLabel(value: number): string {
return Number.isFinite(value) ? Math.max(0, Math.round(value)).toLocaleString("ko-KR") : "0";
}
function costLabel(value: number, precise = false): string {
/**
* · 2 ,
* 2 0 4 .
* costTitle() title .
*/
function costLabel(value: number, digits: 2 | 4 = 2): string {
if (!Number.isFinite(value) || value <= 0) return "$0";
return `$${value.toFixed(precise || value < 0.01 ? 6 : 4)}`;
return `$${value.toFixed(digits)}`;
}
/** 반올림 전 원본 정밀도 — 화면에는 안 쓰고 title 툴팁으로만 노출한다. */
function costTitle(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "$0";
const exact = value.toFixed(8).replace(/0+$/, "").replace(/\.$/, "");
return `$${exact === "0" ? value.toPrecision(3) : exact}`;
}
function rateLabel(value: number): string {
@ -74,11 +96,55 @@ export default function AdminAi() {
const [usage, setUsage] = useState<AdminUsageResponse | null>(null);
const [health, setHealth] = useState<AdminHealthResponse | null>(null);
const [engine, setEngine] = useState<AdminEngineConfigResponse | null>(null);
const [capabilities, setCapabilities] = useState<EngineCapabilitiesResponse | null>(null);
const [capabilitiesLoading, setCapabilitiesLoading] = useState(false);
const [capabilitiesError, setCapabilitiesError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const initialLoad = useRef(true);
const capabilityRequestId = useRef(0);
const loadCapabilities = useCallback(
async (
engineMode: string,
engineUrl: string,
options: { force?: boolean; applyDefault?: boolean } = {},
) => {
const requestId = ++capabilityRequestId.current;
setCapabilitiesLoading(true);
setCapabilitiesError(null);
try {
const next = await adminEngineApi.capabilities(
engineMode,
options.force ?? false,
engineUrl,
);
if (requestId !== capabilityRequestId.current) return;
setCapabilities(next);
if (options.applyDefault && next.available && next.default_model) {
setEngine((current) => {
if (!current || current.engine_mode !== engineMode) return current;
return {
...current,
model: next.default_model ?? "",
reasoning_effort: next.default_reasoning_effort,
};
});
}
} catch (cause) {
if (requestId !== capabilityRequestId.current) return;
setCapabilities(null);
setCapabilitiesError(
cause instanceof Error ? cause.message : "모델 목록을 불러오지 못했습니다.",
);
} finally {
if (requestId === capabilityRequestId.current) setCapabilitiesLoading(false);
}
},
[],
);
const load = useCallback(async () => {
setLoading(true);
@ -91,7 +157,10 @@ export default function AdminAi() {
]);
if (usageResult.status === "fulfilled") setUsage(usageResult.value);
if (healthResult.status === "fulfilled") setHealth(healthResult.value);
if (engineResult.status === "fulfilled") setEngine(engineResult.value);
if (engineResult.status === "fulfilled") {
setEngine(engineResult.value);
await loadCapabilities(engineResult.value.engine_mode, engineResult.value.engine_url);
}
const failures = [usageResult, healthResult, engineResult]
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.map((result) =>
@ -101,7 +170,7 @@ export default function AdminAi() {
} finally {
setLoading(false);
}
}, [windowDays]);
}, [loadCapabilities, windowDays]);
useEffect(() => {
if (initialLoad.current) {
@ -139,6 +208,7 @@ export default function AdminAi() {
engine_mode: engine.engine_mode,
engine_url: engine.engine_url.trim(),
model: engine.model.trim(),
reasoning_effort: engine.reasoning_effort,
});
setEngine(next);
setSaved(true);
@ -159,6 +229,15 @@ export default function AdminAi() {
const costPerTurn = usage && usage.metered_turns > 0 ? usage.cost_usd / usage.metered_turns : 0;
const costPerThousandTokens = usage && totalTokens > 0 ? (usage.cost_usd / totalTokens) * 1000 : 0;
const engineService = health?.services.find((service) => service.key === "engine");
const capabilityModels = capabilities?.models ?? [];
const selectedModel = capabilityModels.find((model) => model.id === engine?.model);
const selectedEfforts = selectedModel?.reasoning_efforts ?? [];
const engineSelectionValid = Boolean(
engine &&
capabilities?.available &&
selectedModel &&
(!engine.reasoning_effort || selectedEfforts.includes(engine.reasoning_effort)),
);
const cache = usage?.evaluator_cache;
const sortedBreakdown = useMemo(
() => [...(usage?.by_provider ?? [])].sort((a, b) => b.cost_usd - a.cost_usd),
@ -170,7 +249,8 @@ export default function AdminAi() {
<div className="aic" data-testid="admin-ai-page" aria-busy={loading}>
<header className="aic-head">
<div>
<Kicker> · AI CONTROL LEDGER</Kicker>
{/* 영문 대문자 eyebrow는 한글로 — "관리자"는 제목에 없는 역할 맥락이라 유지한다. */}
<Kicker> · AI </Kicker>
<h1>AI DB </h1>
<p> DB에 ·· .</p>
</div>
@ -225,7 +305,10 @@ export default function AdminAi() {
<section className="aic-ledger" aria-label="AI 사용 핵심 지표">
<article>
<span> </span>
<b>{usage ? costLabel(usage.cost_usd) : "—"}</b>
{/* 합계 금액은 소수 2자리, 원본 정밀도는 title 로 남긴다. */}
<b title={usage ? costTitle(usage.cost_usd) : undefined}>
{usage ? costLabel(usage.cost_usd) : "—"}
</b>
<small>{windowDays} DB </small>
</article>
<article>
@ -245,9 +328,12 @@ export default function AdminAi() {
</article>
<article>
<span> </span>
<b>{usage ? costLabel(costPerTurn, true) : "—"}</b>
<small>
1 {usage ? (totalTokens > 0 ? costLabel(costPerThousandTokens, true) : "계산 불가") : "—"}
{/* 단가는 2자리면 0이 되므로 4자리. 원본 정밀도는 title. */}
<b title={usage ? costTitle(costPerTurn) : undefined}>
{usage ? costLabel(costPerTurn, 4) : "—"}
</b>
<small title={usage && totalTokens > 0 ? costTitle(costPerThousandTokens) : undefined}>
1 {usage ? (totalTokens > 0 ? costLabel(costPerThousandTokens, 4) : "계산 불가") : "—"}
</small>
</article>
</section>
@ -257,7 +343,8 @@ export default function AdminAi() {
<section className="aic-panel aic-budget">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">SPEND CONTROL</span>
{/* SPEND CONTROL → 한글. 제목이 다루지 않는 "관리" 관점을 남긴다. */}
<span className="aic-eyebrow"> </span>
<h2> </h2>
</div>
<span className={`aic-status aic-status--${usage?.budget.status ?? "disabled"}`}>
@ -268,7 +355,13 @@ export default function AdminAi() {
<div>
<div className="aic-metricline">
<span> </span>
<b>
<b
title={
usage && usage.budget.status !== "disabled"
? `${costTitle(usage.cost_usd)} / ${costTitle(usage.budget.limit_usd)}`
: undefined
}
>
{usage?.budget.status === "disabled"
? "한도 미설정"
: `${costLabel(usage?.cost_usd ?? 0)} / ${costLabel(usage?.budget.limit_usd ?? 0)}`}
@ -277,7 +370,13 @@ export default function AdminAi() {
<div className="aic-track" aria-label={`예산 사용률 ${rateLabel(usage?.budget.used_ratio ?? 0)}`}>
<span style={{ width: `${Math.min(100, (usage?.budget.used_ratio ?? 0) * 100)}%` }} />
</div>
<p>
<p
title={
usage && usage.budget.status !== "disabled"
? `잔여 ${costTitle(usage.budget.remaining_usd ?? 0)}`
: undefined
}
>
{usage?.budget.status === "disabled"
? "서버의 ADMIN_USAGE_BUDGET_USD를 설정하면 80% 주의와 100% 초과 상태를 표시합니다."
: `잔여 ${costLabel(usage?.budget.remaining_usd ?? 0)} · ${rateLabel(usage?.budget.used_ratio ?? 0)} 사용`}
@ -303,7 +402,8 @@ export default function AdminAi() {
<section className="aic-panel">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">DAILY JOURNAL</span>
{/* DAILY JOURNAL → 한글. */}
<span className="aic-eyebrow"> </span>
<h2> </h2>
</div>
<span className="aic-panel__meta">{daily.length} </span>
@ -313,7 +413,8 @@ export default function AdminAi() {
{daily.map((item) => {
const height = maxDailyCost > 0 ? Math.max(3, (item.cost_usd / maxDailyCost) * 100) : 3;
return (
<div className="aic-chart__day" key={item.day} title={`${item.day} ${costLabel(item.cost_usd)}`}>
// 막대 위 라벨은 일별 합계라 소수 2자리, 원본 정밀도는 title 로 남긴다.
<div className="aic-chart__day" key={item.day} title={`${item.day} ${costTitle(item.cost_usd)}`}>
<span className="aic-chart__value">{costLabel(item.cost_usd)}</span>
<span className="aic-chart__rail">
<span className="aic-chart__bar" style={{ height: `${height}%` }} />
@ -332,7 +433,7 @@ export default function AdminAi() {
<section className="aic-panel">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">MODEL LEDGER</span>
{/* MODEL LEDGER eyebrow 제거 — 바로 아래 제목이 그대로 "모델별 사용 원장"이라 중복. */}
<h2>Provider· </h2>
</div>
<span className="aic-panel__meta"> </span>
@ -361,8 +462,11 @@ export default function AdminAi() {
<td>{countLabel(row.turns)}</td>
<td>{countLabel(row.tokens_in)}</td>
<td>{countLabel(row.tokens_out)}</td>
<td>{costLabel(row.cost_usd)}</td>
<td>{costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, true)}</td>
{/* 합계 비용은 2자리, 호출당 단가는 4자리. 원본은 title. */}
<td title={costTitle(row.cost_usd)}>{costLabel(row.cost_usd)}</td>
<td title={costTitle(row.turns > 0 ? row.cost_usd / row.turns : 0)}>
{costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, 4)}
</td>
<td>
<span className="aic-share">
<span style={{ width: `${usage && usage.cost_usd > 0 ? (row.cost_usd / usage.cost_usd) * 100 : 0}%` }} />
@ -384,7 +488,8 @@ export default function AdminAi() {
<section className="aic-panel aic-engine">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">RUNTIME ROUTE</span>
{/* RUNTIME ROUTE → 한글. 제목의 "설정"과 달리 어디로 호출이 나가는지를 가리킨다. */}
<span className="aic-eyebrow"> </span>
<h2>AI </h2>
</div>
<span className={`aic-runtime aic-runtime--${engineService?.status ?? "degraded"}`}>
@ -393,36 +498,159 @@ export default function AdminAi() {
</div>
{engine ? (
<>
<div className="aic-mode" role="radiogroup" aria-label="AI 운영 방식">
{ENGINE_MODES.map((mode) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={engine.engine_mode === mode}
className={engine.engine_mode === mode ? "is-active" : ""}
onClick={() => setEngine({ ...engine, engine_mode: mode })}
>
{ENGINE_MODE_LABEL[mode]}
</button>
))}
</div>
<Field
label="엔진 공급자"
hint="저장 전에 게이트웨이가 실제 가용 모델을 조회하고 검증합니다."
htmlFor="aic-engine-mode"
>
<select
id="aic-engine-mode"
className="vg-input aic-select"
value={engine.engine_mode}
aria-label="AI 엔진 공급자"
onChange={(event) => {
const engineMode = event.target
.value as AdminEngineConfigResponse["engine_mode"];
setCapabilities(null);
setEngine({
...engine,
engine_mode: engineMode,
model: "",
reasoning_effort: null,
});
void loadCapabilities(engineMode, engine.engine_url, {
force: true,
applyDefault: true,
});
}}
>
{ENGINE_MODES.map((mode) => (
<option key={mode} value={mode}>
{ENGINE_MODE_LABEL[mode]}
</option>
))}
</select>
</Field>
<Field label="연결 주소" hint="응답 생성 게이트웨이 또는 API base URL입니다." htmlFor="aic-engine-url">
<Input
id="aic-engine-url"
value={engine.engine_url}
onChange={(event) => setEngine({ ...engine, engine_url: event.target.value })}
onChange={(event) => {
setEngine({ ...engine, engine_url: event.target.value });
setCapabilities(null);
setCapabilitiesError(
"연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.",
);
}}
aria-label="AI 연결 주소"
/>
</Field>
<Field label="기본 모델" hint="개별 평가 모델 override가 없을 때 적용됩니다." htmlFor="aic-engine-model">
<Input
<Field
label="기본 모델"
hint="게이트웨이가 현재 계정에서 확인한 모델만 선택할 수 있습니다."
htmlFor="aic-engine-model"
>
<select
id="aic-engine-model"
className="vg-input aic-select"
value={engine.model}
onChange={(event) => setEngine({ ...engine, model: event.target.value })}
onChange={(event) => {
const model = event.target.value;
const option = capabilityModels.find((item) => item.id === model);
setEngine({
...engine,
model,
reasoning_effort: option?.default_reasoning_effort ?? null,
});
}}
aria-label="AI 기본 모델"
/>
disabled={capabilitiesLoading || !capabilities?.available}
>
{!selectedModel && engine.model ? (
<option value={engine.model} disabled>
{engine.model} ·
</option>
) : null}
{!engine.model ? <option value=""> </option> : null}
{capabilityModels.map((model) => (
<option key={model.id} value={model.id}>
{model.label}
</option>
))}
</select>
</Field>
<Field
label="추론 강도"
hint="선택한 모델이 공식적으로 지원하는 단계만 표시됩니다."
htmlFor="aic-engine-effort"
>
<select
id="aic-engine-effort"
className="vg-input aic-select"
value={engine.reasoning_effort ?? ""}
aria-label="AI 추론 강도"
disabled={capabilitiesLoading || !selectedModel}
onChange={(event) =>
setEngine({
...engine,
reasoning_effort: (event.target.value ||
null) as AdminEngineConfigResponse["reasoning_effort"],
})
}
>
{selectedEfforts.length ? null : (
<option value=""> </option>
)}
{selectedEfforts.map((effort) => (
<option key={effort} value={effort}>
{effort === "low"
? "Low"
: effort === "medium"
? "Medium"
: effort === "high"
? "High"
: effort === "xhigh"
? "Extra High"
: effort === "max"
? "Max"
: "Ultra"}
</option>
))}
</select>
</Field>
<div
className={`aic-capability ${capabilities?.available ? "is-ready" : "is-unavailable"}`}
role={capabilitiesError || capabilities?.available === false ? "alert" : "status"}
>
<div>
<b>
{capabilitiesLoading
? "모델 목록 확인 중"
: capabilities?.available
? `${capabilityModels.length}개 모델 확인됨`
: "모델 목록을 확인할 수 없음"}
</b>
<span>
{capabilitiesError ??
capabilities?.detail ??
"게이트웨이 연결 뒤 모델 목록을 표시합니다."}
</span>
{selectedModel?.description ? <span>{selectedModel.description}</span> : null}
</div>
<Button
size="sm"
variant="secondary"
onClick={() =>
void loadCapabilities(engine.engine_mode, engine.engine_url, {
force: true,
applyDefault: !selectedModel,
})
}
disabled={capabilitiesLoading}
>
</Button>
</div>
<dl className="aic-config-meta">
<div><dt> </dt><dd>{engine.durable ? "DB 영구 저장" : "런타임 적용"}</dd></div>
<div><dt> </dt><dd>{engine.source}</dd></div>
@ -435,7 +663,12 @@ export default function AdminAi() {
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void saveEngine()}
disabled={saving || !engine.engine_url.trim() || !engine.model.trim()}
disabled={
saving ||
capabilitiesLoading ||
!engine.engine_url.trim() ||
!engineSelectionValid
}
>
{saving ? "저장 중" : "운영 설정 저장"}
</Button>
@ -449,7 +682,7 @@ export default function AdminAi() {
<section className="aic-panel">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">EVALUATOR CACHE</span>
{/* EVALUATOR CACHE eyebrow 제거 — 제목 "평가 캐시 효율"과 같은 말. */}
<h2> </h2>
</div>
<span className={`aic-runtime ${cache?.enabled ? "aic-runtime--ok" : ""}`}>
@ -474,7 +707,7 @@ export default function AdminAi() {
<section className="aic-panel">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow">METERING INTEGRITY</span>
{/* METERING INTEGRITY eyebrow 제거 — 제목 "DB 계량 상태"와 같은 말. */}
<h2>DB </h2>
</div>
</div>

View file

@ -52,7 +52,8 @@ export default function AvatarExpressionLab() {
data-rendered-avatar-count={renderedAvatarCount}
>
<header className="axl__head">
<Kicker dot>Live2D persona rig</Kicker>
{/* 상태가 아니라 단순 섹션 라벨이라 accent dot 을 뺀다(D1). */}
<Kicker dot={false}>Live2D persona rig</Kicker>
<div>
<h1>Persona expression board</h1>
<p>

View file

@ -52,6 +52,36 @@ import "./learner-home.css";
type LoadState = "loading" | "ready" | "error";
export type LearnerHomeView = "dashboard" | "practice" | "history";
// ── D3(가운뎃점 라인당 1개) 보조 헬퍼 ────────────────────────────────────────
/**
* display_name "이름(가명) · 학년 · 주호소"
* 2 . ()
* 1 .
*/
function splitPersonaTitle(name: string): { head: string; tail: string } {
const parts = name
.split("·")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length <= 2) return { head: name.trim(), tail: "" };
return {
head: parts.slice(0, 2).join(" · "),
tail: parts.slice(2).join(" · "),
};
}
/**
* ··
* . "큰 구분", "같은 종류의 나열"
* 1 .
* ) "기초 · cbt · humanistic" "기초 · cbt, humanistic"
*/
function oneMiddot(text: string): string {
const parts = text.split(" · ");
if (parts.length <= 2) return text;
return `${parts[0]} · ${parts.slice(1).join(", ")}`;
}
interface LearnerHomeProps {
view?: LearnerHomeView;
}
@ -582,11 +612,17 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
spotlightPersona?.display_name ??
spotlightSession?.persona_name ??
"첫 연습 대상 선택";
// D3: 난도 + 이론 목록이 겹쳐 "기초 · cbt · humanistic"처럼 가운뎃점이 2개가 된다.
const recapPersonaSubtitle = spotlightPersona
? `${difficultyLine(spotlightPersona)} · ${theoryLine(spotlightPersona)}`
? oneMiddot(
`${difficultyLine(spotlightPersona)} · ${theoryLine(spotlightPersona)}`,
)
: spotlightSession
? `${spotlightSession.persona_code} · ${sessionStatusLabel(spotlightSession)}`
: "학습 대상에서 내담자를 선택하세요";
// D3: 페르소나 제목은 "이름 · 학년"만 제목 줄에 두고 주호소는 아랫줄로 내린다.
const recapTitleParts = splitPersonaTitle(recapSessionTitle);
const previewTitleParts = splitPersonaTitle(selected?.display_name ?? "");
const recapProgress = stageProgressValue(spotlightSession?.stage);
const coachHints = spotlightSession
? [
@ -683,9 +719,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<span>{session.persona_code}</span>
<b>{shortPersonaName(session.persona_name)}</b>
</div>
{/* D3: "단계 · 턴 · 날짜"로 가운뎃점이 2개였다. 날짜는 별도 컬럼으로 분리 */}
<p>
{session.stage} · {session.turn_count} ·{" "}
{sessionDateLabel(session.started_at)}
<span>
{session.stage} · {session.turn_count}
</span>
<span>{sessionDateLabel(session.started_at)}</span>
</p>
<span className="lh-session-card__progress" aria-hidden="true">
<span style={{ width: `${progress}%` }} />
@ -780,9 +819,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
</span>
<span className="lh-compact-list__body">
<b>{shortPersonaName(session.persona_name)}</b>
{/* D3: 가운뎃점 2개였던 줄 — 날짜를 오른쪽 컬럼으로 분리 */}
<small>
{session.persona_code} · {session.stage} ·{" "}
{sessionDateLabel(session.started_at)}
<span>
{session.persona_code} · {session.stage}
</span>
<span>{sessionDateLabel(session.started_at)}</span>
</small>
</span>
{mode === "review" ? (
@ -851,10 +893,14 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
const usablePersona = isUsablePersona(persona);
const selectedPersona =
usablePersona && persona.code === selected?.code;
const personaMeta =
// D3: 이론이 2개면 "기초 · cbt · humanistic"처럼 가운뎃점이 2개가 된다.
const personaMeta = oneMiddot(
(DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty) +
" · " +
theoryLine(persona);
" · " +
theoryLine(persona),
);
// D3: 카드 이름줄도 "이름 · 학년"까지만 두고 주호소는 아랫줄로 내린다.
const personaTitle = splitPersonaTitle(persona.display_name);
return (
<button
key={persona.code}
@ -874,12 +920,19 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<span className="lh-persona__mark">{persona.code}</span>
<span className="lh-persona__body">
<span className="lh-persona__name">
{persona.display_name}
{personaTitle.head}
</span>
{personaTitle.tail ? (
<span className="lh-persona__concern">
{personaTitle.tail}
</span>
) : null}
<span className="lh-persona__meta">
{usablePersona
? personaMeta
: `${personaMeta} · 사용 불가`}
<span>{personaMeta}</span>
{/* D3: "· 사용 불가"를 이어 붙이면 가운뎃점이 늘어나 별도 칩으로 분리 */}
{usablePersona ? null : (
<b className="lh-persona__flag"> </b>
)}
</span>
<span className="lh-persona__summary">
{usablePersona
@ -913,7 +966,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<>
<header className="lh-head">
<div className="lh-head__copy">
<Kicker> </Kicker>
{/* D1: 단순 섹션 라벨이라 상태 dot 제거(대시보드 화면 dot 밀집 정리) */}
<Kicker dot={false}> </Kicker>
<h1 className="lh-title"> .</h1>
<p className="lh-sub">
, ,
@ -992,7 +1046,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-work-cluster__head">
<div>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — 상태가 아니므로 dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2>, , .</h2>
</div>
<Button
@ -1013,12 +1068,20 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-session-focus__head">
<div>
<Kicker>
{/* D1: 실제 진행 상태는 우측 status 배지가 맡으므로 dot 제거 */}
<Kicker dot={false}>
{spotlightSession
? "오늘 이어갈 회기"
: "첫 회기 준비"}
</Kicker>
<h2>{recapSessionTitle}</h2>
<h2>
{recapTitleParts.head}
{recapTitleParts.tail ? (
<span className="lh-title-tail">
{recapTitleParts.tail}
</span>
) : null}
</h2>
<p>{recapPersonaSubtitle}</p>
</div>
<span className="lh-session-focus__status">
@ -1149,7 +1212,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
aria-label="AI 코치"
>
<div>
<Kicker>AI </Kicker>
{/* D1: 패널 이름표 — 상태가 아니므로 dot 제거 */}
<Kicker dot={false}>AI </Kicker>
<h2>{coach.title}</h2>
<p>{coach.body}</p>
</div>
@ -1194,7 +1258,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2>
{reviewCount > 0
? "리뷰 확인을 우선합니다."
@ -1248,7 +1313,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — 건수 상태는 우측 badge 가 표시한다 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
<span className="lh-panel__badge">
@ -1277,10 +1343,13 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
)
}
>
{/* D3: 가운뎃점 2개였던 줄 — 평가 점수는 오른쪽 컬럼으로 분리 */}
<b>
{recentFeedback[0].persona_code} ·{" "}
{recentFeedback[0].stage} ·{" "}
{scoreLabel(recentFeedback[0].score)}
<span>
{recentFeedback[0].persona_code} ·{" "}
{recentFeedback[0].stage}
</span>
<span>{scoreLabel(recentFeedback[0].score)}</span>
</b>
<span>
{truncateText(recentFeedback[0].note, 86)}
@ -1310,7 +1379,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
flat: true,
})}
>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
<button
@ -1337,7 +1407,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
flat: true,
})}
>
<Kicker> </Kicker>
{/* D4: eyebrow "리뷰 대기"는 바로 아래 제목과 같은 말이라 삭제 */}
<h2> .</h2>
</div>
<button
@ -1362,6 +1432,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-panel__head">
<div>
{/* D1: 평가 기록에서 파생된 "현재 신호"라 dot 유지 */}
<Kicker> </Kicker>
<h2> .</h2>
</div>
@ -1391,7 +1462,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
flat: true,
})}
>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
</div>
@ -1452,7 +1524,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
</div>
@ -1484,7 +1557,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<>
<header className="lh-head">
<div className="lh-head__copy">
<Kicker> </Kicker>
{/* D4: eyebrow "학습 대상"은 아래 제목("연습할 내담자")과 같은 말이라 삭제 */}
<h1 className="lh-title"> .</h1>
<p className="lh-sub">
.
@ -1529,15 +1602,26 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
: (selected?.code ?? "...")}
</span>
<h2 id="lh-preview-title">
{personaCatalogUnavailable
? "사용 가능한 내담자가 없습니다."
: (selected?.display_name ??
"내담자 정보를 불러오는 중")}
{personaCatalogUnavailable ? (
"사용 가능한 내담자가 없습니다."
) : selected ? (
<>
{previewTitleParts.head}
{previewTitleParts.tail ? (
<span className="lh-title-tail">
{previewTitleParts.tail}
</span>
) : null}
</>
) : (
"내담자 정보를 불러오는 중"
)}
</h2>
{/* D3: 인적 정보도 가운뎃점 1개만 남기고 나머지는 쉼표 나열 */}
<p>
{personaCatalogUnavailable
? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다."
: demographicsLine(selected)}
: oneMiddot(demographicsLine(selected))}
</p>
</div>
</div>
@ -1660,7 +1744,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<>
<header className="lh-head">
<div className="lh-head__copy">
<Kicker> </Kicker>
{/* D4: eyebrow "회기 기록"은 아래 제목과 같은 말이라 삭제 */}
<h1 className="lh-title"> .</h1>
<p className="lh-sub">
, ,
@ -1702,7 +1786,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-history-main__head">
<div>
<Kicker> </Kicker>
{/* D4: eyebrow "기록 목록"은 아래 제목("전체 회기"/필터명)과 겹쳐 삭제 */}
<h2>
{historyFilter === "all"
? "전체 회기"
@ -1833,12 +1917,21 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<b>{row.code}</b>
<em>{row.name}</em>
</span>
{/* D3: 가운뎃점 3개였던 줄 — 진행/리뷰 건수를 별도 컬럼으로 쪼갠다 */}
<span className="lh-persona-progress__meta">
{row.total > 0
? `${row.total}회 · ${row.active}회 진행 · ${row.reviewReady}회 리뷰 · 평균 ${
row.averageTurns ?? 0
}`
: "아직 진행 없음"}
{row.total > 0 ? (
<>
<span>
{row.total} · {" "}
{row.averageTurns ?? 0}
</span>
<span>
{row.active} · {row.reviewReady}
</span>
</>
) : (
<span> </span>
)}
</span>
<span
className="lh-persona-progress__bar"

View file

@ -93,14 +93,14 @@ const STUDIO_TABS: { key: StudioTab; label: string }[] = [
];
const AUTHORING_STEPS = [
{ key: "source", label: "자료", desc: "기록·교재·가이드 정리" },
{ key: "source", label: "자료", desc: "기록, 교재, 가이드 정리" },
{ key: "generate", label: "초안", desc: "근거를 확인하고 초안 생성" },
{ key: "edit", label: "설정", desc: "임상·회기·말투 조정" },
{ key: "review", label: "검토", desc: "근거·위험·버전 확인" },
{ key: "edit", label: "설정", desc: "임상, 회기, 말투 조정" },
{ key: "review", label: "검토", desc: "근거, 위험, 버전 확인" },
] as const;
const WORKSPACE_TABS: { key: WorkspaceTab; label: string; desc: string }[] = [
{ key: "dashboard", label: "대시보드", desc: "운영·학습·검수 현황" },
{ key: "dashboard", label: "대시보드", desc: "운영, 학습, 검수 현황" },
{ key: "catalog", label: "카탈로그", desc: "공개 범위와 구성 정보" },
{ key: "personas", label: "페르소나", desc: "전체 목록과 검수" },
];
@ -123,7 +123,7 @@ const TAB_GUIDANCE: Record<
},
clinical: {
title: "임상 배경은 관찰 사실과 내부 가설을 분리해서 작성",
desc: "가족·관계·학교/직장·촉발 사건은 SSOT 자료에서 확인되는 사실을 우선한다. CCD는 내담자가 직접 설명하지 않는 내부 모델이다.",
desc: "가족, 관계, 학교/직장, 촉발 사건은 SSOT 자료에서 확인되는 사실을 우선한다. CCD는 내담자가 직접 설명하지 않는 내부 모델이다.",
items: [
"가족/관계에는 현재 기능에 영향을 주는 반복 패턴을 적는다.",
"촉발 사건은 날짜보다 상담 장면에서 드러날 행동 변화에 초점을 둔다.",
@ -150,7 +150,7 @@ const TAB_GUIDANCE: Record<
},
speech: {
title: "말투는 캐릭터 장식이 아니라 상담 기술 평가의 입력값",
desc: "말버릇, 군말, 비언어 단서는 상담자가 반영·명료화·공감하기 어렵거나 쉬운 지점을 만든다.",
desc: "말버릇, 군말, 비언어 단서는 상담자가 반영, 명료화, 공감하기 어렵거나 쉬운 지점을 만든다.",
items: [
"문장 길이는 짧음/중간/김처럼 운영 가능한 값으로 둔다.",
"군말과 말버릇은 과하지 않게 2~4개 정도가 적당하다.",
@ -159,7 +159,7 @@ const TAB_GUIDANCE: Record<
},
safety: {
title: "역린과 금기어는 모델이 말하지 말아야 할 말이 아니라 반응 조건",
desc: "금기어 목록만 늘리면 시뮬레이션이 경직된다. 어떤 개입에서 어떤 방어·침묵·분노가 생기는지까지 작성해야 한다.",
desc: "금기어 목록만 늘리면 시뮬레이션이 경직된다. 어떤 개입에서 어떤 방어, 침묵, 분노가 생기는지까지 작성해야 한다.",
items: [
"민감 영역은 내담자가 회피하거나 과민 반응하는 주제다.",
"상담자 금기는 훈련자가 하면 안 되는 개입 방식이다.",
@ -177,14 +177,12 @@ const TAB_GUIDANCE: Record<
},
};
function completenessLabel(result: ValidationResult): string {
if (result.errors.length === 0 && result.warnings.length === 0) return "완료";
if (result.errors.length === 0) return "검토";
return "미완";
}
function average(values: number[]): number | null {
if (values.length === 0) return null;
return values.reduce((sum, value) => sum + value, 0) / values.length;
@ -194,6 +192,21 @@ function percentLabel(value: number | null | undefined): string {
return formatOptionalPercent(value, "자료 없음");
}
// 표시 이름 데이터에 가운뎃점이 2개 들어 있다("서연(가명) · 고2 · 우울/자살사고").
// 데이터는 그대로 두고 화면에서만 "서연(가명) · 고2" + "우울/자살사고" 두 줄로 나눠
// 한 줄에 가운뎃점이 1개만 남게 한다. 가운뎃점이 1개 이하면 원문을 그대로 쓴다.
function splitPersonaName(name: string): { head: string; tail: string } {
const parts = name
.split("·")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length < 3) return { head: name, tail: "" };
return {
head: parts.slice(0, 2).join(" · "),
tail: parts.slice(2).join(", "),
};
}
function personaOrigin(code: string, source?: string): "system" | "custom" {
if (/^P(?:[1-9]|1[01])$/i.test(code)) return "system";
return source === "seed" ? "system" : "custom";
@ -207,7 +220,6 @@ function reviewStatusDescription(status: PersonaReviewStatus): string {
return "새 회기 배정에서 제외된 보관 버전입니다.";
}
function TextField({
label,
value,
@ -403,12 +415,15 @@ function EmptyState({ title, desc }: { title: string; desc: string }) {
return <UiEmptyState className="ps-empty" title={title} description={desc} />;
}
// 페르소나 스튜디오는 Kicker 가 21곳이라 저작 화면 한 폭에 accent dot 이 8개까지 몰렸다.
// 이 화면의 Kicker 는 전부 "섹션 제목 장식"일 뿐 현재 상태(정상/주의/라이브)를 전달하지 않으므로
// dot={false} 로 통일한다. 실제 상태 신호는 그대로 <Dot tone="..."> 컴포넌트가 담당한다.
function GuidancePanel({ tab }: { tab: StudioTab }) {
const guidance = TAB_GUIDANCE[tab];
return (
<div className="ps-guidance">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<b>{guidance.title}</b>
<p>{guidance.desc}</p>
</div>
@ -1653,7 +1668,7 @@ export default function PersonaStudio() {
<article className="ps-performance">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<span> {dashboardPoints.length}</span>
@ -1694,7 +1709,7 @@ export default function PersonaStudio() {
<article className="ps-review-summary">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Button
@ -1729,7 +1744,7 @@ export default function PersonaStudio() {
<section className="ps-active-table">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<span> </span>
@ -1749,6 +1764,7 @@ export default function PersonaStudio() {
{catalog.length ? (
catalog.slice(0, 8).map((persona) => {
const usage = usageByPersona.get(normalizeCode(persona.code));
const name = splitPersonaName(persona.display_name);
return (
<button
type="button"
@ -1763,7 +1779,8 @@ export default function PersonaStudio() {
>
<span>
<b>{persona.code}</b>
<small>{persona.display_name}</small>
<small>{name.head}</small>
{name.tail ? <small>{name.tail}</small> : null}
</span>
<strong>{usage?.learners ?? 0}</strong>
<strong>{usage?.activeSessions ?? 0}</strong>
@ -1801,10 +1818,10 @@ export default function PersonaStudio() {
<section className="ps-catalog-page" aria-label="페르소나 카탈로그 정보">
<article className="ps-catalog-hero">
<div>
<Kicker></Kicker>
<Kicker dot={false}></Kicker>
<h2> .</h2>
<p>
, · ·
, , ,
.
</p>
</div>
@ -1821,7 +1838,7 @@ export default function PersonaStudio() {
<section className="ps-catalog-block">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Badge tone="accent"> </Badge>
@ -1852,7 +1869,7 @@ export default function PersonaStudio() {
<section className="ps-catalog-block">
<div className="ps-section-head">
<div>
<Kicker></Kicker>
{/* eyebrow "구성"은 바로 아래 "이론 분포"와 같은 말이라 제거 */}
<h2> </h2>
</div>
</div>
@ -1860,7 +1877,7 @@ export default function PersonaStudio() {
{Object.entries(catalogTheoryCounts).length ? (
Object.entries(catalogTheoryCounts).map(([theory, count]) => (
<div key={theory}>
<span>{personaTheoryLabel(theory)}</span>
<span>{personaTheoryLabel(theory)}</span>
<b>{count}</b>
<i>
<span
@ -1883,7 +1900,7 @@ export default function PersonaStudio() {
<section className="ps-catalog-block ps-catalog-block--wide">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Button
@ -1895,34 +1912,38 @@ export default function PersonaStudio() {
</Button>
</div>
<div className="ps-catalog-list">
{catalog.map((persona) => (
<button
type="button"
key={persona.code}
onClick={() =>
setSearchParams({
view: "personas",
persona: `approved:${persona.code}`,
})
}
>
<span className="ps-code-box">{persona.code}</span>
<span>
<b>{persona.display_name}</b>
<small>
{personaTheoryLabel(persona.theory_target[0] ?? "미지정")} ·{" "}
{personaDifficultyLabel(persona.difficulty)}
</small>
</span>
<span>
{personaOrigin(persona.code, persona.source) === "system"
? "시스템"
: "추가"}
</span>
<strong>v{persona.version ?? 1}</strong>
<Icon name="chevron-right" size={16} />
</button>
))}
{catalog.map((persona) => {
const name = splitPersonaName(persona.display_name);
return (
<button
type="button"
key={persona.code}
onClick={() =>
setSearchParams({
view: "personas",
persona: `approved:${persona.code}`,
})
}
>
<span className="ps-code-box">{persona.code}</span>
<span>
<b>{name.head}</b>
{name.tail ? <small>{name.tail}</small> : null}
<small>
{personaTheoryLabel(persona.theory_target[0] ?? "미지정")} ·{" "}
{personaDifficultyLabel(persona.difficulty)}
</small>
</span>
<span>
{personaOrigin(persona.code, persona.source) === "system"
? "시스템"
: "추가"}
</span>
<strong>v{persona.version ?? 1}</strong>
<Icon name="chevron-right" size={16} />
</button>
);
})}
</div>
</section>
</section>
@ -1946,69 +1967,73 @@ export default function PersonaStudio() {
</div>
{reviewQueue.length ? (
<div className="ps-review-board">
{reviewQueue.map((persona) => (
<article key={persona.persona_id} className="ps-review-card">
<div className="ps-review-card__top">
<span className="ps-code-box">{persona.code}</span>
<Badge tone={personaReviewTone(persona.status)}>
{personaReviewStatusLabel(persona.status)}
</Badge>
</div>
<h3>{persona.display_name}</h3>
<p>{reviewStatusDescription(persona.status)}</p>
<dl>
<div>
<dt></dt>
<dd>v{persona.version}</dd>
{reviewQueue.map((persona) => {
const name = splitPersonaName(persona.display_name);
return (
<article key={persona.persona_id} className="ps-review-card">
<div className="ps-review-card__top">
<span className="ps-code-box">{persona.code}</span>
<Badge tone={personaReviewTone(persona.status)}>
{personaReviewStatusLabel(persona.status)}
</Badge>
</div>
<div>
<dt></dt>
<dd>{formatDateTime(persona.created_at)}</dd>
<h3>{name.head}</h3>
{name.tail ? <p className="ps-name-tail">{name.tail}</p> : null}
<p>{reviewStatusDescription(persona.status)}</p>
<dl>
<div>
<dt></dt>
<dd>v{persona.version}</dd>
</div>
<div>
<dt></dt>
<dd>{formatDateTime(persona.created_at)}</dd>
</div>
<div>
<dt></dt>
<dd>{persona.is_synthetic ? "작성·합성" : "근거 기반"}</dd>
</div>
</dl>
<div className="ps-review-card__actions">
<Button
size="sm"
variant="secondary"
onClick={() =>
setQuery({ persona: `draft:${persona.persona_id}` })
}
>
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => openDraftEditor(persona.persona_id)}
>
</Button>
<Button
size="sm"
variant="secondary"
onClick={() =>
void decideReview(persona.persona_id, "reject")
}
disabled={busy !== null || persona.status !== "review"}
>
</Button>
<Button
size="sm"
onClick={() =>
void decideReview(persona.persona_id, "approve")
}
disabled={busy !== null || persona.status !== "review"}
>
</Button>
</div>
<div>
<dt></dt>
<dd>{persona.is_synthetic ? "작성·합성" : "근거 기반"}</dd>
</div>
</dl>
<div className="ps-review-card__actions">
<Button
size="sm"
variant="secondary"
onClick={() =>
setQuery({ persona: `draft:${persona.persona_id}` })
}
>
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => openDraftEditor(persona.persona_id)}
>
</Button>
<Button
size="sm"
variant="secondary"
onClick={() =>
void decideReview(persona.persona_id, "reject")
}
disabled={busy !== null || persona.status !== "review"}
>
</Button>
<Button
size="sm"
onClick={() =>
void decideReview(persona.persona_id, "approve")
}
disabled={busy !== null || persona.status !== "review"}
>
</Button>
</div>
</article>
))}
</article>
);
})}
</div>
) : (
<EmptyState
@ -2088,6 +2113,7 @@ export default function PersonaStudio() {
{personaRows.length ? (
personaRows.map((row) => {
const usage = usageByPersona.get(normalizeCode(row.code));
const name = splitPersonaName(row.name);
return (
<div
className="ps-persona-row"
@ -2104,7 +2130,8 @@ export default function PersonaStudio() {
>
<span className="ps-code-box">{row.code}</span>
<span>
<b>{row.name}</b>
<b>{name.head}</b>
{name.tail ? <small>{name.tail}</small> : null}
<small>
{personaDifficultyLabel(row.difficulty)} ·{" "}
{row.theory.map(personaTheoryLabel).join(", ") ||
@ -2189,6 +2216,7 @@ export default function PersonaStudio() {
);
const detailData = selectedReview ? selectedDraftDetail : null;
const detailName = splitPersonaName(selectedRow?.name ?? "");
const personaDetailView = selectedRow ? (
<section className="ps-detail-page" aria-label={`${selectedRow.name} 상세`}>
<div className="ps-drill-head">
@ -2201,10 +2229,13 @@ export default function PersonaStudio() {
</Button>
<div>
<Kicker>
<Kicker dot={false}>
{selectedRow.code} · v{selectedRow.version}
</Kicker>
<h2>{selectedRow.name}</h2>
<h2>{detailName.head}</h2>
{detailName.tail ? (
<p className="ps-name-tail">{detailName.tail}</p>
) : null}
<p>
{selectedRow.kind === "approved"
? "학습자에게 공개된 현재 버전"
@ -2258,8 +2289,11 @@ export default function PersonaStudio() {
{detailTab === "overview" ? (
<div className="ps-detail-grid">
<article className="ps-detail-primary">
<Kicker> </Kicker>
<h3>{selectedRow.name}</h3>
<Kicker dot={false}> </Kicker>
<h3>{detailName.head}</h3>
{detailName.tail ? (
<p className="ps-name-tail">{detailName.tail}</p>
) : null}
<p>
{selectedApproved?.presenting_summary ||
stringValue(objectValue(detailData?.presenting), "complaint") ||
@ -2301,7 +2335,7 @@ export default function PersonaStudio() {
</article>
{detailData ? (
<article className="ps-detail-wide">
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<dl className="ps-settings-list">
<div>
<dt></dt>
@ -2368,7 +2402,7 @@ export default function PersonaStudio() {
<section className="ps-detail-wide">
<div className="ps-section-head">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
</div>
@ -2400,7 +2434,7 @@ export default function PersonaStudio() {
) : (
<div className="ps-detail-grid">
<article className="ps-detail-wide">
<Kicker>· </Kicker>
<Kicker dot={false}>· </Kicker>
<dl className="ps-settings-list">
<div>
<dt> </dt>
@ -2447,7 +2481,7 @@ export default function PersonaStudio() {
<main className="ps-root">
<header className="ps-head">
<div>
<Kicker></Kicker>
{/* eyebrow "페르소나"는 아래 h1(페르소나 운영/카탈로그/관리/상세)과 같은 말이라 제거 */}
<h1>
{detailKey
? "페르소나 상세"
@ -2519,15 +2553,19 @@ export default function PersonaStudio() {
</AppShell>
);
const draftName = splitPersonaName(draft.display_name.trim());
const legacyAuthoring = (
<AppShell contextLabel="페르소나" navRole="teacher" wide>
<main className="ps-root">
<header className="ps-head">
<div>
<Kicker>
<Kicker dot={false}>
{editingId ? "페르소나 수정" : "새로운 페르소나 만들기"}
</Kicker>
<h1>{draft.display_name.trim() || "새 페르소나"}</h1>
<h1>{draftName.head || "새 페르소나"}</h1>
{draftName.tail ? (
<p className="ps-name-tail">{draftName.tail}</p>
) : null}
<p>
.
</p>
@ -2598,8 +2636,13 @@ export default function PersonaStudio() {
<section className="ps-editor">
<div className={surfaceClassName("ps-editor__top")}>
<div>
<Kicker>{editingId ? "초안 편집" : "새 초안"}</Kicker>
<h2>{draft.display_name.trim() || "이름 미정"}</h2>
<Kicker dot={false}>
{editingId ? "초안 편집" : "새 초안"}
</Kicker>
<h2>{draftName.head || "이름 미정"}</h2>
{draftName.tail ? (
<p className="ps-name-tail">{draftName.tail}</p>
) : null}
</div>
<Badge tone={validation.errors.length === 0 ? "accent" : "warn"}>
{completenessLabel(validation)}
@ -2609,8 +2652,9 @@ export default function PersonaStudio() {
<Card className="ps-source-panel">
<div className="ps-source-panel__head">
<div>
<Kicker>RAG </Kicker>
<b> · · </b>
{/* eyebrow "RAG " ,
2( · · ) . */}
<b> , , </b>
</div>
<Badge tone={maskedEntities.length > 0 ? "warn" : "neutral"}>
{sourceReferences.length > 0
@ -2644,7 +2688,7 @@ export default function PersonaStudio() {
}}
/>
<div>
<Kicker>SSOT </Kicker>
<Kicker dot={false}>SSOT </Kicker>
<b>{sourceFilename || "파일을 드래그하거나 선택"}</b>
<span>
txt, md, csv, json, html, xml에
@ -2692,7 +2736,7 @@ export default function PersonaStudio() {
value={sourceText}
onChange={setSourceText}
rows={8}
placeholder="첨부 파일을 읽거나, 내담자 기록·사례 요약·교재 기준·상담 장면 메모를 붙여넣으세요."
placeholder="첨부 파일을 읽거나, 내담자 기록, 사례 요약, 교재 기준, 상담 장면 메모를 붙여넣으세요."
/>
</div>
<div className="ps-source-panel__actions">
@ -2787,7 +2831,7 @@ export default function PersonaStudio() {
<aside className="ps-inspector" aria-label="검증 상태">
<div className={surfaceClassName("ps-inspector__block")}>
<Kicker></Kicker>
<Kicker dot={false}></Kicker>
<div className="ps-check">
{validation.errors.length === 0 &&
validation.warnings.length === 0 ? (
@ -2812,7 +2856,7 @@ export default function PersonaStudio() {
</div>
<div className={surfaceClassName("ps-inspector__block")}>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<div className="ps-check">
{generationSummary ? (
<div className="ps-check-row">
@ -2849,8 +2893,11 @@ export default function PersonaStudio() {
/>
<span>
<b>{item.title}</b>
{/* 한 줄에 가운뎃점 3개가 몰려 읽기 어려워 두 줄로 나눴다(줄당 1개). */}
<small>
{item.source_id} · doc {item.doc_id ?? "-"}
</small>
<small>
{item.source_id} · doc {item.doc_id ?? "-"} ·{" "}
{item.chunk_count} chunks ·{" "}
{item.content_hash.slice(0, 10)}
</small>
@ -2878,7 +2925,7 @@ export default function PersonaStudio() {
</div>
<div className={surfaceClassName("ps-inspector__block")}>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
{reviews.length > 0 ? (
<div className="ps-review-cards">
{reviews.map((persona) => (

View file

@ -51,16 +51,51 @@ type PersonaSessionGroup = {
avgRapport: number | null;
};
// [D5] 데이터 없음을 "평가 부족 / 변화 부족 / 라포 부족 / 기법 부족" 같은 부정어로
// 셀마다 반복하면 345행 × 5컬럼이 전부 경고처럼 읽힌다. 값 없음은 중립 기호 하나로
// 통일하고, 무슨 뜻인지는 표 상단 안내(.pf-table-note)에서 한 번만 설명한다.
const EMPTY_MARK = "-";
const EMPTY_SCORE_LABEL = "아직 평가 없음";
const EMPTY_DELTA_LABEL = "아직 변화 기록 없음";
const EMPTY_RAPPORT_LABEL = "아직 라포 평가 없음";
const EMPTY_TREND_LABEL = "아직 추이 없음";
const EMPTY_TECHNIQUE_LABEL = "아직 기법 기록 없음";
function formatScore(value: number | null | undefined): string {
return formatOptionalPercent(value, "평가 부족");
return formatOptionalPercent(value, EMPTY_MARK);
}
function formatDelta(value: number | null | undefined): string {
return formatPercentagePointDelta(value, "변화 부족");
return formatPercentagePointDelta(value, EMPTY_MARK);
}
function formatRapport(value: number | null | undefined): string {
return formatBipolarPercent(value, "라포 부족");
return formatBipolarPercent(value, EMPTY_MARK);
}
/**
* [D5] , muted "-" .
* . (aria-label)
* (title) . role="img" aria-label이 span에서 .
*/
function MetricText({
text,
emptyLabel,
}: {
text: string;
emptyLabel: string;
}) {
if (text !== EMPTY_MARK) return <>{text}</>;
return (
<span
className="pf-empty-mark"
role="img"
aria-label={emptyLabel}
title={emptyLabel}
>
{EMPTY_MARK}
</span>
);
}
function average(values: Array<number | null | undefined>): number | null {
@ -86,7 +121,8 @@ function trendLabel(value: string): string {
if (value === "up") return "상승";
if (value === "down") return "하락";
if (value === "flat") return "유지";
return "평가 부족";
// [D5] 추이를 판단할 표본이 없을 때도 부정어 대신 중립 기호를 쓴다.
return EMPTY_MARK;
}
function sessionOpenLabel(status: string): string {
@ -260,7 +296,8 @@ export default function Professor({
{
label: "최근 세션",
value: dashboard?.recent_sessions?.length ?? 0,
hint: "종료 · 진행",
// [D3] 한 줄에 가운뎃점 1개. 두 상태를 잇는 기호는 조사로 푼다.
hint: "종료와 진행",
icon: "play" as const,
},
{
@ -388,15 +425,19 @@ export default function Professor({
>
<header className="pf-head">
<div>
<Kicker>{isAnalysisView ? "학생 분석" : "교수 콘솔"}</Kicker>
{/* [D1] 페이지 eyebrow는 상태가 아니라 위치 표시라 accent dot을 뺀다. */}
<Kicker dot={false}>
{isAnalysisView ? "학생 분석" : "교수 콘솔"}
</Kicker>
{isAnalysisView ? (
<>
<h1>
, .
</h1>
{/* [D3] 탭 이름 나열의 가운뎃점 2개를 쉼표로 풀어 한 줄 1개 규칙을 지킨다. */}
<p>
, , ,
· ·
, ,
.
</p>
</>
@ -473,12 +514,13 @@ export default function Professor({
>
<Dot tone={loadState === "error" ? "crit" : "accent"} size={9} />
<div className="pf-triage__copy">
<Kicker> </Kicker>
{/* [D1] 바로 왼쪽 <Dot>이 실제 로드 상태를 이미 전달한다. kicker dot은 중복 장식이라 뺀다. */}
<Kicker dot={false}> </Kicker>
<b>{dashboard?.message ?? "학습 세션을 확인하는 중입니다."}</b>
<span>
{dashboard
? `${dashboard.cohort_label} · 실제 기록`
: "확인 중"}
{/* [D3] " · · " 2 1 .
title() . */}
<span title={dashboard?.cohort_label ?? undefined}>
{dashboard ? "실제 기록" : "확인 중"}
{updatedAt
? ` · ${updatedAt.toLocaleTimeString("ko-KR")} 갱신`
: ""}
@ -580,7 +622,9 @@ export default function Professor({
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
{/* [D1] dot . eyebrow
( ) . */}
<Kicker dot={false}> </Kicker>
<h2>{selectedLearner?.learner_label ?? "학습자 이력"}</h2>
</div>
<Badge tone={learnerAnalysis ? "accent" : "neutral"}>
@ -626,8 +670,8 @@ export default function Professor({
<div className="pf-queue-stack">
<section className="pf-section pf-section--pending">
<div className="pf-section__head">
{/* [D4] eyebrow "검토 대기"는 아래 제목과 같은 말이라 삭제했다. */}
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone="accent">{pendingReviews.length}</Badge>
@ -655,9 +699,12 @@ export default function Professor({
/>
<div className="pf-session__main">
<b>{session.learner_label}</b>
<span>
{session.persona_code} · {session.stage} ·{" "}
{session.turn_count}
{/* [D3] "P1 · 라포 · 2턴" → "P1 · 라포"와 "2턴"을 공백 컬럼으로 분리. */}
<span className="pf-session__facts">
<span>
{session.persona_code} · {session.stage}
</span>
<span>{session.turn_count}</span>
</span>
<code>{session.session_id}</code>
</div>
@ -696,8 +743,8 @@ export default function Professor({
<section className="pf-section pf-section--safety">
<div className="pf-section__head">
{/* [D4] eyebrow "위기 알림"은 아래 제목("109 안전 확인 큐")과 같은 말이라 삭제했다. */}
<div>
<Kicker> </Kicker>
<h2>109 </h2>
</div>
<Badge tone={safetyAlerts.length > 0 ? "warn" : "neutral"}>
@ -724,7 +771,8 @@ export default function Professor({
<section className="pf-section pf-section--persona">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
{/* [D1] 섹션 라벨 dot 제거. eyebrow는 제목에 없는 "페르소나"를 더하므로 유지. */}
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Badge tone={personaReviewCount > 0 ? "accent" : "neutral"}>
@ -763,9 +811,15 @@ export default function Professor({
</span>
<div>
<b>{persona.display_name}</b>
<span>
v{persona.version} · {persona.difficulty} ·{" "}
{persona.theory_target.join(" · ") || "공통"}
{/* [D3] // 3 .
. */}
<span className="pf-persona__facts">
<span>
v{persona.version} · {persona.difficulty}
</span>
<span>
{persona.theory_target.join(", ") || "공통"}
</span>
</span>
</div>
</div>
@ -833,7 +887,8 @@ export default function Professor({
<section className="pf-section pf-section--draft">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
{/* [D1] 섹션 라벨 dot 제거. */}
<Kicker dot={false}> </Kicker>
<h2> </h2>
</div>
<Badge tone="accent"> </Badge>
@ -866,8 +921,8 @@ export default function Professor({
<section className="pf-section pf-section--recent">
<div className="pf-section__head">
{/* [D4] eyebrow "최근 세션"은 아래 제목("최근 회기 기록")과 같은 말이라 삭제했다. */}
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone="neutral">{recentSessions.length}</Badge>
@ -925,11 +980,15 @@ export default function Professor({
{session.status === "ended" ? "종료" : "진행 중"}
</Badge>
{session.status === "ended" ? (
/* [D3] " · "
. */
<small className="pf-recent__review-state">
{sessionSupervisorLabel(session)} ·{" "}
{sessionReviewStatusLabel(
session.review_status,
)}
<span>{sessionSupervisorLabel(session)}</span>
<span>
{sessionReviewStatusLabel(
session.review_status,
)}
</span>
</small>
) : null}
</span>
@ -1039,8 +1098,8 @@ function LearnerOverviewTable({
return (
<section className="pf-section pf-section--learner-overview">
<div className="pf-section__head">
{/* [D4] eyebrow "전체 학습자"는 아래 제목·우측 "N명" 배지와 같은 말이라 삭제했다. */}
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone={totalLearners > 0 ? "accent" : "neutral"}>
@ -1088,49 +1147,56 @@ function LearnerOverviewTable({
desc="검색어를 줄이거나 다른 학습자 이름, ID, 기법 태그로 다시 확인합니다."
/>
) : (
<div
className="pf-learner-table"
data-learner-overview-table="true"
ref={scrollContainerRef}
>
<div className="pf-learner-table__head" aria-hidden="true">
<span></span>
<span></span>
<span> </span>
<span></span>
<span> </span>
<span></span>
<span> </span>
<span></span>
<span></span>
</div>
{/* 세로 가상화 본문 — 전체 높이를 확보하고 화면 주변 행만 절대 배치로 렌더한다. */}
<>
{/* [D5] 빈 셀의 뜻은 여기서 한 번만 설명하고, 표 안에서는 "-"만 반복한다. */}
<p className="pf-table-note" id="pf-learner-table-note">
"-" .
</p>
<div
className="pf-learner-table__body"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
className="pf-learner-table"
data-learner-overview-table="true"
aria-describedby="pf-learner-table-note"
ref={scrollContainerRef}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const learner = learners[virtualRow.index];
const isExpanded = expandedLearnerId === learner.learner_id;
return (
<div
className={`pf-learner-record ${isExpanded ? "is-expanded" : ""}`}
key={learner.learner_id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<LearnerOverviewRecord
learner={learner}
isExpanded={isExpanded}
onToggleExpand={toggleExpanded}
onOpen={onOpen}
/>
</div>
);
})}
<div className="pf-learner-table__head" aria-hidden="true">
<span></span>
<span></span>
<span> </span>
<span></span>
<span> </span>
<span></span>
<span> </span>
<span></span>
<span></span>
</div>
{/* 세로 가상화 본문 — 전체 높이를 확보하고 화면 주변 행만 절대 배치로 렌더한다. */}
<div
className="pf-learner-table__body"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const learner = learners[virtualRow.index];
const isExpanded = expandedLearnerId === learner.learner_id;
return (
<div
className={`pf-learner-record ${isExpanded ? "is-expanded" : ""}`}
key={learner.learner_id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<LearnerOverviewRecord
learner={learner}
isExpanded={isExpanded}
onToggleExpand={toggleExpanded}
onOpen={onOpen}
/>
</div>
);
})}
</div>
</div>
</div>
</>
)}
</Card>
</section>
@ -1171,14 +1237,30 @@ function LearnerOverviewRecord({
</b>
<span>{formatDateTime(learner.latest_at)}</span>
</div>
{/* [D5] 값 없는 칸은 "평가/변화/라포/기법 부족" 대신 muted "-". 뜻은 표 상단 안내에서 한 번만. */}
<div className="pf-learner-row__metric" data-label="최근 적절성">
<b>{formatScore(learner.latest_score)}</b>
<b>
<MetricText
text={formatScore(learner.latest_score)}
emptyLabel={EMPTY_SCORE_LABEL}
/>
</b>
</div>
<div className="pf-learner-row__metric" data-label="변화">
<b>{formatDelta(learner.score_delta)}</b>
<b>
<MetricText
text={formatDelta(learner.score_delta)}
emptyLabel={EMPTY_DELTA_LABEL}
/>
</b>
</div>
<div className="pf-learner-row__metric" data-label="평균 라포">
<b>{formatRapport(learner.avg_rapport)}</b>
<b>
<MetricText
text={formatRapport(learner.avg_rapport)}
emptyLabel={EMPTY_RAPPORT_LABEL}
/>
</b>
</div>
<div className="pf-learner-row__trend" data-label="추이">
<Badge
@ -1190,11 +1272,19 @@ function LearnerOverviewRecord({
: "neutral"
}
>
{trendLabel(learner.trend)}
<MetricText
text={trendLabel(learner.trend)}
emptyLabel={EMPTY_TREND_LABEL}
/>
</Badge>
</div>
<div className="pf-learner-row__tag-summary" data-label="기법 요약">
<b>{topTechniques[0] ?? "기법 부족"}</b>
<b>
<MetricText
text={topTechniques[0] ?? EMPTY_MARK}
emptyLabel={EMPTY_TECHNIQUE_LABEL}
/>
</b>
{topTechniques.length > 1 ? (
<span> {topTechniques.length - 1}</span>
) : null}
@ -1247,10 +1337,22 @@ function LearnerOverviewRecord({
<div className="pf-learner-detail-item">
<span> </span>
<b>
{formatScore(learner.first_score)} {" "}
{formatScore(learner.latest_score)}
<MetricText
text={formatScore(learner.first_score)}
emptyLabel={EMPTY_SCORE_LABEL}
/>{" "}
{" "}
<MetricText
text={formatScore(learner.latest_score)}
emptyLabel={EMPTY_SCORE_LABEL}
/>
</b>
<small>{formatDelta(learner.score_delta)}</small>
<small>
<MetricText
text={formatDelta(learner.score_delta)}
emptyLabel={EMPTY_DELTA_LABEL}
/>
</small>
</div>
<div className="pf-learner-detail-item">
<span> </span>
@ -1265,7 +1367,12 @@ function LearnerOverviewRecord({
{topTechniques.slice(0, 5).map((tag) => (
<span key={tag}>{tag}</span>
))}
{topTechniques.length === 0 ? <span> </span> : null}
{topTechniques.length === 0 ? (
<MetricText
text={EMPTY_MARK}
emptyLabel={EMPTY_TECHNIQUE_LABEL}
/>
) : null}
</div>
</div>
</div>
@ -1395,17 +1502,33 @@ function LearnerAnalysisPanel({
<small> </small>
<b>{analysis.pending_reviews}</b>
</span>
{/* [D5] 요약 지표도 같은 규칙(값 없음 = muted "-")으로 통일한다. */}
<span>
<small> </small>
<b>{formatScore(summary.latest_score)}</b>
<b>
<MetricText
text={formatScore(summary.latest_score)}
emptyLabel={EMPTY_SCORE_LABEL}
/>
</b>
</span>
<span>
<small></small>
<b>{formatDelta(summary.score_delta)}</b>
<b>
<MetricText
text={formatDelta(summary.score_delta)}
emptyLabel={EMPTY_DELTA_LABEL}
/>
</b>
</span>
<span>
<small> </small>
<b>{formatRapport(summary.avg_rapport)}</b>
<b>
<MetricText
text={formatRapport(summary.avg_rapport)}
emptyLabel={EMPTY_RAPPORT_LABEL}
/>
</b>
</span>
</div>
</div>
@ -1472,10 +1595,13 @@ function LearnerAnalysisPanel({
</span>
<span className="pf-persona-group__main">
<b>{group.personaName}</b>
<small>
{formatDateTime(group.latestAt)} · {" "}
{group.endedSessions}/{group.sessions.length} ·
{group.pendingReviews}
{/* [D3] 가운뎃점 2개 → 1개. "최근 …"을 앞 컬럼으로 분리한다. */}
<small className="pf-persona-group__facts">
<span> {formatDateTime(group.latestAt)}</span>
<span>
{group.endedSessions}/{group.sessions.length} ·
{group.pendingReviews}
</span>
</small>
</span>
<span className="pf-persona-group__metrics">
@ -1485,11 +1611,21 @@ function LearnerAnalysisPanel({
</span>
<span>
<small></small>
<b>{formatScore(group.avgScore)}</b>
<b>
<MetricText
text={formatScore(group.avgScore)}
emptyLabel={EMPTY_SCORE_LABEL}
/>
</b>
</span>
<span>
<small></small>
<b>{formatRapport(group.avgRapport)}</b>
<b>
<MetricText
text={formatRapport(group.avgRapport)}
emptyLabel={EMPTY_RAPPORT_LABEL}
/>
</b>
</span>
<span>
<small></small>
@ -1542,7 +1678,12 @@ function LearnerAnalysisPanel({
<section className="pf-analysis__chart" aria-label="회기별 추이">
<div className="pf-analysis__subhead">
<b> </b>
<span>{trendLabel(summary.trend)}</span>
<span>
<MetricText
text={trendLabel(summary.trend)}
emptyLabel={EMPTY_TREND_LABEL}
/>
</span>
</div>
{points.length > 0 ? (
<div className="pf-analysis-bars">
@ -1637,20 +1778,36 @@ function LearnerSessionRow({
<b>
{session.persona_code} · {session.persona_name}
</b>
<span>
{session.stage} · {session.turn_count} · {" "}
{session.learner_turn_count} / {session.client_turn_count}
{/* [D3] "단계 · N턴 · 학습자/내담자"의 가운뎃점 2개를 1개로 줄이고 컬럼을 나눈다. */}
<span className="pf-timeline-row__facts">
<span>
{session.stage} · {session.turn_count}
</span>
<span>
{session.learner_turn_count} / {" "}
{session.client_turn_count}
</span>
</span>
<code>{session.session_id}</code>
</div>
<div className="pf-timeline-row__metrics">
<span>
<small></small>
<b>{formatScore(point?.score)}</b>
<b>
<MetricText
text={formatScore(point?.score)}
emptyLabel={EMPTY_SCORE_LABEL}
/>
</b>
</span>
<span>
<small></small>
<b>{formatRapport(point?.rapport)}</b>
<b>
<MetricText
text={formatRapport(point?.rapport)}
emptyLabel={EMPTY_RAPPORT_LABEL}
/>
</b>
</span>
<span>
<small>AI </small>
@ -1702,9 +1859,12 @@ function SafetyAlertRow({ alert }: { alert: TeacherSafetyAlert }) {
</span>
<div className="pf-alert__main">
<b>{alert.learner_label}</b>
<span>
{alert.persona_code || "세션"} · {alert.ko_risk_level} ·{" "}
{formatDateTime(alert.created_at)}
{/* [D3] "코드 · 위험도 · 시각"의 가운뎃점 2개를 1개로 줄이고 시각을 컬럼으로 분리한다. */}
<span className="pf-alert__facts">
<span>
{alert.persona_code || "세션"} · {alert.ko_risk_level}
</span>
<span>{formatDateTime(alert.created_at)}</span>
</span>
<code>{alert.session_id}</code>
</div>

View file

@ -225,6 +225,22 @@ function buildPersonaUi(
};
}
/* D3( ) "16-18 · 2 · · " ·
2 , · 1 .
personaMeta() . */
function splitMetaLines(meta: string): string[] {
const parts = meta
.split("·")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length <= 2) return [meta];
const lines: string[] = [];
for (let i = 0; i < parts.length; i += 2) {
lines.push(parts.slice(i, i + 2).join(" · "));
}
return lines;
}
/* ── 작은 표현 유틸 ─────────────────────────────────────────────────── */
// 백엔드가 반환한 effective_openness(0~1)를 실시간 관찰 신호로만 변환한다.
@ -317,6 +333,11 @@ export default function Session() {
);
const clientName = personaUi.context.name;
const primaryContext = personaUi.context.rows[0] ?? null;
// D3 — 내담자 한 줄 소개를 줄당 가운뎃점 1개 이하로 나눈 표시용 배열
const clientMetaLines = useMemo(
() => splitMetaLines(personaUi.context.meta),
[personaUi.context.meta],
);
// ── 세션 진행 상태 ──
const [started, setStarted] = useState(false);
@ -418,6 +439,7 @@ export default function Session() {
const ttsChunksRef = useRef<BlobPart[]>([]);
const ttsPlaybackCleanupRef = useRef<(() => void) | null>(null);
const ttsPlaybackRequestRef = useRef(0);
const ttsRequestAbortRef = useRef<AbortController | null>(null);
const playTtsAudioRef = useRef<(() => Promise<void>) | null>(null);
const pendingVoiceLearnerIdRef = useRef<number | null>(null);
const pendingVoiceLearnerTextRef = useRef<string>("");
@ -464,6 +486,8 @@ export default function Session() {
const stopTtsPlayback = useCallback(() => {
ttsPlaybackRequestRef.current += 1;
ttsRequestAbortRef.current?.abort();
ttsRequestAbortRef.current = null;
const cleanup = ttsPlaybackCleanupRef.current;
ttsPlaybackCleanupRef.current = null;
if (cleanup) cleanup();
@ -1058,21 +1082,33 @@ export default function Session() {
async (sessionId: string, turnSeq: number) => {
const requestId = ttsPlaybackRequestRef.current + 1;
ttsPlaybackRequestRef.current = requestId;
const abortController = new AbortController();
ttsRequestAbortRef.current?.abort();
ttsRequestAbortRef.current = abortController;
setVoiceStatus("speaking");
setVoiceDetail("OpenAI 음성을 생성하고 있습니다.");
setVoiceDetail("내담자 음성을 준비하고 있습니다. 입력은 계속할 수 있습니다.");
try {
const audio = await sessionApi.speakClientTurn(sessionId, turnSeq);
const audio = await sessionApi.speakClientTurn(
sessionId,
turnSeq,
abortController.signal,
);
if (ttsPlaybackRequestRef.current !== requestId) return;
ttsChunksRef.current = [audio];
const play = playTtsAudioRef.current;
if (!play) throw new Error("voice playback is not ready");
await play();
} catch {
} catch (error) {
if (ttsPlaybackRequestRef.current !== requestId) return;
if (error instanceof DOMException && error.name === "AbortError") return;
setAvatarState("listening");
setVoiceStatus("degraded");
setVoiceDetail("OpenAI 음성을 재생하지 못했습니다. 자막 응답은 화면에 남겼습니다.");
setVoiceDetail("내담자 음성을 재생하지 못했습니다. 자막 응답은 화면에 남겼습니다.");
pushSignal("warn", "AI 음성 재생 실패");
} finally {
if (ttsRequestAbortRef.current === abortController) {
ttsRequestAbortRef.current = null;
}
}
},
[pushSignal],
@ -1908,6 +1944,7 @@ export default function Session() {
sessionStatusLabel,
transcriptLiveLabel,
transcriptIsLive,
textComposerDisabled,
textTurnBlocked,
voiceInputStatus,
responseStatus,
@ -2093,8 +2130,13 @@ export default function Session() {
<span className={"sx-sessionbar__dot" + (sessionEnded ? " is-ended" : "")} />
<span>
<b>{sessionStatusLabel}</b>
{/* D3 " · · 0:03" · 2
"서연 · 라포 단계" + "0:03" . */}
<small>
{clientName} · {stage} · {elapsedLabel}
<span>
{clientName} · {stage}
</span>
<span>{elapsedLabel}</span>
</small>
</span>
</div>
@ -2131,30 +2173,24 @@ export default function Session() {
showCaption={false}
showMeta={false}
/>
{/* + + .
·· .sx-prestart__facts
(3·2 ) .
. */}
<div className="sx-prestart__case" aria-label="내담자 요약">
<span className="sx-prestart__case-initial" aria-hidden="true">
{personaUi.context.initial}
</span>
<div>
<b>{personaUi.context.name}</b>
<p>{personaUi.context.meta}</p>
</div>
<ul>
{personaUi.context.rows.slice(0, 3).map((row) => (
<li className={surfaceClassName(undefined, { variant: "inset", flat: true })} key={row.l}>
<span>{row.l}</span>
<em>{row.v}</em>
</li>
<b>{personaUi.context.name}</b>
{/* D3 — 한 줄에 · 가 3개 몰리던 소개를 줄당 1개로 나눠 표시 */}
<p>
{clientMetaLines.map((line, i) => (
<span key={`${i}-${line}`}>{line}</span>
))}
</ul>
</p>
</div>
</div>
<div className="sx-prestart__main">
<span className="sx-prestart__eyebrow">
<span aria-hidden="true" />
{currentPhase.key}
</span>
{/* D4 eyebrow " " " ."
( · · ). */}
<h2 className="sx-prestart__title">{prestartTitle}</h2>
<p className="sx-prestart__desc">
,
@ -2461,7 +2497,8 @@ export default function Session() {
aria-expanded={!ctxCollapsed}
onClick={() => setCtxCollapsed((prev) => !prev)}
>
<Kicker> </Kicker>
{/* D1 — 단순 섹션 라벨이라 accent dot 제거(상태를 나타내지 않음) */}
<Kicker dot={false}> </Kicker>
<span className="sx-panel-toggle__hint">
{ctxCollapsed ? `${personaUi.context.name} · 펼치기` : "접기"}
</span>
@ -2472,7 +2509,12 @@ export default function Session() {
<span className="sx-ctx__pf">{personaUi.context.initial}</span>
<span>
<span className="sx-ctx__nm">{personaUi.context.name}</span>
<div className="sx-ctx__mt">{personaUi.context.meta}</div>
{/* D3 — 줄당 가운뎃점 1개로 나눠 표시 */}
<div className="sx-ctx__mt">
{clientMetaLines.map((line, i) => (
<span key={`${i}-${line}`}>{line}</span>
))}
</div>
</span>
</div>
<div className="sx-ctx__meta">
@ -2496,7 +2538,8 @@ export default function Session() {
<section className={surfaceClassName("sx-panel sx-session-progress")}>
<div className="sx-session-progress__head">
<Kicker> </Kicker>
{/* D1 — 섹션 라벨(수치는 아래 셀이 전달) → dot 제거 */}
<Kicker dot={false}> </Kicker>
</div>
<div className="sx-session-progress__grid">
<span>
@ -2575,7 +2618,8 @@ export default function Session() {
{/* 실시간 자막(대본 스타일) + 텍스트 입력 폴백 */}
<section className={surfaceClassName("sx-transcript")}>
<div className="sx-transcript__head">
<Kicker> </Kicker>
{/* D1 — 라이브 여부는 우측 sx-transcript__live-dot 이 이미 전달 → 라벨 dot 제거 */}
<Kicker dot={false}> </Kicker>
<span className="sx-transcript__live">
<span
className={"sx-transcript__live-dot" + (transcriptIsLive ? " is-live" : "")}
@ -2708,7 +2752,7 @@ export default function Session() {
? "일시정지 중입니다."
: "학습자 발화를 입력하세요."
}
disabled={sessionEnded || paused || textTurnBlocked}
disabled={textComposerDisabled}
aria-label="학습자 발화 입력"
/>
</div>
@ -2739,7 +2783,8 @@ export default function Session() {
{feedbackMode !== "immersive" ? (
<section className={surfaceClassName("sx-panel sx-signal")}>
<div className="sx-signal__head">
<Kicker> </Kicker>
{/* D1 — 신호 상태는 아래 sx-signal__one-dot 이 색으로 전달 → 라벨 dot 제거 */}
<Kicker dot={false}> </Kicker>
</div>
{liveSignal ? (
@ -2925,7 +2970,8 @@ export default function Session() {
) : (
<section className={surfaceClassName("sx-panel sx-signal")}>
<div className="sx-signal__head">
<Kicker> </Kicker>
{/* D1 — 섹션 라벨 → dot 제거 */}
<Kicker dot={false}> </Kicker>
</div>
<div className="sx-signal__defer">
.
@ -2942,7 +2988,8 @@ export default function Session() {
aria-expanded={!metersCollapsed}
onClick={() => setMetersCollapsed((prev) => !prev)}
>
<Kicker> </Kicker>
{/* D1 — 수치는 아래 게이지가 전달하는 섹션 라벨 → dot 제거 */}
<Kicker dot={false}> </Kicker>
<span className="sx-panel-toggle__hint">
{metersCollapsed
? progress

View file

@ -57,6 +57,26 @@ import "./session-review/session-review.css";
type LoadState = "loading" | "ready" | "error";
/** 축어록/페르소나 메타에 남아 있는 원문 난도 토큰을 한글 표기로 바꾼다. */
const RAW_DIFFICULTY_KO: Record<string, string> = {
easy: "기초",
moderate: "중간",
hard: "고난도",
};
/**
* API가 "P1 · hard · 인본주의" .
* , (D3).
* (hard ) .
*/
function personaMetaParts(persona: string): string[] {
return persona
.split("·")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => RAW_DIFFICULTY_KO[part.toLowerCase()] ?? part);
}
function renderSummary(summary: string) {
const parts = summary.split(/(<hl>.*?<\/hl>)/g);
return parts.map((part, i) => {
@ -293,9 +313,10 @@ function JumpablePoint({
const targetTs = point.jumpTo
? turns.find((turn) => turn.id === point.jumpTo)?.ts
: null;
// D1: 항목마다 색이 고정된 앞머리 점은 상태가 아니라 장식이라 제거했다.
// 좋았던 순간/개선점 구분은 카드 제목이 이미 말해 준다.
return (
<div className="sr-point">
<span className="sr-point__mk" aria-hidden="true" />
<div>
<div className="sr-point__h">{point.title}</div>
<div className="sr-point__d">
@ -418,7 +439,7 @@ function CaseWorksheetCard({
<Card className="sr-card sr-card--side sr-card--worksheet">
<div className="sr-ws-toolbar">
<div>
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<span className="sr-ws-state">{stateLabel}</span>
</div>
{readOnly ? (
@ -620,7 +641,7 @@ function PrepostMeasureCard() {
<Card className="sr-card sr-card--side sr-card--prepost">
<div className="sr-prepost__head">
<div>
<Kicker>Phase 3 /</Kicker>
<Kicker dot={false}>Phase 3 /</Kicker>
<b> </b>
</div>
<span className="sr-prepost__badge">{completePairs}/3 </span>
@ -983,8 +1004,8 @@ export default function SessionReview() {
return (
<AppShell contextLabel={contextLabel} navRole={reviewNavRole}>
<div className="sr-root">
{/* D4: eyebrow "회기 리뷰"는 상단 컨텍스트 라벨과 같은 말이라 제거 */}
<Card>
<Kicker> </Kicker>
<EmptyBlock
title="리뷰를 불러오는 중"
desc="회기 축어록을 확인하고 있습니다."
@ -1000,7 +1021,6 @@ export default function SessionReview() {
<AppShell contextLabel={contextLabel} navRole={reviewNavRole}>
<div className="sr-root">
<Card>
<Kicker> </Kicker>
<EmptyBlock
title="리뷰를 표시할 수 없습니다"
desc={error ?? "세션을 찾을 수 없거나 접근 권한이 없습니다."}
@ -1062,12 +1082,18 @@ export default function SessionReview() {
</span>
<div>
<div className="sr-head__name">{data.client.name}</div>
{/* D3: 날짜·소요시간만 가운뎃점 1개로 묶고, 페르소나 메타는 아래 줄 칩으로 분리 */}
<div className="sr-head__meta">
<span className="sr-mono">{data.date}</span>
<span className="sr-meta-sep" aria-hidden="true" />
<span>{data.durationLabel}</span>
<span className="sr-meta-sep" aria-hidden="true" />
<span>{data.client.persona}</span>
</div>
<div className="sr-head__tags">
{personaMetaParts(data.client.persona).map((part) => (
<span className="sr-head__tag" key={part}>
{part}
</span>
))}
<Badge tone={isSupervisorView ? "neutral" : "accent"}>
{isSupervisorView ? "교수자 검토 화면" : "학습자 리뷰"}
</Badge>
@ -1083,7 +1109,8 @@ export default function SessionReview() {
flat: true,
})}
>
<span className="sr-stat__lab">SESSION</span>
{/* D2: 영문 대문자 eyebrow(SESSION/SUPERVISOR)를 한글 라벨로 교체 */}
<span className="sr-stat__lab"></span>
<span className="sr-stat__val sr-stat__val--accent">
<Dot tone={data.reviewReady ? "accent" : "warn"} size={7} />
{data.sessionSignal}
@ -1095,7 +1122,7 @@ export default function SessionReview() {
flat: true,
})}
>
<span className="sr-stat__lab">SUPERVISOR</span>
<span className="sr-stat__lab"></span>
<span className="sr-stat__val">
<span
className="sr-avatar"
@ -1111,7 +1138,7 @@ export default function SessionReview() {
{/* 리뷰 요약 — 상단 전폭 밴드(2026-07-15 재설계): 도킹/스크롤 추종 없이 한 번 읽고 지나가는 요약 */}
<Card className="sr-overview sr-overview--band">
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<p className="sr-summary">
{renderSummary(displayReviewSummary(data.summary))}
</p>
@ -1273,7 +1300,7 @@ export default function SessionReview() {
>
<div className="sr-left">
<Card className="sr-card sr-card--chart">
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<div style={{ marginTop: "var(--sp-4)" }}>
{hasValence ? (
<ValenceChart
@ -1291,7 +1318,7 @@ export default function SessionReview() {
</Card>
<Card className="sr-card sr-card--flow">
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<div className="sr-phasebar" style={{ marginTop: "var(--sp-4)" }}>
{phases.length > 0 ? (
<>
@ -1308,7 +1335,7 @@ export default function SessionReview() {
}}
title={`${phase.label} · ${Math.round((phase.weight / phaseTotal) * 100)}%`}
>
<span className="sr-phase__dot" aria-hidden="true" />
{/* D1: 단계 이름 앞 장식 점 제거 — 구간 배경색이 이미 구분한다 */}
{phase.label}
</div>
))}
@ -1331,7 +1358,7 @@ export default function SessionReview() {
<Card className="sr-card sr-card--transcript">
<div className="sr-tx__head">
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<div className="sr-tx__filters">
<button
type="button"
@ -1428,18 +1455,19 @@ export default function SessionReview() {
<div className="sr-right">
{isSupervisorView ? (
<Card className="sr-card sr-card--side sr-card--teacher-review">
{/* D4:
. (is-done) . */}
<div className="sr-teacher-review__head">
<div>
<Kicker> </Kicker>
<b>{teacherReviewStatusLabel}</b>
<Kicker dot={false}> </Kicker>
<b
className={
teacherReviewStatus === "closed" ? "is-done" : undefined
}
>
{teacherReviewStatusLabel}
</b>
</div>
<Badge
tone={
teacherReviewStatus === "closed" ? "accent" : "neutral"
}
>
{teacherReviewStatusLabel}
</Badge>
</div>
<EvaluationScopeNotice />
<label className="sr-teacher-review__note">
@ -1501,18 +1529,17 @@ export default function SessionReview() {
<div className="sr-teacher-review__worksheet">
<div className="sr-teacher-review__head sr-teacher-review__head--sub">
<div>
<Kicker> </Kicker>
<b>{worksheetReviewLabel}</b>
<Kicker dot={false}> </Kicker>
<b
className={
worksheetReviewStatus === "approved"
? "is-done"
: undefined
}
>
{worksheetReviewLabel}
</b>
</div>
<Badge
tone={
worksheetReviewStatus === "approved"
? "accent"
: "neutral"
}
>
{worksheetReviewLabel}
</Badge>
</div>
<label className="sr-teacher-review__note">
<span> </span>
@ -1585,7 +1612,7 @@ export default function SessionReview() {
) : null}
<Card className="sr-card sr-card--side sr-card--rubric">
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
{rubric.length > 0 ? (
rubric.map((row) => (
@ -1625,7 +1652,7 @@ export default function SessionReview() {
</Card>
<Card className="sr-card sr-card--side sr-card--good">
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<div
className="sr-points sr-points--good"
style={{ marginTop: "var(--sp-4)" }}
@ -1649,7 +1676,7 @@ export default function SessionReview() {
</Card>
<Card className="sr-card sr-card--side sr-card--growth">
<Kicker> </Kicker>
<Kicker dot={false}> </Kicker>
<div
className="sr-points sr-points--grow"
style={{ marginTop: "var(--sp-4)" }}
@ -1693,10 +1720,8 @@ export default function SessionReview() {
{ variant: "inset" },
)}
>
<div className="sr-feedback__kicker">
<span className="sr-technique__dot" aria-hidden="true" />
</div>
{/* D1: 섹션 라벨 장식 점 제거 */}
<div className="sr-feedback__kicker"> </div>
{data.clientFeedback ? (
<>
<p className="sr-feedback__quote">
@ -1729,6 +1754,7 @@ export default function SessionReview() {
readOnly={isSupervisorView}
/>
{/* 배경 잎사귀 이미지 위에 맨 텍스트로 얹히면 읽히지 않아 불투명 칩으로 감싼다 */}
{sessionId ? (
<p className="sr-session-id">
ID · <span className="tabular">{sessionId}</span>

View file

@ -19,6 +19,7 @@ import {
userApi,
type AdminHealthResponse,
type AdminEngineConfigResponse,
type EngineCapabilitiesResponse,
type NotificationPreferences,
type UserPreferencesResponse,
type UserProfileResponse,
@ -42,10 +43,19 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
claude_cli: "Claude CLI 게이트웨이",
messages_api: "클라우드 연결",
claude_api: "Anthropic API",
codex_cli: "Codex CLI 게이트웨이",
agy_cli: "Agy CLI 게이트웨이",
openai: "OpenAI 호환",
solar: "Solar",
};
const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const;
const ENGINE_MODES = [
"claude_cli",
"claude_api",
"codex_cli",
"agy_cli",
"openai",
"solar",
] as const;
const TICKET_STATUS_LABEL: Record<UserSupportTicketListItem["status"], string> = {
open: "접수",
@ -217,6 +227,9 @@ export default function Settings() {
const [supportTickets, setSupportTickets] = useState<UserSupportTicketsResponse | null>(null);
const [voicePresets, setVoicePresets] = useState<VoicePresetResponse[]>([]);
const [engineConfig, setEngineConfig] = useState<AdminEngineConfigResponse | null>(null);
const [engineCapabilities, setEngineCapabilities] = useState<EngineCapabilitiesResponse | null>(null);
const [engineCapabilitiesLoading, setEngineCapabilitiesLoading] = useState(false);
const [engineCapabilitiesError, setEngineCapabilitiesError] = useState<string | null>(null);
const [adminHealth, setAdminHealth] = useState<AdminHealthResponse | null>(null);
const [profileReady, setProfileReady] = useState(false);
const [preferencesReady, setPreferencesReady] = useState(false);
@ -228,13 +241,12 @@ export default function Settings() {
const savedTimer = useRef<number | null>(null);
const displayNameInputRef = useRef<HTMLInputElement | null>(null);
const affiliationInputRef = useRef<HTMLInputElement | null>(null);
const engineUrlInputRef = useRef<HTMLInputElement | null>(null);
const engineModelInputRef = useRef<HTMLInputElement | null>(null);
const displayNameValueRef = useRef(user?.name ?? "");
const affiliationValueRef = useRef("");
const displayNameDirtyRef = useRef(false);
const affiliationDirtyRef = useRef(false);
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null);
const engineCapabilityRequestRef = useRef(0);
const loadSettingsRequestRef = useRef(0);
// 아바타 업로드 — 온보딩과 같은 계약(3MB, PNG/JPEG/WebP)을 설정에서도 쓴다.
const avatarInputRef = useRef<HTMLInputElement | null>(null);
@ -252,9 +264,54 @@ export default function Settings() {
savedTimer.current = window.setTimeout(() => setSavedKey(null), 2400);
};
const updateEngineConfig = useCallback((patch: Partial<AdminEngineConfigResponse>) => {
if (!engineConfigRef.current) return;
engineConfigRef.current = { ...engineConfigRef.current, ...patch };
setEngineConfig((current) => (current ? { ...current, ...patch } : current));
}, []);
const loadEngineCapabilities = useCallback(
async (
engineMode: string,
engineUrl: string,
options: { force?: boolean; applyDefault?: boolean } = {},
) => {
const requestId = ++engineCapabilityRequestRef.current;
setEngineCapabilitiesLoading(true);
setEngineCapabilitiesError(null);
try {
const next = await adminEngineApi.capabilities(
engineMode,
options.force ?? false,
engineUrl,
);
if (requestId !== engineCapabilityRequestRef.current) return;
setEngineCapabilities(next);
if (options.applyDefault && next.available && next.default_model) {
updateEngineConfig({
model: next.default_model,
reasoning_effort: next.default_reasoning_effort,
});
}
} catch (cause) {
if (requestId !== engineCapabilityRequestRef.current) return;
setEngineCapabilities(null);
setEngineCapabilitiesError(
cause instanceof Error ? cause.message : "모델 목록을 불러오지 못했습니다.",
);
} finally {
if (requestId === engineCapabilityRequestRef.current) {
setEngineCapabilitiesLoading(false);
}
}
},
[updateEngineConfig],
);
const loadSettings = useCallback(async () => {
const requestId = loadSettingsRequestRef.current + 1;
loadSettingsRequestRef.current = requestId;
engineCapabilityRequestRef.current += 1;
setLoading(true);
setError(null);
setProfileReady(false);
@ -265,6 +322,8 @@ export default function Settings() {
setSupportTicketsError(null);
setVoicePresets([]);
setEngineConfig(null);
setEngineCapabilities(null);
setEngineCapabilitiesError(null);
engineConfigRef.current = null;
try {
let nextSupportTicketsError: string | null = null;
@ -300,6 +359,7 @@ export default function Settings() {
if (nextEngine) {
engineConfigRef.current = nextEngine;
setEngineConfig(nextEngine);
await loadEngineCapabilities(nextEngine.engine_mode, nextEngine.engine_url);
}
if (isAdmin) {
void adminApi.health()
@ -316,7 +376,7 @@ export default function Settings() {
} finally {
if (loadSettingsRequestRef.current === requestId) setLoading(false);
}
}, [isAdmin, setDark]);
}, [isAdmin, loadEngineCapabilities, setDark]);
useEffect(() => {
void loadSettings();
@ -441,12 +501,6 @@ export default function Settings() {
document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const updateEngineConfig = (patch: Partial<AdminEngineConfigResponse>) => {
if (!engineConfigRef.current) return;
engineConfigRef.current = { ...engineConfigRef.current, ...patch };
setEngineConfig((cur) => (cur ? { ...cur, ...patch } : cur));
};
const saveProfile = async () => {
if (!profileReady) return;
const next = await userApi.updateMe({
@ -531,13 +585,11 @@ export default function Settings() {
const saveEngine = async () => {
const currentEngine = engineConfigRef.current;
if (!currentEngine) return;
const selectedMode =
document.querySelector<HTMLElement>("#set-engine [data-engine-mode][aria-checked='true']")
?.dataset.engineMode ?? currentEngine.engine_mode;
const next = await adminEngineApi.update({
engine_mode: selectedMode,
engine_url: engineUrlInputRef.current?.value ?? currentEngine.engine_url,
model: engineModelInputRef.current?.value ?? currentEngine.model,
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: currentEngine.model,
reasoning_effort: currentEngine.reasoning_effort,
});
engineConfigRef.current = next;
setEngineConfig(next);
@ -566,9 +618,17 @@ export default function Settings() {
const avatarSrc = profile?.avatar_url ? apiUrl(profile.avatar_url) : user?.avatarUrl ?? "";
const isLearner = role === "learner";
const consentAt = user?.consentAt ?? null;
const engineHasRequiredFields = engineConfig
? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0
: false;
const engineModels = engineCapabilities?.models ?? [];
const selectedEngineModel = engineModels.find((model) => model.id === engineConfig?.model);
const selectedEngineEfforts = selectedEngineModel?.reasoning_efforts ?? [];
const engineHasRequiredFields = Boolean(
engineConfig &&
engineConfig.engine_url.trim().length > 0 &&
engineCapabilities?.available &&
selectedEngineModel &&
(!engineConfig.reasoning_effort ||
selectedEngineEfforts.includes(engineConfig.reasoning_effort)),
);
const engineStorageLabel = !engineConfig
? "확인 중"
: engineConfig.durable
@ -847,7 +907,9 @@ export default function Settings() {
>
<div className="vg-set__group-head vg-set__group-head--split">
<div>
<Kicker></Kicker>
{/* dot( /) .
kicker dot (D1). */}
<Kicker dot={false}></Kicker>
<div className="vg-set__group-title vg-set__group-title--kicker">
AI
</div>
@ -873,27 +935,34 @@ export default function Settings() {
<div className="vg-set__engine-grid">
<div className={surfaceClassName("vg-set__control-block", { variant: "inset", flat: true })}>
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__seg" role="radiogroup" aria-label="AI 운영 방식">
<select
className="vg-set__select"
value={engineConfig.engine_mode}
aria-label="AI 엔진 공급자"
onChange={(event) => {
const engineMode = event.target
.value as AdminEngineConfigResponse["engine_mode"];
setEngineCapabilities(null);
updateEngineConfig({
engine_mode: engineMode,
model: "",
reasoning_effort: null,
});
void loadEngineCapabilities(engineMode, engineConfig.engine_url, {
force: true,
applyDefault: true,
});
}}
>
{ENGINE_MODES.map((mode) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={engineConfig.engine_mode === mode}
data-engine-mode={mode}
className={
"vg-set__seg-btn" +
(engineConfig.engine_mode === mode ? " is-active" : "")
}
onClick={() => updateEngineConfig({ engine_mode: mode })}
>
<option key={mode} value={mode}>
{ENGINE_MODE_LABEL[mode] ?? mode}
</button>
</option>
))}
</div>
</select>
</div>
<div className="vg-set__field-grid vg-set__field-grid--engine">
@ -904,11 +973,14 @@ export default function Settings() {
</div>
<Field>
<Input
ref={engineUrlInputRef}
value={engineConfig.engine_url}
onChange={(event) =>
updateEngineConfig({ engine_url: event.target.value })
}
onChange={(event) => {
updateEngineConfig({ engine_url: event.target.value });
setEngineCapabilities(null);
setEngineCapabilitiesError(
"연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.",
);
}}
placeholder="운영 연결 주소를 입력하세요"
aria-label="AI 연결 주소"
/>
@ -918,24 +990,122 @@ export default function Settings() {
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={engineModelInputRef}
<select
className="vg-set__select"
value={engineConfig.model}
onChange={(event) =>
updateEngineConfig({ model: event.target.value })
}
placeholder="기본 모델 이름을 입력하세요"
aria-label="모델"
/>
aria-label="AI 기본 모델"
disabled={engineCapabilitiesLoading || !engineCapabilities?.available}
onChange={(event) => {
const model = event.target.value;
const option = engineModels.find((item) => item.id === model);
updateEngineConfig({
model,
reasoning_effort: option?.default_reasoning_effort ?? null,
});
}}
>
{!selectedEngineModel && engineConfig.model ? (
<option value={engineConfig.model} disabled>
{engineConfig.model} ·
</option>
) : null}
{!engineConfig.model ? <option value=""> </option> : null}
{engineModels.map((model) => (
<option key={model.id} value={model.id}>
{model.label}
</option>
))}
</select>
</Field>
<div className="vg-set__meta">
<span> : {engineConfig.updated_by ?? "-"}</span>
<span> : {engineStorageLabel}</span>
</div>
</div>
<div className={surfaceClassName("vg-set__field", { variant: "inset", flat: true })}>
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<Field>
<select
className="vg-set__select"
value={engineConfig.reasoning_effort ?? ""}
aria-label="AI 추론 강도"
disabled={engineCapabilitiesLoading || !selectedEngineModel}
onChange={(event) =>
updateEngineConfig({
reasoning_effort: (event.target.value ||
null) as AdminEngineConfigResponse["reasoning_effort"],
})
}
>
{selectedEngineEfforts.length ? null : (
<option value=""> </option>
)}
{selectedEngineEfforts.map((effort) => (
<option key={effort} value={effort}>
{effort === "low"
? "Low"
: effort === "medium"
? "Medium"
: effort === "high"
? "High"
: effort === "xhigh"
? "Extra High"
: effort === "max"
? "Max"
: "Ultra"}
</option>
))}
</select>
</Field>
</div>
<div
className={`vg-set__engine-catalog vg-set__field--wide ${engineCapabilities?.available ? "is-ready" : "is-unavailable"}`}
role={
engineCapabilitiesError || engineCapabilities?.available === false
? "alert"
: "status"
}
>
<div>
<b>
{engineCapabilitiesLoading
? "모델 목록 확인 중"
: engineCapabilities?.available
? `${engineModels.length}개 모델 확인됨`
: "모델 목록을 확인할 수 없음"}
</b>
<span>
{engineCapabilitiesError ??
engineCapabilities?.detail ??
"게이트웨이 연결 뒤 모델 목록을 표시합니다."}
</span>
</div>
<Button
size="sm"
variant="secondary"
onClick={() =>
void loadEngineCapabilities(
engineConfig.engine_mode,
engineConfig.engine_url,
{
force: true,
applyDefault: !selectedEngineModel,
},
)
}
disabled={engineCapabilitiesLoading}
>
</Button>
</div>
</div>
</div>

View file

@ -104,8 +104,7 @@
line-height: 1.45;
}
.aic-window,
.aic-mode {
.aic-window {
display: flex;
gap: 3px;
padding: 3px;
@ -114,8 +113,7 @@
background: var(--bg-surface-2);
}
.aic-window button,
.aic-mode button {
.aic-window button {
min-height: 34px;
padding: 0 11px;
border: 0;
@ -126,20 +124,17 @@
cursor: pointer;
}
.aic-window button:hover,
.aic-mode button:hover {
.aic-window button:hover {
color: var(--text-strong);
background: var(--neutral-100);
}
.aic-window button.is-active,
.aic-mode button.is-active {
.aic-window button.is-active {
color: var(--text-on-accent);
background: var(--accent);
}
.aic-window button:focus-visible,
.aic-mode button:focus-visible {
.aic-window button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
@ -216,11 +211,15 @@
margin-bottom: 16px;
}
/* eyebrow 영문 대문자에서 한글로 바꿨다. 자간 0.12em 영문 대문자 전용
트래킹이라 한글에서는 글자가 흩어져 보이고, 10px 한글 판독 하한 아래라
자간을 좁히고 11px 올린다. (uppercase 변환은 원래 없었다) */
.aic-eyebrow {
display: block;
color: var(--accent-deep);
font: 750 10px/1.2 var(--font-num);
letter-spacing: 0.12em;
font: 750 11px/1.3 var(--font-sans);
letter-spacing: 0.01em;
text-transform: none;
}
.aic-panel h2 {
@ -230,6 +229,11 @@
line-height: 1.3;
}
/* 중복 eyebrow 를 지운 패널은 제목이 첫 줄이므로 윗여백을 없애 우측 배지와 정렬한다. */
.aic-panel__head h2:first-child {
margin-top: 0;
}
.aic-panel__meta {
color: var(--text-muted);
font: 600 11px/1.4 var(--font-num);
@ -316,7 +320,7 @@
.aic-chart {
display: grid;
grid-auto-columns: minmax(44px, 1fr);
grid-auto-columns: minmax(46px, 1fr);
grid-auto-flow: column;
align-items: end;
gap: 8px;
@ -326,19 +330,21 @@
}
.aic-chart__day {
min-width: 44px;
min-width: 46px;
display: grid;
grid-template-rows: 18px 126px auto auto;
grid-template-rows: 20px 126px auto auto;
gap: 5px;
text-align: center;
}
/* 9px 숫자 판독이 불가능해 11px 올린다. 금액이 소수 2자리로 짧아져
같은 폭에서도 잘리지 않는다. */
.aic-chart__value,
.aic-chart__date,
.aic-chart__day small {
overflow: hidden;
color: var(--text-muted);
font: 600 9px/1.2 var(--font-num);
font: 600 11px/1.3 var(--font-num);
text-overflow: ellipsis;
white-space: nowrap;
}
@ -455,18 +461,6 @@
border-color: color-mix(in srgb, var(--accent) 34%, var(--border-subtle));
}
.aic-mode {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 14px;
}
.aic-mode button {
min-width: 0;
white-space: normal;
line-height: 1.25;
}
.aic-engine .vg-field + .vg-field {
margin-top: 13px;
}
@ -481,12 +475,70 @@
font-size: 10px;
}
.aic-engine .vg-input {
.aic-engine .vg-input,
.aic-engine .aic-select {
width: 100%;
font-family: var(--font-num);
font-size: 12px;
}
.aic-select {
min-height: 40px;
appearance: auto;
}
.aic-select:disabled {
cursor: not-allowed;
opacity: 0.64;
}
.aic-capability {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding: 11px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
}
.aic-capability.is-ready {
border-color: color-mix(in srgb, var(--accent) 36%, var(--border-subtle));
background: var(--accent-tint);
}
.aic-capability.is-unavailable {
border-color: color-mix(in srgb, var(--crit-solid) 42%, var(--border-subtle));
background: var(--crit-tint);
}
.aic-capability > div {
min-width: 0;
}
.aic-capability b,
.aic-capability span {
display: block;
}
.aic-capability b {
color: var(--text-strong);
font-size: 11px;
}
.aic-capability span {
margin-top: 3px;
color: var(--text-muted);
font-size: 10px;
line-height: 1.45;
}
.aic-capability .vg-btn {
flex: 0 0 auto;
}
.aic-config-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

File diff suppressed because it is too large Load diff

View file

@ -83,6 +83,7 @@
background:var(--bg-surface-2);
}
.lh-tabs button{
position:relative;
flex:1 1 0;
min-width:0;
padding:9px 12px;
@ -94,8 +95,27 @@
font-weight:700;
cursor:pointer;
}
/* D6( 구분 강화): 비활성 탭이 배경과 같아 "여러 개 중 하나" 읽혔다.
비활성 사이에만 세로 구분선을 넣는다. 활성 탭은 자체 배경/테두리로
이미 구분되므로 활성 탭에 붙는 구분선은 숨겨 이중 경계선을 막는다. */
.lh-tabs button + button::before{
content:"";
position:absolute;
left:-4px;
top:22%;
bottom:22%;
width:1px;
background:var(--border-strong);
opacity:.55;
pointer-events:none;
}
.lh-tabs button.is-active::before,
.lh-tabs button.is-active + button::before{
opacity:0;
}
.lh-tabs button:hover{
color:var(--text-strong);
background:color-mix(in srgb,var(--text-strong) 5%,transparent);
}
.lh-tabs button:focus-visible{
outline:2px solid var(--focus-ring);
@ -190,11 +210,25 @@
justify-content:space-between;
gap:var(--sp-4);
}
/* 페르소나 제목이 페이지 h1(.lh-title)보다 커서 위계가 역전돼 있었다(2026-07-27).
크기는 h1 아래로 낮추고 강조는 weight 로만 준다. */
.lh-session-focus__head h2{
margin:8px 0 0;
color:var(--text-strong);
font-size:clamp(24px,2.3vw,34px);
line-height:1.12;
font-size:clamp(19px,1.7vw,24px);
font-weight:820;
line-height:1.18;
letter-spacing:0;
word-break:keep-all;
}
/* D3: 제목에서 떼어낸 주호소 조각. 크기를 낮춰 제목 한 줄에 가운뎃점이 1개만 남게 한다. */
.lh-title-tail{
display:block;
margin-top:4px;
color:var(--text-body);
font-size:.64em;
font-weight:740;
line-height:1.34;
letter-spacing:0;
word-break:keep-all;
}
@ -278,6 +312,9 @@
.lh-recap__avatar{
min-width:0;
display:grid;
/* L3: 암시적 auto 트랙이면 자식(고정 196px) max-content 트랙이 부풀어
컨테이너( 320px 에서 148px) 밖으로 나간다. 트랙을 명시해 컨테이너에 묶는다. */
grid-template-columns:minmax(0,1fr);
place-items:center;
min-height:156px;
border:1px solid var(--border-subtle);
@ -285,8 +322,24 @@
background:var(--bg-surface);
overflow:hidden;
}
/* L3(2026-07-27): ClientAvatar size prop 인라인 style(width/height:196px) 박는다.
실측 /learn 320px 에서 컨테이너 148px, 360px 에서 188px 이라
자식 figure(196px) 오른쪽으로 각각 48px / 8px 잘려 나갔다
(.lh-recap__avatar overflow:hidden 걸림).
자식 폭을 컨테이너에 맞춰 줄인다. */
.lh-recap__avatar .vg-avatar{
margin:0;
/* min-width:auto(기본) 이면 인라인 width 196px 그리드 트랙의 자동 최소값이
트랙 자체가 196px 부풀고 컨테이너(148px) 밖으로 삐져나온다. 0 으로 풀어야
아래 max-width 실제로 걸린다. */
min-width:0;
max-width:100%;
}
/* 폭만 줄이면 인라인 height(고정 196px) 때문에 원형 마스크가 타원이 되므로
높이를 폭에서 다시 계산한다. 인라인 스타일이라 !important 없이는 이긴다. */
.lh-recap__avatar .vg-avatar__stage{
height:auto !important;
aspect-ratio:1;
}
.lh-recap__body{
min-width:0;
@ -796,16 +849,27 @@
text-overflow:ellipsis;
white-space:nowrap;
}
/* D3: "코드 · 단계 · 날짜"에서 날짜를 오른쪽 컬럼으로 떼어 가운뎃점을 1개로 줄인다. */
.lh-compact-list__body small{
min-width:0;
display:flex;
align-items:baseline;
justify-content:space-between;
gap:10px;
color:var(--text-muted);
font-family:var(--font-num);
font-size:11.5px;
line-height:1.25;
}
.lh-compact-list__body small > span{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.lh-compact-list__body small > span:last-child{
flex:none;
}
.lh-compact-list__action,
.lh-compact-list__status{
min-height:28px;
@ -861,11 +925,29 @@
.lh-feedback-mini__row:hover{
color:var(--accent-deep);
}
/* D3: "코드 · 단계 · 점수"에서 점수를 오른쪽 컬럼으로 분리 */
.lh-feedback-mini__row b{
display:flex;
align-items:baseline;
justify-content:space-between;
gap:10px;
color:var(--text-strong);
font-size:13.5px;
line-height:1.35;
}
.lh-feedback-mini__row b > span:first-child{
min-width:0;
color:var(--text-strong);
font-size:inherit;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.lh-feedback-mini__row b > span:last-child{
flex:none;
color:var(--text-body);
font-size:inherit;
}
.lh-feedback-mini__row span{
color:var(--text-body);
font-size:12.5px;
@ -990,14 +1072,49 @@
-webkit-line-clamp:2;
-webkit-box-orient:vertical;
}
/* D3: 이름줄에서 떼어낸 주호소. 이름과 다른 층으로 읽히게 색·굵기를 낮춘다. */
.lh-persona__concern{
min-width:0;
color:var(--text-body);
font-size:12.5px;
font-weight:700;
line-height:1.35;
word-break:keep-all;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:2;
-webkit-box-orient:vertical;
}
.lh-persona__meta{
min-width:0;
display:flex;
align-items:center;
gap:6px;
color:var(--text-muted);
font-size:12px;
line-height:1.45;
}
.lh-persona__meta > span{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
/* D3: "· 사용 불가"를 이어 붙이면 가운뎃점이 늘어나므로 별도 칩으로 뺀다. */
.lh-persona__flag{
flex:none;
display:inline-flex;
align-items:center;
min-height:20px;
padding:0 6px;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface);
color:var(--warn-text);
font-size:11px;
font-weight:800;
white-space:nowrap;
}
.lh-persona__summary{
min-width:0;
color:var(--text-body);
@ -1044,14 +1161,23 @@
align-content:start;
padding:var(--sp-5);
}
/* L1(2026-07-27): 이전 `grid-template-columns:minmax(0,1fr) minmax(220px,auto)`
우측 액션 열이 max-content(=.lh-actions__note max-width 280px)까지 먼저 자라고
좌측 신원 열은 최소 0 이라 남은 폭을 전부 빼앗겼다.
실측( 1181px): 프리뷰 히어로 423px 열이 "127px 280px" 잡혀 제목 열이 47px,
1280px 에서도 146px 밖에 26~28px 제목이 글자 단위로 쪼개져 흘러내렸다.
액션을 "줄바꿈되는 항목"으로 바꾼다. 신원 열이 340px 받는 폭에서는
액션이 통째로 아랫줄로 내려가고 제목 열은 히어로 폭을 쓴다. */
.lh-preview__hero{
min-width:0;
display:grid;
grid-template-columns:minmax(0,1fr) minmax(220px,auto);
display:flex;
flex-wrap:wrap;
gap:var(--sp-4);
align-items:start;
align-items:flex-start;
}
.lh-preview__identity{
/* 340px 미만이면 같은 줄에 액션을 두지 않는다(=제목 열 최소 260px 확보) */
flex:1 1 340px;
min-width:0;
display:grid;
grid-template-columns:auto minmax(0,1fr);
@ -1090,7 +1216,10 @@
line-height:1.2;
letter-spacing:0;
word-break:keep-all;
overflow-wrap:anywhere;
/* L1: `anywhere` min-content "한 글자" 만들어 부모 flex/grid 제목 열을
0 까지 줄여도 되는 것처럼 계산한다. `break-word` 줄바꿈 결과는 같지만
min-content "가장 긴 단어"라서 열이 통째로 붕괴하는 구조적으로 막는다. */
overflow-wrap:break-word;
}
.lh-preview__copy p{
margin:7px 0 0;
@ -1100,6 +1229,11 @@
word-break:keep-all;
}
.lh-actions{
/* L1: 히어로가 좁으면 늘어나지도 억지로 버티지도 말고 아랫줄로 내려간다.
아랫줄로 내려갔을 때도 margin-left:auto 카드 오른쪽에 붙여
줄일 (=넓은 ) 같은 "액션은 오른쪽" 규칙을 유지한다. */
flex:0 1 auto;
margin-left:auto;
min-width:0;
display:grid;
justify-items:end;
@ -1209,6 +1343,9 @@
display:grid;
gap:10px;
background:transparent;
/* L2: 카드가 깨지는 기준은 뷰포트가 아니라 "카드 폭"이라 컨테이너 질의를 쓴다.
같은 1181px 뷰포트에서도 목록 위치에 따라 카드 폭이 391px~794px 갈린다. */
container:lh-session-list / inline-size;
}
.lh-session-list li{
min-width:0;
@ -1221,6 +1358,26 @@
border-radius:var(--radius);
background:var(--bg-surface);
}
/* L2(2026-07-27): 3열을 끝까지 붙들면 중앙 본문이 뭉개졌다.
실측( 1181px, /learn/practice): 카드 391px 에서 열이
"140px 67.27px 131.73px" 잡혀 본문이 67px, 제목 b("서연") 24px,
"라포 단계 · 0턴 / 날짜" 4줄로 접혔다( 1280px 에서도 본문 166px).
140 + 본문 180 + 액션 132 + 24 + 패딩 26 500px 3열의 손익분기라
여유를 600px 아래에서는 액션을 아랫줄로 내리고 본문에 최소 180px 준다. */
@container lh-session-list (max-width:600px){
.lh-session-list li{
grid-template-columns:140px minmax(180px,1fr);
}
.lh-session-actions{
grid-column:1 / -1;
}
}
/* 2열(140+180+갭12+패딩26=358px)조차 못 넣는 폭에서는 완전 세로 스택 */
@container lh-session-list (max-width:380px){
.lh-session-list li{
grid-template-columns:minmax(0,1fr);
}
}
.lh-session-card:hover{
border-color:var(--border-strong);
background:color-mix(in srgb,var(--bg-surface) 78%,var(--bg-surface-2));
@ -1302,6 +1459,13 @@
font-size:12px;
line-height:1.45;
}
/* D3: "단계 · 턴 · 날짜"를 한 줄에 붙이면 가운뎃점이 2개다. 날짜를 컬럼으로 분리. */
.lh-session-card__body p{
display:flex;
flex-wrap:wrap;
align-items:baseline;
gap:2px 14px;
}
.lh-session-card__progress{
width:100%;
height:5px;
@ -1433,13 +1597,25 @@
line-height:1.35;
}
.lh-persona-progress__title em,
.lh-persona-progress__meta,
.lh-persona-progress__latest{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
/* D3: "N회 · N회 진행 · N회 리뷰 · 평균 N턴" 가운뎃점이 3개였다.
줄로 확정 분리해 줄마다 가운뎃점이 1개만 남게 한다. */
.lh-persona-progress__meta{
min-width:0;
display:grid;
gap:2px;
}
.lh-persona-progress__meta > span{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.lh-persona-progress__title em{
font-style:normal;
font-weight:720;
@ -1585,10 +1761,11 @@
.lh-history-filter{
justify-content:flex-start;
}
.lh-preview__hero{
grid-template-columns:1fr;
}
/* L1: 히어로가 flex-wrap 바뀌어 grid-template-columns 이상 쓰이지 않는다.
구간은 폭과 무관하게 항상 액션을 아랫줄에 두던 기존 동작을 유지한다. */
.lh-actions{
flex-basis:100%;
margin-left:0;
justify-items:start;
}
.lh-actions__note{
@ -2205,8 +2382,11 @@
position: relative;
z-index: 1;
}
/* 위계 복구(2026-07-27): 이전 clamp(30px,3vw,38px) h1(clamp(29px,2.7vw,36px))보다
커서 페르소나 이름이 페이지 제목을 눌렀다. h1 아래 단계로 내린다. */
.vg-shell--learner-dashboard .lh-session-focus__head h2 {
font-size: clamp(30px, 3vw, 38px);
font-size: clamp(21px, 1.95vw, 28px);
font-weight: 820;
}
.vg-shell--learner-dashboard .lh-recap {
grid-template-columns: 224px minmax(0, 1fr);

View file

@ -21,6 +21,26 @@
flex-wrap:wrap;
justify-content:flex-end;
}
/* 표시 이름을 줄로 나눌 때의 아랫줄(: "서연(가명) · 고2" 아래 "우울/자살사고").
제목에 바로 붙여 덩어리로 읽히게 하고, 아래 설명문과는 간격으로 구분한다. */
.ps-name-tail{
margin:5px 0 0;
color:var(--text-body);
font-size:var(--fs-sm);
line-height:1.4;
overflow-wrap:anywhere;
}
.ps-review-card > .ps-name-tail{
margin-top:-6px;
font-size:var(--fs-xs);
}
.ps-detail-primary > .ps-name-tail,
.ps-drill-head .ps-name-tail{
margin-top:6px;
color:var(--text-body);
font-size:var(--fs-sm);
line-height:1.4;
}
.ps-error{
display:flex;
align-items:center;
@ -751,13 +771,27 @@
.ps-review-summary__grid b{color:var(--text-strong);font-family:var(--font-num);font-size:28px;}
.ps-active-table{padding:0;overflow:hidden;}
.ps-active-table > .ps-section-head{padding:18px 20px;}
.ps-usage-table{border-top:1px solid var(--border-subtle);}
/* [S1] 사용 현황 761~859px 사각지대 잘림 수리.
파일의 브레이크포인트는 760/860/1180 뿐이라 761~859 구간에는 규칙이 없었다.
구간의 본문 컨테이너는 613~711px 인데 트랙 최소치 합이
260 + 90*4 + gap 12*4 + padding 16*2 = 700px 라서 컨테이너보다 컸다.
조상 .ps-active-table overflow:hidden 이라 넘친 마지막 ("진척도")
스크롤도 되고 그대로 잘렸다(761px 71px / 768px 64px / 820px 12px).
트랙 최소치를 축소 가능하게 낮추고(minmax(0,...) + 숫자열 58px),
그래도 모자라면 문서 전체가 아니라 영역만 가로 스크롤한다. */
.ps-usage-table{
border-top:1px solid var(--border-subtle);
overflow-x:auto;
scrollbar-width:thin;
}
.ps-usage-table__head,
.ps-usage-row{
display:grid;
grid-template-columns:minmax(260px,1.4fr) repeat(4,minmax(90px,.45fr));
grid-template-columns:minmax(0,1.4fr) repeat(4,minmax(58px,.45fr));
gap:12px;
align-items:center;
/* 최소 폭 미만에서는 잘리지 않고 .ps-usage-table 이 가로 스크롤된다. */
min-width:520px;
}
.ps-usage-table__head{
padding:9px 16px;
@ -915,7 +949,9 @@
.ps-review-summary__grid{grid-template-columns:1fr;}
.ps-review-summary__grid > div{padding:12px 0;border-right:0;border-bottom:1px solid var(--hair);}
.ps-usage-table__head{display:none;}
.ps-usage-row{grid-template-columns:minmax(0,1fr) repeat(2,58px);gap:8px;}
/* [S1] 좁은 폭은 열을 접는 기존 설계를 유지한다 여기서는 넓은 폭용
min-width:520px 반드시 풀어야 가로 스크롤이 생기지 않는다. */
.ps-usage-row{min-width:0;grid-template-columns:minmax(0,1fr) repeat(2,58px);gap:8px;}
.ps-usage-row strong:nth-last-child(-n+2){display:none;}
.ps-catalog-hero{grid-template-columns:1fr;padding:22px 18px;}
.ps-catalog-hero__count{padding:16px 0 0;border-top:1px solid var(--border-subtle);border-left:0;}

View file

@ -691,8 +691,11 @@
font-size:12px;
line-height:1.4;
}
/* [D3] "기록 대기 · 검토 대기"의 가운뎃점을 없애고 두 상태를 공백 컬럼으로 나란히 둔다. */
.pf-recent__review-state{
display:block;
display:flex;
flex-wrap:wrap;
gap:0 10px;
margin-top:3px;
line-height:1.2;
}
@ -1501,6 +1504,45 @@
.pf-recent-row--action:hover .pf-recent__open{
border-color:var(--accent);
}
/* =====================================================================
2026-07-27 화면 정리 D1(dot 밀도) / D3(가운뎃점) / D5( 상태 중립 기호)
===================================================================== */
/* [D5] 값이 없는 칸은 "평가 부족" 같은 부정어 대신 중립 기호 "-" 쓰고, muted
눌러 값이 실제로 있는 행이 도드라지게 한다. 셀의 <b>/<small> text-strong
지정하므로 .pf-root 앞에 붙여 우선순위를 확보한다. */
.pf-root .pf-empty-mark{
color:var(--text-muted);
font-weight:600;
}
/* [D5] 빈 칸의 뜻을 표 상단에서 한 번만 설명하는 안내 줄. */
.pf-table-note{
margin:0;
padding:8px 12px;
border-bottom:1px solid var(--hair);
color:var(--text-muted);
background:color-mix(in srgb,var(--bg-surface) 86%,var(--bg-surface-2));
font-size:var(--fs-xs);
line-height:1.45;
}
/* [D3] 한 줄에 가운뎃점 1개. 남은 정보는 공백 컬럼으로 분리한다. */
.pf-session__facts,
.pf-persona__facts,
.pf-persona-group__facts,
.pf-timeline-row__facts,
.pf-alert__facts{
min-width:0;
display:flex;
flex-wrap:wrap;
gap:0 12px;
}
/* 분리된 컬럼도 원래대로 셀 안에서 잘리게 유지한다(겹침 회귀 방지). */
.pf-persona-group__facts > span,
.pf-timeline-row__facts > span{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
@media (max-width:1100px){
.pf-signal-strip,
.pf-workspace{

View file

@ -67,6 +67,28 @@
.sr-head__meta .sr-mono {
font-family: var(--font-num);
}
/* D3: 날짜 줄과 분리한 페르소나·상태 칩 줄. 가운뎃점 대신 칩 간격으로 구분한다. */
.sr-head__tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 7px;
}
.sr-head__tag {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-muted);
background: var(--bg-surface-2);
font-size: var(--fs-xs);
font-weight: 650;
line-height: 1.35;
white-space: nowrap;
}
.sr-meta-sep {
width: 3px;
height: 3px;
@ -88,11 +110,12 @@
border-radius: var(--radius);
background: var(--bg-surface-2);
}
/* D2: 라벨이 한글(회기·지도)이 되면서 영문 대문자용 자간·숫자 서체는 무의미해 제거 */
.sr-stat__lab {
font-family: var(--font-num);
font-family: var(--font-sans);
font-size: var(--fs-kicker);
font-weight: 650;
letter-spacing: 0.06em;
font-weight: 700;
letter-spacing: 0;
color: var(--text-muted);
}
.sr-stat__val {
@ -212,6 +235,7 @@
background: var(--bg-surface-2);
}
.sr-tabs button {
position: relative;
flex: 1 1 0;
min-width: 0;
padding: 9px 12px;
@ -223,6 +247,19 @@
font-weight: 700;
cursor: pointer;
}
/* D6: 비활성 탭이 배경과 붙어 보이지 않으므로 사이마다 얇은 구분선을 세워
"여러 개 중 하나"임을 드러낸다. 가운데 탭이 활성일 때도 구분이 남도록 항상 그리고,
활성 카드와 겹치지 않게 6px 간격 한가운데(-4px) 둔다. */
.sr-tabs button + button::before {
content: "";
position: absolute;
left: -4px;
top: 24%;
bottom: 24%;
width: 1px;
background: var(--border-strong);
opacity: 0.8;
}
.sr-tabs button:hover {
color: var(--text-strong);
}
@ -263,6 +300,10 @@
font-size: var(--fs-body);
line-height: 1.35;
}
/* D4: 중복 배지를 없앤 대신 완료·승인 상태만 강조색으로 남긴다 */
.sr-teacher-review__head b.is-done {
color: var(--accent-deep);
}
.sr-teacher-review__head--sub {
margin-bottom: var(--sp-2);
}
@ -508,13 +549,13 @@
gap: 8px;
align-items: center;
}
/* D2: 한글 라벨(척도·사전·사후)에는 대문자 변환·영문 자간이 무의미해 제거 */
.sr-prepost__row--head {
color: var(--text-muted);
font-family: var(--font-num);
font-family: var(--font-sans);
font-size: 10.5px;
font-weight: 750;
letter-spacing: 0.04em;
text-transform: uppercase;
letter-spacing: 0;
}
.sr-prepost__row--head span:not(:first-child) {
text-align: center;
@ -701,13 +742,6 @@
.sr-phase:last-child {
border-right: none;
}
.sr-phase__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-bright);
flex: none;
}
.sr-phasebar__axis {
display: flex;
justify-content: space-between;
@ -995,7 +1029,6 @@
font-size: 10px;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.sr-note__body {
display: grid;
@ -1301,28 +1334,20 @@
background: linear-gradient(135deg, rgba(30, 39, 36, .95), rgba(30, 39, 36, .82));
box-shadow: var(--shadow-sm);
}
/* D1·D2: 장식 점과 영문용 넓은 자간을 빼고 한글 섹션 라벨로 맞춘다 */
.sr-feedback__kicker {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: var(--sp-3);
color: var(--text-muted);
font-family: var(--font-num);
font-family: var(--font-sans);
font-size: var(--fs-kicker);
font-weight: 650;
letter-spacing: 0.08em;
}
.sr-feedback__kicker .sr-technique__dot {
width: 6px;
height: 6px;
background: var(--text-muted);
font-weight: 700;
letter-spacing: 0;
}
.sr-feedback--filled .sr-feedback__kicker {
color: var(--accent-bright);
}
.sr-feedback--filled .sr-feedback__kicker .sr-technique__dot {
background: var(--accent-bright);
}
.sr-feedback__quote {
margin: 0;
color: var(--text-body);
@ -1353,25 +1378,11 @@
display: grid;
gap: var(--sp-4);
}
/* D1: 항목 앞 고정색 점을 없애 단일 컬럼으로 정리 */
.sr-point {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
column-gap: 11px;
align-items: start;
}
.sr-point__mk {
width: 7px;
height: 7px;
margin-top: 7px;
border-radius: 50%;
flex: none;
}
.sr-points--good .sr-point__mk {
background: var(--pos-solid);
}
.sr-points--grow .sr-point__mk {
background: var(--warn-solid);
grid-template-columns: minmax(0, 1fr);
}
.sr-point__h {
margin-bottom: 2px;
@ -1405,14 +1416,14 @@
border-radius: var(--radius);
background: color-mix(in srgb, var(--warn-tint) 58%, var(--bg-surface-2));
}
/* D2: 한글 라벨이라 대문자 변환·영문 자간 제거 */
.sr-nextline__lab {
margin-bottom: 7px;
color: var(--accent-deep);
font-family: var(--font-num);
font-family: var(--font-sans);
font-size: 11px;
font-weight: 650;
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0;
}
.sr-nextline__q {
color: var(--text-strong);
@ -1426,12 +1437,21 @@
border-radius: var(--radius-sm);
background: var(--accent-tint);
}
/* 배경 잎사귀 이미지 위에 그냥 얹히면 대비가 부족해 읽히지 않는다.
내용만큼만 차지하는 불투명 칩으로 감싸 본문 대비를 확보한다. */
.sr-session-id {
min-width: 0;
width: fit-content;
max-width: 100%;
margin: 0;
color: var(--text-muted);
padding: 6px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-body);
font-family: var(--font-num);
font-size: var(--fs-xs);
line-height: 1.45;
overflow-wrap: anywhere;
}
@ -1632,9 +1652,6 @@
padding: 0 5px;
font-size: 11px;
}
.sr-phase__dot {
display: none;
}
.sr-tx__head {
align-items: flex-start;
}

View file

@ -147,13 +147,30 @@
font-size: 13px;
line-height: 1.15;
}
/* D3 "이름 · 단계 · 경과" (· 2개) "이름 · 단계" + "경과" 칸으로 분리했다.
칸을 gap 으로 띄워 가운뎃점 없이도 구분되게 한다. */
.sx-sessionbar__meta small {
color: rgba(238, 244, 242, 0.55);
font: 600 11px/1.2 var(--font-sans);
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
overflow: hidden;
}
.sx-sessionbar__meta small span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 바깥 .sx-sessionbar__meta span:last-child(세로 스택) 규칙이 안쪽 span 까지 잡지
않도록 여기서 다시 눌러 준다. */
.sx-sessionbar__meta small span:last-child {
display: block;
flex: none;
font-variant-numeric: tabular-nums;
}
.sx-sessionbar__dot {
width: 8px;
height: 8px;
@ -504,6 +521,10 @@
margin-top: 1px;
line-height: 1.35;
}
/* D3 — 한 줄에 · 가 몰리지 않도록 소개를 여러 줄로 흘린다 */
.sx-ctx__mt span {
display: block;
}
.sx-ctx__meta {
margin-top: 12px;
}
@ -2692,6 +2713,9 @@
radial-gradient(circle at 20% 22%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 32%);
pointer-events: none;
}
/* 중복 목록을 걷어내 카드 내용이 초상+이름+ 소개로 줄었다. 예전처럼 전체 높이로
늘리면(align-self:stretch) 위아래가 크게 비어 허전해 보여서, 내용 높이만큼만 차지하고
부모 그리드의 align-items:center 세로 가운데에 놓이게 바꿨다. */
.sx-prestart__visual {
min-width: 0;
display: grid;
@ -2699,28 +2723,21 @@
gap: var(--sp-3);
position: relative;
z-index: 1;
align-self: stretch;
align-self: center;
align-content: center;
padding: var(--sp-4);
padding: var(--sp-4) var(--sp-3) var(--sp-5);
border-radius: var(--radius);
}
/* 아바타 카드는 이름 + 소개만 남긴다. 호소·대상·난도 목록(ul) 이니셜 원은
본문 사실 목록/초상과 중복이라 제거했다(관련 규칙도 함께 삭제).
내용이 줄어든 만큼 초상 바로 아래 가운데 정렬로 붙여 허전해 보이지 않게 한다. */
.sx-prestart__case {
width: 100%;
min-width: 0;
display: grid;
gap: var(--sp-4);
}
.sx-prestart__case-initial {
width: 76px;
height: 76px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--clay-tint);
color: var(--clay-deep);
font-size: 28px;
font-weight: 780;
box-shadow: inset 0 0 0 1px rgba(204, 143, 119, 0.25);
gap: 4px;
justify-items: center;
text-align: center;
}
.sx-prestart__case b {
display: block;
@ -2729,64 +2746,25 @@
line-height: 1.35;
}
.sx-prestart__case p {
margin: 5px 0 0;
margin: 0;
color: var(--text-body);
font-size: 13px;
line-height: 1.45;
line-height: 1.5;
max-width: 24ch;
}
.sx-prestart__case ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.sx-prestart__case li {
min-width: 0;
display: grid;
gap: 3px;
padding: 9px 10px;
border-radius: var(--radius-sm);
}
.sx-prestart__case li span {
color: var(--text-muted);
font-size: 11px;
font-weight: 760;
}
.sx-prestart__case li em {
color: var(--text-strong);
font-size: 12.5px;
font-style: normal;
font-weight: 680;
line-height: 1.35;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
/* D3 — 줄당 가운뎃점 1개가 되도록 나눠 흘린 소개 줄 */
.sx-prestart__case p span {
display: block;
}
.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);
font-family: var(--font-num);
font-size: var(--fs-kicker);
font-weight: 700;
}
.sx-prestart__eyebrow span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-bright);
flex: none;
}
/* D4 .sx-prestart__eyebrow("○○ 단계 준비") 페이지 제목과 같은 말이라 마크업과
함께 제거했다. 이제 제목이 본문 요소라 여백을 지운다. */
.sx-prestart__title {
margin-top: var(--sp-3);
margin-top: 0;
font-size: var(--fs-h2);
font-weight: 700;
letter-spacing: 0;
@ -3070,6 +3048,11 @@
.sx-prestart {
grid-template-columns: minmax(180px, 230px) minmax(0, 1fr);
}
/* 2열 구간은 본문 열이 훨씬 길어서 초상 카드를 세로 가운데에 두면 혼자 아래로 처진다.
제목과 같은 높이에서 시작하도록 위쪽에 붙인다. */
.sx-prestart__visual {
align-self: start;
}
.sx-prestart__plan {
grid-column: 1 / -1;
padding-top: var(--sp-4);

View file

@ -109,6 +109,7 @@ export interface SessionVoiceStatusView {
sessionStatusLabel: string;
transcriptLiveLabel: string;
transcriptIsLive: boolean;
textComposerDisabled: boolean;
textTurnBlocked: boolean;
voiceInputStatus: string;
responseStatus: string;
@ -185,6 +186,9 @@ export function sessionVoiceStatusView(input: SessionVoiceStatusInput): SessionV
voiceStatus === "thinking" ||
voiceStatus === "speaking" ||
sending;
// 답변 생성/음성 재생 중에도 다음 발화를 미리 작성할 수 있어야 한다.
// 직렬 턴 계약은 보내기 버튼과 handleSend 가 지키고, 작성 자체는 막지 않는다.
const textComposerDisabled = sessionEnded || paused;
const textTurnBlocked = sending || voiceStatus === "thinking";
const voiceInputStatus =
voiceAvailable === false
@ -209,6 +213,7 @@ export function sessionVoiceStatusView(input: SessionVoiceStatusInput): SessionV
sessionStatusLabel,
transcriptLiveLabel,
transcriptIsLive,
textComposerDisabled,
textTurnBlocked,
voiceInputStatus,
responseStatus,

View file

@ -701,6 +701,15 @@
}
@media (max-width: 480px) {
.vg-set__engine-catalog {
align-items: stretch;
flex-direction: column;
}
.vg-set__engine-catalog .vg-btn {
width: 100%;
}
.vg-set__seg {
grid-template-columns: 1fr;
}
@ -985,6 +994,52 @@
padding: 8px 10px;
}
.vg-set__engine-catalog {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
padding: 11px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface);
}
.vg-set__engine-catalog.is-ready {
border-color: color-mix(in srgb, var(--accent) 36%, var(--border-subtle));
background: var(--accent-tint);
}
.vg-set__engine-catalog.is-unavailable {
border-color: color-mix(in srgb, var(--crit-solid) 42%, var(--border-subtle));
background: var(--crit-tint);
}
.vg-set__engine-catalog > div {
min-width: 0;
}
.vg-set__engine-catalog b,
.vg-set__engine-catalog span {
display: block;
}
.vg-set__engine-catalog b {
color: var(--text-strong);
font-size: var(--fs-xs);
}
.vg-set__engine-catalog span {
margin-top: 3px;
color: var(--text-muted);
font-size: 11px;
line-height: 1.45;
}
.vg-set__engine-catalog .vg-btn {
flex: 0 0 auto;
}
.vg-set__engine-grid {
display: grid;
grid-template-columns: minmax(270px, 0.8fr) minmax(0, 1.2fr);