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

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

View file

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