low/medium/high leaked as raw English into the bands legend, the tier badges on the Routing page, the pair complexity selects, the benchmark's tier column and the Playground decision panel. New trTier() helper renders the human name (ru: лёгкий/средний/сложный, de: leicht/mittel/schwer, fr/es/zh/tr likewise); custom tier names pass through untouched, badge colors stay keyed by the raw tier. Verified in the browser in Russian and German.
389 lines
15 KiB
JavaScript
Vendored
389 lines
15 KiB
JavaScript
Vendored
// Routing — tier editor (ordered model chips), default model, and policy.
|
||
import { h, mount, toast } from "../ui.js";
|
||
import { t, trTier } from "../i18n.js";
|
||
import { api } from "../api.js";
|
||
import { appState } from "../app.js";
|
||
|
||
// Known labels of the hosted taxonomy — used for localized names/descriptions
|
||
// and as the offline fallback; the live list comes from /routing/labels.
|
||
const TASK_META = {
|
||
chitchat: { icon: "💬" },
|
||
code: { icon: "⌨" },
|
||
"creative-writing": { icon: "✍" },
|
||
extraction: { icon: "📑" },
|
||
math: { icon: "∑" },
|
||
qa: { icon: "❓" },
|
||
summarization: { icon: "📋" },
|
||
translation: { icon: "🌍" },
|
||
};
|
||
|
||
export async function renderRouting() {
|
||
const meta = appState.meta || (await api.meta());
|
||
const [routing, modelsData] = await Promise.all([api.getRouting(), api.listModels()]);
|
||
const ids = (modelsData.models || []).map((m) => m.id);
|
||
|
||
// Task labels: live from the router when reachable, built-in set otherwise;
|
||
// labels that already have a rule are always shown (custom taxonomies).
|
||
let taskLabels = Object.keys(TASK_META);
|
||
let labelsSource = "builtin";
|
||
try {
|
||
const r = await api.routingLabels();
|
||
if (r.labels && r.labels.length) {
|
||
taskLabels = r.labels;
|
||
labelsSource = "router";
|
||
}
|
||
} catch {
|
||
/* offline — builtin list */
|
||
}
|
||
for (const k of Object.keys(routing.tasks || {})) {
|
||
if (!taskLabels.includes(k)) taskLabels.push(k);
|
||
}
|
||
|
||
// Share of recent traffic per task — shows which rules actually matter.
|
||
let taskShare = {};
|
||
try {
|
||
const stat = await api.stats("7d", "label");
|
||
const total = (stat.breakdown || []).reduce((a, b) => a + (b.requests || 0), 0);
|
||
if (total > 0) {
|
||
for (const b of stat.breakdown || []) taskShare[b.key] = (b.requests || 0) / total;
|
||
}
|
||
} catch {
|
||
/* no stats yet */
|
||
}
|
||
|
||
// local editable state — ensure standard tiers exist for a friendly editor
|
||
const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {});
|
||
const tasks = {};
|
||
// A task rule is edited as ordered PAIRS (model, tier): "this model serves
|
||
// this task at this complexity" (tier "any" = at every complexity).
|
||
// GET returns {any:[], low:[], medium:[], high:[]}; legacy arrays = any.
|
||
const taskPairs = {};
|
||
for (const l of taskLabels) {
|
||
const v = (routing.tasks || {})[l];
|
||
const pairs = [];
|
||
if (Array.isArray(v)) for (const m of v) pairs.push({ model: m, tier: "any" });
|
||
else if (v && typeof v === "object") {
|
||
for (const tier of ["low", "medium", "high", "any"])
|
||
for (const m of v[tier] || []) pairs.push({ model: m, tier });
|
||
}
|
||
taskPairs[l] = pairs;
|
||
}
|
||
const st = {
|
||
tiers,
|
||
taskPairs,
|
||
bands: routing.bands || null, // null = пороги роутера как есть
|
||
default: routing.default || ids[0] || "",
|
||
policy: routing.policy || { mode: "fixed_table", min_class: {} },
|
||
};
|
||
|
||
const root = h("div");
|
||
|
||
function moveChip(map, key, i, dir) {
|
||
const arr = map[key];
|
||
const ni = i + dir;
|
||
if (ni < 0 || ni >= arr.length) return;
|
||
[arr[i], arr[ni]] = [arr[ni], arr[i]];
|
||
render();
|
||
}
|
||
function removeChip(map, key, i) {
|
||
map[key].splice(i, 1);
|
||
render();
|
||
}
|
||
function addChip(map, key, id) {
|
||
if (id && !map[key].includes(id)) map[key].push(id);
|
||
render();
|
||
}
|
||
function chipEditor(map, key, emptyLabel) {
|
||
const arr = map[key];
|
||
const avail = ids.filter((id) => !arr.includes(id));
|
||
const addSel = h(
|
||
"select",
|
||
{ style: "width:auto", onChange: (e) => addChip(map, key, e.target.value) },
|
||
h("option", { value: "" }, t("routing.addModel")),
|
||
...avail.map((id) => h("option", { value: id }, id))
|
||
);
|
||
return h(
|
||
"div",
|
||
{ class: "chips" },
|
||
arr.length === 0 && emptyLabel ? h("span", { class: "muted small chips-empty" }, emptyLabel) : null,
|
||
...arr.map((id, i) =>
|
||
h(
|
||
"div",
|
||
{ class: "chip" },
|
||
h("span", { class: "ord" }, i + 1),
|
||
id,
|
||
h("span", { class: "arrow", title: "←", onClick: () => moveChip(map, key, i, -1) }, "◄"),
|
||
h("span", { class: "arrow", title: "→", onClick: () => moveChip(map, key, i, 1) }, "►"),
|
||
h("span", { class: "x", onClick: () => removeChip(map, key, i) }, "×")
|
||
)
|
||
),
|
||
avail.length ? addSel : null
|
||
);
|
||
}
|
||
|
||
// Linked boundary sliders: one track, two handles (low|medium boundaries),
|
||
// three colored zones. Handles push each other, numbers update live —
|
||
// nobody types score ranges by hand.
|
||
function bandsCard() {
|
||
const DEF = { low: 0.33, medium: 0.48 };
|
||
const enabled = !!st.bands;
|
||
const b = st.bands || { ...DEF };
|
||
const MIN_GAP = 0.02;
|
||
|
||
const track = h("div", { class: "bands-track" });
|
||
const zoneLow = h("div", { class: "bands-zone z-low" });
|
||
const zoneMed = h("div", { class: "bands-zone z-med" });
|
||
const zoneHigh = h("div", { class: "bands-zone z-high" });
|
||
const hLow = h("div", { class: "bands-handle", tabindex: "0" }, h("span", { class: "bands-val" }));
|
||
const hMed = h("div", { class: "bands-handle", tabindex: "0" }, h("span", { class: "bands-val" }));
|
||
const legend = h("div", { class: "bands-legend" });
|
||
track.append(zoneLow, zoneMed, zoneHigh, hLow, hMed);
|
||
|
||
function paint() {
|
||
const lo = b.low, me = b.medium;
|
||
zoneLow.style.width = lo * 100 + "%";
|
||
zoneMed.style.left = lo * 100 + "%";
|
||
zoneMed.style.width = (me - lo) * 100 + "%";
|
||
zoneHigh.style.left = me * 100 + "%";
|
||
zoneHigh.style.width = (1 - me) * 100 + "%";
|
||
hLow.style.left = lo * 100 + "%";
|
||
hMed.style.left = me * 100 + "%";
|
||
hLow.querySelector(".bands-val").textContent = lo.toFixed(2);
|
||
hMed.querySelector(".bands-val").textContent = me.toFixed(2);
|
||
// подписи не наезжают, когда ручки рядом
|
||
hMed.querySelector(".bands-val").classList.toggle("raised", me - lo < 0.09);
|
||
mount(legend,
|
||
h("span", { class: "badge tier-low" }, `${trTier("low")} ≤ ${lo.toFixed(2)}`),
|
||
h("span", { class: "badge tier-medium" }, `${trTier("medium")} ≤ ${me.toFixed(2)}`),
|
||
h("span", { class: "badge tier-high" }, `${trTier("high")} > ${me.toFixed(2)}`));
|
||
track.classList.toggle("disabled", !st.bands);
|
||
}
|
||
|
||
function drag(handle, which) {
|
||
handle.addEventListener("pointerdown", (e) => {
|
||
if (!st.bands) return;
|
||
handle.setPointerCapture(e.pointerId);
|
||
const rect = track.getBoundingClientRect();
|
||
const move = (ev) => {
|
||
let v = (ev.clientX - rect.left) / rect.width;
|
||
v = Math.min(0.98, Math.max(0.02, Math.round(v * 100) / 100));
|
||
if (which === "low") {
|
||
b.low = v;
|
||
// связанность: low толкает medium перед собой
|
||
if (b.medium < b.low + MIN_GAP) b.medium = Math.min(0.99, b.low + MIN_GAP);
|
||
} else {
|
||
b.medium = v;
|
||
// и medium толкает low назад
|
||
if (b.low > b.medium - MIN_GAP) b.low = Math.max(0.01, b.medium - MIN_GAP);
|
||
}
|
||
st.bands = { low: b.low, medium: b.medium };
|
||
paint();
|
||
};
|
||
const up = () => {
|
||
handle.removeEventListener("pointermove", move);
|
||
handle.removeEventListener("pointerup", up);
|
||
};
|
||
handle.addEventListener("pointermove", move);
|
||
handle.addEventListener("pointerup", up);
|
||
});
|
||
}
|
||
drag(hLow, "low");
|
||
drag(hMed, "medium");
|
||
|
||
const toggle = h("input", { type: "checkbox", checked: enabled ? "" : null });
|
||
toggle.checked = enabled;
|
||
toggle.addEventListener("change", () => {
|
||
st.bands = toggle.checked ? { low: b.low, medium: b.medium } : null;
|
||
paint();
|
||
});
|
||
paint();
|
||
return h(
|
||
"div",
|
||
{ class: "bands-wrap" },
|
||
h("label", { class: "check", style: "margin-bottom:8px" }, toggle, t("routing.bands.enable")),
|
||
h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.bands.hint")),
|
||
track,
|
||
legend
|
||
);
|
||
}
|
||
|
||
function tierRow(tier) {
|
||
return h(
|
||
"div",
|
||
{ class: "tierrow" },
|
||
h("div", { class: "tname" }, h("span", { class: "badge tier-" + tier }, trTier(tier))),
|
||
chipEditor(st.tiers, tier)
|
||
);
|
||
}
|
||
|
||
function taskRow(label) {
|
||
const known = TASK_META[label];
|
||
const name = known ? t("task." + label) : label;
|
||
const share = taskShare[label];
|
||
return h(
|
||
"div",
|
||
{ class: "tierrow taskrow" },
|
||
h(
|
||
"div",
|
||
{ class: "tname taskname" },
|
||
h("div", {}, (known ? known.icon + " " : "") + name),
|
||
h("div", { class: "muted small mono" }, label),
|
||
share != null ? h("div", { class: "muted small" }, t("routing.tasks.share", { p: Math.round(share * 100) })) : null
|
||
),
|
||
pairsEditor(label)
|
||
);
|
||
}
|
||
|
||
// Pairs editor: each row is [model ▾][complexity ▾][×]; "any" serves every
|
||
// tier. Order matters — it is the fallback order inside the tier.
|
||
function pairsEditor(label) {
|
||
const pairs = st.taskPairs[label];
|
||
const wrap = h("div", { class: "pairs grow" });
|
||
function paint() {
|
||
mount(
|
||
wrap,
|
||
...pairs.map((p, i) => {
|
||
const modelSel = h(
|
||
"select",
|
||
{ class: "pair-model" },
|
||
...ids.map((id) => h("option", { value: id }, id))
|
||
);
|
||
modelSel.value = p.model;
|
||
modelSel.addEventListener("change", () => { p.model = modelSel.value; });
|
||
const tierSel = h(
|
||
"select",
|
||
{ class: "pair-tier", title: t("routing.tasks.tierHint") },
|
||
h("option", { value: "any" }, t("routing.tasks.tierAny")),
|
||
...Object.keys(st.tiers).map((tr) => h("option", { value: tr }, trTier(tr)))
|
||
);
|
||
tierSel.value = p.tier;
|
||
tierSel.addEventListener("change", () => { p.tier = tierSel.value; });
|
||
const del = h("button", { class: "btn sm ghost danger", onClick: () => { pairs.splice(i, 1); paint(); } }, "×");
|
||
return h("div", { class: "pair-row" }, modelSel, tierSel, del);
|
||
}),
|
||
pairs.length === 0 ? h("div", { class: "muted small", style: "align-self:center" }, t("routing.tasks.inherit")) : null,
|
||
h("button", { class: "btn ghost sm", onClick: () => { pairs.push({ model: ids[0] || "", tier: "any" }); paint(); } }, "+ " + t("routing.tasks.addModel"))
|
||
);
|
||
}
|
||
paint();
|
||
return wrap;
|
||
}
|
||
|
||
function tasksCard() {
|
||
const addIn = h("input", { placeholder: t("routing.tasks.addPlaceholder"), style: "max-width:220px" });
|
||
const addBtn = h("button", { class: "btn ghost sm" }, "+ " + t("routing.tasks.addCustom"));
|
||
addBtn.addEventListener("click", () => {
|
||
const v = addIn.value.trim().toLowerCase();
|
||
if (!v || st.taskPairs[v]) return;
|
||
st.taskPairs[v] = [];
|
||
render();
|
||
});
|
||
return h(
|
||
"div",
|
||
{ class: "card" },
|
||
h("div", { class: "card-head" },
|
||
h("h3", {}, "🎯 " + t("routing.tasks.title")),
|
||
h("span", { class: "card-head sub" },
|
||
labelsSource === "router" ? t("routing.tasks.fromRouter") : t("routing.tasks.builtin"))),
|
||
h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.tasks.hint")),
|
||
...Object.keys(st.taskPairs).map(taskRow),
|
||
h("div", { class: "flex", style: "gap:8px; margin-top:10px" }, addIn, addBtn)
|
||
);
|
||
}
|
||
|
||
function policyCard() {
|
||
const modeSel = h(
|
||
"select",
|
||
{ onChange: (e) => { st.policy.mode = e.target.value; render(); } },
|
||
h("option", { value: "fixed_table", selected: st.policy.mode === "fixed_table" }, t("routing.policy.fixed")),
|
||
h("option", { value: "cost_aware", selected: st.policy.mode === "cost_aware" }, t("routing.policy.costAware"))
|
||
);
|
||
const children = [
|
||
h("label", { class: "field" }, h("span", {}, t("routing.policy.mode")), modeSel),
|
||
];
|
||
if (st.policy.mode === "cost_aware") {
|
||
const maxIn = h("input", {
|
||
type: "number",
|
||
step: "0.01",
|
||
value: st.policy.max_cost_usd_per_request ?? "",
|
||
placeholder: "0.50",
|
||
onChange: (e) => { st.policy.max_cost_usd_per_request = e.target.value === "" ? null : parseFloat(e.target.value); },
|
||
});
|
||
children.push(h("label", { class: "field" }, h("span", {}, t("routing.policy.maxCost")), maxIn));
|
||
st.policy.min_class = st.policy.min_class || {};
|
||
const rows = Object.keys(st.tiers).map((tier) => {
|
||
const sel = h(
|
||
"select",
|
||
{ onChange: (e) => { st.policy.min_class[tier] = e.target.value; } },
|
||
h("option", { value: "" }, "—"),
|
||
...(meta.cost_tiers || []).map((c) => h("option", { value: c, selected: st.policy.min_class[tier] === c }, c))
|
||
);
|
||
return h("label", { class: "field" }, h("span", {}, t("routing.policy.minClass") + " · " + tier), sel);
|
||
});
|
||
children.push(h("div", { class: "row" }, ...rows));
|
||
}
|
||
return h("div", { class: "card" }, h("div", { class: "card-head" }, h("h3", {}, t("routing.policy"))), ...children);
|
||
}
|
||
|
||
function render() {
|
||
const defSel = h(
|
||
"select",
|
||
{ onChange: (e) => { st.default = e.target.value; } },
|
||
...ids.map((id) => h("option", { value: id, selected: id === st.default }, id))
|
||
);
|
||
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
|
||
saveBtn.addEventListener("click", async () => {
|
||
saveBtn.disabled = true;
|
||
try {
|
||
const tasks = {};
|
||
for (const [k, pairs] of Object.entries(st.taskPairs)) {
|
||
const rule = {};
|
||
for (const p of pairs) {
|
||
if (!p.model) continue;
|
||
(rule[p.tier] = rule[p.tier] || []).push(p.model);
|
||
}
|
||
if (Object.keys(rule).length) tasks[k] = rule;
|
||
}
|
||
await api.putRouting({ tiers: st.tiers, tasks, bands: st.bands, default: st.default, policy: st.policy });
|
||
toast(t("toast.saved"), "good");
|
||
} catch (e) {
|
||
toast(String(e.message || e), "bad");
|
||
}
|
||
saveBtn.disabled = false;
|
||
});
|
||
|
||
mount(
|
||
root,
|
||
h(
|
||
"div",
|
||
{ class: "page-head" },
|
||
h(
|
||
"div",
|
||
{ class: "flex" },
|
||
h("div", { class: "grow" }, h("h2", {}, t("routing.title")), h("p", {}, t("routing.subtitle"))),
|
||
saveBtn
|
||
)
|
||
),
|
||
ids.length === 0 ? h("div", { class: "callout" }, t("routing.empty")) : null,
|
||
h(
|
||
"div",
|
||
{ class: "card" },
|
||
h("div", { class: "card-head" }, h("h3", {}, t("routing.tiers")), h("span", { class: "card-head sub" }, t("routing.tierHint"))),
|
||
bandsCard(),
|
||
...Object.keys(st.tiers).map(tierRow)
|
||
),
|
||
tasksCard(),
|
||
h(
|
||
"div",
|
||
{ class: "card" },
|
||
h("div", { class: "card-head" }, h("h3", {}, t("routing.default"))),
|
||
h("label", { class: "field" }, h("span", {}, t("routing.defaultHint")), defSel)
|
||
),
|
||
policyCard()
|
||
);
|
||
}
|
||
|
||
render();
|
||
return root;
|
||
}
|