2026-05-27 21:49:12 +08:00
|
|
|
|
<!doctype html>
|
2026-06-03 11:05:10 +08:00
|
|
|
|
<html lang="zh-CN" data-theme="dark">
|
2026-05-27 21:49:12 +08:00
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
|
<title>Provider Admin</title>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<link rel="stylesheet" href="/portal/portal.css">
|
|
|
|
|
|
<link rel="stylesheet" href="/portal/admin-common.css">
|
2026-05-27 21:49:12 +08:00
|
|
|
|
<style>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
/* 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;
|
2026-05-27 21:49:12 +08:00
|
|
|
|
transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
|
|
|
|
|
|
}
|
2026-06-03 09:11:07 +08:00
|
|
|
|
.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); }
|
2026-05-27 21:49:12 +08:00
|
|
|
|
pre {
|
2026-06-03 09:11:07 +08:00
|
|
|
|
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; }
|
2026-05-27 21:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<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>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</ul>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
</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>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</div>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<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>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</div>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<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>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</div>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
</div>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</section>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
<section class="layout">
|
2026-05-27 21:49:12 +08:00
|
|
|
|
<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>
|
2026-05-28 11:01:29 +08:00
|
|
|
|
<label>Admin Token(可选)
|
2026-05-27 21:49:12 +08:00
|
|
|
|
<input id="admin-token" type="password" placeholder="crm-admin-token">
|
2026-05-28 11:01:29 +08:00
|
|
|
|
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-28 11:01:29 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-27 21:49:12 +08:00
|
|
|
|
<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
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
<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>。导入页会自动拆成字符串数组传给 CRM。</span>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</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">
|
2026-05-28 07:30:02 +08:00
|
|
|
|
这部分既能生成与保存 provider 草稿,也能从已保存草稿一键发布到 pack/provider 文件并提交到仓库。
|
|
|
|
|
|
页面本身不直接拼 Git 命令,所有写仓库动作都经由 CRM 服务端完成。
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
|
2026-05-28 12:18:10 +08:00
|
|
|
|
<div class="statusbar" id="recent-template-meta">最近成功模板:暂无。首次可以先按一份已有 provider 作为参考修改。</div>
|
|
|
|
|
|
|
2026-05-27 21:49:12 +08:00
|
|
|
|
<div class="field-grid two">
|
2026-05-28 12:18:10 +08:00
|
|
|
|
<label>Provider ID(自动生成,可手改)
|
2026-05-27 21:49:12 +08:00
|
|
|
|
<input id="draft-provider-id" type="text" placeholder="openai-zhongzhuan">
|
2026-05-28 12:18:10 +08:00
|
|
|
|
<span class="hint">根据 display name / base url / models 自动生成,并尽量避免与现有 provider_id 冲突。</span>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>Display Name
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
<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>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-28 12:18:10 +08:00
|
|
|
|
<div class="statusbar" id="provider-id-preview">Provider ID 预览:等待填写模型信息。</div>
|
|
|
|
|
|
<div class="statusbar" id="model-conflicts">同模型已存在:当前未发现冲突。</div>
|
|
|
|
|
|
|
2026-05-27 21:49:12 +08:00
|
|
|
|
<div class="field-grid two">
|
|
|
|
|
|
<label>Platform
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
<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>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>Smoke Test Model
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
<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>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="field-grid two">
|
|
|
|
|
|
<label>Base URL Placeholder
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
<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>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>Models
|
|
|
|
|
|
<input id="draft-models" type="text" placeholder="gpt-5.4,gpt-5.4-mini">
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
<span class="hint">用英文逗号分隔。点击下方常用模型快速加入。</span>
|
|
|
|
|
|
<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>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-28 07:30:02 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-27 21:49:12 +08:00
|
|
|
|
<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>
|
2026-05-28 07:30:02 +08:00
|
|
|
|
<button class="primary" id="publish-draft-btn">发布到仓库</button>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
<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>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
|
|
|
|
|
|
<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");
|
|
|
|
|
|
})();
|
2026-05-27 21:49:12 +08:00
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
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 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");
|
|
|
|
|
|
|
|
|
|
|
|
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 ![
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
// 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];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 09:11:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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() {
|
|
|
|
|
|
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: 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(),
|
|
|
|
|
|
};
|
|
|
|
|
|
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 || "";
|
|
|
|
|
|
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 || "";
|
|
|
|
|
|
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 = 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("&", "&")
|
|
|
|
|
|
.replaceAll("<", "<")
|
|
|
|
|
|
.replaceAll(">", ">")
|
|
|
|
|
|
.replaceAll('"', """)
|
|
|
|
|
|
.replaceAll("'", "'");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
|
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>
|
|
|
|
|
|
<script>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
const storageKey = "sub2api-crm-provider-admin-v1";
|
2026-05-28 12:18:10 +08:00
|
|
|
|
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"],
|
|
|
|
|
|
};
|
2026-05-27 21:49:12 +08:00
|
|
|
|
const state = {
|
|
|
|
|
|
packs: [],
|
|
|
|
|
|
hosts: [],
|
|
|
|
|
|
providers: [],
|
|
|
|
|
|
selectedProvider: null,
|
|
|
|
|
|
drafts: [],
|
|
|
|
|
|
currentDraftID: "",
|
2026-05-28 12:18:10 +08:00
|
|
|
|
draftTemplateHydrated: false,
|
|
|
|
|
|
draftProviderIDAuto: true,
|
2026-05-27 21:49:12 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const apiBaseInput = document.getElementById("api-base");
|
|
|
|
|
|
const adminTokenInput = document.getElementById("admin-token");
|
2026-05-28 11:01:29 +08:00
|
|
|
|
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");
|
2026-05-27 21:49:12 +08:00
|
|
|
|
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");
|
2026-05-28 07:30:02 +08:00
|
|
|
|
const draftCommitMessageInput = document.getElementById("draft-commit-message");
|
2026-05-28 12:18:10 +08:00
|
|
|
|
const recentTemplateMeta = document.getElementById("recent-template-meta");
|
|
|
|
|
|
const providerIdPreview = document.getElementById("provider-id-preview");
|
|
|
|
|
|
const modelConflicts = document.getElementById("model-conflicts");
|
2026-05-27 21:49:12 +08:00
|
|
|
|
|
|
|
|
|
|
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() {
|
2026-05-28 11:01:29 +08:00
|
|
|
|
const headers = {
|
2026-05-27 21:49:12 +08:00
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
};
|
2026-05-28 11:01:29 +08:00
|
|
|
|
const token = adminTokenInput.value.trim();
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
headers.Authorization = `Bearer ${token}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return headers;
|
2026-05-27 21:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function requestJSON(path, options = {}) {
|
2026-05-28 11:01:29 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-05-27 21:49:12 +08:00
|
|
|
|
.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 || "-";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 12:18:10 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
// 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];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-28 12:18:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 21:49:12 +08:00
|
|
|
|
function saveConfig() {
|
|
|
|
|
|
localStorage.setItem(storageKey, JSON.stringify({
|
|
|
|
|
|
apiBase: apiBaseInput.value.trim(),
|
|
|
|
|
|
adminToken: adminTokenInput.value,
|
2026-05-28 11:01:29 +08:00
|
|
|
|
adminUsername: adminUsernameInput.value.trim(),
|
2026-05-27 21:49:12 +08:00
|
|
|
|
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(),
|
2026-05-28 07:30:02 +08:00
|
|
|
|
draftCommitMessage: draftCommitMessageInput.value.trim(),
|
2026-05-27 21:49:12 +08:00
|
|
|
|
}));
|
|
|
|
|
|
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 || "";
|
2026-05-28 11:01:29 +08:00
|
|
|
|
adminUsernameInput.value = payload.adminUsername || "";
|
2026-05-27 21:49:12 +08:00
|
|
|
|
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 || "";
|
2026-05-28 07:30:02 +08:00
|
|
|
|
draftCommitMessageInput.value = payload.draftCommitMessage || "";
|
2026-05-27 21:49:12 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
apiBaseInput.value = defaultApiBase();
|
|
|
|
|
|
}
|
|
|
|
|
|
syncMetrics();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateAccessModeFields() {
|
|
|
|
|
|
subscriptionFields.hidden = accessModeInput.value !== "subscription";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 11:01:29 +08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 21:49:12 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-28 12:18:10 +08:00
|
|
|
|
fillDraftForm(draft, { lockProviderID: true });
|
2026-05-27 21:49:12 +08:00
|
|
|
|
importResult.textContent = JSON.stringify(draft.manifest || {}, null, 2);
|
|
|
|
|
|
setStatus(draftStatus, `已回填服务端草稿:${draft.draft_id}`, "success");
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearDraftFormSelection() {
|
|
|
|
|
|
state.currentDraftID = "";
|
2026-05-28 12:18:10 +08:00
|
|
|
|
state.draftProviderIDAuto = true;
|
2026-05-27 21:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-05-28 12:18:10 +08:00
|
|
|
|
hydrateDraftTemplateIfNeeded();
|
2026-05-27 21:49:12 +08:00
|
|
|
|
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();
|
2026-05-28 12:18:10 +08:00
|
|
|
|
hydrateDraftTemplateIfNeeded();
|
2026-05-27 21:49:12 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
serverDraftList.innerHTML = `<div class="empty">加载草稿失败:${escapeHTML(error.message)}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 07:30:02 +08:00
|
|
|
|
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);
|
2026-05-28 12:18:10 +08:00
|
|
|
|
rememberLastPublishedTemplate();
|
2026-05-28 07:30:02 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 21:49:12 +08:00
|
|
|
|
function escapeHTML(value) {
|
|
|
|
|
|
return String(value)
|
|
|
|
|
|
.replaceAll("&", "&")
|
|
|
|
|
|
.replaceAll("<", "<")
|
|
|
|
|
|
.replaceAll(">", ">")
|
|
|
|
|
|
.replaceAll('"', """)
|
|
|
|
|
|
.replaceAll("'", "'");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-05-28 07:30:02 +08:00
|
|
|
|
document.getElementById("publish-draft-btn").addEventListener("click", publishDraftToRepo);
|
2026-05-27 21:49:12 +08:00
|
|
|
|
document.getElementById("delete-draft-btn").addEventListener("click", deleteDraftOnServer);
|
|
|
|
|
|
document.getElementById("copy-draft-btn").addEventListener("click", copyDraft);
|
|
|
|
|
|
document.getElementById("refresh-drafts-btn").addEventListener("click", loadServerDrafts);
|
2026-05-28 11:01:29 +08:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-05-27 21:49:12 +08:00
|
|
|
|
accessModeInput.addEventListener("change", updateAccessModeFields);
|
|
|
|
|
|
packIDInput.addEventListener("change", loadCatalog);
|
|
|
|
|
|
providerIDInput.addEventListener("input", syncMetrics);
|
|
|
|
|
|
apiBaseInput.addEventListener("input", syncMetrics);
|
2026-05-28 12:18:10 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-05-27 21:49:12 +08:00
|
|
|
|
|
|
|
|
|
|
restoreConfig();
|
|
|
|
|
|
updateAccessModeFields();
|
|
|
|
|
|
syncMetrics();
|
2026-05-28 12:18:10 +08:00
|
|
|
|
renderRecentTemplateMeta(readLastPublishedTemplate());
|
|
|
|
|
|
syncDraftHelperState();
|
2026-05-28 11:01:29 +08:00
|
|
|
|
refreshAdminSession().catch(() => {});
|
2026-05-27 21:49:12 +08:00
|
|
|
|
renderServerDrafts();
|
feat(portal): make provider/batch-import form fields self-explanatory + auto-fill
Problem: provider manifest form had all-empty fields with cryptic
placeholders, users had to know what model IDs to type.
Fix on /portal/admin/providers.html (Provider Manifest 草稿):
- DISPLAY NAME: datalist of common vendors (OpenAI / DeepSeek /
硅基流动 / Moonshot / 智谱 / Anthropic / 零一万物 / MiniMax / Qwen / Baichuan / 混元)
- PLATFORM: datalist of common platforms (openai / openai-compatible /
deepseek / anthropic / gemini / zhipu / moonshot / minimax / qwen / ...)
- SMOKE TEST MODEL: datalist of common smoke models + auto-fills with
first model from MODELS field if user leaves it empty
- BASE URL PLACEHOLDER: datalist of common base URLs (12 presets)
- MODELS: chip-row of 11 common models (gpt-5.4, gpt-5.4-mini,
deepseek-chat, MiniMax-M2.7-highspeed, kimi-k2.6, glm-4.6,
claude-sonnet-4-5, gemini-2.5-pro, qwen3-coder-plus, gpt-4o, o4-mini)
+ clear button. Click chip → append to MODELS field (dedup).
- KEYS textarea: 6 rows + example placeholder (sk-example-1/2/3)
Fix on /portal/admin-batch-import.html (发起导入):
- HOST ID: datalist of common host_ids + hint about loading pack first
- ENTRIES textarea: 6 rows + multi-line hint explaining
base_url|api_key|model1,model2 format, optional model, batch import
JS change: syncDraftHelperState() in providers.html now auto-fills
smoke_test_model with first model if user hasn't filled it yet.
Also fixed: 2 duplicate copies of syncDraftHelperState (from
earlier batch script restoration) — both now have the new logic.
Verification:
- bash scripts/test/test_tksea_portal_assets.sh → PASS
- bash scripts/test/verify_frontend_smoke.sh → PASS
- browser_console click test: gpt-5.4 + deepseek-chat + kimi-k2.6 chips
→ models='gpt-5.4,deepseek-chat,kimi-k2.6' + smoke='gpt-5.4' auto-fill ✓
- screenshot: /tmp/portal-screenshots/admin-providers-v5.png
2026-06-03 11:24:54 +08:00
|
|
|
|
|
|
|
|
|
|
// 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");
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</script>
|
2026-06-03 09:11:07 +08:00
|
|
|
|
</body>
|
2026-05-27 21:49:12 +08:00
|
|
|
|
</html>
|