Files
sub2api-cn-relay-manager/deploy/tksea-portal/admin/providers.html
Hermes 62b3c657a9
Some checks failed
CI / Build & Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Release (push) Has been cancelled
fix(portal): restore missing inputs in providers.html (revert buggy reorder)
The reorder_hints.py script incorrectly removed several <input> elements
when moving .hint spans before inputs. This commit restores the file to
the correct state from commit 56474264, preserving all inputs while
keeping the hints in their proper places.

Fixes: admin-username input missing, several other inputs corrupted
2026-06-03 21:02:50 +08:00

1447 lines
64 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" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Provider Admin</title>
<link rel="stylesheet" href="/portal/portal.css">
<link rel="stylesheet" href="/portal/admin-common.css">
<style>
/* Page-specific layout only. Tokens, cards, buttons come from portal.css + admin-common.css. */
.layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: var(--s-5); }
.field-grid { display: grid; gap: 12px; }
.field-grid.two { grid-template-columns: 1fr 1fr; }
.field-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.catalog { display: grid; gap: 12px; max-height: 32rem; overflow: auto; padding-right: 4px; }
.catalog-item, .route-item {
padding: 16px; border-radius: var(--r-lg); border: 1px solid var(--border-subtle);
background: var(--bg-elev-1); cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
}
.catalog-item:hover, .route-item:hover { transform: translateY(-1px); border-color: rgba(20,184,166,0.32); }
.catalog-item.is-selected, .route-item.is-selected { background: var(--color-primary-soft); border-color: rgba(20,184,166,0.32); }
.catalog-item strong, .route-item strong { display: block; margin-bottom: 6px; font-size: 15px; color: var(--text-strong); }
.catalog-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.grid-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-5); }
.list-card { padding: 16px; border-radius: var(--r-md); border: 1px solid var(--border-subtle); background: var(--bg-elev-1); }
.list-card strong { display: block; margin-bottom: 6px; color: var(--text-strong); }
.empty { color: var(--text-muted); font-size: 13px; line-height: 1.6; }
.panel-desc { color: var(--text-muted); font-size: 13px; line-height: 1.6; margin: 8px 0 0; }
.inline-code { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); word-break: break-word; }
.tone-healthy { background: var(--color-success-soft); color: var(--color-success); border-color: rgba(34,197,94,0.2); }
.tone-cooldown { background: var(--color-warning-soft); color: var(--color-warning); border-color: rgba(245,158,11,0.2); }
.tone-failing { background: var(--color-danger-soft); color: var(--color-danger); border-color: rgba(239,68,68,0.2); }
.tone-disabled { background: var(--color-neutral-soft); color: var(--color-neutral); border-color: rgba(100,116,139,0.2); }
.tone-ready { background: var(--color-success-soft); color: var(--color-success); border-color: rgba(34,197,94,0.2); }
.tone-note { background: var(--color-primary-soft); color: var(--color-primary); border-color: rgba(20,184,166,0.2); }
.tone-warn { background: var(--color-warning-soft); color: var(--color-warning); border-color: rgba(245,158,11,0.2); }
pre {
margin: 0; padding: 16px; border-radius: var(--r-md);
border: 1px solid var(--border-subtle);
background: rgba(2, 6, 23, 0.6); color: var(--text-default);
font-size: 12px; line-height: 1.65; overflow: auto; white-space: pre-wrap; word-break: break-word;
}
[data-theme="light"] pre { background: var(--slate-900); color: var(--slate-100); }
.table-wrap { margin-top: 12px; }
@media (max-width: 1200px) {
.layout, .grid-columns, .field-grid.two, .field-grid.three { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell fade-in">
<nav class="topnav" aria-label="Admin Navigation" data-admin-nav data-admin-current="providers"></nav>
<section class="page-hero">
<div>
<span class="page-hero__eyebrow">Provider Admin</span>
<h1>新增模型 / 供应商目录:先看 pack 再做 preview-import</h1>
<p>这页负责浏览已安装 pack、选择 provider、调用 <code>preview-import</code> / <code>import</code>,同时提供 provider manifest 草稿生成与发布。Provider Manifest 草稿支持先保存到服务端,再经由 CRM 提交到仓库。</p>
<ul style="display:flex;flex-wrap:wrap;gap:10px;margin:18px 0 0;padding:0;list-style:none;">
<li style="padding:8px 12px;border-radius:999px;border:1px solid var(--border-subtle);background:var(--bg-elev-1);font-size:13px;font-weight:700;color:var(--text-muted);">默认 API Base<code>/portal-admin-api</code></li>
<li style="padding:8px 12px;border-radius:999px;border:1px solid var(--border-subtle);background:var(--bg-elev-1);font-size:13px;font-weight:700;color:var(--text-muted);">支持管理员登录,也保留 Bearer admin token 兜底</li>
<li style="padding:8px 12px;border-radius:999px;border:1px solid var(--border-subtle);background:var(--bg-elev-1);font-size:13px;font-weight:700;color:var(--text-muted);">服务端草稿路径:<code>/api/provider-drafts</code>,发布:<code>/publish</code></li>
</ul>
</div>
<div class="stack" style="gap:var(--s-3);">
<div class="stat-card">
<div class="stat-icon stat-icon-primary" id="pr-m1"></div>
<div class="min-w-0">
<p class="stat-label">API Root</p>
<p class="stat-value" id="metric-api-root">-</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-info" id="pr-m2"></div>
<div class="min-w-0">
<p class="stat-label">Packs</p>
<p class="stat-value" id="metric-pack-count">0</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-success" id="pr-m3"></div>
<div class="min-w-0">
<p class="stat-label">Providers</p>
<p class="stat-value" id="metric-provider-count">0</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-warning" id="pr-m4"></div>
<div class="min-w-0">
<p class="stat-label">草稿</p>
<p class="stat-value" id="metric-draft-count">0</p>
</div>
</div>
</div>
</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" id="provider-workflow">
<h2>Provider 工作流 · 模板 + 批量导入</h2>
<p class="panel-desc">
这页整合了 <strong>Provider Manifest 草稿</strong>生成、<code>preview-import</code> 预检与 <code>import</code> 走 access closure 的批量导入。
三步走:<strong>① 选/新建 provider</strong><strong>② 维护模板</strong><strong>③ 选模型 + 加 key</strong>
模板存在时(已加载目录或已保存草稿)自动填充;否则就是新建。
改完模板字段点「保存草稿」生效,发布前可继续改。
</p>
<!-- ============ Step 1: Provider ID + Display Name ============ -->
<div class="field-grid two">
<label>Provider ID共享从目录选 或 新建一个)
<input id="provider-id" type="text" list="preset-provider-id-from-catalog" placeholder="选现有 / 新建一个">
<datalist id="preset-provider-id-from-catalog">
<!-- 由 JS 从 state.providers 自动填充 -->
</datalist>
<span class="hint">从目录选 → 模板自动填;手填新名字 → 新建。系统会根据 display name / base url / models 自动生成 provider_id 并尽量避免与现有 provider_id 冲突。</span>
</label>
<label>Display Name
<input id="draft-display-name" type="text" placeholder="OpenAI 中转 / DeepSeek / 硅基流动" list="preset-display-name">
<datalist id="preset-display-name">
<option value="OpenAI 中转"></option>
<option value="OpenAI 直连"></option>
<option value="DeepSeek"></option>
<option value="硅基流动 (SiliconFlow)"></option>
<option value="Moonshot Kimi"></option>
<option value="智谱 GLM"></option>
<option value="Anthropic Claude"></option>
<option value="零一万物"></option>
<option value="MiniMax"></option>
<option value="通义千问 Qwen"></option>
<option value="百川 Baichuan"></option>
<option value="腾讯混元"></option>
</datalist>
</label>
</div>
<div class="statusbar" id="provider-id-preview">Provider ID 预览:等待填写模型信息。</div>
<div class="statusbar" id="model-conflicts">同模型已存在:当前未发现冲突。</div>
<!-- ============ Step 2: 模板参数(默认展开,可折叠) ============ -->
<details open id="template-section">
<summary style="cursor:pointer; padding: 8px 0; font-weight: 700; color: var(--text-strong);">
② 模板参数(修改后请「保存草稿」生效)
</summary>
<div class="field-grid two" style="margin-top: 8px;">
<label>Platform
<input id="draft-platform" type="text" placeholder="openai" list="preset-platform">
<datalist id="preset-platform">
<option value="openai"></option>
<option value="openai-compatible"></option>
<option value="deepseek"></option>
<option value="anthropic"></option>
<option value="gemini"></option>
<option value="zhipu"></option>
<option value="moonshot"></option>
<option value="minimax"></option>
<option value="qwen"></option>
<option value="baichuan"></option>
<option value="hunyuan"></option>
<option value="yi"></option>
</datalist>
</label>
<label>Smoke Test Model
<input id="draft-smoke-model" type="text" placeholder="从 models 自动取第一个" list="preset-smoke-model">
<datalist id="preset-smoke-model">
<option value="gpt-5.4"></option>
<option value="gpt-5.4-mini"></option>
<option value="deepseek-chat"></option>
<option value="MiniMax-M2.7-highspeed"></option>
<option value="claude-sonnet-4-5"></option>
<option value="gemini-2.5-pro"></option>
<option value="kimi-k2.6"></option>
<option value="glm-4.6"></option>
<option value="qwen3-coder-plus"></option>
</datalist>
<span class="hint">建议填「最便宜最快」的模型作为健康探针,留空则从 Models 取第一个</span>
</label>
</div>
<div class="field-grid two">
<label>Base URL Placeholder
<input id="draft-base-url" type="text" placeholder="https://api.example.com/v1" list="preset-base-url">
<datalist id="preset-base-url">
<option value="https://api.openai.com/v1"></option>
<option value="https://api.deepseek.com/v1"></option>
<option value="https://api.siliconflow.cn/v1"></option>
<option value="https://api.moonshot.cn/v1"></option>
<option value="https://open.bigmodel.cn/api/paas/v4"></option>
<option value="https://api.anthropic.com/v1"></option>
<option value="https://generativelanguage.googleapis.com/v1beta"></option>
<option value="https://api.minimax.chat/v1"></option>
<option value="https://dashscope.aliyuncs.com/compatible-mode/v1"></option>
<option value="https://api.baichuan-ai.com/v1"></option>
<option value="https://hunyuan.tencent.com/v1"></option>
<option value="https://api.lingyiwanwu.com/v1"></option>
</datalist>
</label>
<label>Models用逗号分隔或点击下方 chip 一键加入)
<input id="draft-models" type="text" placeholder="gpt-5.4,gpt-5.4-mini">
<div class="chip-row" id="model-presets" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;">
<button type="button" class="chip" data-model="gpt-5.4">+ gpt-5.4</button>
<button type="button" class="chip" data-model="gpt-5.4-mini">+ gpt-5.4-mini</button>
<button type="button" class="chip" data-model="deepseek-chat">+ deepseek-chat</button>
<button type="button" class="chip" data-model="MiniMax-M2.7-highspeed">+ MiniMax-M2.7-highspeed</button>
<button type="button" class="chip" data-model="kimi-k2.6">+ kimi-k2.6</button>
<button type="button" class="chip" data-model="glm-4.6">+ glm-4.6</button>
<button type="button" class="chip" data-model="claude-sonnet-4-5">+ claude-sonnet-4-5</button>
<button type="button" class="chip" data-model="gemini-2.5-pro">+ gemini-2.5-pro</button>
<button type="button" class="chip" data-model="qwen3-coder-plus">+ qwen3-coder-plus</button>
<button type="button" class="chip" data-model="gpt-4o">+ gpt-4o</button>
<button type="button" class="chip" data-model="o4-mini">+ o4-mini</button>
<button type="button" class="ghost chip-clear" data-action="clear-models" style="margin-left:6px;">清空</button>
</div>
</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">填写模板后保存草稿。</div>
<div class="statusbar" id="recent-template-meta">最近成功模板:暂无。首次可以先按一份已有 provider 作为参考修改。</div>
</details>
<hr style="border:0; border-top: 1px solid var(--border-subtle); margin: 20px 0;">
<!-- ============ Step 3: 批量导入 key ============ -->
<h3 style="margin: 0 0 8px 0; font-size: 17px;">③ 批量导入 Key</h3>
<p class="panel-desc">
模板保存后,从 <strong>模板支持的模型</strong> 里勾选要启用的,粘 key每行一个。
点「预检导入」先验证,再「执行导入」走 access closure。
</p>
<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>Mode
<select id="mode">
<option value="strict">strict</option>
<option value="partial">partial</option>
</select>
</label>
</div>
<div class="field-grid two">
<label>Probe API Keyself_service 必填)
<input id="access-api-key" type="text" placeholder="sk-probe">
</label>
<div id="subscription-fields" hidden style="display:grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<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>
</div>
<label>选择要启用的模型
<div id="model-picker" class="model-picker" style="padding: 10px 12px; border-radius: var(--r-md); border: 1px solid var(--border-subtle); background: var(--bg-elev-1); min-height: 48px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center;">
<span class="hint" style="margin: 0;">保存模板后,这里会自动列出模板的 supported_models勾选要启用的</span>
</div>
</label>
<label>Keys
<textarea id="provider-keys" rows="6" placeholder="sk-example-1
sk-example-2
sk-example-3"></textarea>
<span class="hint">一行一个供应商帐号 key多 key 批量导入)。例如 <code>sk-prod-xxx</code></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>
</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>
<span id="metric-pack-id" hidden></span>
<span id="metric-provider-id" hidden></span>
</main>
<script src="/portal/admin-common.js"></script>
<script src="/portal/portal.js"></script>
<script>
const AdminCommon = window.Sub2ApiAdminCommon;
window.Sub2ApiPortal.renderModernAdminNav(document.querySelector("[data-admin-nav]"), "providers");
(function injectIcons(){
const M = (id, n) => { const el = document.getElementById(id); if (el) el.innerHTML = window.Sub2ApiPortal.svg(n, 22); };
M("pr-m1", "shield");
M("pr-m2", "package");
M("pr-m3", "package");
M("pr-m4", "edit");
})();
// <script>
AdminCommon.renderAdminNav(document.querySelector("[data-admin-nav]"), "providers");
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 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");
const adminRuntime = AdminCommon.createAdminPageRuntime({
apiBaseInput,
adminTokenInput,
adminUsernameInput,
adminPasswordInput,
adminSessionStatus,
onSessionPersist: saveConfig,
});
function defaultApiBase() {
return adminRuntime.defaultApiBase();
}
function normalizeApiBase() {
return adminRuntime.normalizeApiBase();
}
function setStatus(target, message, tone = "note") {
AdminCommon.setStatus(target, message, tone);
}
function authHeaders() {
return {
"Content-Type": "application/json",
...adminRuntime.authHeaders(),
};
}
async function requestJSON(path, options = {}) {
return adminRuntime.requestJSON(path, options);
}
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 ![
providerIDInput.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(), providerIDInput.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(), providerIDInput.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 = providerIDInput.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 || !providerIDInput.value.trim()) {
providerIDInput.value = suggested;
}
// Auto-fill smoke_test_model with first model if user hasn't filled it yet
if (!draftSmokeModelInput.value.trim()) {
const models = parseDraftModels();
if (models.length > 0) {
draftSmokeModelInput.value = models[0];
}
}
}
function rememberLastPublishedTemplate() {
const payload = {
provider_id: providerIDInput.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;
providerIDInput.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() {
const payload = {
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: providerIDInput.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(),
};
AdminCommon.writeStoredConfig(storageKey, payload);
syncMetrics();
setStatus(catalogStatus, "本地配置已保存。", "success");
}
function restoreConfig() {
const payload = AdminCommon.readStoredConfig(storageKey);
apiBaseInput.value = defaultApiBase();
packPathInput.value = "/app/packs/openai-cn-pack";
subscriptionDaysInput.value = "30";
if (!Object.keys(payload).length) {
syncMetrics();
return;
}
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 || "";
providerIDInput.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 || "";
syncMetrics();
}
function updateAccessModeFields() {
subscriptionFields.hidden = accessModeInput.value !== "subscription";
}
async function refreshAdminSession() {
return adminRuntime.refreshAdminSession();
}
async function loginAdminSession() {
return adminRuntime.loginAdminSession();
}
async function logoutAdminSession() {
return adminRuntime.logoutAdminSession();
}
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 storedConfig = AdminCommon.readStoredConfig(storageKey);
const savedPackID = storedConfig.packID || "";
const savedHostID = storedConfig.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 payload = AdminCommon.readStoredConfig(storageKey);
payload.packID = packIDInput.value;
payload.hostID = hostIDInput.value;
AdminCommon.writeStoredConfig(storageKey, 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 = providerIDInput.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) {}
});
adminLogoutButton.addEventListener("click", async () => {
try {
await logoutAdminSession();
await refreshAdminSession();
} catch (error) {}
});
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));
providerIDInput.addEventListener("input", () => {
const currentValue = providerIDInput.value.trim();
state.draftProviderIDAuto = !currentValue || currentValue === buildSuggestedProviderID();
syncDraftHelperState(false);
});
restoreConfig();
updateAccessModeFields();
syncMetrics();
renderRecentTemplateMeta(readLastPublishedTemplate());
syncDraftHelperState();
refreshAdminSession().catch(() => {});
renderServerDrafts();
// ===== Unified Provider Workflow glue =====
// When the user types/selects a provider_id, look it up in the loaded
// catalog and auto-populate the template fields + model picker.
(function wireUnifiedProviderWorkflow() {
const providerIdInput = document.getElementById("provider-id");
const modelPicker = document.getElementById("model-picker");
const catalogDatalist = document.getElementById("preset-provider-id-from-catalog");
if (!providerIdInput || !modelPicker) return;
// Render model checkboxes into the picker area
function renderModelPicker(models) {
if (!models || models.length === 0) {
modelPicker.innerHTML = '<span class="hint" style="margin:0;">模板还没保存或未选模型,<a href="#template-section">先在上面填 Models 字段</a> 再保存草稿</span>';
return;
}
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexWrap = "wrap";
wrap.style.gap = "6px";
models.forEach((m) => {
const label = document.createElement("label");
label.style.cssText = "display:inline-flex; align-items:center; gap:4px; padding:4px 10px; border-radius:9999px; background:var(--bg-elev-2); border:1px solid var(--border-subtle); font-size:12px; font-weight:600; cursor:pointer; color:var(--text-default);";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.value = m;
cb.checked = true;
cb.dataset.role = "model-pick";
label.appendChild(cb);
label.appendChild(document.createTextNode(m));
wrap.appendChild(label);
});
const actions = document.createElement("div");
actions.style.cssText = "display:flex; gap:6px; margin-left:auto;";
const allBtn = document.createElement("button");
allBtn.type = "button";
allBtn.className = "ghost";
allBtn.textContent = "全选";
allBtn.style.cssText = "padding:2px 8px; font-size:11px;";
allBtn.addEventListener("click", (ev) => {
ev.preventDefault();
wrap.querySelectorAll('input[data-role="model-pick"]').forEach((c) => (c.checked = true));
});
const noneBtn = document.createElement("button");
noneBtn.type = "button";
noneBtn.className = "ghost";
noneBtn.textContent = "全不选";
noneBtn.style.cssText = "padding:2px 8px; font-size:11px;";
noneBtn.addEventListener("click", (ev) => {
ev.preventDefault();
wrap.querySelectorAll('input[data-role="model-pick"]').forEach((c) => (c.checked = false));
});
actions.appendChild(allBtn);
actions.appendChild(noneBtn);
wrap.appendChild(actions);
modelPicker.innerHTML = "";
modelPicker.appendChild(wrap);
}
function findProvider(id) {
if (!id || !Array.isArray(state.providers)) return null;
return state.providers.find((p) => p.provider_id === id) || null;
}
function syncFromProviderId() {
const id = providerIdInput.value.trim();
// Mirror into provider-id (the shared input) so the existing draft logic picks it up
if (providerIdInput && id) {
providerIdInput.value = id;
state.draftProviderIDAuto = false;
}
const p = findProvider(id);
if (p) {
// Auto-fill template fields from catalog
if (p.display_name && draftDisplayNameInput) draftDisplayNameInput.value = p.display_name;
if (p.platform && draftPlatformInput) draftPlatformInput.value = p.platform;
if (p.smoke_test_model && draftSmokeModelInput) draftSmokeModelInput.value = p.smoke_test_model;
if (p.base_url_placeholder && draftBaseURLInput) draftBaseURLInput.value = p.base_url_placeholder;
if (Array.isArray(p.supported_models) && draftModelsInput) {
draftModelsInput.value = p.supported_models.join(",");
}
renderModelPicker(p.supported_models || []);
AdminCommon.setStatus(document.getElementById("provider-status"), `已从目录加载 provider "${id}" 的模板和模型。可直接修改后点「保存草稿」或继续填 Keys 批量导入。`, "note");
} else if (id) {
// New provider — clear picker, leave template fields for the user to fill
renderModelPicker([]);
AdminCommon.setStatus(document.getElementById("provider-status"), `新 provider "${id}":先在 ② 模板参数 里填 Models / Display Name / Base URL 等,再「保存草稿」。`, "note");
} else {
renderModelPicker([]);
}
syncDraftHelperState(false);
}
// Re-render catalog datalist when providers list changes
function refreshCatalogDatalist() {
if (!catalogDatalist) return;
catalogDatalist.innerHTML = "";
if (Array.isArray(state.providers)) {
state.providers.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.provider_id;
catalogDatalist.appendChild(opt);
});
}
}
// Hook: input change on provider-id
providerIdInput.addEventListener("input", syncFromProviderId);
providerIdInput.addEventListener("change", syncFromProviderId);
// Hook: refresh catalog datalist after loadCatalog runs.
// loadCatalog is the function name in the main script. We poll state
// with a short interval since it's not directly exposed.
let lastProvidersRef = null;
setInterval(() => {
if (state.providers !== lastProvidersRef) {
lastProvidersRef = state.providers;
refreshCatalogDatalist();
// Also re-sync if the current id is now known
syncFromProviderId();
}
}, 500);
// Hook: when Models field changes, refresh the picker
draftModelsInput.addEventListener("input", () => {
// Only refresh if no provider is currently auto-populating from catalog
const id = providerIdInput.value.trim();
const p = findProvider(id);
if (!p) {
const models = parseDraftModels();
if (models.length > 0) {
renderModelPicker(models);
}
}
});
// Initial render
refreshCatalogDatalist();
syncFromProviderId();
})();
// Wire up the "常用模型" preset chips — click to append to Models field.
// Placed after the main script so the input refs are available.
(function wireModelPresets() {
const wrap = document.getElementById("model-presets");
if (!wrap) return;
wrap.addEventListener("click", (ev) => {
const btn = ev.target.closest("button[data-model]");
if (!btn) return;
ev.preventDefault();
const model = btn.getAttribute("data-model");
const current = (draftModelsInput.value || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (current.includes(model)) {
setStatus(document.getElementById("draft-status"), `模型 ${model} 已在列表中`, "note");
return;
}
current.push(model);
draftModelsInput.value = current.join(",");
syncDraftHelperState(false);
setStatus(document.getElementById("draft-status"), `已加入 ${model}(共 ${current.length} 个)`, "success");
});
const clearBtn = wrap.querySelector('[data-action="clear-models"]');
if (clearBtn) {
clearBtn.addEventListener("click", (ev) => {
ev.preventDefault();
draftModelsInput.value = "";
syncDraftHelperState(false);
setStatus(document.getElementById("draft-status"), "已清空 Models 字段", "note");
});
}
})();
</script>>
</html>