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

@ -314,12 +314,31 @@ func (c *Config) ApplyDefaults() error {
// RuntimeConfig is the legacy runtime file format (kept for migration only). // RuntimeConfig is the legacy runtime file format (kept for migration only).
type RuntimeConfig struct { type RuntimeConfig struct {
Sources []Source `json:"sources,omitempty"` Sources []Source `json:"sources,omitempty"`
DeletedSources []string `json:"deleted_sources,omitempty"` SourceTemplates []SourceTemplate `json:"source_templates,omitempty"`
DeletedAdapters []string `json:"deleted_adapters,omitempty"` DeletedSources []string `json:"deleted_sources,omitempty"`
Keys []GWKey `json:"keys,omitempty"` DeletedAdapters []string `json:"deleted_adapters,omitempty"`
Auto []ModelScope `json:"auto,omitempty"` Keys []GWKey `json:"keys,omitempty"`
AutoImage []ModelScope `json:"auto_image,omitempty"` Auto []ModelScope `json:"auto,omitempty"`
AutoImage []ModelScope `json:"auto_image,omitempty"`
}
// SourceTemplate stores reusable source configuration (everything except
// name and api_key) so the WebUI can spin up multiple key-bearing sources
// from one shared template.
type SourceTemplate struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
Adapter string `json:"adapter"`
Endpoint string `json:"endpoint,omitempty"`
ImageEndpoint string `json:"image_endpoint,omitempty"`
Models []Model `json:"models"`
Headers map[string]string `json:"headers,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxConcurrent int `json:"max_concurrent,omitempty"`
RPM int `json:"rpm,omitempty"`
} }
// GWKey is a gateway API key persisted in the config file. Role is "admin" // GWKey is a gateway API key persisted in the config file. Role is "admin"

View File

@ -138,6 +138,49 @@ func (s *Store) DeletedAdapters() map[string]bool {
return out return out
} }
// UpsertTemplate adds or replaces a source template and persists.
func (s *Store) UpsertTemplate(t SourceTemplate) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.data.SourceTemplates {
if s.data.SourceTemplates[i].Name == t.Name {
s.data.SourceTemplates[i] = t
return s.persistLocked()
}
}
s.data.SourceTemplates = append(s.data.SourceTemplates, t)
return s.persistLocked()
}
// ListTemplates returns the saved source templates.
func (s *Store) ListTemplates() []SourceTemplate {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]SourceTemplate, len(s.data.SourceTemplates))
copy(out, s.data.SourceTemplates)
return out
}
// RemoveTemplate deletes a source template by name and persists.
func (s *Store) RemoveTemplate(name string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
kept := s.data.SourceTemplates[:0]
removed := false
for _, t := range s.data.SourceTemplates {
if t.Name == name {
removed = true
continue
}
kept = append(kept, t)
}
s.data.SourceTemplates = kept
if removed {
return true, s.persistLocked()
}
return false, nil
}
// LoadLegacy returns the full legacy RuntimeConfig from the file (used for // LoadLegacy returns the full legacy RuntimeConfig from the file (used for
// one-time migration into config.yaml). Returns nil if file is missing. // one-time migration into config.yaml). Returns nil if file is missing.
func (s *Store) LoadLegacy() *RuntimeConfig { func (s *Store) LoadLegacy() *RuntimeConfig {

View File

@ -675,6 +675,33 @@ func (c *Core) Sources() []config.Source {
return c.mergedSources() return c.mergedSources()
} }
// Templates returns the saved source templates.
func (c *Core) Templates() []config.SourceTemplate {
c.mu.Lock()
defer c.mu.Unlock()
return c.store.ListTemplates()
}
// SaveTemplate upserts a source template.
func (c *Core) SaveTemplate(t config.SourceTemplate) error {
c.mu.Lock()
defer c.mu.Unlock()
if t.Name == "" {
return fmt.Errorf("template requires a name")
}
return c.store.UpsertTemplate(t)
}
// RemoveTemplate deletes a source template by name.
func (c *Core) RemoveTemplate(name string) error {
c.mu.Lock()
defer c.mu.Unlock()
if _, err := c.store.RemoveTemplate(name); err != nil {
return err
}
return nil
}
func normalizeSource(s *config.Source) error { func normalizeSource(s *config.Source) error {
if s.Name == "" || s.BaseURL == "" { if s.Name == "" || s.BaseURL == "" {
return fmt.Errorf("source requires name and base_url") return fmt.Errorf("source requires name and base_url")

View File

@ -134,6 +134,45 @@ func csvHeaders(w http.ResponseWriter, filename string) {
w.Header().Set("Content-Disposition", "attachment; filename="+filename) w.Header().Set("Content-Disposition", "attachment; filename="+filename)
} }
// handleSourceTemplatesAPI manages reusable source templates (a Source minus
// name and api_key) stored in the runtime file so the WebUI can spin up
// multiple key-bearing sources from one shared template.
func (g *Gateway) handleSourceTemplatesAPI(w http.ResponseWriter, r *http.Request) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/source_templates")
path = strings.Trim(path, "/")
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{"templates": g.core.Templates()})
case http.MethodPost:
var t config.SourceTemplate
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if err := g.core.SaveTemplate(t); err != nil {
writeError(w, http.StatusBadRequest, "template_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "templates": g.core.Templates()})
case http.MethodDelete:
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "template name required")
return
}
if err := g.core.RemoveTemplate(path); err != nil {
writeError(w, http.StatusBadRequest, "template_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// exportKey resolves which masked key id an export covers: admins may pass // exportKey resolves which masked key id an export covers: admins may pass
// any key filter, user keys are always scoped to themselves (records and // any key filter, user keys are always scoped to themselves (records and
// aggregates are keyed by the masked keyID form). // aggregates are keyed by the masked keyID form).

View File

@ -218,6 +218,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
g.handleAdaptersAPI(w, r) g.handleAdaptersAPI(w, r)
case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"): case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"):
g.handleSourcesAPI(w, r) g.handleSourcesAPI(w, r)
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
g.handleSourceTemplatesAPI(w, r)
case r.URL.Path == "/api/chat": case r.URL.Path == "/api/chat":
g.handleChat(w, r) g.handleChat(w, r)
case r.URL.Path == "/api/status": case r.URL.Path == "/api/status":

View File

@ -192,6 +192,8 @@
.row{display:flex;gap:12px}.row>div{flex:1} .row{display:flex;gap:12px}.row>div{flex:1}
.model-row{display:flex;gap:6px;align-items:center;width:100%} .model-row{display:flex;gap:6px;align-items:center;width:100%}
.model-row .m-id{flex:1;min-width:0;width:0} .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 .m-kind{flex:0 0 96px;width:96px}
.model-row .del{flex:0 0 auto;padding:4px 8px} .model-row .del{flex:0 0 auto;padding:4px 8px}
.muted{color:var(--muted)} .muted{color:var(--muted)}
@ -794,6 +796,14 @@
cStream: "调用 /v1/chat/completions 流式 SSE", cStream: "调用 /v1/chat/completions 流式 SSE",
srcEmpty: "还没有配置任何源", srcEmpty: "还没有配置任何源",
srcAdd: "+ 新增源", srcAdd: "+ 新增源",
tplManage: "模板管理",
tplFrom: "从模板创建",
tplSave: "存为模板",
tplTitle: "模板管理",
tplEmpty: "还没有模板",
tplNew: "新建模板",
tplNamePrompt: "输入模板名称",
tplConfirmDel: "确认删除模板?",
srcEdit: "编辑", srcEdit: "编辑",
srcDel: "删除", srcDel: "删除",
adEmpty: "尚未加载适配器", adEmpty: "尚未加载适配器",
@ -994,6 +1004,14 @@
cStream: "calls /v1/chat/completions streaming SSE", cStream: "calls /v1/chat/completions streaming SSE",
srcEmpty: "No sources configured yet", srcEmpty: "No sources configured yet",
srcAdd: "+ Add source", 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", srcEdit: "Edit",
srcDel: "Delete", srcDel: "Delete",
adEmpty: "No adapters loaded", adEmpty: "No adapters loaded",
@ -2407,6 +2425,7 @@
.join(""); .join("");
$("#tab-sources").innerHTML = ` $("#tab-sources").innerHTML = `
<div class="card"><h2><span>${t("srcTitle")}</span><span class="grow"></span> <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> <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> <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> ${rows || `<tr><td colspan="5" class="empty">${t("srcEmpty")}</td></tr>`}</table></div>
@ -2434,7 +2453,9 @@
) )
.join("") + .join("") +
"</select>"; "</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 class="row">
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div> <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("mAlias")}</label>${adSel}</div>
@ -2533,6 +2554,266 @@
btn.disabled = false; 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) { async function delSource(name) {
if (!confirm(tFmt("confirmDelSrc", name))) return; if (!confirm(tFmt("confirmDelSrc", name))) return;
await api("/api/sources/" + encodeURIComponent(name), { await api("/api/sources/" + encodeURIComponent(name), {