mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 17:07:59 +00:00
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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user