feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue