mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
feat(templates): source templates for multi-key balancing
A template stores every source field except name and api_key, so operators spin up N key-bearing sources from one shared skeleton instead of duplicating the whole source block N times. - config: SourceTemplate type + RuntimeConfig.SourceTemplates stored in runtime.json alongside runtime sources - store: UpsertTemplate / ListTemplates / RemoveTemplate - core: Templates / SaveTemplate / RemoveTemplate - gateway: GET/POST/DELETE /api/source_templates - webui: source list gains a Templates button opening a manager with per-template edit/delete; the add-source dialog gains 'from template' (event-delegated picker) and 'as template' (card modal) buttons in its header; z-index fixed so the template editor layers above the manager
This commit is contained in:
@ -192,6 +192,8 @@
|
||||
.row{display:flex;gap:12px}.row>div{flex:1}
|
||||
.model-row{display:flex;gap:6px;align-items:center;width:100%}
|
||||
.model-row .m-id{flex:1;min-width:0;width:0}
|
||||
.tpl-pick-row{padding:12px 14px;border-radius:13px;cursor:pointer;background:var(--card2);border:1px solid var(--line);margin-bottom:8px;transition:background .15s,border-color .15s}
|
||||
.tpl-pick-row:hover{background:var(--accent-soft,#1e2a52);border-color:var(--primary)}
|
||||
.model-row .m-kind{flex:0 0 96px;width:96px}
|
||||
.model-row .del{flex:0 0 auto;padding:4px 8px}
|
||||
.muted{color:var(--muted)}
|
||||
@ -794,6 +796,14 @@
|
||||
cStream: "调用 /v1/chat/completions 流式 SSE",
|
||||
srcEmpty: "还没有配置任何源",
|
||||
srcAdd: "+ 新增源",
|
||||
tplManage: "模板管理",
|
||||
tplFrom: "从模板创建",
|
||||
tplSave: "存为模板",
|
||||
tplTitle: "模板管理",
|
||||
tplEmpty: "还没有模板",
|
||||
tplNew: "新建模板",
|
||||
tplNamePrompt: "输入模板名称",
|
||||
tplConfirmDel: "确认删除模板?",
|
||||
srcEdit: "编辑",
|
||||
srcDel: "删除",
|
||||
adEmpty: "尚未加载适配器",
|
||||
@ -994,6 +1004,14 @@
|
||||
cStream: "calls /v1/chat/completions streaming SSE",
|
||||
srcEmpty: "No sources configured yet",
|
||||
srcAdd: "+ Add source",
|
||||
tplManage: "Templates",
|
||||
tplFrom: "From template",
|
||||
tplSave: "As template",
|
||||
tplTitle: "Template Manager",
|
||||
tplEmpty: "No templates yet",
|
||||
tplNew: "New template",
|
||||
tplNamePrompt: "Template name",
|
||||
tplConfirmDel: "Delete this template?",
|
||||
srcEdit: "Edit",
|
||||
srcDel: "Delete",
|
||||
adEmpty: "No adapters loaded",
|
||||
@ -2407,6 +2425,7 @@
|
||||
.join("");
|
||||
$("#tab-sources").innerHTML = `
|
||||
<div class="card"><h2><span>${t("srcTitle")}</span><span class="grow"></span>
|
||||
<button class="ghost small" onclick="openTemplateModal()">${t("tplManage")}</button>
|
||||
<button class="small" onclick="editSource('')">${t("srcAdd")}</button></h2>
|
||||
<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tURL")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th></th></tr>
|
||||
${rows || `<tr><td colspan="5" class="empty">${t("srcEmpty")}</td></tr>`}</table></div>
|
||||
@ -2434,7 +2453,9 @@
|
||||
)
|
||||
.join("") +
|
||||
"</select>";
|
||||
wrap.innerHTML = `<div class="card"><h2>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</h2>
|
||||
wrap.innerHTML = `<div class="card"><h2><span>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</span><span class="grow"></span>
|
||||
${!name ? `<button class="ghost small" onclick="srcFromTemplate()">${t("tplFrom")}</button>` : ""}
|
||||
<button class="ghost small" onclick="srcSaveAsTemplate()">${t("tplSave")}</button></h2>
|
||||
<div class="row">
|
||||
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
|
||||
<div><label>${t("mAlias")}</label>${adSel}</div>
|
||||
@ -2533,6 +2554,266 @@
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
// collect the source form fields except name & api_key — the fields a
|
||||
// template reuses. Returns the cleaned object or null on invalid JSON.
|
||||
function readSourceFormExceptKeys() {
|
||||
const models = [...document.querySelectorAll("#s-models .model-row")]
|
||||
.map((row) => ({
|
||||
id: row.querySelector(".m-id").value.trim(),
|
||||
priority: parseInt(row.dataset.priority) || 0,
|
||||
kind: row.querySelector(".m-kind").value,
|
||||
}))
|
||||
.filter((m) => m.id);
|
||||
let meta = {};
|
||||
try {
|
||||
meta = JSON.parse($("#s-meta").value || "{}");
|
||||
} catch (e) {
|
||||
toast(t("toastBadJson"));
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
base_url: $("#s-url").value.trim(),
|
||||
adapter: $("#s-adapter").value.trim(),
|
||||
endpoint: $("#s-ep").value.trim(),
|
||||
image_endpoint: $("#s-img").value.trim(),
|
||||
max_concurrent: parseInt($("#s-conc").value) || 8,
|
||||
rpm: parseInt($("#s-rpm").value) || 0,
|
||||
temperature: parseFloat($("#s-temp").value) || 0,
|
||||
models,
|
||||
meta,
|
||||
};
|
||||
}
|
||||
// "存为模板": save every non-key/non-name form field as a template.
|
||||
async function srcSaveAsTemplate() {
|
||||
const body = readSourceFormExceptKeys();
|
||||
if (!body) return;
|
||||
const dflt = ($("#s-name").value || "").trim() || "tpl";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "tpl-save-modal";
|
||||
wrap.style.cssText =
|
||||
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:70";
|
||||
wrap.innerHTML = `<div class="card" style="width:420px"><h2>${t("tplSave")}</h2>
|
||||
<label>${t("tplNamePrompt")}</label>
|
||||
<input id="tpl-save-name" value="${escAttr(dflt)}">
|
||||
<p><button onclick="confirmSaveTemplate()">${t("mSave")}</button>
|
||||
<button class="ghost" onclick="this.closest('#tpl-save-modal').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
$("#tpl-save-name").focus();
|
||||
window._templateSaveBody = body;
|
||||
}
|
||||
async function confirmSaveTemplate() {
|
||||
const nm = ($("#tpl-save-name").value || "").trim();
|
||||
if (!nm) { toast(t("tplNamePrompt")); return; }
|
||||
const body = window._templateSaveBody;
|
||||
if (!body) return;
|
||||
try {
|
||||
const r = await api("/api/source_templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: nm, ...body }),
|
||||
});
|
||||
toast(t("toastSaved"));
|
||||
$("#tpl-save-modal").remove();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
// "从模板创建": pick a template and fill the form (except name/key).
|
||||
async function srcFromTemplate() {
|
||||
let tpls = [];
|
||||
try {
|
||||
tpls = (await api("/api/source_templates")).templates || [];
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
return;
|
||||
}
|
||||
if (!tpls.length) {
|
||||
toast(t("tplEmpty"));
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "template-pick";
|
||||
wrap.style.cssText =
|
||||
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:60";
|
||||
const rows = tpls
|
||||
.map(
|
||||
(tpl) =>
|
||||
`<div class="tpl-pick-row" data-tpl="${escAttr(tpl.name)}">
|
||||
<div><b>${esc(tpl.name)}</b><span class="muted"> · ${esc(tpl.base_url || "")}</span></div>
|
||||
<div class="muted">${esc((tpl.adapter || "") + " · " + (tpl.models || []).map((m) => m.id).join(", "))}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
wrap.innerHTML = `<div class="card" style="width:420px"><h2>${t("tplFrom")}</h2>
|
||||
${rows}
|
||||
<p><button class="ghost" onclick="this.closest('#template-pick').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
// delegate click to avoid inline-handler quoting issues
|
||||
wrap.addEventListener("click", (e) => {
|
||||
const row = e.target.closest(".tpl-pick-row");
|
||||
if (!row) return;
|
||||
pickTemplate(row.dataset.tpl);
|
||||
});
|
||||
}
|
||||
// applies a template's fields onto the open source form (name + key stay).
|
||||
function pickTemplate(name) {
|
||||
api("/api/source_templates")
|
||||
.then((r) => {
|
||||
const tpl = (r.templates || []).find((t) => t.name === name);
|
||||
if (!tpl) return;
|
||||
$("#s-url").value = tpl.base_url || "";
|
||||
$("#s-adapter").value = tpl.adapter || "";
|
||||
$("#s-ep").value = tpl.endpoint || "";
|
||||
$("#s-img").value = tpl.image_endpoint || "";
|
||||
$("#s-conc").value = tpl.max_concurrent || 8;
|
||||
$("#s-rpm").value = tpl.rpm || 0;
|
||||
$("#s-temp").value = tpl.temperature ?? 0.7;
|
||||
$("#s-meta").value = JSON.stringify(tpl.meta || {}, null, 2);
|
||||
const box = $("#s-models");
|
||||
box.innerHTML = "";
|
||||
(tpl.models && tpl.models.length
|
||||
? tpl.models
|
||||
: [{ id: "", priority: 0, kind: "chat" }]
|
||||
).forEach((m, i) =>
|
||||
box.insertAdjacentHTML("beforeend", modelRow(m, i)),
|
||||
);
|
||||
const w = $("#template-pick");
|
||||
if (w) w.remove();
|
||||
toast(t("toastSaved"));
|
||||
})
|
||||
.catch((e) => toast(e.message));
|
||||
}
|
||||
// template manager: list all templates with edit + delete.
|
||||
async function openTemplateModal() {
|
||||
let tpls = [];
|
||||
try {
|
||||
tpls = (await api("/api/source_templates")).templates || [];
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "tpl-manage";
|
||||
wrap.style.cssText =
|
||||
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:60";
|
||||
wrap.innerHTML = `<div class="card" style="width:560px;max-width:96%"><h2>${t("tplTitle")}<span class="grow"></span>
|
||||
<button class="small" onclick="editTemplate('')">${t("tplNew")}</button></h2>
|
||||
${tpls.length
|
||||
? `<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tURL")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th></th></tr>` +
|
||||
tpls
|
||||
.map(
|
||||
(t) =>
|
||||
`<tr><td><b>${esc(t.name)}</b></td><td>${esc(t.base_url)}</td><td>${esc(t.adapter)}</td>
|
||||
<td><div class="src-models">${(t.models || []).map((m) => `<span class="tag tag-blue">${esc(m.id)}</span>`).join("")}</div></td>
|
||||
<td><button class="ghost small" onclick="editTemplate(${JSON.stringify(t.name).replace(/["<>]/g, (c) => ({ '"': """, "<": "<", ">": ">" }[c]))})">${t("srcEdit")}</button>
|
||||
<button class="danger small" onclick="delTemplate('${escAttr(t.name)}')">${t("srcDel")}</button></td></tr>`,
|
||||
)
|
||||
.join("") +
|
||||
"</table></div>"
|
||||
: `<div class="empty">${t("tplEmpty")}</div>`}
|
||||
<p><button class="ghost" onclick="this.closest('#tpl-manage').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
}
|
||||
async function delTemplate(name) {
|
||||
if (!confirm(tFmt("tplConfirmDel", name))) return;
|
||||
try {
|
||||
await api("/api/source_templates/" + encodeURIComponent(name), {});
|
||||
toast(t("toastDelOk"));
|
||||
openTemplateModal();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
// edit or create a template (no name / api_key fields).
|
||||
async function editTemplate(name) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.id = "modal-wrap";
|
||||
Promise.all([api("/api/source_templates"), api("/api/status")])
|
||||
.then(([tplsR, st]) => {
|
||||
const tlist = tplsR.templates || [];
|
||||
const s = tlist.find((x) => x.name === name) || {
|
||||
name: name,
|
||||
models: [{ id: "", priority: 0, kind: "chat" }],
|
||||
};
|
||||
const cur = s.adapter || "openai";
|
||||
const apps = ["", ...(st.adapters || []).map((a) => a.name)];
|
||||
if (cur && !apps.includes(cur)) apps.push(cur);
|
||||
const adSel =
|
||||
`<select id="s-adapter"><option value="" ${!cur ? "selected" : ""}>${esc(t("mAliasAuto"))}</option>` +
|
||||
apps
|
||||
.filter((n) => n)
|
||||
.map(
|
||||
(n) =>
|
||||
`<option value="${escAttr(n)}" ${n === cur ? "selected" : ""}>${esc(n)}</option>`,
|
||||
)
|
||||
.join("") +
|
||||
"</select>";
|
||||
wrap.innerHTML = `<div class="card"><h2>${esc(name ? t("tplTitle") + ": " + name : t("tplNew"))}</h2>
|
||||
<div class="row">
|
||||
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
|
||||
<div><label>${t("mAlias")}</label>${adSel}</div>
|
||||
</div>
|
||||
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
|
||||
<div class="row">
|
||||
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
|
||||
<div><label>${t("mImgEp")}</label><input id="s-img" value="${escAttr(s.image_endpoint || "")}"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>${t("mConc")}</label><input id="s-conc" type="number" value="${s.max_concurrent || 8}"></div>
|
||||
<div><label>${t("mTemp")}</label><input id="s-temp" type="number" step="0.1" value="${s.temperature ?? 0.7}"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div><label>${t("mRPM")}</label><input id="s-rpm" type="number" min="0" placeholder="0 = 不限" value="${s.rpm || 0}"></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<label>${t("mModels")}</label>
|
||||
<div id="s-models"></div>
|
||||
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
|
||||
<label>${t("mMeta")}</label>
|
||||
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
|
||||
<p><button onclick="saveTemplate(this)">${t("mSave")}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
wrap.style.cssText =
|
||||
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:70";
|
||||
document.body.appendChild(wrap);
|
||||
const box = $("#s-models");
|
||||
(s.models && s.models.length
|
||||
? s.models
|
||||
: [{ id: "", priority: 0, kind: "chat" }]
|
||||
).forEach((m, i) =>
|
||||
box.insertAdjacentHTML("beforeend", modelRow(m, i)),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
async function saveTemplate(btn) {
|
||||
const nm = $("#s-name").value.trim();
|
||||
if (!nm) {
|
||||
toast(t("tplNamePrompt"));
|
||||
return;
|
||||
}
|
||||
const body = readSourceFormExceptKeys();
|
||||
if (!body) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("/api/source_templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: nm, ...body }),
|
||||
});
|
||||
toast(t("toastSaved"));
|
||||
const w = $("#modal-wrap");
|
||||
if (w) w.remove();
|
||||
openTemplateModal();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
async function delSource(name) {
|
||||
if (!confirm(tFmt("confirmDelSrc", name))) return;
|
||||
await api("/api/sources/" + encodeURIComponent(name), {
|
||||
|
||||
Reference in New Issue
Block a user