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 = { 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(null); const [health, setHealth] = useState(null); const [engine, setEngine] = useState(null); const [capabilities, setCapabilities] = useState(null); const [capabilitiesLoading, setCapabilitiesLoading] = useState(false); const [capabilitiesError, setCapabilitiesError] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(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 (
{/* 영문 대문자 eyebrow는 한글로 — "관리자"는 제목에 없는 역할 맥락이라 유지한다. */} 관리자 · AI 운영 원장

AI 운영과 DB 계량

상담 응답 엔진의 적용값과 실제 DB에 기록된 호출·토큰·비용을 한 화면에서 확인합니다.

최근 집계 {dateTimeLabel(usage?.generated_at)}
{error ? (
{error}
) : null}
누적 비용 {/* 합계 금액은 소수 2자리, 원본 정밀도는 title 로 남긴다. */} {usage ? costLabel(usage.cost_usd) : "—"} {windowDays}일 DB 집계
계량 호출 {usage ? countLabel(usage.metered_turns) : "—"} 전체 {usage ? countLabel(usage.total_turns) : "—"}턴
입력 토큰 {usage ? countLabel(usage.tokens_in) : "—"} 프롬프트·컨텍스트
출력 토큰 {usage ? countLabel(usage.tokens_out) : "—"} 생성 응답
호출당 비용 {/* 단가는 2자리면 0이 되므로 4자리. 원본 정밀도는 title. */} {usage ? costLabel(costPerTurn, 4) : "—"} 0 ? costTitle(costPerThousandTokens) : undefined}> 1천 토큰당 {usage ? (totalTokens > 0 ? costLabel(costPerThousandTokens, 4) : "계산 불가") : "—"}
{/* SPEND CONTROL → 한글. 제목이 다루지 않는 "관리" 관점을 남긴다. */} 예산 관리

예산과 계량 커버리지

{usage ? budgetStatusLabel(usage.budget.status) : "확인 중"}
예산 사용 {usage?.budget.status === "disabled" ? "한도 미설정" : `${costLabel(usage?.cost_usd ?? 0)} / ${costLabel(usage?.budget.limit_usd ?? 0)}`}

{usage?.budget.status === "disabled" ? "서버의 ADMIN_USAGE_BUDGET_USD를 설정하면 80% 주의와 100% 초과 상태를 표시합니다." : `잔여 ${costLabel(usage?.budget.remaining_usd ?? 0)} · ${rateLabel(usage?.budget.used_ratio ?? 0)} 사용`}

DB 계량 커버리지 {rateLabel(coverage)}

{unmeteredTurns > 0 ? `${countLabel(unmeteredTurns)}개 턴은 provider·model·token·cost 계량 필드가 없습니다.` : "조회 기간의 모든 완료 턴에 계량 정보가 연결되어 있습니다."}

{/* DAILY JOURNAL → 한글. */} 일별 기록

일별 비용 흐름

{daily.length}일 기록
{daily.length > 0 ? (
{daily.map((item) => { const height = maxDailyCost > 0 ? Math.max(3, (item.cost_usd / maxDailyCost) * 100) : 3; return ( // 막대 위 라벨은 일별 합계라 소수 2자리, 원본 정밀도는 title 로 남긴다.
{costLabel(item.cost_usd)} {dayLabel(item.day)} {countLabel(item.turns)}턴
); })}
) : (
선택한 기간에 비용이 계량된 AI 호출이 없습니다.
)}
{/* MODEL LEDGER eyebrow 제거 — 바로 아래 제목이 그대로 "모델별 사용 원장"이라 중복. */}

Provider·모델별 사용 원장

비용 높은 순
{sortedBreakdown.length > 0 ? (
{sortedBreakdown.map((row) => ( {/* 합계 비용은 2자리, 호출당 단가는 4자리. 원본은 title. */} ))}
Provider / 모델 호출 입력 출력 비용 호출당 비용 비중
{row.model} {row.provider} {countLabel(row.turns)} {countLabel(row.tokens_in)} {countLabel(row.tokens_out)}{costLabel(row.cost_usd)} 0 ? row.cost_usd / row.turns : 0)}> {costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, 4)} 0 ? (row.cost_usd / usage.cost_usd) * 100 : 0}%` }} /> {rateLabel(usage && usage.cost_usd > 0 ? row.cost_usd / usage.cost_usd : 0)}
) : (
모델별 계량 행이 아직 없습니다.
)}
{/* RUNTIME ROUTE → 한글. 제목의 "설정"과 달리 어디로 호출이 나가는지를 가리킨다. */} 엔진 경로

AI 엔진 설정

{healthStatusLabel(engineService?.status)}
{engine ? ( <> { setEngine({ ...engine, engine_url: event.target.value }); setCapabilities(null); setCapabilitiesError( "연결 주소가 바뀌었습니다. 새 주소의 모델 목록을 확인하세요.", ); }} aria-label="AI 연결 주소" />
{capabilitiesLoading ? "모델 목록 확인 중" : capabilities?.available ? `${capabilityModels.length}개 모델 확인됨` : "모델 목록을 확인할 수 없음"} {capabilitiesError ?? capabilities?.detail ?? "게이트웨이 연결 뒤 모델 목록을 표시합니다."} {selectedModel?.description ? {selectedModel.description} : null}
저장 원천
{engine.durable ? "DB 영구 저장" : "런타임 적용"}
현재 소스
{engine.source}
최근 변경
{dateTimeLabel(engine.updated_at)}
변경자
{engine.updated_by ?? "기록 없음"}
{saved ? <> 저장됨 : engineService?.detail ?? "엔진 상태를 확인합니다."}
) : (
현재 AI 엔진 설정을 불러오는 중입니다.
)}
{/* EVALUATOR CACHE eyebrow 제거 — 제목 "평가 캐시 효율"과 같은 말. */}

평가 캐시 효율

{cache?.enabled ? "사용 중" : "비활성"}
{cache ? rateLabel(cache.hit_rate) : "—"} hit-rate
요청
{countLabel(cache?.requests ?? 0)}
적중
{countLabel(cache?.hits ?? 0)}
미스
{countLabel(cache?.misses ?? 0)}
저장
{countLabel(cache?.stores ?? 0)}
현재 엔트리
{countLabel(cache?.entries ?? 0)}
축출
{countLabel(cache?.evictions ?? 0)}

캐시는 성공적으로 파싱된 평가 결과만 보관하며 원문 프롬프트와 응답은 저장하지 않습니다.

{/* METERING INTEGRITY eyebrow 제거 — 제목 "DB 계량 상태"와 같은 말. */}

DB 계량 상태

원천
{usage ? sourceLabel(usage) : "확인 중"}
{usage?.source ?? "—"}
영속성
{usage?.durable ? "재시작 후 유지" : "런타임 한정"}
{usage?.durable ? "durable=true" : "durable=false"}
미계량 턴
{countLabel(unmeteredTurns)}건
{unmeteredTurns > 0 ? "감사 필요" : "누락 없음"}
); }