Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin/providers.html
2026-05-29 13:37:43 +08:00

1544 lines
57 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Provider Admin</title>
<style>
:root {
--bg: #f2ede4;
--panel: rgba(255, 252, 246, 0.94);
--ink: #201b17;
--muted: #685d54;
--line: rgba(32, 27, 23, 0.12);
--accent: #0b6bcb;
--accent-soft: rgba(11, 107, 203, 0.12);
--success: #126b43;
--success-soft: rgba(18, 107, 67, 0.1);
--warn: #9b6215;
--warn-soft: rgba(155, 98, 21, 0.12);
--danger: #b23131;
--danger-soft: rgba(178, 49, 49, 0.1);
--shadow: 0 26px 72px rgba(47, 38, 29, 0.1);
--radius: 24px;
--radius-sm: 16px;
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
font-family: var(--font-sans);
background:
radial-gradient(circle at top left, rgba(11, 107, 203, 0.16), transparent 26rem),
radial-gradient(circle at bottom right, rgba(18, 107, 67, 0.12), transparent 24rem),
var(--bg);
}
a { color: inherit; }
.shell {
max-width: 1440px;
margin: 0 auto;
padding: 34px 20px 64px;
}
.topnav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 18px;
}
.topnav a {
text-decoration: none;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.78);
color: var(--muted);
font-size: 13px;
font-weight: 700;
transition: transform 120ms ease, background 120ms ease;
}
.topnav a:hover { transform: translateY(-1px); background: #fff; }
.topnav a.is-current {
background: var(--ink);
border-color: var(--ink);
color: #fff;
}
.hero {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
margin-bottom: 18px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hero-card, .panel {
padding: 26px;
}
.hero-card {
position: relative;
overflow: hidden;
}
.hero-card::after {
content: "";
position: absolute;
right: -4rem;
bottom: -4rem;
width: 18rem;
height: 18rem;
border-radius: 999px;
background: linear-gradient(135deg, rgba(11, 107, 203, 0.18), rgba(18, 107, 67, 0.06));
filter: blur(10px);
}
.eyebrow {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
h1 {
margin: 18px 0 10px;
font-size: clamp(32px, 4vw, 46px);
line-height: 1.02;
letter-spacing: -0.05em;
}
.hero-copy {
max-width: 58rem;
color: var(--muted);
line-height: 1.75;
font-size: 15px;
}
.hero-points {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.hero-points li {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.78);
font-size: 13px;
font-weight: 700;
}
.metrics {
display: grid;
gap: 12px;
align-content: start;
}
.metric {
border-radius: 20px;
border: 1px solid var(--line);
background: #fff;
padding: 16px;
}
.metric-label {
color: var(--muted);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.metric-value {
margin-top: 8px;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.04em;
word-break: break-word;
}
.layout {
display: grid;
grid-template-columns: 440px minmax(0, 1fr);
gap: 18px;
margin-bottom: 18px;
}
.stack {
display: grid;
gap: 18px;
}
.panel h2 {
margin: 0 0 8px;
font-size: 24px;
letter-spacing: -0.04em;
}
.panel-desc {
margin: 0 0 18px;
color: var(--muted);
line-height: 1.7;
font-size: 14px;
}
.field-grid {
display: grid;
gap: 12px;
}
.field-grid.two {
grid-template-columns: 1fr 1fr;
}
label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
font: inherit;
color: var(--ink);
background: #fff;
}
textarea {
min-height: 126px;
resize: vertical;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
}
.hint {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
font-weight: 500;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
button {
border: 0;
cursor: pointer;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
font-weight: 800;
transition: transform 120ms ease, opacity 120ms ease, background 120ms ease;
}
button:hover { transform: translateY(-1px); }
button:disabled { cursor: not-allowed; opacity: 0.6; transform: none; }
.primary { background: var(--ink); color: #fff; }
.secondary { background: var(--accent-soft); color: var(--accent); }
.ghost {
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
}
.statusbar {
margin-top: 16px;
min-height: 54px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: #fff;
display: flex;
align-items: center;
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.statusbar[data-tone="success"] { background: var(--success-soft); color: var(--success); border-color: rgba(18,107,67,0.2); }
.statusbar[data-tone="warning"] { background: var(--warn-soft); color: var(--warn); border-color: rgba(155,98,21,0.2); }
.statusbar[data-tone="danger"] { background: var(--danger-soft); color: var(--danger); border-color: rgba(178,49,49,0.2); }
.catalog {
display: grid;
gap: 12px;
margin-top: 16px;
max-height: 28rem;
overflow: auto;
padding-right: 4px;
}
.catalog-item {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.84);
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
}
.catalog-item:hover { transform: translateY(-1px); border-color: rgba(11,107,203,0.22); }
.catalog-item.is-selected {
background: rgba(11,107,203,0.08);
border-color: rgba(11,107,203,0.22);
}
.catalog-item strong {
display: block;
margin-bottom: 6px;
font-size: 16px;
}
.catalog-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255,255,255,0.72);
border: 1px solid var(--line);
font-size: 12px;
font-weight: 700;
color: var(--muted);
}
.tone-ready { background: var(--success-soft); color: var(--success); border-color: rgba(18,107,67,0.18); }
.tone-note { background: var(--accent-soft); color: var(--accent); border-color: rgba(11,107,203,0.18); }
.result-grid {
display: grid;
grid-template-columns: 1fr 1fr 0.9fr;
gap: 18px;
}
.draft-list {
display: grid;
gap: 10px;
max-height: 30rem;
overflow: auto;
padding-right: 4px;
}
.draft-item {
border: 1px solid var(--line);
border-radius: 18px;
padding: 14px;
background: rgba(255,255,255,0.84);
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease;
}
.draft-item:hover {
transform: translateY(-1px);
border-color: rgba(11,107,203,0.22);
}
.draft-item strong {
display: block;
margin-bottom: 4px;
font-size: 14px;
}
pre {
margin: 0;
border-radius: 18px;
border: 1px solid var(--line);
background: #fff;
padding: 16px;
overflow: auto;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
}
.empty {
color: var(--muted);
font-size: 13px;
}
code {
font-family: var(--font-mono);
font-size: 12px;
}
@media (max-width: 1120px) {
.hero, .layout, .result-grid, .field-grid.two { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell">
<nav class="topnav" aria-label="Admin Navigation">
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/providers.html" class="is-current">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
</nav>
<section class="hero">
<article class="card hero-card">
<div class="eyebrow">Provider Admin</div>
<h1>在一个页面里看目录、预检导入、执行导入</h1>
<p class="hero-copy">
这页把“新增模型供应商”和“导入供应商帐号”的前置动作收口在一起。当前版本会先列出
pack 里已经存在的 provider允许直接做 <code>preview-import</code><code>import</code>
如果你要新增 provider 模板,本页也支持把草稿保存到 CRM再一键发布成 pack/provider 文件并自动提交到仓库。
</p>
<ul class="hero-points">
<li>默认 API Base<code>/portal-admin-api</code></li>
<li>支持管理员登录会话,也保留 Bearer admin token 兜底</li>
<li>支持 provider 草稿发布到 pack 仓库</li>
</ul>
</article>
<aside class="card metrics">
<div class="metric">
<div class="metric-label">API Root</div>
<div class="metric-value" id="metric-api-root">-</div>
</div>
<div class="metric">
<div class="metric-label">当前 Pack</div>
<div class="metric-value" id="metric-pack-id">-</div>
</div>
<div class="metric">
<div class="metric-label">当前 Provider</div>
<div class="metric-value" id="metric-provider-id">-</div>
</div>
</aside>
</section>
<section class="layout">
<article class="card panel">
<h2>连接与目录</h2>
<p class="panel-desc">
先建立到 CRM 的连接,再拉取 pack 与 provider 目录。当前页面优先使用 <code>host_id</code> 驱动导入,
不再要求浏览器直接知道宿主 base URL。
</p>
<div class="field-grid two">
<label>API Base
<input id="api-base" type="text" placeholder="https://sub.tksea.top/portal-admin-api">
</label>
<label>Admin Token可选
<input id="admin-token" type="password" placeholder="crm-admin-token">
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
</label>
</div>
<div class="field-grid two">
<label>管理员用户名
<input id="admin-username" type="text" placeholder="admin">
</label>
<label>管理员密码
<input id="admin-password" type="password" placeholder="请输入管理员密码">
</label>
</div>
<div class="actions">
<button id="admin-login-btn" type="button">管理员登录</button>
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
<span id="admin-session-status" class="status">尚未检查管理员会话。</span>
</div>
<div class="field-grid two">
<label>Pack
<select id="pack-id"></select>
</label>
<label>Host ID
<select id="host-id"></select>
</label>
</div>
<div class="field-grid">
<label>Pack Path
<input id="pack-path" type="text" value="/app/packs/openai-cn-pack">
<span class="hint">preview/import 当前仍需显式带上 pack_path默认按 remote43 的运行路径填写。</span>
</label>
</div>
<div class="actions">
<button class="primary" id="load-catalog-btn">加载目录</button>
<button class="ghost" id="save-config-btn">保存本地配置</button>
</div>
<div class="statusbar" id="catalog-status">等待加载目录。</div>
<div class="catalog" id="provider-catalog">
<div class="empty">还没有 provider 目录。</div>
</div>
</article>
<section class="stack">
<article class="card panel">
<h2>Provider 预检与导入</h2>
<p class="panel-desc">
选择 pack/provider 后,可以先预检再执行导入。<code>preview-import</code> 侧重帐号本身与模型探测,
<code>import</code> 会继续走 access closure。
</p>
<div class="field-grid two">
<label>Provider ID
<input id="provider-id" type="text" placeholder="minimax-53hk">
</label>
<label>Mode
<select id="mode">
<option value="strict">strict</option>
<option value="partial">partial</option>
</select>
</label>
</div>
<div class="field-grid two">
<label>Access Mode
<select id="access-mode">
<option value="self_service">self_service</option>
<option value="subscription">subscription</option>
</select>
</label>
<label>Probe API Key
<input id="access-api-key" type="text" placeholder="sk-probe-or-access">
</label>
</div>
<div class="field-grid two" id="subscription-fields" hidden>
<label>Subscription Users
<input id="subscription-users" type="text" placeholder="relay-sub-1,relay-sub-2">
</label>
<label>Subscription Days
<input id="subscription-days" type="number" min="1" value="30">
</label>
</div>
<label>Keys
<textarea id="provider-keys" placeholder="一行一个 key"></textarea>
<span class="hint">一行一个供应商帐号 key。导入页会自动拆成字符串数组传给 CRM。</span>
</label>
<div class="actions">
<button class="secondary" id="preview-provider-btn">预检导入</button>
<button class="primary" id="import-provider-btn">执行导入</button>
<a class="ghost" href="/portal/admin/batch-import.html" style="text-decoration:none; display:inline-flex; align-items:center;">打开 Batch Import</a>
</div>
<div class="statusbar" id="provider-status">先从左侧目录选择 provider。</div>
</article>
<article class="card panel" id="manifest-draft">
<h2>Provider Manifest 草稿</h2>
<p class="panel-desc">
这部分既能生成与保存 provider 草稿,也能从已保存草稿一键发布到 pack/provider 文件并提交到仓库。
页面本身不直接拼 Git 命令,所有写仓库动作都经由 CRM 服务端完成。
</p>
<div class="statusbar" id="recent-template-meta">最近成功模板:暂无。首次可以先按一份已有 provider 作为参考修改。</div>
<div class="field-grid two">
<label>Provider ID自动生成可手改
<input id="draft-provider-id" type="text" placeholder="openai-zhongzhuan">
<span class="hint">根据 display name / base url / models 自动生成,并尽量避免与现有 provider_id 冲突。</span>
</label>
<label>Display Name
<input id="draft-display-name" type="text" placeholder="OpenAI 中转">
</label>
</div>
<div class="statusbar" id="provider-id-preview">Provider ID 预览:等待填写模型信息。</div>
<div class="statusbar" id="model-conflicts">同模型已存在:当前未发现冲突。</div>
<div class="field-grid two">
<label>Platform
<input id="draft-platform" type="text" placeholder="openai">
</label>
<label>Smoke Test Model
<input id="draft-smoke-model" type="text" placeholder="gpt-5.4">
</label>
</div>
<div class="field-grid two">
<label>Base URL Placeholder
<input id="draft-base-url" type="text" placeholder="https://api.example.com/v1">
</label>
<label>Models
<input id="draft-models" type="text" placeholder="gpt-5.4,gpt-5.4-mini">
</label>
</div>
<label>发布 Commit Message
<input id="draft-commit-message" type="text" placeholder="feat(pack): publish provider draft openai-zhongzhuan">
<span class="hint">留空时会按 provider_id 自动生成标准 commit message。</span>
</label>
<div class="actions">
<button class="secondary" id="generate-draft-btn">生成草稿</button>
<button class="primary" id="save-draft-btn">保存到服务端</button>
<button class="secondary" id="update-draft-btn">更新草稿</button>
<button class="primary" id="publish-draft-btn">发布到仓库</button>
<button class="ghost" id="delete-draft-btn">删除草稿</button>
<button class="ghost" id="copy-draft-btn">复制 JSON</button>
<button class="ghost" id="refresh-drafts-btn">刷新草稿列表</button>
</div>
<div class="statusbar" id="draft-status">填写后生成 provider manifest 草稿。</div>
</article>
</section>
</section>
<section class="result-grid">
<article class="card panel">
<h2>Preview 结果</h2>
<p class="panel-desc">这里直接展示 <code>POST /api/providers/{providerID}/preview-import</code> 的原始 JSON。</p>
<pre id="preview-result">{
"hint": "还没有 preview 结果"
}</pre>
</article>
<article class="card panel">
<h2>Import / Draft 结果</h2>
<p class="panel-desc">导入结果与 manifest 草稿都收在这里,便于直接复制或继续跳转到 batch-import 页面。</p>
<pre id="import-result">{
"hint": "还没有 import 或 draft 结果"
}</pre>
</article>
<article class="card panel">
<h2>服务端草稿</h2>
<p class="panel-desc">
这里展示已经保存到 CRM SQLite 的 provider 草稿。点击条目会把内容回填到 manifest 表单,继续编辑或再次复制。
</p>
<div class="draft-list" id="server-draft-list">
<div class="empty">还没有服务端草稿。</div>
</div>
</article>
</section>
</main>
<script>
const storageKey = "sub2api-crm-provider-admin-v1";
const lastPublishedTemplateKey = "sub2api-crm-provider-admin:last-published-template";
const sampleDraftTemplate = {
provider_id: "openai-zhongzhuan",
display_name: "OpenAI 中转",
platform: "openai",
base_url: "https://api.example.com/v1",
smoke_test_model: "gpt-5.4",
supported_models: ["gpt-5.4", "gpt-5.4-mini"],
};
const state = {
packs: [],
hosts: [],
providers: [],
selectedProvider: null,
drafts: [],
currentDraftID: "",
draftTemplateHydrated: false,
draftProviderIDAuto: true,
};
const apiBaseInput = document.getElementById("api-base");
const adminTokenInput = document.getElementById("admin-token");
const adminUsernameInput = document.getElementById("admin-username");
const adminPasswordInput = document.getElementById("admin-password");
const adminLoginButton = document.getElementById("admin-login-btn");
const adminLogoutButton = document.getElementById("admin-logout-btn");
const adminSessionStatus = document.getElementById("admin-session-status");
const packIDInput = document.getElementById("pack-id");
const hostIDInput = document.getElementById("host-id");
const packPathInput = document.getElementById("pack-path");
const providerIDInput = document.getElementById("provider-id");
const modeInput = document.getElementById("mode");
const accessModeInput = document.getElementById("access-mode");
const accessAPIKeyInput = document.getElementById("access-api-key");
const subscriptionUsersInput = document.getElementById("subscription-users");
const subscriptionDaysInput = document.getElementById("subscription-days");
const providerKeysInput = document.getElementById("provider-keys");
const subscriptionFields = document.getElementById("subscription-fields");
const providerCatalog = document.getElementById("provider-catalog");
const catalogStatus = document.getElementById("catalog-status");
const providerStatus = document.getElementById("provider-status");
const draftStatus = document.getElementById("draft-status");
const previewResult = document.getElementById("preview-result");
const importResult = document.getElementById("import-result");
const serverDraftList = document.getElementById("server-draft-list");
const metricApiRoot = document.getElementById("metric-api-root");
const metricPackID = document.getElementById("metric-pack-id");
const metricProviderID = document.getElementById("metric-provider-id");
const draftProviderIDInput = document.getElementById("draft-provider-id");
const draftDisplayNameInput = document.getElementById("draft-display-name");
const draftPlatformInput = document.getElementById("draft-platform");
const draftSmokeModelInput = document.getElementById("draft-smoke-model");
const draftBaseURLInput = document.getElementById("draft-base-url");
const draftModelsInput = document.getElementById("draft-models");
const draftCommitMessageInput = document.getElementById("draft-commit-message");
const recentTemplateMeta = document.getElementById("recent-template-meta");
const providerIdPreview = document.getElementById("provider-id-preview");
const modelConflicts = document.getElementById("model-conflicts");
function defaultApiBase() {
return `${window.location.origin}/portal-admin-api`;
}
function normalizeApiBase() {
return (apiBaseInput.value.trim() || defaultApiBase()).replace(/\/+$/, "");
}
function setStatus(target, message, tone = "") {
target.textContent = message;
if (tone) {
target.dataset.tone = tone;
} else {
delete target.dataset.tone;
}
}
function authHeaders() {
const headers = {
"Content-Type": "application/json",
};
const token = adminTokenInput.value.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function requestJSON(path, options = {}) {
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { ...headers };
if (!skipAuth) {
Object.assign(finalHeaders, authHeaders(), finalHeaders);
}
return fetch(`${normalizeApiBase()}${path}`, {
...rest,
credentials: "include",
headers: finalHeaders,
})
.then(async (response) => {
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch (error) {
payload = { raw: text };
}
if (!response.ok) {
const message = payload?.error?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
throw new Error(message);
}
return payload;
});
}
function syncMetrics() {
metricApiRoot.textContent = normalizeApiBase();
metricPackID.textContent = packIDInput.value || "-";
metricProviderID.textContent = providerIDInput.value || "-";
}
function parseDraftModels() {
return draftModelsInput.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
function draftFieldsAreEmpty() {
return ![
draftProviderIDInput.value,
draftDisplayNameInput.value,
draftPlatformInput.value,
draftSmokeModelInput.value,
draftBaseURLInput.value,
draftModelsInput.value,
].some((value) => String(value || "").trim());
}
function slugifyProviderPart(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/https?:\/\//g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function inferRouteSuffix(displayName, baseURL) {
const display = String(displayName || "").toLowerCase();
const url = String(baseURL || "").toLowerCase();
if (display.includes("官方") || display.includes("official") || url.includes("deepseek.com")) {
return "official";
}
if (display.includes("中转") || display.includes("relay")) {
const host = slugifyProviderPart(url.split("/")[0] || "");
if (host && !["api", "com", "www", "example"].includes(host)) {
return host;
}
return "relay";
}
const host = slugifyProviderPart(url.split("/")[0] || "");
return host || "";
}
function draftPrimaryModel() {
return parseDraftModels()[0] || draftSmokeModelInput.value.trim() || "";
}
function existingProviderIDs() {
const ids = new Set();
state.providers.forEach((provider) => ids.add((provider.provider_id || "").trim()));
state.drafts.forEach((draft) => ids.add((draft.provider_id || "").trim()));
return ids;
}
function detectModelConflicts(models, ignoreProviderID = "") {
const normalizedModels = models.map((value) => value.trim()).filter(Boolean);
const ignored = ignoreProviderID.trim();
const conflicts = [];
const seen = new Set();
const scan = (entries, source) => {
entries.forEach((entry) => {
const providerID = (entry.provider_id || entry.providerID || "").trim();
if (!providerID || providerID === ignored) {
return;
}
const supportedModels = Array.isArray(entry.supported_models || entry.supportedModels)
? (entry.supported_models || entry.supportedModels)
: [];
const matched = supportedModels.filter((model) => normalizedModels.includes(String(model || "").trim()));
if (!matched.length) {
return;
}
const key = `${providerID}:${matched.join(",")}`;
if (seen.has(key)) {
return;
}
seen.add(key);
conflicts.push({
providerID,
displayName: entry.display_name || entry.displayName || providerID,
matchedModels: matched,
source,
});
});
};
scan(state.providers, "provider");
scan(state.drafts, "draft");
return conflicts;
}
function updateConflictSummary() {
const conflicts = detectModelConflicts(parseDraftModels(), draftProviderIDInput.value);
if (!conflicts.length) {
setStatus(modelConflicts, "同模型已存在:当前未发现冲突。", "success");
return conflicts;
}
const labels = conflicts
.map((item) => `${item.matchedModels.join("/")} -> ${item.providerID}`)
.join("");
setStatus(modelConflicts, `同模型已存在:${labels}。通常不需要因为“官方 / 中转”再重复新增 provider优先复用或修改已有 provider。`, "warning");
return conflicts;
}
function buildSuggestedProviderID() {
const primaryModel = draftPrimaryModel();
const displayName = draftDisplayNameInput.value.trim();
const baseURL = draftBaseURLInput.value.trim();
const conflicts = detectModelConflicts(parseDraftModels(), draftProviderIDInput.value);
if (primaryModel && conflicts.length === 1) {
return conflicts[0].providerID;
}
const baseCandidate = slugifyProviderPart(primaryModel) || slugifyProviderPart(displayName) || "provider";
const suffix = inferRouteSuffix(displayName, baseURL);
let candidate = [baseCandidate, suffix].filter(Boolean).join("-");
if (!candidate) {
candidate = "provider";
}
const ids = existingProviderIDs();
const currentValue = draftProviderIDInput.value.trim();
if (currentValue) {
ids.delete(currentValue);
}
if (!ids.has(candidate)) {
return candidate;
}
let index = 2;
while (ids.has(`${candidate}-${index}`)) {
index += 1;
}
return `${candidate}-${index}`;
}
function syncDraftHelperState(forceProviderID = false) {
const suggested = buildSuggestedProviderID();
setStatus(providerIdPreview, `providerIdPreview: ${suggested}`, "note");
updateConflictSummary();
if (forceProviderID || state.draftProviderIDAuto || !draftProviderIDInput.value.trim()) {
draftProviderIDInput.value = suggested;
}
}
function rememberLastPublishedTemplate() {
const payload = {
provider_id: draftProviderIDInput.value.trim(),
display_name: draftDisplayNameInput.value.trim(),
platform: draftPlatformInput.value.trim(),
base_url: draftBaseURLInput.value.trim(),
smoke_test_model: draftSmokeModelInput.value.trim(),
supported_models: parseDraftModels(),
saved_at: new Date().toISOString(),
};
localStorage.setItem(lastPublishedTemplateKey, JSON.stringify(payload));
renderRecentTemplateMeta(payload);
}
function readLastPublishedTemplate() {
try {
const raw = localStorage.getItem(lastPublishedTemplateKey);
if (!raw) {
return null;
}
return JSON.parse(raw);
} catch (error) {
return null;
}
}
function renderRecentTemplateMeta(template) {
if (!template) {
setStatus(recentTemplateMeta, "最近成功模板:暂无。首次可以先按一份已有 provider 作为参考修改。", "note");
return;
}
const models = Array.isArray(template.supported_models) ? template.supported_models.join(", ") : "";
setStatus(recentTemplateMeta, `最近成功模板:${template.provider_id || "-"} · ${template.display_name || "-"} · ${models || "-"}`, "success");
}
function fillDraftForm(draft, options = {}) {
const { preserveCommitMessage = false, lockProviderID = false } = options;
state.currentDraftID = draft.draft_id || "";
state.draftProviderIDAuto = !lockProviderID;
draftProviderIDInput.value = draft.provider_id || "";
draftDisplayNameInput.value = draft.display_name || "";
draftPlatformInput.value = draft.platform || "";
draftSmokeModelInput.value = draft.smoke_test_model || "";
draftBaseURLInput.value = draft.base_url || "";
draftModelsInput.value = Array.isArray(draft.supported_models) ? draft.supported_models.join(",") : "";
if (!preserveCommitMessage && !draftCommitMessageInput.value.trim()) {
draftCommitMessageInput.value = `feat(pack): publish provider draft ${draft.provider_id || ""}`.trim();
}
syncDraftHelperState();
}
function hydrateDraftTemplateIfNeeded() {
if (state.draftTemplateHydrated || !draftFieldsAreEmpty()) {
return;
}
const template = readLastPublishedTemplate()
|| state.drafts[0]
|| state.providers[0]
|| sampleDraftTemplate;
fillDraftForm(template, { preserveCommitMessage: true });
state.currentDraftID = "";
state.draftTemplateHydrated = true;
renderRecentTemplateMeta(template === sampleDraftTemplate ? null : template);
}
function saveConfig() {
localStorage.setItem(storageKey, JSON.stringify({
apiBase: apiBaseInput.value.trim(),
adminToken: adminTokenInput.value,
adminUsername: adminUsernameInput.value.trim(),
packID: packIDInput.value,
hostID: hostIDInput.value,
packPath: packPathInput.value.trim(),
providerID: providerIDInput.value.trim(),
mode: modeInput.value,
accessMode: accessModeInput.value,
accessAPIKey: accessAPIKeyInput.value.trim(),
subscriptionUsers: subscriptionUsersInput.value.trim(),
subscriptionDays: subscriptionDaysInput.value,
providerKeys: providerKeysInput.value,
draftProviderID: draftProviderIDInput.value.trim(),
draftDisplayName: draftDisplayNameInput.value.trim(),
draftPlatform: draftPlatformInput.value.trim(),
draftSmokeModel: draftSmokeModelInput.value.trim(),
draftBaseURL: draftBaseURLInput.value.trim(),
draftModels: draftModelsInput.value.trim(),
draftCommitMessage: draftCommitMessageInput.value.trim(),
}));
syncMetrics();
setStatus(catalogStatus, "本地配置已保存。", "success");
}
function restoreConfig() {
const raw = localStorage.getItem(storageKey);
apiBaseInput.value = defaultApiBase();
packPathInput.value = "/app/packs/openai-cn-pack";
subscriptionDaysInput.value = "30";
if (!raw) {
syncMetrics();
return;
}
try {
const payload = JSON.parse(raw);
apiBaseInput.value = payload.apiBase || defaultApiBase();
adminTokenInput.value = payload.adminToken || "";
adminUsernameInput.value = payload.adminUsername || "";
packPathInput.value = payload.packPath || "/app/packs/openai-cn-pack";
providerIDInput.value = payload.providerID || "";
modeInput.value = payload.mode || "strict";
accessModeInput.value = payload.accessMode || "self_service";
accessAPIKeyInput.value = payload.accessAPIKey || "";
subscriptionUsersInput.value = payload.subscriptionUsers || "";
subscriptionDaysInput.value = payload.subscriptionDays || "30";
providerKeysInput.value = payload.providerKeys || "";
draftProviderIDInput.value = payload.draftProviderID || "";
draftDisplayNameInput.value = payload.draftDisplayName || "";
draftPlatformInput.value = payload.draftPlatform || "";
draftSmokeModelInput.value = payload.draftSmokeModel || "";
draftBaseURLInput.value = payload.draftBaseURL || "";
draftModelsInput.value = payload.draftModels || "";
draftCommitMessageInput.value = payload.draftCommitMessage || "";
} catch (error) {
apiBaseInput.value = defaultApiBase();
}
syncMetrics();
}
function updateAccessModeFields() {
subscriptionFields.hidden = accessModeInput.value !== "subscription";
}
async function refreshAdminSession() {
try {
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
if (payload.username && !adminUsernameInput.value.trim()) {
adminUsernameInput.value = payload.username;
}
if (payload.authenticated) {
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
} else if (payload.login_enabled) {
setStatus(adminSessionStatus, "未登录。可直接使用管理员用户名密码建立会话。", "note");
} else {
setStatus(adminSessionStatus, "当前实例未启用管理员密码登录,只能使用 Bearer token。", "warning");
}
return payload;
} catch (error) {
setStatus(adminSessionStatus, `管理员会话检查失败:${error.message}`, "danger");
throw error;
}
}
async function loginAdminSession() {
const username = adminUsernameInput.value.trim();
const password = adminPasswordInput.value;
if (!username || !password) {
throw new Error("管理员用户名和密码不能为空");
}
const payload = await requestJSON("/api/admin/session/login", {
method: "POST",
skipAuth: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
adminPasswordInput.value = "";
saveConfig();
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
return payload;
}
async function logoutAdminSession() {
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
method: "POST",
credentials: "include",
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
adminPasswordInput.value = "";
setStatus(adminSessionStatus, "管理员会话已退出。", "note");
}
function renderSelectOptions(select, values, currentValue, emptyLabel) {
select.innerHTML = "";
if (!values.length) {
const option = document.createElement("option");
option.value = "";
option.textContent = emptyLabel;
select.appendChild(option);
return;
}
values.forEach((entry) => {
const option = document.createElement("option");
option.value = entry.value;
option.textContent = entry.label;
if (entry.value === currentValue) {
option.selected = true;
}
select.appendChild(option);
});
}
function renderCatalog() {
if (!state.providers.length) {
providerCatalog.innerHTML = '<div class="empty">当前 pack 下没有 provider。</div>';
return;
}
providerCatalog.innerHTML = state.providers.map((provider) => `
<button type="button" class="catalog-item ${state.selectedProvider?.provider_id === provider.provider_id ? "is-selected" : ""}" data-provider-id="${escapeHTML(provider.provider_id)}">
<strong>${escapeHTML(provider.display_name || provider.provider_id)}</strong>
<div class="empty">${escapeHTML(provider.provider_id)}</div>
<div class="catalog-meta">
<span class="pill tone-note">${escapeHTML(provider.platform || "unknown")}</span>
<span class="pill ${provider.host_overlays > 0 ? "tone-ready" : ""}">host overlays: ${escapeHTML(provider.host_overlays || 0)}</span>
</div>
</button>
`).join("");
providerCatalog.querySelectorAll("[data-provider-id]").forEach((element) => {
element.addEventListener("click", () => {
const providerID = element.getAttribute("data-provider-id");
const selected = state.providers.find((item) => item.provider_id === providerID);
if (!selected) return;
state.selectedProvider = selected;
providerIDInput.value = selected.provider_id;
syncMetrics();
renderCatalog();
setStatus(providerStatus, `已选择 provider${selected.provider_id}`, "success");
});
});
}
function renderServerDrafts() {
if (!state.drafts.length) {
serverDraftList.innerHTML = '<div class="empty">还没有服务端草稿。</div>';
return;
}
serverDraftList.innerHTML = state.drafts.map((draft) => `
<button type="button" class="draft-item" data-draft-id="${escapeHTML(draft.draft_id)}">
<strong>${escapeHTML(draft.display_name || draft.provider_id)}</strong>
<div class="empty">${escapeHTML(draft.provider_id)} · ${escapeHTML(draft.pack_id)}</div>
<div class="catalog-meta">
<span class="pill tone-note">${escapeHTML(draft.platform || "unknown")}</span>
<span class="pill">${escapeHTML(draft.draft_id)}</span>
</div>
</button>
`).join("");
serverDraftList.querySelectorAll("[data-draft-id]").forEach((element) => {
element.addEventListener("click", () => {
const draftID = element.getAttribute("data-draft-id");
const draft = state.drafts.find((item) => item.draft_id === draftID);
if (!draft) {
return;
}
fillDraftForm(draft, { lockProviderID: true });
importResult.textContent = JSON.stringify(draft.manifest || {}, null, 2);
setStatus(draftStatus, `已回填服务端草稿:${draft.draft_id}`, "success");
});
});
}
function clearDraftFormSelection() {
state.currentDraftID = "";
state.draftProviderIDAuto = true;
}
async function loadCatalog() {
const button = document.getElementById("load-catalog-btn");
button.disabled = true;
try {
setStatus(catalogStatus, "正在加载 pack / host / provider 目录 ...");
syncMetrics();
const [packsPayload, hostsPayload] = await Promise.all([
requestJSON("/api/packs", { headers: authHeaders() }),
requestJSON("/api/hosts", { headers: authHeaders() }),
]);
state.packs = Array.isArray(packsPayload.packs) ? packsPayload.packs : [];
state.hosts = Array.isArray(hostsPayload.hosts) ? hostsPayload.hosts : [];
const packOptions = state.packs.map((pack) => ({
value: pack.pack_id,
label: `${pack.pack_id} (${pack.version})`,
}));
const hostOptions = state.hosts.map((host) => ({
value: host.host_id,
label: `${host.host_id} · ${host.host_version || "unknown"}`,
}));
const savedPackID = JSON.parse(localStorage.getItem(storageKey) || "{}").packID || "";
const savedHostID = JSON.parse(localStorage.getItem(storageKey) || "{}").hostID || "";
renderSelectOptions(packIDInput, packOptions, savedPackID || packOptions[0]?.value || "", "暂无 pack");
renderSelectOptions(hostIDInput, hostOptions, savedHostID || hostOptions[0]?.value || "", "暂无 host");
const packID = packIDInput.value;
if (!packID) {
state.providers = [];
renderCatalog();
setStatus(catalogStatus, "没有可用 pack。", "warning");
return;
}
const providersPayload = await requestJSON(`/api/packs/${encodeURIComponent(packID)}/providers`, {
headers: authHeaders(),
});
state.providers = Array.isArray(providersPayload.providers) ? providersPayload.providers : [];
state.selectedProvider = state.providers.find((item) => item.provider_id === providerIDInput.value) || state.providers[0] || null;
if (state.selectedProvider) {
providerIDInput.value = state.selectedProvider.provider_id;
}
renderCatalog();
syncMetrics();
saveCurrentIDsOnly();
await loadServerDrafts();
hydrateDraftTemplateIfNeeded();
setStatus(catalogStatus, `目录已加载:${state.packs.length} 个 pack${state.providers.length} 个 provider。`, "success");
} catch (error) {
setStatus(catalogStatus, `加载目录失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
function saveCurrentIDsOnly() {
const raw = localStorage.getItem(storageKey);
let payload = {};
try {
payload = raw ? JSON.parse(raw) : {};
} catch (error) {
payload = {};
}
payload.packID = packIDInput.value;
payload.hostID = hostIDInput.value;
localStorage.setItem(storageKey, JSON.stringify(payload));
}
function parseKeys() {
const keys = providerKeysInput.value
.split("\n")
.map((value) => value.trim())
.filter(Boolean);
if (!keys.length) {
throw new Error("至少填写一把供应商 key");
}
return keys;
}
function selectedProviderID() {
const providerID = providerIDInput.value.trim();
if (!providerID) {
throw new Error("provider_id 不能为空");
}
return providerID;
}
function baseProviderPayload() {
const hostID = hostIDInput.value.trim();
const packPath = packPathInput.value.trim();
if (!hostID) {
throw new Error("host_id 不能为空");
}
if (!packPath) {
throw new Error("pack_path 不能为空");
}
return {
host_id: hostID,
pack_path: packPath,
provider_id: selectedProviderID(),
keys: parseKeys(),
mode: modeInput.value,
};
}
function buildImportPayload() {
const payload = {
...baseProviderPayload(),
access_mode: accessModeInput.value,
access_api_key: accessAPIKeyInput.value.trim(),
};
if (!payload.access_api_key) {
throw new Error("access_api_key / probe_api_key 不能为空");
}
if (payload.access_mode === "subscription") {
payload.subscription_users = subscriptionUsersInput.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
payload.subscription_days = Number(subscriptionDaysInput.value || 30);
if (!payload.subscription_users.length) {
throw new Error("subscription 模式下 subscription_users 不能为空");
}
}
return payload;
}
async function previewProvider() {
const button = document.getElementById("preview-provider-btn");
button.disabled = true;
try {
const providerID = selectedProviderID();
setStatus(providerStatus, `正在预检 ${providerID} ...`);
const payload = baseProviderPayload();
const preview = await requestJSON(`/api/providers/${encodeURIComponent(providerID)}/preview-import`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(payload),
});
previewResult.textContent = JSON.stringify(preview, null, 2);
setStatus(providerStatus, `preview-import 已完成:${providerID}`, "success");
} catch (error) {
setStatus(providerStatus, `预检失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
async function importProvider() {
const button = document.getElementById("import-provider-btn");
button.disabled = true;
try {
const providerID = selectedProviderID();
setStatus(providerStatus, `正在导入 ${providerID} ...`);
const payload = buildImportPayload();
const result = await requestJSON(`/api/providers/${encodeURIComponent(providerID)}/import`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(payload),
});
importResult.textContent = JSON.stringify(result, null, 2);
setStatus(providerStatus, `import 已完成:${providerID}`, "success");
} catch (error) {
setStatus(providerStatus, `导入失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
function buildDraftPayload() {
const providerID = draftProviderIDInput.value.trim();
const displayName = draftDisplayNameInput.value.trim();
if (!providerID || !displayName) {
throw new Error("provider_id 和 display_name 不能为空");
}
const models = draftModelsInput.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
return {
provider_id: providerID,
display_name: displayName,
platform: draftPlatformInput.value.trim() || "openai",
smoke_test_model: draftSmokeModelInput.value.trim() || (models[0] || ""),
base_url_placeholder: draftBaseURLInput.value.trim() || "https://api.example.com/v1",
supported_models: models,
};
}
function buildServerDraftPayload() {
const draft = buildDraftPayload();
return {
pack_id: packIDInput.value || "openai-cn-pack",
provider_id: draft.provider_id,
display_name: draft.display_name,
platform: draft.platform,
base_url: draft.base_url_placeholder,
smoke_test_model: draft.smoke_test_model,
supported_models: draft.supported_models,
source_host_id: hostIDInput.value || "",
manifest: {
provider_id: draft.provider_id,
display_name: draft.display_name,
platform: draft.platform,
base_url: draft.base_url_placeholder,
smoke_test_model: draft.smoke_test_model,
supported_models: draft.supported_models,
},
};
}
function generateDraft() {
try {
const draft = buildDraftPayload();
const output = {
provider_id: draft.provider_id,
display_name: draft.display_name,
platform: draft.platform,
smoke_test_model: draft.smoke_test_model,
base_url: draft.base_url_placeholder,
supported_models: draft.supported_models,
};
importResult.textContent = JSON.stringify(output, null, 2);
setStatus(draftStatus, "manifest 草稿已生成,可以直接复制。", "success");
} catch (error) {
setStatus(draftStatus, `生成失败:${error.message}`, "danger");
}
}
async function copyDraft() {
try {
await navigator.clipboard.writeText(importResult.textContent);
setStatus(draftStatus, "JSON 草稿已复制。", "success");
} catch (error) {
setStatus(draftStatus, "复制失败,请手动复制下方 JSON。", "warning");
}
}
async function saveDraftToServer() {
const button = document.getElementById("save-draft-btn");
button.disabled = true;
try {
const payload = buildServerDraftPayload();
const result = await requestJSON("/api/provider-drafts", {
method: "POST",
body: JSON.stringify(payload),
});
importResult.textContent = JSON.stringify(result.draft || result, null, 2);
state.currentDraftID = result.draft?.draft_id || "";
setStatus(draftStatus, `草稿已保存:${result.draft?.draft_id || "-"}`, "success");
await loadServerDrafts();
} catch (error) {
setStatus(draftStatus, `保存失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
async function updateDraftOnServer() {
const button = document.getElementById("update-draft-btn");
button.disabled = true;
try {
if (!state.currentDraftID) {
throw new Error("请先从服务端草稿列表选择一条草稿");
}
const payload = buildServerDraftPayload();
const result = await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}`, {
method: "PUT",
body: JSON.stringify(payload),
});
importResult.textContent = JSON.stringify(result.draft || result, null, 2);
setStatus(draftStatus, `草稿已更新:${state.currentDraftID}`, "success");
await loadServerDrafts();
} catch (error) {
setStatus(draftStatus, `更新失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
async function deleteDraftOnServer() {
const button = document.getElementById("delete-draft-btn");
button.disabled = true;
try {
if (!state.currentDraftID) {
throw new Error("请先从服务端草稿列表选择一条草稿");
}
await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}`, {
method: "DELETE",
});
const deletedDraftID = state.currentDraftID;
clearDraftFormSelection();
importResult.textContent = JSON.stringify({ deleted_draft_id: deletedDraftID }, null, 2);
setStatus(draftStatus, `草稿已删除:${deletedDraftID}`, "success");
await loadServerDrafts();
} catch (error) {
setStatus(draftStatus, `删除失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
async function loadServerDrafts() {
try {
const params = new URLSearchParams();
if (packIDInput.value) {
params.set("pack_id", packIDInput.value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
const payload = await requestJSON(`/api/provider-drafts${suffix}`, {
});
state.drafts = Array.isArray(payload.provider_drafts) ? payload.provider_drafts : [];
renderServerDrafts();
hydrateDraftTemplateIfNeeded();
} catch (error) {
serverDraftList.innerHTML = `<div class="empty">加载草稿失败:${escapeHTML(error.message)}</div>`;
}
}
async function publishDraftToRepo() {
const button = document.getElementById("publish-draft-btn");
button.disabled = true;
try {
if (!state.currentDraftID) {
throw new Error("请先从服务端草稿列表选择一条草稿");
}
const result = await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}/publish`, {
method: "POST",
body: JSON.stringify({
commit_message: draftCommitMessageInput.value.trim(),
}),
});
importResult.textContent = JSON.stringify(result.publish || result, null, 2);
rememberLastPublishedTemplate();
setStatus(
draftStatus,
`已发布到仓库:${result.publish?.provider_path || "-"} · ${result.publish?.pack_version_before || "-"} -> ${result.publish?.pack_version_after || "-"} · ${result.publish?.commit_sha || "-"}`,
"success",
);
} catch (error) {
setStatus(draftStatus, `发布失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
document.getElementById("load-catalog-btn").addEventListener("click", loadCatalog);
document.getElementById("save-config-btn").addEventListener("click", saveConfig);
document.getElementById("preview-provider-btn").addEventListener("click", previewProvider);
document.getElementById("import-provider-btn").addEventListener("click", importProvider);
document.getElementById("generate-draft-btn").addEventListener("click", generateDraft);
document.getElementById("save-draft-btn").addEventListener("click", saveDraftToServer);
document.getElementById("update-draft-btn").addEventListener("click", updateDraftOnServer);
document.getElementById("publish-draft-btn").addEventListener("click", publishDraftToRepo);
document.getElementById("delete-draft-btn").addEventListener("click", deleteDraftOnServer);
document.getElementById("copy-draft-btn").addEventListener("click", copyDraft);
document.getElementById("refresh-drafts-btn").addEventListener("click", loadServerDrafts);
adminLoginButton.addEventListener("click", async () => {
try {
await loginAdminSession();
await loadCatalog();
} catch (error) {
setStatus(adminSessionStatus, error.message, "danger");
}
});
adminLogoutButton.addEventListener("click", async () => {
try {
await logoutAdminSession();
await refreshAdminSession();
} catch (error) {
setStatus(adminSessionStatus, error.message, "danger");
}
});
accessModeInput.addEventListener("change", updateAccessModeFields);
packIDInput.addEventListener("change", loadCatalog);
providerIDInput.addEventListener("input", syncMetrics);
apiBaseInput.addEventListener("input", syncMetrics);
draftDisplayNameInput.addEventListener("input", () => syncDraftHelperState(false));
draftBaseURLInput.addEventListener("input", () => syncDraftHelperState(false));
draftSmokeModelInput.addEventListener("input", () => syncDraftHelperState(false));
draftModelsInput.addEventListener("input", () => syncDraftHelperState(false));
draftProviderIDInput.addEventListener("input", () => {
const currentValue = draftProviderIDInput.value.trim();
state.draftProviderIDAuto = !currentValue || currentValue === buildSuggestedProviderID();
syncDraftHelperState(false);
});
restoreConfig();
updateAccessModeFields();
syncMetrics();
renderRecentTemplateMeta(readLastPublishedTemplate());
syncDraftHelperState();
refreshAdminSession().catch(() => {});
renderServerDrafts();
</script>
</body>
</html>