feat(scheduler): separate image-generation AUTO chain with UI toggle

Image models previously could not be scheduled through a priority
chain: the chat AUTO chain explicitly skips image-kind slots, and
AUTO image requests fell back to unordered registry discovery.

- config: add auto_image rules (auto_image yaml / image_rules json);
  legacy auto rules keep their meaning as the chat chain
- core: buildAutoImageChain mirrors buildAutoChain with inverted kind
  filter (image-only); SaveAutoImageRules + AutoImageRules/AutoImageChain
- scheduler: ChainImage walks the chain tier-by-tier with round-robin
  and preference ordering, skipping cooling slots
- gateway: handleImage AUTO now runs down AutoImageChain when one is
  configured (falls back to legacy discovery otherwise) and records
  the actual served model; handleAutoAPI GET returns image_rules and
  PUT accepts image_rules independently of rules
- webui: priority page gains a chat/image toggle editing two
  independent lane sets; add-slot picker filters by active kind;
  persistAuto writes only the active chain's field
This commit is contained in:
JianFeeeee
2026-08-26 21:02:33 +08:00
parent 66585549f1
commit a42ff62d06
7 changed files with 299 additions and 64 deletions

View File

@ -897,11 +897,45 @@ func (g *Gateway) handleImage(w http.ResponseWriter, r *http.Request) {
if model == "" {
model = g.core.DefaultModel()
}
if !isAuto(model) {
if allow := g.allowedModels(r.Context()); allow != nil && !g.hasScopeModel(allow, model) {
writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model))
if isAuto(model) {
if chain := g.core.AutoImageChain(); chain != nil && len(chain.Tiers) > 0 {
if msg := g.checkModelScope(r.Context(), "AUTO"); msg != "" {
writeError(w, http.StatusForbidden, "model_not_allowed", msg)
return
}
done := g.stats.Begin()
defer done()
rec := &Req{Key: keyID(reqKey(r.Context())), Type: "image", Model: model, OK: false}
t0 := time.Now()
resp, usedSrc, usedModel, err := g.core.Scheduler().ChainImage(r.Context(), chain, &req)
rec.LatMs = time.Since(t0).Milliseconds()
if err != nil {
rec.Status = upstreamErrStatus(err)
rec.Err = err.Error()
g.writeRec(rec)
writeError(w, rec.Status, "upstream_error", clientUpstreamErr(err))
return
}
rec.Source = usedSrc
if usedModel != "" {
rec.Model = usedModel // actual image model served, not "AUTO"
}
rec.OK = true
rec.Status = http.StatusOK
rec.Compl = int64(len(resp.ImageData))
g.writeRec(rec)
writeJSON(w, http.StatusOK, types.ImageGenResponse{
Created: time.Now().Unix(),
Data: resp.ImageData,
})
return
}
// no image chain configured: fall through to legacy discovery (all
// sources exposing an image model, tried in registry order)
}
if allow := g.allowedModels(r.Context()); allow != nil && !g.hasScopeModel(allow, model) {
writeError(w, http.StatusForbidden, "model_not_allowed", fmt.Sprintf("model %q is not allowed for this key", model))
return
}
cands, _ := g.resolveByModel(model)
cands = imageOnly(cands)

View File

@ -143,8 +143,9 @@ func (g *Gateway) allowedModels(ctx context.Context) []config.ModelScope {
func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
writeJSON(w, http.StatusOK, map[string]interface{}{
"rules": g.core.AutoRules(),
"states": g.core.AutoSlotStates(),
"rules": g.core.AutoRules(),
"image_rules": g.core.AutoImageRules(),
"states": g.core.AutoSlotStates(),
})
return
}
@ -155,17 +156,30 @@ func (g *Gateway) handleAutoAPI(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut, http.MethodPost:
var body struct {
Rules []config.ModelScope `json:"rules"`
Rules []config.ModelScope `json:"rules"`
ImageRules []config.ModelScope `json:"image_rules"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if err := g.core.SaveAutoRules(body.Rules); err != nil {
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
return
if body.Rules != nil {
if err := g.core.SaveAutoRules(body.Rules); err != nil {
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "rules": g.core.AutoRules()})
if body.ImageRules != nil {
if err := g.core.SaveAutoImageRules(body.ImageRules); err != nil {
writeError(w, http.StatusBadRequest, "auto_error", err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true,
"rules": g.core.AutoRules(),
"image_rules": g.core.AutoImageRules(),
})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}

View File

@ -365,6 +365,8 @@
#modal-wrap .card{width:100%;max-width:480px;animation:cardIn .28s var(--ease-spring) both}
/* ---- scratch-style priority canvas (sakura restyle, same classes) ---- */
.kind-toggle{display:inline-flex;gap:4px;margin-right:8px}
.kind-toggle button{padding:4px 12px;font-size:11.5px}
.sort-canvas{position:relative;min-height:420px;padding:22px 14px 30px;margin:4px 0 10px;background-color:var(--card2);
background-image:linear-gradient(var(--line-strong) 1px,transparent 1px),linear-gradient(90deg,var(--line-strong) 1px,transparent 1px);
background-size:28px 28px;border:1px solid var(--line);border-radius:16px;overflow-x:auto;backdrop-filter:blur(10px)}
@ -2563,14 +2565,19 @@
function srcShort(name) {
return (name || "?").slice(0, 2).toUpperCase();
}
const sortState = { lanes: [], origin: null, drag: null };
const sortState = { lanes: [], origin: null, drag: null, kind: "chat" };
// per-kind editor state: chat lanes and image lanes are independent;
// the toggle switches which chain the canvas edits
const sortLanes = { chat: [], image: [] };
let sortStateMap = new Map();
async function renderSort() {
const j = await api("/api/sources");
let autoR = [];
let autoImg = [];
try {
const a = await api("/api/auto");
autoR = a.rules || [];
autoImg = a.image_rules || [];
sortStateMap = new Map(
(a.states || []).map((st) => [
st.model + "|" + (st.source || "*"),
@ -2590,50 +2597,62 @@
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
}),
);
if (autoR.length) {
const tiers = [];
autoR.forEach((r, i) => {
const src = normSrc(r.source);
const pair = src ? byPair.get(r.model + "|" + src) : null;
if (src && !pair) return;
const ref = pair ||
byModel.get(r.model) || { src: "*", id: r.model };
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
if (!tiers[ti]) tiers[ti] = [];
tiers[ti].push({
src: ref.src || "*",
id: r.model,
uid: nexUid(),
meta: {
quota: r.token_quota || 0,
period: r.period || "",
hours: r.hours || 0,
},
// build a lane structure for one kind from its saved rules; when
// rules are empty, fall back to discovery from sourceRows filtered by
// kind so an unconfigured chain still shows all available slots
const buildLanes = (rules, kind) => {
if (rules.length) {
const tiers = [];
rules.forEach((r, i) => {
const src = normSrc(r.source);
const pair = src ? byPair.get(r.model + "|" + src) : null;
if (src && !pair) return;
const ref = pair ||
byModel.get(r.model) || { src: "*", id: r.model };
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
if (!tiers[ti]) tiers[ti] = [];
tiers[ti].push({
src: ref.src || "*",
id: r.model,
uid: nexUid(),
meta: {
quota: r.token_quota || 0,
period: r.period || "",
hours: r.hours || 0,
},
});
});
});
sortState.lanes = tiers
.filter(Boolean)
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
} else {
sortState.lanes = [...sourceRows.entries()]
return tiers
.filter(Boolean)
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
}
// discovery: group slots of this kind by their source priority
return [...sourceRows.entries()]
.sort((a, b) => b[0] - a[0])
.map(([prio, models]) => {
models.sort((x, y) =>
x.src < y.src ? -1 : x.src > y.src ? 1 : 0,
);
models.forEach((m) => {
m.uid = nexUid();
m.meta = null;
const same = models.filter((m) => {
const it = byPair.get(m.id + "|" + m.src);
const k = it ? it.kind : "chat";
return k === kind;
});
return { prio, models };
});
}
if (!same.length) return null;
same.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
same.forEach((m) => { m.uid = nexUid(); m.meta = null; });
return { prio, models: same };
})
.filter(Boolean);
};
sortLanes.chat = buildLanes(autoR, "chat");
sortLanes.image = buildLanes(autoImg, "image");
sortState.lanes = sortLanes[sortState.kind] || [];
// model picker for the "add slot" control: one option per (source, model)
// pair so identical model ids on different sources stay distinguishable
await loadModelPairs();
sortState.origin = JSON.stringify(sortState.lanes);
const kindToggle =
`<div class="kind-toggle"><button class="${sortState.kind === "chat" ? "" : "ghost"} small" onclick="switchSortKind('chat')">${t("kChat") || "聊天"}</button><button class="${sortState.kind === "image" ? "" : "ghost"} small" onclick="switchSortKind('image')">${t("kImage") || "生图"}</button></div>`;
$("#tab-sort").innerHTML = `
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>${kindToggle}
<button class="ghost small" onclick="scrAddModal()">+ ${t("sortAdd")}</button>
<button class="ghost small" onclick="sortReset()">${t("sortReset")}</button>
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
@ -2653,6 +2672,25 @@
</div>`;
paintSort();
}
function switchSortKind(k) {
if (sortState.kind === k) return;
sortLanes[sortState.kind] = sortState.lanes;
sortState.kind = k;
sortState.lanes = sortLanes[k] || [];
sortState.origin = JSON.stringify(sortState.lanes);
sortState.drag = null;
const h = $("#tab-sort").querySelector("h2");
if (h) {
const tg = h.querySelector(".kind-toggle");
if (tg) {
tg.querySelectorAll("button").forEach((b) => {
const isK = (b.getAttribute("onclick") || "").indexOf("'" + k + "'") >= 0;
b.className = isK ? "small" : "ghost small";
});
}
}
paintSort();
}
function healthTag(it) {
const st = sortStateMap.get(it.id + "|" + (it.src || "*"));
if (!st) return "";
@ -3115,10 +3153,15 @@
});
}),
);
// each kind persists to its own chain; the other kind's saved rules
// are left untouched (omitting the field means "no change")
const body = { rules: undefined, image_rules: undefined };
if (sortState.kind === "image") body.image_rules = rules;
else body.rules = rules;
await api("/api/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rules }),
body: JSON.stringify(body),
});
}
function scrAddModal() {
@ -3127,15 +3170,25 @@
wrap.id = "modal-wrap";
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:50";
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("sortAdd")}</h2>
// filter the model picker to the kind currently edited (image models
// in image mode, chat models in chat mode)
const kind = sortState.kind;
const pickModels = (allModels || []).filter((m) => {
const k = typeof m === "string" ? "chat" : m.kind || "chat";
return kind === "image" ? k === "image" : k !== "image";
});
const optsHtml = pickModels.length
? pickModels
.map((m) =>
typeof m === "string"
? `<option value="${escAttr(m)}">${esc(m)}</option>`
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`,
)
.join("")
: `<option value="" disabled selected>${esc(t("kEmpty") || "No models")}</option>`;
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("sortAdd")} ${kind === "image" ? "· 生图" : "· 聊天"}</h2>
<label>${t("kModelB")} <span class="muted">${t("kSrcHint")}</span></label>
<select id="a-model">${allModels
.map((m) =>
typeof m === "string"
? `<option value="${escAttr(m)}">${esc(m)}</option>`
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`,
)
.join("")}</select>
<select id="a-model">${optsHtml}</select>
<label>${t("kQuotaB")} <span class="muted">${t("kQuotaHintB")}</span></label>
<input id="a-quota" type="number" min="0" step="1" placeholder="${escAttr(t("kQuotaHintB"))}">
<label>${t("kPeriodB")}</label>