feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue