737 lines
32 KiB
TypeScript
737 lines
32 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { AppShell } from "../components/shell/AppShell";
|
|
import { Button, Field, Icon, Input, Kicker } from "../components/ui";
|
|
import {
|
|
adminApi,
|
|
adminEngineApi,
|
|
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",
|
|
"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",
|
|
};
|
|
|
|
function countLabel(value: number): string {
|
|
return Number.isFinite(value) ? Math.max(0, Math.round(value)).toLocaleString("ko-KR") : "0";
|
|
}
|
|
|
|
/**
|
|
* 금액 표기 정밀도 — 누적·일별처럼 합계 성격의 금액은 소수 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(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 {
|
|
if (!Number.isFinite(value) || value <= 0) return "0%";
|
|
return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
|
|
}
|
|
|
|
function dateTimeLabel(value?: number | null): string {
|
|
if (!value || !Number.isFinite(value)) return "기록 없음";
|
|
return new Intl.DateTimeFormat("ko-KR", {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}).format(new Date(value * 1000));
|
|
}
|
|
|
|
function dayLabel(value: string): string {
|
|
const date = new Date(`${value}T00:00:00`);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return new Intl.DateTimeFormat("ko-KR", { month: "numeric", day: "numeric" }).format(date);
|
|
}
|
|
|
|
function sourceLabel(usage: AdminUsageResponse): string {
|
|
return usage.durable && usage.source === "database" ? "운영 DB 원장" : "서버 런타임 임시 집계";
|
|
}
|
|
|
|
function budgetStatusLabel(status: AdminUsageResponse["budget"]["status"]): string {
|
|
if (status === "exceeded") return "예산 초과";
|
|
if (status === "warn") return "예산 주의";
|
|
if (status === "ok") return "예산 정상";
|
|
return "예산 경고 비활성";
|
|
}
|
|
|
|
function healthStatusLabel(status?: string): string {
|
|
if (status === "ok") return "정상";
|
|
if (status === "down") return "중단";
|
|
if (status === "degraded") return "제한 운영";
|
|
return "확인 불가";
|
|
}
|
|
|
|
export default function AdminAi() {
|
|
const [windowDays, setWindowDays] = useState<(typeof WINDOW_OPTIONS)[number]>(30);
|
|
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);
|
|
setError(null);
|
|
try {
|
|
const [usageResult, healthResult, engineResult] = await Promise.allSettled([
|
|
adminApi.usage(windowDays),
|
|
adminApi.health(),
|
|
adminEngineApi.get(),
|
|
]);
|
|
if (usageResult.status === "fulfilled") setUsage(usageResult.value);
|
|
if (healthResult.status === "fulfilled") setHealth(healthResult.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) =>
|
|
result.reason instanceof Error ? result.reason.message : "일부 운영 정보를 불러오지 못했습니다.",
|
|
);
|
|
if (failures.length > 0) setError([...new Set(failures)].join(" · "));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [loadCapabilities, windowDays]);
|
|
|
|
useEffect(() => {
|
|
if (initialLoad.current) {
|
|
initialLoad.current = false;
|
|
void load();
|
|
return;
|
|
}
|
|
let active = true;
|
|
setLoading(true);
|
|
setError(null);
|
|
void adminApi.usage(windowDays)
|
|
.then((nextUsage) => {
|
|
if (active) setUsage(nextUsage);
|
|
})
|
|
.catch((cause) => {
|
|
if (active) {
|
|
setError(cause instanceof Error ? cause.message : "AI 사용량을 불러오지 못했습니다.");
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (active) setLoading(false);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [windowDays]);
|
|
|
|
const saveEngine = async () => {
|
|
if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return;
|
|
setSaving(true);
|
|
setSaved(false);
|
|
setError(null);
|
|
try {
|
|
const next = await adminEngineApi.update({
|
|
engine_mode: engine.engine_mode,
|
|
engine_url: engine.engine_url.trim(),
|
|
model: engine.model.trim(),
|
|
reasoning_effort: engine.reasoning_effort,
|
|
});
|
|
setEngine(next);
|
|
setSaved(true);
|
|
setHealth(await adminApi.health().catch(() => health));
|
|
window.setTimeout(() => setSaved(false), 2400);
|
|
} catch (cause) {
|
|
setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const daily = usage?.daily_cost ?? [];
|
|
const maxDailyCost = Math.max(0, ...daily.map((item) => item.cost_usd));
|
|
const totalTokens = (usage?.tokens_in ?? 0) + (usage?.tokens_out ?? 0);
|
|
const coverage = usage && usage.total_turns > 0 ? usage.metered_turns / usage.total_turns : 0;
|
|
const unmeteredTurns = usage ? Math.max(0, usage.total_turns - usage.metered_turns) : 0;
|
|
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),
|
|
[usage],
|
|
);
|
|
|
|
return (
|
|
<AppShell contextLabel="AI 운영" navRole="admin" wide>
|
|
<div className="aic" data-testid="admin-ai-page" aria-busy={loading}>
|
|
<header className="aic-head">
|
|
<div>
|
|
{/* 영문 대문자 eyebrow는 한글로 — "관리자"는 제목에 없는 역할 맥락이라 유지한다. */}
|
|
<Kicker>관리자 · AI 운영 원장</Kicker>
|
|
<h1>AI 운영과 DB 계량</h1>
|
|
<p>상담 응답 엔진의 적용값과 실제 DB에 기록된 호출·토큰·비용을 한 화면에서 확인합니다.</p>
|
|
</div>
|
|
<div className="aic-head__actions">
|
|
<span className="aic-head__stamp">
|
|
최근 집계 <b>{dateTimeLabel(usage?.generated_at)}</b>
|
|
</span>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
leading={<Icon name="arrow-flat" size={15} />}
|
|
onClick={() => void load()}
|
|
disabled={loading}
|
|
>
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
{error ? (
|
|
<div className="aic-alert" role="alert">
|
|
<Icon name="alert" size={18} />
|
|
<span>{error}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<section className="aic-source" aria-label="계량 데이터 원천">
|
|
<span className={`aic-source__dot ${usage?.durable ? "is-live" : ""}`} aria-hidden="true" />
|
|
<div>
|
|
<b>{usage ? sourceLabel(usage) : "계량 원천 확인 중"}</b>
|
|
<span>
|
|
{usage?.durable
|
|
? "호출 완료 시점의 provider·model·token·cost 필드를 DB에서 다시 집계합니다."
|
|
: "DB 계량이 아니므로 서버 재시작 시 집계가 사라질 수 있습니다."}
|
|
</span>
|
|
</div>
|
|
<div className="aic-window" role="group" aria-label="계량 기간">
|
|
{WINDOW_OPTIONS.map((days) => (
|
|
<button
|
|
key={days}
|
|
type="button"
|
|
className={windowDays === days ? "is-active" : ""}
|
|
aria-pressed={windowDays === days}
|
|
onClick={() => setWindowDays(days)}
|
|
>
|
|
{days}일
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="aic-ledger" aria-label="AI 사용 핵심 지표">
|
|
<article>
|
|
<span>누적 비용</span>
|
|
{/* 합계 금액은 소수 2자리, 원본 정밀도는 title 로 남긴다. */}
|
|
<b title={usage ? costTitle(usage.cost_usd) : undefined}>
|
|
{usage ? costLabel(usage.cost_usd) : "—"}
|
|
</b>
|
|
<small>{windowDays}일 DB 집계</small>
|
|
</article>
|
|
<article>
|
|
<span>계량 호출</span>
|
|
<b>{usage ? countLabel(usage.metered_turns) : "—"}</b>
|
|
<small>전체 {usage ? countLabel(usage.total_turns) : "—"}턴</small>
|
|
</article>
|
|
<article>
|
|
<span>입력 토큰</span>
|
|
<b>{usage ? countLabel(usage.tokens_in) : "—"}</b>
|
|
<small>프롬프트·컨텍스트</small>
|
|
</article>
|
|
<article>
|
|
<span>출력 토큰</span>
|
|
<b>{usage ? countLabel(usage.tokens_out) : "—"}</b>
|
|
<small>생성 응답</small>
|
|
</article>
|
|
<article>
|
|
<span>호출당 비용</span>
|
|
{/* 단가는 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>
|
|
|
|
<div className="aic-grid">
|
|
<div className="aic-column aic-column--main">
|
|
<section className="aic-panel aic-budget">
|
|
<div className="aic-panel__head">
|
|
<div>
|
|
{/* SPEND CONTROL → 한글. 제목이 다루지 않는 "관리" 관점을 남긴다. */}
|
|
<span className="aic-eyebrow">예산 관리</span>
|
|
<h2>예산과 계량 커버리지</h2>
|
|
</div>
|
|
<span className={`aic-status aic-status--${usage?.budget.status ?? "disabled"}`}>
|
|
{usage ? budgetStatusLabel(usage.budget.status) : "확인 중"}
|
|
</span>
|
|
</div>
|
|
<div className="aic-budget__split">
|
|
<div>
|
|
<div className="aic-metricline">
|
|
<span>예산 사용</span>
|
|
<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)}`}
|
|
</b>
|
|
</div>
|
|
<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
|
|
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)} 사용`}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<div className="aic-metricline">
|
|
<span>DB 계량 커버리지</span>
|
|
<b>{rateLabel(coverage)}</b>
|
|
</div>
|
|
<div className="aic-track aic-track--coverage" aria-label={`DB 계량 커버리지 ${rateLabel(coverage)}`}>
|
|
<span style={{ width: `${Math.min(100, coverage * 100)}%` }} />
|
|
</div>
|
|
<p>
|
|
{unmeteredTurns > 0
|
|
? `${countLabel(unmeteredTurns)}개 턴은 provider·model·token·cost 계량 필드가 없습니다.`
|
|
: "조회 기간의 모든 완료 턴에 계량 정보가 연결되어 있습니다."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="aic-panel">
|
|
<div className="aic-panel__head">
|
|
<div>
|
|
{/* DAILY JOURNAL → 한글. */}
|
|
<span className="aic-eyebrow">일별 기록</span>
|
|
<h2>일별 비용 흐름</h2>
|
|
</div>
|
|
<span className="aic-panel__meta">{daily.length}일 기록</span>
|
|
</div>
|
|
{daily.length > 0 ? (
|
|
<div className="aic-chart" role="img" aria-label={`${windowDays}일 일별 AI 비용 막대 차트`}>
|
|
{daily.map((item) => {
|
|
const height = maxDailyCost > 0 ? Math.max(3, (item.cost_usd / maxDailyCost) * 100) : 3;
|
|
return (
|
|
// 막대 위 라벨은 일별 합계라 소수 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}%` }} />
|
|
</span>
|
|
<span className="aic-chart__date">{dayLabel(item.day)}</span>
|
|
<small>{countLabel(item.turns)}턴</small>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="aic-empty">선택한 기간에 비용이 계량된 AI 호출이 없습니다.</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="aic-panel">
|
|
<div className="aic-panel__head">
|
|
<div>
|
|
{/* MODEL LEDGER eyebrow 제거 — 바로 아래 제목이 그대로 "모델별 사용 원장"이라 중복. */}
|
|
<h2>Provider·모델별 사용 원장</h2>
|
|
</div>
|
|
<span className="aic-panel__meta">비용 높은 순</span>
|
|
</div>
|
|
{sortedBreakdown.length > 0 ? (
|
|
<div className="aic-table-wrap">
|
|
<table className="aic-table">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Provider / 모델</th>
|
|
<th scope="col">호출</th>
|
|
<th scope="col">입력</th>
|
|
<th scope="col">출력</th>
|
|
<th scope="col">비용</th>
|
|
<th scope="col">호출당</th>
|
|
<th scope="col">비용 비중</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedBreakdown.map((row) => (
|
|
<tr key={`${row.provider}:${row.model}`}>
|
|
<td>
|
|
<b>{row.model}</b>
|
|
<span>{row.provider}</span>
|
|
</td>
|
|
<td>{countLabel(row.turns)}</td>
|
|
<td>{countLabel(row.tokens_in)}</td>
|
|
<td>{countLabel(row.tokens_out)}</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}%` }} />
|
|
</span>
|
|
{rateLabel(usage && usage.cost_usd > 0 ? row.cost_usd / usage.cost_usd : 0)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="aic-empty">모델별 계량 행이 아직 없습니다.</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
<div className="aic-column aic-column--side">
|
|
<section className="aic-panel aic-engine">
|
|
<div className="aic-panel__head">
|
|
<div>
|
|
{/* RUNTIME ROUTE → 한글. 제목의 "설정"과 달리 어디로 호출이 나가는지를 가리킨다. */}
|
|
<span className="aic-eyebrow">엔진 경로</span>
|
|
<h2>AI 엔진 설정</h2>
|
|
</div>
|
|
<span className={`aic-runtime aic-runtime--${engineService?.status ?? "degraded"}`}>
|
|
{healthStatusLabel(engineService?.status)}
|
|
</span>
|
|
</div>
|
|
{engine ? (
|
|
<>
|
|
<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 });
|
|
setCapabilities(null);
|
|
setCapabilitiesError(
|
|
"연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.",
|
|
);
|
|
}}
|
|
aria-label="AI 연결 주소"
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="기본 모델"
|
|
hint="게이트웨이가 현재 계정에서 확인한 모델만 선택할 수 있습니다."
|
|
htmlFor="aic-engine-model"
|
|
>
|
|
<select
|
|
id="aic-engine-model"
|
|
className="vg-input aic-select"
|
|
value={engine.model}
|
|
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>
|
|
<div><dt>최근 변경</dt><dd>{dateTimeLabel(engine.updated_at)}</dd></div>
|
|
<div><dt>변경자</dt><dd>{engine.updated_by ?? "기록 없음"}</dd></div>
|
|
</dl>
|
|
<div className="aic-engine__foot">
|
|
<span>{saved ? <><Icon name="check" size={15} /> 저장됨</> : engineService?.detail ?? "엔진 상태를 확인합니다."}</span>
|
|
<Button
|
|
size="sm"
|
|
leading={<Icon name="check" size={14} />}
|
|
onClick={() => void saveEngine()}
|
|
disabled={
|
|
saving ||
|
|
capabilitiesLoading ||
|
|
!engine.engine_url.trim() ||
|
|
!engineSelectionValid
|
|
}
|
|
>
|
|
{saving ? "저장 중" : "운영 설정 저장"}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="aic-empty">현재 AI 엔진 설정을 불러오는 중입니다.</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="aic-panel">
|
|
<div className="aic-panel__head">
|
|
<div>
|
|
{/* EVALUATOR CACHE eyebrow 제거 — 제목 "평가 캐시 효율"과 같은 말. */}
|
|
<h2>평가 캐시 효율</h2>
|
|
</div>
|
|
<span className={`aic-runtime ${cache?.enabled ? "aic-runtime--ok" : ""}`}>
|
|
{cache?.enabled ? "사용 중" : "비활성"}
|
|
</span>
|
|
</div>
|
|
<div className="aic-cache-score">
|
|
<b>{cache ? rateLabel(cache.hit_rate) : "—"}</b>
|
|
<span>hit-rate</span>
|
|
</div>
|
|
<dl className="aic-mini-grid">
|
|
<div><dt>요청</dt><dd>{countLabel(cache?.requests ?? 0)}</dd></div>
|
|
<div><dt>적중</dt><dd>{countLabel(cache?.hits ?? 0)}</dd></div>
|
|
<div><dt>미스</dt><dd>{countLabel(cache?.misses ?? 0)}</dd></div>
|
|
<div><dt>저장</dt><dd>{countLabel(cache?.stores ?? 0)}</dd></div>
|
|
<div><dt>현재 엔트리</dt><dd>{countLabel(cache?.entries ?? 0)}</dd></div>
|
|
<div><dt>축출</dt><dd>{countLabel(cache?.evictions ?? 0)}</dd></div>
|
|
</dl>
|
|
<p className="aic-note">캐시는 성공적으로 파싱된 평가 결과만 보관하며 원문 프롬프트와 응답은 저장하지 않습니다.</p>
|
|
</section>
|
|
|
|
<section className="aic-panel">
|
|
<div className="aic-panel__head">
|
|
<div>
|
|
{/* METERING INTEGRITY eyebrow 제거 — 제목 "DB 계량 상태"와 같은 말. */}
|
|
<h2>DB 계량 상태</h2>
|
|
</div>
|
|
</div>
|
|
<dl className="aic-integrity">
|
|
<div>
|
|
<dt>원천</dt>
|
|
<dd>{usage ? sourceLabel(usage) : "확인 중"}</dd>
|
|
<span>{usage?.source ?? "—"}</span>
|
|
</div>
|
|
<div>
|
|
<dt>영속성</dt>
|
|
<dd>{usage?.durable ? "재시작 후 유지" : "런타임 한정"}</dd>
|
|
<span>{usage?.durable ? "durable=true" : "durable=false"}</span>
|
|
</div>
|
|
<div>
|
|
<dt>미계량 턴</dt>
|
|
<dd>{countLabel(unmeteredTurns)}건</dd>
|
|
<span>{unmeteredTurns > 0 ? "감사 필요" : "누락 없음"}</span>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|