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:
JianFeeeee
2026-08-27 12:09:38 +08:00
parent a42ff62d06
commit 8334cffbc9
6 changed files with 418 additions and 7 deletions

View File

@ -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) => ({ '"': "&quot;", "<": "&lt;", ">": "&gt;" }[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), {