mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-21 01:17:59 +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:
@ -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"
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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) => ({ '"': """, "<": "<", ">": ">" }[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), {
|
||||||
|
|||||||
Reference in New Issue
Block a user