feat(admin): harden provider draft model conflicts
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user