<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin</title>
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
<link
rel="stylesheet"
href="https://unpkg.com/franken-ui@latest/dist/css/slate.min.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
* {
font-family:
"Pretendard",
-apple-system,
BlinkMacSystemFont,
system-ui,
Roboto,
sans-serif;
}
body {
background: #f9fafb;
color: #191f28;
}
.tab-active {
color: #3182f6;
font-weight: 600;
}
.btn-primary {
background: #3182f6;
color: white;
padding: 16px 24px;
border-radius: 12px;
font-weight: 600;
font-size: 17px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover {
background: #1b64da;
}
.btn-secondary {
background: #f2f4f6;
color: #4e5968;
padding: 16px 24px;
border-radius: 12px;
font-weight: 600;
font-size: 17px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: #e5e8eb;
}
.card {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.input-field {
width: 100%;
padding: 16px 20px;
border: 1.5px solid #e5e8eb;
border-radius: 12px;
font-size: 16px;
transition: all 0.2s;
}
.input-field:focus {
outline: none;
border-color: #3182f6;
}
.label {
font-size: 15px;
font-weight: 600;
color: #333d4b;
margin-bottom: 8px;
display: block;
}
.helper-text {
font-size: 14px;
color: #6b7684;
margin-top: 6px;
}
/* Fade out animation for deletions */
@keyframes fadeOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-20px);
}
}
.fade-out {
animation: fadeOut 0.3s ease-out forwards;
}
/* Fade in animation for new items */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
</style>
</head>
<body>
<div class="flex min-h-screen">
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col">
<div class="px-6 py-8">
<div class="mb-12">
<h1 class="text-2xl font-bold">Claude Code Mux</h1>
<p class="text-sm text-gray-500 mt-1">Admin</p>
</div>
<nav class="space-y-2">
<button
onclick="showTab('overview')"
id="tab-overview"
class="tab-active w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px]"
>
Overview
</button>
<button
onclick="showTab('models')"
id="tab-models"
class="w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px] text-gray-600"
>
Models
</button>
<button
onclick="showTab('providers')"
id="tab-providers"
class="w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px] text-gray-600"
>
Providers
</button>
<button
onclick="showTab('router')"
id="tab-router"
class="w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px] text-gray-600"
>
Router
</button>
<button
onclick="showTab('settings')"
id="tab-settings"
class="w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px] text-gray-600"
>
Settings
</button>
</nav>
</div>
<div class="mt-auto border-t border-gray-200 p-6 space-y-3">
<button
onclick="saveAllConfig()"
class="w-full btn-primary text-sm py-3"
>
Save All
</button>
<button
onclick="saveAndRestart()"
class="w-full btn-secondary text-sm py-3 flex items-center justify-center gap-2"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>Save & Restart</span>
</button>
<div class="text-xs text-gray-500 text-center pt-2">
Last saved: <span id="last-saved">-</span>
</div>
</div>
</aside>
<main class="flex-1 p-12 max-w-5xl">
<div id="content-overview" class="tab-content">
<h1 class="text-4xl font-bold mb-3">Routing Status</h1>
<p class="text-gray-600 text-lg mb-12">
Monitor your AI routing configuration
</p>
<div class="grid grid-cols-3 gap-6 mb-12">
<div class="card">
<div class="text-gray-600 text-sm mb-2">
Providers
</div>
<div class="text-4xl font-bold" id="provider-count">
0
</div>
</div>
<div class="card">
<div class="text-gray-600 text-sm mb-2">
Models
</div>
<div
class="text-4xl font-bold"
id="model-count-overview"
>
0
</div>
</div>
<div class="card">
<div class="text-gray-600 text-sm mb-2">
Status
</div>
<div class="text-4xl font-bold text-blue-600">
Active
</div>
</div>
</div>
<div class="card mb-6">
<h2 class="text-2xl font-bold mb-6">
Router Configuration
</h2>
<div class="space-y-4" id="router-status">
<div
class="flex justify-between items-center py-3 border-b"
>
<span class="text-gray-600">Default Model</span>
<span class="font-semibold" id="current-default"
>-</span
>
</div>
<div
class="flex justify-between items-center py-3 border-b"
>
<span class="text-gray-600">Think Model</span>
<span class="font-semibold" id="current-think"
>-</span
>
</div>
<div
class="flex justify-between items-center py-3 border-b"
>
<span class="text-gray-600"
>Background Model</span
>
<span
class="font-semibold"
id="current-background"
>-</span
>
</div>
<div class="flex justify-between items-center py-3">
<span class="text-gray-600">WebSearch Model</span>
<span
class="font-semibold"
id="current-websearch"
>-</span
>
</div>
</div>
</div>
<div class="card">
<h2 class="text-2xl font-bold mb-6">Server Info</h2>
<div class="space-y-4">
<div class="flex justify-between items-center py-3">
<span class="text-gray-600">Address</span>
<code
class="font-mono text-sm"
id="server-address"
>-</code
>
</div>
</div>
</div>
</div>
<div id="content-providers" class="tab-content hidden">
<div id="providers-list-view">
<h1 class="text-4xl font-bold mb-3">Providers</h1>
<p class="text-gray-600 text-lg mb-12">
Connect and manage AI providers
</p>
<button
onclick="showAddProvider()"
class="btn-primary mb-8"
>
Add Provider
</button>
<div id="providers-list" class="space-y-4">
<div class="card hidden" id="provider-card-example">
<div class="flex items-start justify-between">
<div class="flex-1">
<div
class="flex items-center gap-3 mb-2"
>
<h3 class="text-xl font-bold">
Anthropic
</h3>
<span
class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-sm font-semibold"
>활성화</span
>
</div>
<p class="text-gray-600 mb-4">
anthropic-native
</p>
<div class="text-sm text-gray-500">
3개 모델 • API 키 등록됨
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary">
수정
</button>
<button
class="btn-secondary text-red-600"
>
삭제
</button>
</div>
</div>
</div>
<div
class="card text-center py-16"
id="empty-providers"
>
<div class="text-6xl mb-4">🔌</div>
<h3 class="text-2xl font-bold mb-2">
연결된 Provider가 없어요
</h3>
<p class="text-gray-600 mb-6">
Provider를 추가하면 더 많은 AI 모델을 사용할
수 있어요
</p>
<button
onclick="showAddProvider()"
class="btn-primary"
>
첫 Provider 추가하기
</button>
</div>
</div>
</div>
<div id="providers-add-view" class="hidden">
<button
onclick="showProvidersList()"
class="text-blue-600 font-semibold mb-8 hover:underline"
>
← Provider 목록으로
</button>
<h1 class="text-4xl font-bold mb-3">Provider 추가</h1>
<p class="text-gray-600 text-lg mb-12">
API 키를 입력하면 바로 사용할 수 있어요
</p>
<form id="add-provider-form" class="space-y-8">
<div class="card">
<h2 class="text-2xl font-bold mb-8">
어떤 Provider를 추가하시나요?
</h2>
<div class="grid grid-cols-2 gap-4">
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="anthropic"
class="peer sr-only"
required
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Anthropic
</div>
<div class="text-sm text-gray-600">
Claude 모델
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="openai"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
OpenAI
</div>
<div class="text-sm text-gray-600">
GPT 모델
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="openrouter"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
OpenRouter
</div>
<div class="text-sm text-gray-600">
통합 라우터
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="z.ai"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
z.ai
</div>
<div class="text-sm text-gray-600">
국내 모델
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="minimax"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Minimax
</div>
<div class="text-sm text-gray-600">
중국 모델
</div>
</div>
</label>
</div>
</div>
<div class="card">
<h2 class="text-2xl font-bold mb-8">
Provider 이름을 정해주세요
</h2>
<div>
<label class="label">이름</label>
<input
type="text"
name="provider_name"
class="input-field"
placeholder="예: anthropic-main"
required
/>
<div class="helper-text">
Provider를 구분할 수 있는 이름이에요
</div>
</div>
</div>
<div class="card">
<h2 class="text-2xl font-bold mb-8">
API 키를 입력해주세요
</h2>
<div>
<label class="label">API Key</label>
<input
type="password"
name="api_key"
class="input-field font-mono"
placeholder="sk-ant-..."
required
/>
<div class="helper-text">
API 키는 안전하게 보관돼요
</div>
</div>
</div>
<div class="card">
<h2 class="text-2xl font-bold mb-4">
추가 설정
</h2>
<p class="text-gray-600 mb-8">
필요한 경우만 입력하세요
</p>
<div>
<label class="label"
>커스텀 엔드포인트 (선택)</label
>
<input
type="url"
name="base_url"
class="input-field font-mono"
placeholder="https://api.anthropic.com"
/>
<div class="helper-text">
기본 엔드포인트 대신 다른 URL을 사용하고
싶을 때만 입력하세요
</div>
</div>
</div>
<div class="flex gap-4">
<button
type="button"
onclick="showProvidersList()"
class="btn-secondary flex-1"
>
취소
</button>
<button
type="submit"
class="btn-primary flex-1"
>
Provider 추가하기
</button>
</div>
</form>
</div>
</div>
<div id="content-models" class="tab-content hidden">
<div id="models-list-view">
<h1 class="text-4xl font-bold mb-3">모델 관리</h1>
<p class="text-gray-600 text-lg mb-12">
여러 Provider로 안정성을 높이세요
</p>
<button
onclick="showAddModel()"
class="btn-primary mb-8"
>
새 모델 추가하기
</button>
<div id="models-list" class="space-y-4">
<div class="card hidden" id="model-card-example">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-xl font-bold mb-2">
claude-sonnet-4-5
</h3>
<p class="text-sm text-gray-600 mb-4">
외부에 노출되는 모델 이름
</p>
<div class="space-y-2">
<div
class="flex items-center gap-2 text-sm"
>
<span
class="px-2 py-1 bg-blue-50 text-blue-600 rounded-lg font-semibold"
>1순위</span
>
<span class="text-gray-600"
>anthropic/claude-sonnet-4-5</span
>
</div>
<div
class="flex items-center gap-2 text-sm"
>
<span
class="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg font-semibold"
>2순위</span
>
<span class="text-gray-500"
>openrouter/anthropic/claude-sonnet-4-5</span
>
</div>
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary">
수정
</button>
<button
class="btn-secondary text-red-600"
>
삭제
</button>
</div>
</div>
</div>
<div
class="card text-center py-16"
id="empty-models"
>
<div class="text-6xl mb-4">🎯</div>
<h3 class="text-2xl font-bold mb-2">
등록된 모델이 없어요
</h3>
<p class="text-gray-600 mb-6">
모델을 추가하면 API를 통해 사용할 수 있어요
</p>
<button
onclick="showAddModel()"
class="btn-primary"
>
첫 모델 추가하기
</button>
</div>
</div>
</div>
<div id="models-add-view" class="hidden">
<button
onclick="showModelsList()"
class="text-blue-600 font-semibold mb-8 hover:underline"
>
← 모델 목록으로
</button>
<h1 class="text-4xl font-bold mb-3">모델 추가</h1>
<p class="text-gray-600 text-lg mb-12">
여러 Provider를 설정해 안정성을 높일 수 있어요
</p>
<form id="add-model-form" class="space-y-8">
<div class="card">
<h2 class="text-2xl font-bold mb-8">
외부에 노출할 모델 이름을 정해주세요
</h2>
<div>
<label class="label">모델 이름</label>
<input
type="text"
name="model_name"
class="input-field font-mono"
placeholder="예: claude-sonnet-4-5"
required
/>
<div class="helper-text">
API 요청 시 이 이름으로 사용하게 돼요
</div>
</div>
</div>
<div class="card">
<h2 class="text-2xl font-bold mb-4">
Provider와 실제 모델을 연결하세요
</h2>
<p class="text-gray-600 mb-8">
순서대로 시도하며, 실패 시 다음 Provider로
자동 전환돼요
</p>
<div id="provider-mappings" class="space-y-4">
<div
class="provider-mapping border-2 border-blue-200 bg-blue-50 rounded-xl p-6"
>
<div
class="flex items-center justify-between mb-4"
>
<div
class="flex items-center gap-3"
>
<span
class="px-3 py-1 bg-blue-600 text-white rounded-lg font-bold text-sm"
>1순위</span
>
<span
class="text-blue-600 font-semibold"
>Primary</span
>
</div>
</div>
<div class="space-y-4">
<div>
<label class="label"
>Provider 선택</label
>
<select
name="mappings[0][provider]"
class="input-field"
required
>
<option value="">
Provider를 선택하세요
</option>
<option value="anthropic">
Anthropic
</option>
<option value="openrouter">
OpenRouter
</option>
<option value="openai">
OpenAI
</option>
</select>
</div>
<div>
<label class="label"
>실제 모델명</label
>
<input
type="text"
name="mappings[0][actual_model]"
class="input-field font-mono"
placeholder="예: claude-sonnet-4-5 또는 anthropic/claude-sonnet-4-5"
required
/>
<div class="helper-text">
Provider에서 사용하는 실제
모델 ID
</div>
</div>
</div>
</div>
</div>
<button
type="button"
onclick="addProviderMapping()"
class="mt-4 w-full py-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-600 font-semibold hover:border-blue-400 hover:text-blue-600 transition-colors"
>
+ Fallback Provider 추가
</button>
</div>
<div class="flex gap-4">
<button
type="button"
onclick="showModelsList()"
class="btn-secondary flex-1"
>
취소
</button>
<button
type="submit"
class="btn-primary flex-1"
>
모델 추가하기
</button>
</div>
</form>
</div>
</div>
<div id="content-router" class="tab-content hidden">
<h1 class="text-4xl font-bold mb-3">라우터 설정</h1>
<p class="text-gray-600 text-lg mb-12">
상황에 따라 다른 모델을 사용하도록 설정하세요
</p>
<form id="router-form" class="space-y-6">
<div class="card">
<h2 class="text-xl font-bold mb-6">기본 모델</h2>
<p class="text-gray-600 mb-6">
대부분의 요청에 사용될 모델이에요
</p>
<select
name="default_model"
class="input-field"
required
>
<option value="">모델을 선택하세요</option>
</select>
</div>
<div class="card">
<h2 class="text-xl font-bold mb-6">사고 모델</h2>
<p class="text-gray-600 mb-6">
복잡한 추론이 필요할 때 사용할 모델이에요
</p>
<select name="think_model" class="input-field">
<option value="">사용 안 함</option>
</select>
</div>
<div class="card">
<h2 class="text-xl font-bold mb-6">
백그라운드 모델
</h2>
<p class="text-gray-600 mb-6">
간단한 작업에 사용할 빠른 모델이에요
</p>
<select name="background_model" class="input-field">
<option value="">사용 안 함</option>
</select>
</div>
<div class="card">
<h2 class="text-xl font-bold mb-6">웹검색 모델</h2>
<p class="text-gray-600 mb-6">
웹 검색이 필요할 때 사용할 모델이에요
</p>
<select name="websearch_model" class="input-field">
<option value="">사용 안 함</option>
</select>
</div>
<div class="flex gap-4">
<button type="submit" class="btn-primary flex-1">
저장
</button>
</div>
</form>
</div>
<div id="content-settings" class="tab-content hidden">
<h1 class="text-4xl font-bold mb-3">설정</h1>
<p class="text-gray-600 text-lg mb-12">
서버 설정을 관리하세요
</p>
<form id="settings-form" class="space-y-6">
<div class="flex gap-4">
<button
type="button"
onclick="restartServer()"
class="btn-primary flex-1"
>
서버 재시작
</button>
</div>
</form>
</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/uikit@dev/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@dev/dist/js/uikit-icons.min.js"></script>
<script>
const appState = {
config: null,
loaded: false,
editingProvider: null, editingModel: null, };
function notify(message, status = "primary") {
if (typeof UIkit === "undefined" || !UIkit.notification) {
alert(message);
return;
}
UIkit.notification({
message: message,
status: status,
pos: "top-right",
timeout: 3000,
});
}
function notifySuccess(message) {
notify(message, "success");
}
function notifyError(message) {
notify(message, "danger");
}
function notifyWarning(message) {
notify(message, "warning");
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
const STORAGE_KEY = "ccm_config";
function saveToLocalStorage(config) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
return true;
} catch (error) {
console.error("Failed to save to localStorage:", error);
return false;
}
}
function loadFromLocalStorage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.error("Failed to load from localStorage:", error);
return null;
}
}
async function loadConfig() {
try {
const response = await fetch("/api/config/json");
const config = await response.json();
appState.config = config;
appState.loaded = true;
saveToLocalStorage(config);
return config;
} catch (error) {
console.error("Failed to load config:", error);
notifyError("설정을 불러오는데 실패했습니다");
return null;
}
}
async function syncToServer() {
try {
const response = await fetch("/api/config/json", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(appState.config),
});
if (response.ok) {
saveToLocalStorage(appState.config);
}
return response.ok;
} catch (error) {
console.error("Failed to sync to server:", error);
return false;
}
}
function getURLParams() {
return new URLSearchParams(window.location.search);
}
function updateURL(params, replace = false) {
const url = new URL(window.location);
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined) {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, value);
}
});
if (replace) {
window.history.replaceState({}, "", url);
} else {
window.history.pushState({}, "", url);
}
handleRoute();
}
function navigate(params) {
updateURL(params, false);
}
function showTab(tabName) {
navigate({ tab: tabName, view: null });
}
function handleRoute() {
const params = getURLParams();
const tab = params.get("tab") || "overview";
const view = params.get("view");
document
.querySelectorAll(".tab-content")
.forEach((el) => el.classList.add("hidden"));
document.querySelectorAll('[id^="tab-"]').forEach((el) => {
el.classList.remove("tab-active");
el.classList.add("text-gray-600");
});
document
.getElementById("content-" + tab)
.classList.remove("hidden");
const tabBtn = document.getElementById("tab-" + tab);
if (tabBtn) {
tabBtn.classList.add("tab-active");
tabBtn.classList.remove("text-gray-600");
}
if (tab === "providers") {
if (view === "add") {
document
.getElementById("providers-list-view")
.classList.add("hidden");
document
.getElementById("providers-add-view")
.classList.remove("hidden");
} else {
document
.getElementById("providers-list-view")
.classList.remove("hidden");
document
.getElementById("providers-add-view")
.classList.add("hidden");
renderProvidersList();
}
} else if (tab === "models") {
if (view === "add") {
document
.getElementById("models-list-view")
.classList.add("hidden");
document
.getElementById("models-add-view")
.classList.remove("hidden");
renderAddModelView();
} else {
document
.getElementById("models-list-view")
.classList.remove("hidden");
document
.getElementById("models-add-view")
.classList.add("hidden");
renderModelsList();
}
}
}
function renderProvidersList() {
if (!appState.loaded) return;
const providers = appState.config.providers || [];
const emptyState = document.getElementById("empty-providers");
const providersList = document.getElementById("providers-list");
const existingCards = providersList.querySelectorAll(
".card:not(#empty-providers)",
);
existingCards.forEach((card) => card.remove());
if (providers.length === 0) {
emptyState.classList.remove("hidden");
} else {
emptyState.classList.add("hidden");
providers.forEach((provider, index) => {
const providerCard = document.createElement("div");
providerCard.className = "card fade-in";
providerCard.id = `provider-card-${index}`;
providerCard.innerHTML = `
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-xl font-bold">${escapeHtml(provider.name)}</h3>
<span class="px-3 py-1 ${provider.enabled ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-500"} rounded-full text-sm font-semibold">
${provider.enabled ? "활성화" : "비활성화"}
</span>
</div>
<p class="text-gray-600 mb-4">${escapeHtml(provider.provider_type)}</p>
<div class="text-sm text-gray-500">
API 키 등록됨
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary" onclick="editProvider(${index})">수정</button>
<button class="btn-secondary text-red-600" onclick="deleteProvider(${index})">삭제</button>
</div>
</div>
`;
providersList.insertBefore(providerCard, emptyState);
});
}
}
function addProviderCardToUI(provider, index) {
const providersList = document.getElementById("providers-list");
const emptyState = document.getElementById("empty-providers");
const providerCard = document.createElement("div");
providerCard.className = "card fade-in";
providerCard.id = `provider-card-${index}`;
providerCard.innerHTML = `
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-xl font-bold">${escapeHtml(provider.name)}</h3>
<span class="px-3 py-1 ${provider.enabled ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-500"} rounded-full text-sm font-semibold">
${provider.enabled ? "활성화" : "비활성화"}
</span>
</div>
<p class="text-gray-600 mb-4">${escapeHtml(provider.provider_type)}</p>
<div class="text-sm text-gray-500">
API 키 등록됨
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary" onclick="editProvider(${index})">수정</button>
<button class="btn-secondary text-red-600" onclick="deleteProvider(${index})">삭제</button>
</div>
</div>
`;
providersList.insertBefore(providerCard, emptyState);
}
async function deleteProvider(index) {
if (!confirm("정말로 이 Provider를 삭제하시겠습니까?")) {
return;
}
const card = document.getElementById(`provider-card-${index}`);
try {
card.classList.add("fade-out");
appState.config.providers.splice(index, 1);
saveToLocalStorage(appState.config);
setTimeout(() => {
renderProvidersList();
renderOverview();
}, 300);
notifySuccess(
"Provider가 삭제되었어요 (저장 버튼을 눌러 적용하세요)",
);
} catch (error) {
console.error("Failed to delete provider:", error);
card.classList.remove("fade-out");
notifyError("Provider 삭제에 실패했습니다");
}
}
function editProvider(index) {
if (!appState.loaded || !appState.config.providers[index]) {
notifyError("Provider를 찾을 수 없습니다");
return;
}
appState.editingProvider = index;
const provider = appState.config.providers[index];
navigate({ tab: "providers", view: "add" });
setTimeout(() => {
const form = document.getElementById("add-provider-form");
if (!form) return;
const providerTypeRadio = form.querySelector(`input[name="provider_type"][value="${provider.provider_type}"]`);
if (providerTypeRadio) {
providerTypeRadio.checked = true;
}
form.querySelector('[name="provider_name"]').value = provider.name;
form.querySelector('[name="api_key"]').value = provider.api_key || '';
const baseUrlField = form.querySelector('[name="base_url"]');
if (baseUrlField && provider.base_url) {
baseUrlField.value = provider.base_url;
}
document.querySelector('#providers-add-view h1').textContent = 'Provider 수정';
document.querySelector('#providers-add-view > p').textContent = 'Provider 정보를 수정하세요';
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.textContent = 'Provider 수정하기';
}
}, 100);
}
function showAddProvider() {
appState.editingProvider = null;
navigate({ tab: "providers", view: "add" });
setTimeout(() => {
document.querySelector('#providers-add-view h1').textContent = 'Provider 추가';
document.querySelector('#providers-add-view > p').textContent = 'API 키를 입력하면 바로 사용할 수 있어요';
const form = document.getElementById("add-provider-form");
if (form) {
form.reset();
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.textContent = 'Provider 추가하기';
}
}
}, 100);
}
function showProvidersList() {
navigate({ tab: "providers", view: null });
}
let mappingCount = 1;
function renderModelsList() {
if (!appState.loaded) return;
const models = appState.config.models || [];
const emptyState = document.getElementById("empty-models");
const modelsList = document.getElementById("models-list");
const existingCards = modelsList.querySelectorAll(
".card:not(#empty-models)",
);
existingCards.forEach((card) => card.remove());
if (models.length === 0) {
emptyState.classList.remove("hidden");
} else {
emptyState.classList.add("hidden");
models.forEach((model, index) => {
const modelCard = document.createElement("div");
modelCard.className = "card fade-in";
modelCard.id = `model-card-${index}`;
const mappingsHtml = model.mappings
.sort((a, b) => a.priority - b.priority)
.map((mapping) => {
const isPrimary = mapping.priority === 1;
return `
<div class="flex items-center gap-2 text-sm">
<span class="px-2 py-1 ${isPrimary ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-600"} rounded-lg font-semibold">${mapping.priority}순위</span>
<span class="text-gray-600">${escapeHtml(mapping.provider)} → ${escapeHtml(mapping.actual_model)}</span>
</div>
`;
})
.join("");
modelCard.innerHTML = `
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-xl font-bold mb-2">${escapeHtml(model.name)}</h3>
<p class="text-sm text-gray-600 mb-4">외부에 노출되는 모델 이름</p>
<div class="space-y-2">
${mappingsHtml}
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary" onclick="editModel(${index})">수정</button>
<button class="btn-secondary text-red-600" onclick="deleteModel(${index})">삭제</button>
</div>
</div>
`;
modelsList.insertBefore(modelCard, emptyState);
});
}
}
function addModelCardToUI(model, index) {
const modelsList = document.getElementById("models-list");
const emptyState = document.getElementById("empty-models");
const modelCard = document.createElement("div");
modelCard.className = "card fade-in";
modelCard.id = `model-card-${index}`;
const mappingsHtml = model.mappings
.sort((a, b) => a.priority - b.priority)
.map((mapping) => {
const isPrimary = mapping.priority === 1;
return `
<div class="flex items-center gap-2 text-sm">
<span class="px-2 py-1 ${isPrimary ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-600"} rounded-lg font-semibold">${mapping.priority}순위</span>
<span class="text-gray-600">${escapeHtml(mapping.provider)} → ${escapeHtml(mapping.actual_model)}</span>
</div>
`;
})
.join("");
modelCard.innerHTML = `
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-xl font-bold mb-2">${escapeHtml(model.name)}</h3>
<p class="text-sm text-gray-600 mb-4">외부에 노출되는 모델 이름</p>
<div class="space-y-2">
${mappingsHtml}
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary" onclick="editModel(${index})">수정</button>
<button class="btn-secondary text-red-600" onclick="deleteModel(${index})">삭제</button>
</div>
</div>
`;
modelsList.insertBefore(modelCard, emptyState);
}
async function deleteModel(index) {
if (!confirm("정말로 이 모델을 삭제하시겠습니까?")) {
return;
}
const card = document.getElementById(`model-card-${index}`);
try {
card.classList.add("fade-out");
appState.config.models.splice(index, 1);
saveToLocalStorage(appState.config);
setTimeout(() => {
renderModelsList();
renderOverview();
}, 300);
notifySuccess(
"모델이 삭제되었어요 (저장 버튼을 눌러 적용하세요)",
);
} catch (error) {
console.error("Failed to delete model:", error);
card.classList.remove("fade-out");
notifyError("모델 삭제에 실패했습니다");
}
}
function editModel(index) {
if (!appState.loaded || !appState.config.models[index]) {
notifyError("모델을 찾을 수 없습니다");
return;
}
appState.editingModel = index;
const model = appState.config.models[index];
navigate({ tab: "models", view: "add" });
setTimeout(() => {
const form = document.getElementById("add-model-form");
if (!form) return;
form.querySelector('[name="model_name"]').value = model.name;
const mappingsContainer = document.getElementById("provider-mappings");
mappingsContainer.innerHTML = '';
mappingCount = model.mappings.length;
model.mappings.forEach((mapping, index) => {
const isPrimary = mapping.priority === 1;
const mappingDiv = document.createElement("div");
mappingDiv.className = isPrimary
? "provider-mapping border-2 border-blue-200 bg-blue-50 rounded-xl p-6"
: "provider-mapping border-2 border-gray-200 rounded-xl p-6";
mappingDiv.setAttribute("data-priority", mapping.priority);
const providers = appState.config.providers || [];
const providerOptions = providers
.filter((p) => p.enabled)
.map((p) =>
`<option value="${escapeHtml(p.name)}" ${p.name === mapping.provider ? 'selected' : ''}>${escapeHtml(p.name)} (${escapeHtml(p.provider_type)})</option>`
)
.join("");
if (isPrimary) {
mappingDiv.innerHTML = `
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-blue-600 text-white rounded-lg font-bold text-sm">1순위</span>
<span class="text-blue-600 font-semibold">Primary</span>
</div>
</div>
<div class="space-y-4">
<div>
<label class="label">Provider 선택</label>
<select name="mappings[${index}][provider]" class="input-field" required>
<option value="">Provider를 선택하세요</option>
${providerOptions}
</select>
</div>
<div>
<label class="label">실제 모델명</label>
<input type="text" name="mappings[${index}][actual_model]" class="input-field font-mono" value="${escapeHtml(mapping.actual_model)}" placeholder="예: claude-sonnet-4-5 또는 anthropic/claude-sonnet-4-5" required>
<div class="helper-text">Provider에서 사용하는 실제 모델 ID</div>
</div>
</div>
`;
} else {
mappingDiv.innerHTML = `
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-gray-200 text-gray-700 rounded-lg font-bold text-sm">${mapping.priority}순위</span>
<span class="text-gray-600 font-semibold">Fallback</span>
</div>
<div class="flex gap-2">
<button type="button" onclick="moveMappingUp(this)" class="p-2 hover:bg-gray-100 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
</svg>
</button>
<button type="button" onclick="moveMappingDown(this)" class="p-2 hover:bg-gray-100 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<button type="button" onclick="removeMapping(this)" class="p-2 hover:bg-red-50 text-red-600 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<div class="space-y-4">
<div>
<label class="label">Provider 선택</label>
<select name="mappings[${index}][provider]" class="input-field" required>
<option value="">Provider를 선택하세요</option>
${providerOptions}
</select>
</div>
<div>
<label class="label">실제 모델명</label>
<input type="text" name="mappings[${index}][actual_model]" class="input-field font-mono" value="${escapeHtml(mapping.actual_model)}" placeholder="예: claude-sonnet-4-5 또는 anthropic/claude-sonnet-4-5" required>
<div class="helper-text">Provider에서 사용하는 실제 모델 ID</div>
</div>
</div>
`;
}
mappingsContainer.appendChild(mappingDiv);
});
document.querySelector('#models-add-view h1').textContent = '모델 수정';
document.querySelector('#models-add-view > p').textContent = '모델 정보를 수정하세요';
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.textContent = '모델 수정하기';
}
}, 100);
}
function renderAddModelView() {
if (!appState.loaded) return;
const providers = appState.config.providers || [];
const providerOptions = providers
.filter((p) => p.enabled)
.map(
(p) =>
`<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)} (${escapeHtml(p.provider_type)})</option>`,
)
.join("");
mappingCount = 1;
document.getElementById("provider-mappings").innerHTML = `
<div class="provider-mapping border-2 border-blue-200 bg-blue-50 rounded-xl p-6" data-priority="1">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-blue-600 text-white rounded-lg font-bold text-sm">1순위</span>
<span class="text-blue-600 font-semibold">Primary</span>
</div>
</div>
<div class="space-y-4">
<div>
<label class="label">Provider 선택</label>
<select name="mappings[0][provider]" class="input-field" required>
<option value="">Provider를 선택하세요</option>
${providerOptions}
</select>
</div>
<div>
<label class="label">실제 모델명</label>
<input type="text" name="mappings[0][actual_model]" class="input-field font-mono" placeholder="예: claude-sonnet-4-5 또는 anthropic/claude-sonnet-4-5" required>
<div class="helper-text">Provider에서 사용하는 실제 모델 ID</div>
</div>
</div>
</div>
`;
}
function showAddModel() {
appState.editingModel = null;
navigate({ tab: "models", view: "add" });
setTimeout(() => {
document.querySelector('#models-add-view h1').textContent = '모델 추가';
document.querySelector('#models-add-view > p').textContent = '등록된 Provider를 선택하여 모델을 만드세요';
const form = document.getElementById("add-model-form");
if (form) {
form.reset();
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.textContent = '모델 추가하기';
}
}
renderAddModelView();
}, 100);
}
function showModelsList() {
navigate({ tab: "models", view: null });
}
function addProviderMapping() {
if (!appState.loaded) return;
mappingCount++;
const priority = mappingCount;
const mappingsContainer =
document.getElementById("provider-mappings");
const providers = appState.config.providers || [];
const providerOptions = providers
.filter((p) => p.enabled)
.map(
(p) =>
`<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)} (${escapeHtml(p.provider_type)})</option>`,
)
.join("");
const newMapping = document.createElement("div");
newMapping.className =
"provider-mapping border-2 border-gray-200 rounded-xl p-6";
newMapping.setAttribute("data-priority", priority);
newMapping.innerHTML = `
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-gray-200 text-gray-700 rounded-lg font-bold text-sm">${priority}순위</span>
<span class="text-gray-600 font-semibold">Fallback</span>
</div>
<div class="flex gap-2">
<button type="button" onclick="moveMappingUp(this)" class="p-2 hover:bg-gray-100 rounded-lg" ${priority === 2 ? "disabled" : ""}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
</svg>
</button>
<button type="button" onclick="moveMappingDown(this)" class="p-2 hover:bg-gray-100 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<button type="button" onclick="removeMapping(this)" class="p-2 hover:bg-red-50 text-red-600 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<div class="space-y-4">
<div>
<label class="label">Provider 선택</label>
<select name="mappings[${priority - 1}][provider]" class="input-field" required>
<option value="">Provider를 선택하세요</option>
${providerOptions}
</select>
</div>
<div>
<label class="label">실제 모델명</label>
<input type="text" name="mappings[${priority - 1}][actual_model]" class="input-field font-mono" placeholder="예: claude-sonnet-4-5 또는 anthropic/claude-sonnet-4-5" required>
<div class="helper-text">Provider에서 사용하는 실제 모델 ID</div>
</div>
</div>
`;
mappingsContainer.appendChild(newMapping);
}
function removeMapping(btn) {
const mapping = btn.closest(".provider-mapping");
mapping.remove();
updateMappingPriorities();
}
function moveMappingUp(btn) {
const mapping = btn.closest(".provider-mapping");
const prev = mapping.previousElementSibling;
if (prev) {
mapping.parentNode.insertBefore(mapping, prev);
updateMappingPriorities();
}
}
function moveMappingDown(btn) {
const mapping = btn.closest(".provider-mapping");
const next = mapping.nextElementSibling;
if (next) {
mapping.parentNode.insertBefore(next, mapping);
updateMappingPriorities();
}
}
function updateMappingPriorities() {
const mappings = document.querySelectorAll(".provider-mapping");
mappings.forEach((mapping, index) => {
const priority = index + 1;
mapping.setAttribute("data-priority", priority);
const badge = mapping.querySelector(".px-3");
const label = mapping.querySelector(".font-semibold");
if (priority === 1) {
badge.className =
"px-3 py-1 bg-blue-600 text-white rounded-lg font-bold text-sm";
badge.textContent = "1순위";
label.className = "text-blue-600 font-semibold";
label.textContent = "Primary";
mapping.className =
"provider-mapping border-2 border-blue-200 bg-blue-50 rounded-xl p-6";
} else {
badge.className =
"px-3 py-1 bg-gray-200 text-gray-700 rounded-lg font-bold text-sm";
badge.textContent = `${priority}순위`;
label.className = "text-gray-600 font-semibold";
label.textContent = "Fallback";
mapping.className =
"provider-mapping border-2 border-gray-200 rounded-xl p-6";
}
mapping
.querySelectorAll("select, input")
.forEach((input) => {
const name = input.name;
input.name = name.replace(/\[\d+\]/, `[${index}]`);
});
});
}
function renderOverview() {
if (!appState.loaded) return;
const config = appState.config;
document.getElementById("current-default").textContent =
config.router.default || "-";
document.getElementById("current-think").textContent =
config.router.think || "사용 안 함";
document.getElementById("current-background").textContent =
config.router.background || "사용 안 함";
document.getElementById("current-websearch").textContent =
config.router.websearch || "사용 안 함";
document.getElementById("server-address").textContent =
`${config.server.host}:${config.server.port}`;
const providerCount = config.providers
? config.providers.length
: 0;
document.getElementById("provider-count").textContent =
`${providerCount}개`;
const modelCount = config.models ? config.models.length : 0;
document.getElementById("model-count-overview").textContent =
`${modelCount}개`;
populateModelSelects(config.models || []);
document.querySelector('[name="default_model"]').value =
config.router.default || "";
document.querySelector('[name="think_model"]').value =
config.router.think || "";
document.querySelector('[name="background_model"]').value =
config.router.background || "";
document.querySelector('[name="websearch_model"]').value =
config.router.websearch || "";
}
function populateModelSelects(models) {
const selects = document.querySelectorAll(
'select[name$="_model"]',
);
selects.forEach((select) => {
const isRequired = select.required;
const currentValue = select.value;
select.innerHTML = isRequired
? '<option value="">모델을 선택하세요</option>'
: '<option value="">사용 안 함</option>';
models.forEach((model) => {
const option = document.createElement("option");
option.value = model.name;
option.textContent = model.name;
if (currentValue === model.name) {
option.selected = true;
}
select.appendChild(option);
});
});
}
document
.getElementById("add-model-form")
.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(e.target);
const modelName = formData.get("model_name")?.trim();
if (!modelName) {
notifyError("모델 이름을 입력해주세요.");
return;
}
const mappings = [];
document
.querySelectorAll(".provider-mapping")
.forEach((mapping, index) => {
const provider = formData
.get(`mappings[${index}][provider]`)
?.trim();
const actualModel = formData
.get(`mappings[${index}][actual_model]`)
?.trim();
if (provider && actualModel) {
mappings.push({
priority: index + 1,
provider: provider,
actual_model: actualModel,
});
}
});
if (mappings.length === 0) {
notifyError(
"최소 1개 이상의 Provider 매핑을 추가해주세요.",
);
return;
}
const modelData = {
name: modelName,
mappings: mappings,
};
try {
const isEditing = appState.editingModel !== null;
if (isEditing) {
const editIndex = appState.editingModel;
if (
appState.config.models &&
appState.config.models.some(
(m, idx) =>
idx !== editIndex &&
m.name.toLowerCase() === modelName.toLowerCase(),
)
) {
notifyError(
`이미 "${modelName}" 이름의 모델이 존재합니다. 다른 이름을 사용해주세요.`,
);
return;
}
appState.config.models[editIndex] = modelData;
saveToLocalStorage(appState.config);
notifySuccess(
"모델이 수정되었어요 (저장 버튼을 눌러 적용하세요)",
);
appState.editingModel = null;
e.target.reset();
navigate({ tab: "models", view: null });
} else {
if (
appState.config.models &&
appState.config.models.some(
(m) =>
m.name.toLowerCase() ===
modelName.toLowerCase(),
)
) {
notifyError(
`이미 "${modelName}" 이름의 모델이 존재합니다. 다른 이름을 사용해주세요.`,
);
return;
}
if (!appState.config.models) {
appState.config.models = [];
}
appState.config.models.push(modelData);
saveToLocalStorage(appState.config);
notifySuccess(
"모델이 추가되었어요 (저장 버튼을 눌러 적용하세요)",
);
e.target.reset();
navigate({ tab: "models", view: null });
}
} catch (error) {
console.error("Failed to save model:", error);
notifyError("모델 저장 중 오류가 발생했어요");
}
});
document
.getElementById("add-provider-form")
.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(e.target);
const providerName = formData.get("provider_name")?.trim();
const providerType = formData.get("provider_type")?.trim();
const apiKey = formData.get("api_key")?.trim();
const baseUrl = formData.get("base_url")?.trim();
if (!providerName) {
notifyError("Provider 이름을 입력해주세요.");
return;
}
if (!providerType) {
notifyError("Provider 타입을 선택해주세요.");
return;
}
if (!apiKey) {
notifyError("API Key를 입력해주세요.");
return;
}
const providerData = {
name: providerName,
provider_type: providerType,
api_key: apiKey,
models: [], enabled: true,
};
if (baseUrl) {
providerData.base_url = baseUrl;
}
try {
const isEditing = appState.editingProvider !== null;
if (isEditing) {
const editIndex = appState.editingProvider;
if (
appState.config.providers &&
appState.config.providers.some(
(p, idx) =>
idx !== editIndex &&
p.name.toLowerCase() === providerName.toLowerCase(),
)
) {
notifyError(
`이미 "${providerName}" 이름의 Provider가 존재합니다. 다른 이름을 사용해주세요.`,
);
return;
}
providerData.models = appState.config.providers[editIndex].models || [];
appState.config.providers[editIndex] = providerData;
saveToLocalStorage(appState.config);
notifySuccess(
"Provider가 수정되었어요 (저장 버튼을 눌러 적용하세요)",
);
appState.editingProvider = null;
e.target.reset();
navigate({ tab: "providers", view: null });
} else {
if (
appState.config.providers &&
appState.config.providers.some(
(p) =>
p.name.toLowerCase() ===
providerName.toLowerCase(),
)
) {
notifyError(
`이미 "${providerName}" 이름의 Provider가 존재합니다. 다른 이름을 사용해주세요.`,
);
return;
}
if (!appState.config.providers) {
appState.config.providers = [];
}
appState.config.providers.push(providerData);
saveToLocalStorage(appState.config);
notifySuccess(
"Provider가 추가되었어요 (저장 버튼을 눌러 적용하세요)",
);
e.target.reset();
navigate({ tab: "providers", view: null });
}
} catch (error) {
console.error("Failed to save provider:", error);
notifyError("Provider 저장 중 오류가 발생했어요");
}
});
document
.getElementById("router-form")
.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(e.target);
const defaultModel = formData.get("default_model");
const thinkModel = formData.get("think_model");
const backgroundModel = formData.get("background_model");
const websearchModel = formData.get("websearch_model");
if (!defaultModel) {
notifyError("기본 모델을 선택해주세요.");
return;
}
try {
appState.config.router.default = defaultModel;
if (thinkModel) {
appState.config.router.think = thinkModel;
} else {
delete appState.config.router.think;
}
if (backgroundModel) {
appState.config.router.background = backgroundModel;
} else {
delete appState.config.router.background;
}
if (websearchModel) {
appState.config.router.websearch = websearchModel;
} else {
delete appState.config.router.websearch;
}
saveToLocalStorage(appState.config);
notifySuccess("라우터 설정이 변경되었어요 (저장 버튼을 눌러 적용하세요)");
renderOverview();
} catch (error) {
console.error("Failed to save router config:", error);
notifyError("저장 중 오류가 발생했어요");
}
});
async function restartServer() {
if (!confirm("서버를 재시작하시겠어요?")) return;
try {
await fetch("/api/restart", { method: "POST" });
notifySuccess("서버가 재시작되었어요");
} catch (error) {
console.error("Failed to restart server:", error);
notifyError("서버 재시작에 실패했습니다");
}
}
async function saveAllConfig() {
console.log("Saving all configuration...");
try {
const success = await syncToServer();
if (success) {
updateLastSaved();
notifySuccess("모든 설정이 저장되었어요");
renderOverview();
} else {
notifyError("저장에 실패했어요");
}
} catch (error) {
console.error("Failed to save all config:", error);
notifyError("저장 중 오류가 발생했어요");
}
}
async function saveAndRestart() {
if (!confirm("설정을 저장하고 서버를 재시작하시겠어요?"))
return;
try {
await saveAllConfig();
setTimeout(async () => {
await fetch("/api/restart", { method: "POST" });
notifySuccess("서버가 재시작되었어요");
}, 500);
} catch (error) {
console.error("Failed to save and restart:", error);
notifyError("저장 및 재시작에 실패했습니다");
}
}
function updateLastSaved() {
const now = new Date();
const timeStr = now.toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
document.getElementById("last-saved").textContent = timeStr;
}
window.addEventListener("DOMContentLoaded", async () => {
await loadConfig();
handleRoute();
renderOverview();
updateLastSaved();
});
window.addEventListener("popstate", handleRoute);
</script>
</body>
</html>