feat(admin): harden provider draft model conflicts

This commit is contained in:
phamnazage-jpg
2026-05-28 12:18:10 +08:00
parent de33ff3492
commit 6b03eb8fb9
5 changed files with 702 additions and 28 deletions

View File

@@ -519,15 +519,21 @@
页面本身不直接拼 Git 命令,所有写仓库动作都经由 CRM 服务端完成。
</p>
<div class="statusbar" id="recent-template-meta">最近成功模板:暂无。首次可以先按一份已有 provider 作为参考修改。</div>
<div class="field-grid two">
<label>Provider ID
<label>Provider ID(自动生成,可手改)
<input id="draft-provider-id" type="text" placeholder="openai-zhongzhuan">
<span class="hint">根据 display name / base url / models 自动生成,并尽量避免与现有 provider_id 冲突。</span>
</label>
<label>Display Name
<input id="draft-display-name" type="text" placeholder="OpenAI 中转">
</label>
</div>
<div class="statusbar" id="provider-id-preview">Provider ID 预览:等待填写模型信息。</div>
<div class="statusbar" id="model-conflicts">同模型已存在:当前未发现冲突。</div>
<div class="field-grid two">
<label>Platform
<input id="draft-platform" type="text" placeholder="openai">
@@ -597,6 +603,15 @@
<script>
const storageKey = "sub2api-crm-provider-admin-v1";
const lastPublishedTemplateKey = "sub2api-crm-provider-admin:last-published-template";
const sampleDraftTemplate = {
provider_id: "openai-zhongzhuan",
display_name: "OpenAI 中转",
platform: "openai",
base_url: "https://api.example.com/v1",
smoke_test_model: "gpt-5.4",
supported_models: ["gpt-5.4", "gpt-5.4-mini"],
};
const state = {
packs: [],
hosts: [],
@@ -604,6 +619,8 @@
selectedProvider: null,
drafts: [],
currentDraftID: "",
draftTemplateHydrated: false,
draftProviderIDAuto: true,
};
const apiBaseInput = document.getElementById("api-base");
@@ -643,6 +660,9 @@
const draftBaseURLInput = document.getElementById("draft-base-url");
const draftModelsInput = document.getElementById("draft-models");
const draftCommitMessageInput = document.getElementById("draft-commit-message");
const recentTemplateMeta = document.getElementById("recent-template-meta");
const providerIdPreview = document.getElementById("provider-id-preview");
const modelConflicts = document.getElementById("model-conflicts");
function defaultApiBase() {
return `${window.location.origin}/portal-admin-api`;
@@ -705,6 +725,213 @@
metricProviderID.textContent = providerIDInput.value || "-";
}
function parseDraftModels() {
return draftModelsInput.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
function draftFieldsAreEmpty() {
return ![
draftProviderIDInput.value,
draftDisplayNameInput.value,
draftPlatformInput.value,
draftSmokeModelInput.value,
draftBaseURLInput.value,
draftModelsInput.value,
].some((value) => String(value || "").trim());
}
function slugifyProviderPart(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/https?:\/\//g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function inferRouteSuffix(displayName, baseURL) {
const display = String(displayName || "").toLowerCase();
const url = String(baseURL || "").toLowerCase();
if (display.includes("官方") || display.includes("official") || url.includes("deepseek.com")) {
return "official";
}
if (display.includes("中转") || display.includes("relay")) {
const host = slugifyProviderPart(url.split("/")[0] || "");
if (host && !["api", "com", "www", "example"].includes(host)) {
return host;
}
return "relay";
}
const host = slugifyProviderPart(url.split("/")[0] || "");
return host || "";
}
function draftPrimaryModel() {
return parseDraftModels()[0] || draftSmokeModelInput.value.trim() || "";
}
function existingProviderIDs() {
const ids = new Set();
state.providers.forEach((provider) => ids.add((provider.provider_id || "").trim()));
state.drafts.forEach((draft) => ids.add((draft.provider_id || "").trim()));
return ids;
}
function detectModelConflicts(models, ignoreProviderID = "") {
const normalizedModels = models.map((value) => value.trim()).filter(Boolean);
const ignored = ignoreProviderID.trim();
const conflicts = [];
const seen = new Set();
const scan = (entries, source) => {
entries.forEach((entry) => {
const providerID = (entry.provider_id || entry.providerID || "").trim();
if (!providerID || providerID === ignored) {
return;
}
const supportedModels = Array.isArray(entry.supported_models || entry.supportedModels)
? (entry.supported_models || entry.supportedModels)
: [];
const matched = supportedModels.filter((model) => normalizedModels.includes(String(model || "").trim()));
if (!matched.length) {
return;
}
const key = `${providerID}:${matched.join(",")}`;
if (seen.has(key)) {
return;
}
seen.add(key);
conflicts.push({
providerID,
displayName: entry.display_name || entry.displayName || providerID,
matchedModels: matched,
source,
});
});
};
scan(state.providers, "provider");
scan(state.drafts, "draft");
return conflicts;
}
function updateConflictSummary() {
const conflicts = detectModelConflicts(parseDraftModels(), draftProviderIDInput.value);
if (!conflicts.length) {
setStatus(modelConflicts, "同模型已存在:当前未发现冲突。", "success");
return conflicts;
}
const labels = conflicts
.map((item) => `${item.matchedModels.join("/")} -> ${item.providerID}`)
.join("");
setStatus(modelConflicts, `同模型已存在:${labels}。通常不需要因为“官方 / 中转”再重复新增 provider优先复用或修改已有 provider。`, "warning");
return conflicts;
}
function buildSuggestedProviderID() {
const primaryModel = draftPrimaryModel();
const displayName = draftDisplayNameInput.value.trim();
const baseURL = draftBaseURLInput.value.trim();
const conflicts = detectModelConflicts(parseDraftModels(), draftProviderIDInput.value);
if (primaryModel && conflicts.length === 1) {
return conflicts[0].providerID;
}
const baseCandidate = slugifyProviderPart(primaryModel) || slugifyProviderPart(displayName) || "provider";
const suffix = inferRouteSuffix(displayName, baseURL);
let candidate = [baseCandidate, suffix].filter(Boolean).join("-");
if (!candidate) {
candidate = "provider";
}
const ids = existingProviderIDs();
const currentValue = draftProviderIDInput.value.trim();
if (currentValue) {
ids.delete(currentValue);
}
if (!ids.has(candidate)) {
return candidate;
}
let index = 2;
while (ids.has(`${candidate}-${index}`)) {
index += 1;
}
return `${candidate}-${index}`;
}
function syncDraftHelperState(forceProviderID = false) {
const suggested = buildSuggestedProviderID();
setStatus(providerIdPreview, `providerIdPreview: ${suggested}`, "note");
updateConflictSummary();
if (forceProviderID || state.draftProviderIDAuto || !draftProviderIDInput.value.trim()) {
draftProviderIDInput.value = suggested;
}
}
function rememberLastPublishedTemplate() {
const payload = {
provider_id: draftProviderIDInput.value.trim(),
display_name: draftDisplayNameInput.value.trim(),
platform: draftPlatformInput.value.trim(),
base_url: draftBaseURLInput.value.trim(),
smoke_test_model: draftSmokeModelInput.value.trim(),
supported_models: parseDraftModels(),
saved_at: new Date().toISOString(),
};
localStorage.setItem(lastPublishedTemplateKey, JSON.stringify(payload));
renderRecentTemplateMeta(payload);
}
function readLastPublishedTemplate() {
try {
const raw = localStorage.getItem(lastPublishedTemplateKey);
if (!raw) {
return null;
}
return JSON.parse(raw);
} catch (error) {
return null;
}
}
function renderRecentTemplateMeta(template) {
if (!template) {
setStatus(recentTemplateMeta, "最近成功模板:暂无。首次可以先按一份已有 provider 作为参考修改。", "note");
return;
}
const models = Array.isArray(template.supported_models) ? template.supported_models.join(", ") : "";
setStatus(recentTemplateMeta, `最近成功模板:${template.provider_id || "-"} · ${template.display_name || "-"} · ${models || "-"}`, "success");
}
function fillDraftForm(draft, options = {}) {
const { preserveCommitMessage = false, lockProviderID = false } = options;
state.currentDraftID = draft.draft_id || "";
state.draftProviderIDAuto = !lockProviderID;
draftProviderIDInput.value = draft.provider_id || "";
draftDisplayNameInput.value = draft.display_name || "";
draftPlatformInput.value = draft.platform || "";
draftSmokeModelInput.value = draft.smoke_test_model || "";
draftBaseURLInput.value = draft.base_url || "";
draftModelsInput.value = Array.isArray(draft.supported_models) ? draft.supported_models.join(",") : "";
if (!preserveCommitMessage && !draftCommitMessageInput.value.trim()) {
draftCommitMessageInput.value = `feat(pack): publish provider draft ${draft.provider_id || ""}`.trim();
}
syncDraftHelperState();
}
function hydrateDraftTemplateIfNeeded() {
if (state.draftTemplateHydrated || !draftFieldsAreEmpty()) {
return;
}
const template = readLastPublishedTemplate()
|| state.drafts[0]
|| state.providers[0]
|| sampleDraftTemplate;
fillDraftForm(template, { preserveCommitMessage: true });
state.currentDraftID = "";
state.draftTemplateHydrated = true;
renderRecentTemplateMeta(template === sampleDraftTemplate ? null : template);
}
function saveConfig() {
localStorage.setItem(storageKey, JSON.stringify({
apiBase: apiBaseInput.value.trim(),
@@ -893,28 +1120,16 @@
if (!draft) {
return;
}
fillDraftForm(draft);
fillDraftForm(draft, { lockProviderID: true });
importResult.textContent = JSON.stringify(draft.manifest || {}, null, 2);
setStatus(draftStatus, `已回填服务端草稿:${draft.draft_id}`, "success");
});
});
}
function fillDraftForm(draft) {
state.currentDraftID = draft.draft_id || "";
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 (!draftCommitMessageInput.value.trim()) {
draftCommitMessageInput.value = `feat(pack): publish provider draft ${draft.provider_id || ""}`.trim();
}
}
function clearDraftFormSelection() {
state.currentDraftID = "";
state.draftProviderIDAuto = true;
}
async function loadCatalog() {
@@ -964,6 +1179,7 @@
syncMetrics();
saveCurrentIDsOnly();
await loadServerDrafts();
hydrateDraftTemplateIfNeeded();
setStatus(catalogStatus, `目录已加载:${state.packs.length} 个 pack${state.providers.length} 个 provider。`, "success");
} catch (error) {
setStatus(catalogStatus, `加载目录失败:${error.message}`, "danger");
@@ -1162,7 +1378,6 @@
const payload = buildServerDraftPayload();
const result = await requestJSON("/api/provider-drafts", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(payload),
});
importResult.textContent = JSON.stringify(result.draft || result, null, 2);
@@ -1186,7 +1401,6 @@
const payload = buildServerDraftPayload();
const result = await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}`, {
method: "PUT",
headers: authHeaders(),
body: JSON.stringify(payload),
});
importResult.textContent = JSON.stringify(result.draft || result, null, 2);
@@ -1208,7 +1422,6 @@
}
await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}`, {
method: "DELETE",
headers: authHeaders(),
});
const deletedDraftID = state.currentDraftID;
clearDraftFormSelection();
@@ -1230,10 +1443,10 @@
}
const suffix = params.toString() ? `?${params.toString()}` : "";
const payload = await requestJSON(`/api/provider-drafts${suffix}`, {
headers: authHeaders(),
});
state.drafts = Array.isArray(payload.provider_drafts) ? payload.provider_drafts : [];
renderServerDrafts();
hydrateDraftTemplateIfNeeded();
} catch (error) {
serverDraftList.innerHTML = `<div class="empty">加载草稿失败:${escapeHTML(error.message)}</div>`;
}
@@ -1248,12 +1461,12 @@
}
const result = await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}/publish`, {
method: "POST",
headers: authHeaders(),
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 || "-"}`,
@@ -1306,10 +1519,21 @@
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>