gui, provider, gateway: a group's models are dragged into order, a group can be switched off, several models are added at once, and a page shows a scrollbar on the Mac (PAMI on Discord) — in a group's editor a model went up one step a click and only up; its number is now a handle (grip on hover) that drags it, Alt+↑/↓ moves it from the keyboard, and Up and Down stay beside it; a pattern's members follow the named ones. A group of the user's has a switch on its card: off, agents aren't offered it, a request to it is turned away with 404 "the routing group … is switched off", a group that has it in it goes on to its next member, a save from the editor leaves it off, and one that classifies for another can't be switched off (a found group is removed instead, as before). Each member already had its own switch. The editor's Add a model picker stays open: each model clicked is added and ticked, clicked again taken out, and each says the other groups it is in. Pages, dialogs and framed editors had their scrollbar hidden everywhere (width 0); on the Mac they now have a thin 8px thumb, elsewhere the 10px one other lists have. TestSwitchGroup, TestSwitchedOffGroup, group-member-drag, group-switch-add-many and view-scrollbar (WebKit, en and zh) fail without the change. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn is flaky on origin/main as well).

This commit is contained in:
yetone committed 2026-10-05 16:46:59 +08:00
1 parent 1cf07610c2
commit 4dab45e3ff
13 files changed
+724 -20

No files matched your search

+4 -1
View File
@@ -1152,7 +1152,10 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
return
}
msg := fmt.Sprintf("magpie knows no model %q", call.Model)
if g, ok := emptyGroup(asked); ok {
if g, ok := provider.DisabledGroup(asked); ok {
call.Error = "group switched off"
msg = fmt.Sprintf("the routing group %s is switched off in Magpie, so %q is not served; switch it on again on Magpie's Routing page to use it", g.Name, call.Model)
} else if g, ok := emptyGroup(asked); ok {
// a group of its own with nothing in it now — its patterns
// match no model served (#766) — said so, not the whole list
msg = emptyGroupError(g)
+50
View File
@@ -0,0 +1,50 @@
package gateway
import (
"net/http/httptest"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// A routing group switched off (PAMI on Discord) is not in /v1/models, a
// request to it is turned away saying it is off rather than sent to its
// models, and a group that has it in it goes on to its next member.
func TestSwitchedOffGroup(t *testing.T) {
fresh(t)
a, b := &keyed{}, &keyed{}
serveOn(t, "pa", "ka", []string{"x"}, a)
serveOn(t, "pb", "kb", []string{"y"}, b)
if err := provider.SaveGroup(provider.Group{Name: "Inner", Members: []string{"pa/x"}}); err != nil {
t.Fatal(err)
}
if err := provider.SaveGroup(provider.Group{Name: "Outer", Members: []string{"group/inner", "pb/y"}, Routing: provider.Ordered}); err != nil {
t.Fatal(err)
}
if err := provider.SwitchGroup("inner", false); err != nil {
t.Fatal(err)
}
s := New()
code, body := postAs(t, s, "", `{"model":"group/inner","messages":[{"role":"user","content":"hi"}]}`)
if code != 404 || !strings.Contains(body, "switched off") || len(a.tried) != 0 {
t.Fatalf("to the group switched off: %d %s, tried %v", code, body, a.tried)
}
code, body = postAs(t, s, "", `{"model":"group/outer","messages":[{"role":"user","content":"hi"}]}`)
if code != 200 || !strings.Contains(body, "from kb") || len(a.tried) != 0 {
t.Fatalf("the group with it in it: %d %s, tried %v", code, body, a.tried)
}
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil))
if strings.Contains(rec.Body.String(), `"group/inner"`) || !strings.Contains(rec.Body.String(), `"group/outer"`) {
t.Fatalf("listed: %s", rec.Body.String())
}
// on again, it answers
if err := provider.SwitchGroup("inner", true); err != nil {
t.Fatal(err)
}
code, body = postAs(t, s, "", `{"model":"group/inner","messages":[{"role":"user","content":"hi"}]}`)
if code != 200 || !strings.Contains(body, "from ka") {
t.Fatalf("on again: %d %s", code, body)
}
}
+8 -3
View File
@@ -300,13 +300,20 @@ body.window .view { padding: 2px 16px 8px; }
Sessions take the whole width */
body.window :is(#view-agents, #view-providers, #view-gateway, #view-library, #view-plugins, #view-settings) { padding-left: max(16px, calc(50% - 600px)); padding-right: max(16px, calc(50% - 600px)); }
.view > .view-room { flex: none; pointer-events: none; } /* see "where the reader is" */
.view::-webkit-scrollbar { width: 0; }
/* Windows and Linux draw a classic scrollbar, arrows and all; a thin rounded
thumb instead, like the overlay one macOS shows (left as it is there) */
:where(body:not(.mac)) ::-webkit-scrollbar { width: 10px; height: 10px; background: transparent; }
:where(body:not(.mac)) ::-webkit-scrollbar-thumb { background: var(--faint); border: 3px solid transparent; border-radius: 10px; background-clip: padding-box; }
:where(body:not(.mac)) ::-webkit-scrollbar-thumb:hover { background-color: var(--muted); }
:where(body:not(.mac)) ::-webkit-scrollbar-button, :where(body:not(.mac)) ::-webkit-scrollbar-corner { display: none; background: transparent; }
/* a page, a dialog and a framed editor that run longer than the window
show where the reader is in them with a thin thumb, on the Mac too
(PAMI on Discord: no page had a scrollbar) — the WebView draws the
Mac's overlay one only while a trackpad scrolls, and a mouse never */
body.mac :is(.view, .modal .dialog, .modal .editor.framed > .ebody)::-webkit-scrollbar { width: 8px; background: transparent; }
body.mac :is(.view, .modal .dialog, .modal .editor.framed > .ebody)::-webkit-scrollbar-thumb { background: var(--faint); border: 2px solid transparent; border-radius: 8px; background-clip: padding-box; }
body.mac :is(.view, .modal .dialog, .modal .editor.framed > .ebody)::-webkit-scrollbar-thumb:hover { background-color: var(--muted); }
body.mac :is(.view, .modal .dialog, .modal .editor.framed > .ebody)::-webkit-scrollbar-corner { background: transparent; }
.list { background: var(--card); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; flex: none; }
body.panel .list { border: 0; border-radius: 10px; box-shadow: var(--card-edge); }
.row { display: flex; align-items: center; gap: 9px; min-height: 40px; padding: 5px 9px 5px 11px; }
@@ -2450,7 +2457,6 @@ html.theming, html.theming *, html.theming *::before, html.theming *::after {
box-shadow: 0 0 0 1px var(--line), 0 24px 60px rgba(0, 0, 0, .32), 0 2px 8px rgba(0, 0, 0, .12);
}
.modal .dialog.swap { animation: content-in .22s var(--ease); }
.modal .dialog::-webkit-scrollbar { width: 0; }
/* In a dialog the editor's head and buttons stay put and only the fields
between them scroll; both bars are the same height, their contents
centred in it. */
@@ -2460,7 +2466,6 @@ html.theming, html.theming *, html.theming *::before, html.theming *::after {
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
display: grid; grid-template-columns: inherit; gap: 10px 14px; align-items: start; padding: 14px 18px;
}
.modal .editor.framed > .ebody::-webkit-scrollbar { width: 0; }
.modal .editor.framed > .ehead, .modal .editor.framed > .bar { flex: none; min-height: 48px; margin: 0; padding: 0 18px; }
.modal .editor.framed > .ehead { border-bottom: 1px solid var(--line-2); }
.modal .editor.framed > .bar { border-top: 1px solid var(--line-2); }
+11 -1
View File
@@ -3850,7 +3850,7 @@ function renderList() {
if (!q && o.group && o.group !== group) list.append(el("li", "group", o.group === ROUTING_GROUPS ? t(o.group) : o.group));
if (!q) group = o.group ?? group;
if (o.fold) { list.append(foldRow(o, idx, hasIcons)); return; }
const li = el("li", (idx === pick.cursor ? "sel" : "") + (o.value === pick.field.value ? " cur" : "") + (o.custom ? " custom" : "") + (o.reset ? " reset" : ""));
const li = el("li", (idx === pick.cursor ? "sel" : "") + (o.value === pick.field.value || pick.field.picked?.(o.value) ? " cur" : "") + (o.custom ? " custom" : "") + (o.reset ? " reset" : ""));
li.dataset.i = idx;
if (hasIcons) li.append(optionIcon(o));
const words = el("span", "option-words");
@@ -3962,6 +3962,16 @@ async function commit(value) {
if (!pick || value == null) return;
const { agent, field, anchor } = pick;
const opt = pick.options.find((o) => o.value === value);
if (field.multi && !opt?.run) {
// a picker that takes several (a group's models, PAMI on Discord)
// stays open: each pick adds one, or takes it out again, and is ticked
field.onPick(value, opt);
if (opt) renderList();
else { $("#q").value = ""; filter(); } // a model typed in is in now
// the filter keeps the keys, for the next one and Escape
if (!web || !matchMedia("(pointer: coarse)").matches) $("#q").focus({ preventScroll: true });
return;
}
closePicker();
if (opt?.run) return opt.run(); // an act rather than a value (Disconnect from magpie)
if (field.onPick) return field.onPick(value, opt); // a picker opened for something other than an agent's setting
+24
View File
@@ -775,6 +775,14 @@ const I18N = {
"add a model: filter, or type provider/model…": "添加模型:筛选,或输入 provider/model…",
"No provider serves {id} now; it is skipped": "现在没有供应商提供 {id},会跳过它",
"Up": "上移",
"Down": "下移",
"switched off": "已关闭",
"Off: agents aren't offered {name} and a request to it is turned away. Click to switch it on": "已关闭:Agent 列表里没有 {name},发给它的请求会被拒绝。点击打开",
"On: agents may pick {name}. Click to switch it off and keep it as it is": "已打开:Agent 可以选用 {name}。点击暂时关闭,组的设置原样保留",
"{name} switched on": "已打开 {name}",
"{name} switched off": "已关闭 {name}",
"in {groups}": "已在 {groups}",
"Move {name}": "移动 {name}",
"Enter adds {id}": "回车添加 {id}",
"No model matches": "没有匹配的模型",
"When {name} is out of quota, rate limited or down, a request goes to these instead, top first. It only happens before any of the reply is sent, and {name} then sits out a minute.": "{name} 额度用完、限流或不可用时,请求按从上到下的顺序改发给这些模型。仅在回复开始前切换,之后 {name} 暂停使用一分钟。",
@@ -3953,6 +3961,14 @@ const I18N = {
"add a model: filter, or type provider/model…": "モデルを追加:絞り込み、または provider/model を入力…",
"No provider serves {id} now; it is skipped": "現在 {id} を提供しているプロバイダはないため、スキップされます",
"Up": "上へ",
"Down": "下へ",
"switched off": "オフ",
"Off: agents aren't offered {name} and a request to it is turned away. Click to switch it on": "オフ:{name} はエージェントに表示されず、届いたリクエストは断られます。クリックでオン",
"On: agents may pick {name}. Click to switch it off and keep it as it is": "オン:エージェントは {name} を選べます。クリックで一時的にオフ(設定はそのまま)",
"{name} switched on": "{name} をオンにしました",
"{name} switched off": "{name} をオフにしました",
"in {groups}": "{groups} に追加済み",
"Move {name}": "{name} を移動",
"Enter adds {id}": "Enter で {id} を追加",
"No model matches": "一致するモデルはありません",
"When {name} is out of quota, rate limited or down, a request goes to these instead, top first. It only happens before any of the reply is sent, and {name} then sits out a minute.": "{name} のクォータ切れ、レート制限、ダウン時には、リクエストは代わりにこれらへ上から順に送られます。応答が送られ始める前にのみ切り替わり、その後 {name} は 1 分間休止します。",
@@ -7123,6 +7139,14 @@ const I18N = {
"add a model: filter, or type provider/model…": "Modell hinzufügen: filtern oder provider/model eingeben…",
"No provider serves {id} now; it is skipped": "Derzeit stellt kein Anbieter {id} bereit; es wird übersprungen",
"Up": "Nach oben",
"Down": "Nach unten",
"switched off": "ausgeschaltet",
"Off: agents aren't offered {name} and a request to it is turned away. Click to switch it on": "Aus: Agents wird {name} nicht angeboten, eine Anfrage daran wird abgewiesen. Klicken zum Einschalten",
"On: agents may pick {name}. Click to switch it off and keep it as it is": "An: Agents können {name} wählen. Klicken zum Ausschalten – die Gruppe bleibt, wie sie ist",
"{name} switched on": "{name} eingeschaltet",
"{name} switched off": "{name} ausgeschaltet",
"in {groups}": "in {groups}",
"Move {name}": "{name} verschieben",
"Enter adds {id}": "Enter fügt {id} hinzu",
"No model matches": "Kein Modell passt",
"When {name} is out of quota, rate limited or down, a request goes to these instead, top first. It only happens before any of the reply is sent, and {name} then sits out a minute.": "Hat {name} kein Kontingent mehr, wird gedrosselt oder fällt aus, geht eine Anfrage stattdessen an diese, von oben nach unten. Das geschieht nur, bevor ein Teil der Antwort gesendet wurde, und {name} pausiert dann eine Minute.",
+13
View File
@@ -279,6 +279,9 @@ button.rt-session { cursor: pointer; }
.rt-group { display: flex; align-items: center; gap: 12px; padding: 10px 12px 10px 14px; cursor: pointer; transition: background .12s; }
.rt-group:hover { background: var(--card-2); }
.rt-group.off { opacity: .6; }
/* switched off as a whole: dimmed but for its switch, which brings it back */
.rt-group.disabled > :not(.rt-gon) { opacity: .5; }
.rt-group .rt-gon { flex: none; transform: scale(.85); }
.rt-group .ics { display: flex; gap: 3px; flex: none; min-width: 18px; }
.rt-group .ics .ic { width: 16px; height: 16px; }
/* a group's logos are its handle (#779), as an agent row's logo is: the
@@ -392,6 +395,16 @@ button.rt-session { cursor: pointer; }
.rt-gedit .fbrow .n small { color: var(--muted); font-size: 11.5px; }
.rt-gedit .fbrow.off .n { color: var(--muted); text-decoration: line-through; }
.rt-gedit .fbrow .rt-mon { transform: scale(.85); }
/* a named member's number is its handle: the grip beside it shows on
hover, dragged or from the keyboard only, as on the groups' cards */
.rt-gedit .fbrow .rt-mhandle.ag-handle { position: relative; width: 18px; height: 22px; margin: 0 -2px; font: inherit; color: var(--muted); font-variant-numeric: tabular-nums; }
.rt-gedit .fbrow .rt-mhandle::before { content: ""; position: absolute; left: -7px; top: 50%; width: 5px; height: 11px; margin-top: -5.5px; background: radial-gradient(circle, var(--muted) .9px, transparent 1.2px) 0 0 / 3.5px 3.75px; opacity: 0; transition: opacity .15s; }
.rt-gedit .fbrow:is(:hover, .dragging) .rt-mhandle::before, .rt-gedit .fbrow .rt-mhandle:focus-visible::before { opacity: 1; }
.rt-gedit .fbl.sorting { user-select: none; -webkit-user-select: none; }
.rt-gedit .fbl.sorting > .fbrow { transition: transform .22s cubic-bezier(.22, 1, .36, 1); }
.rt-gedit .fbl.sorting .rt-mhandle { cursor: grabbing; }
.rt-gedit .fbl > .fbrow.dragging { position: relative; z-index: 2; background: var(--card); box-shadow: 0 6px 18px rgba(20, 22, 30, .12), 0 0 0 1px var(--line); border-radius: 8px; transition: none; }
@media (prefers-reduced-motion: reduce) { .rt-gedit .fbl.sorting > .fbrow { transition: none; } }
.rt-gedit .fbrow.muted > :not(.rt-mon):not(.text) { opacity: .45; }
/* a member's own reasoning, beside its Up and Remove */
.rt-gedit .fbrow .rt-fixed { height: 24px; padding: 0 9px; font-size: 11.5px; flex: none; }
+76 -10
View File
@@ -2609,7 +2609,7 @@
}
}
function groupRow(g) {
const row = el("div", "rt-group" + (g.ready ? "" : " off"));
const row = el("div", "rt-group" + (g.ready ? "" : " off") + (g.disabled ? " disabled" : ""));
row.dataset.id = g.id;
const ics = groupHandle(g, row);
const main = el("div", "main");
@@ -2641,13 +2641,33 @@
r.title = (manual ? t("The rules wait while you pick the model by hand.") + "\n" : "") + g.rules.map((x, i) => `${i + 1}. ${ruleText(x)} → ${memberLabel(g, x.use)}`).join("\n");
tags.append(r);
}
if (!g.ready) tags.append(el("span", "tag bad", t("no member ready")));
if (g.disabled) tags.append(el("span", "tag idle", t("switched off")));
else if (!g.ready) tags.append(el("span", "tag bad", t("no member ready")));
const edit = el("button", "text", t("Edit"));
edit.onclick = (e) => { e.stopPropagation(); open(); };
const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], match: [...(g.match || [])], matched: [...(g.matched || [])], fast: [...(g.fast || [])], off: [...(g.off || [])], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", sink: !!g.sink, firstToken: g.firstToken || 0, context: g.context || 0, classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])], time: r.time ? { ...r.time, days: [...(r.time.days || [])] } : null })) } }; renderGroups(); };
row.onclick = open;
row.oncontextmenu = (e) => { e.preventDefault(); groupMenu(ics, g); };
row.append(ics, main, tags, edit);
// a group of the user's switches off as a whole, kept as it is (PAMI
// on Discord); one magpie found is removed rather (provider.SwitchGroup)
if (!g.auto) {
const sw = el("button", "lib-switch rt-gon" + (g.disabled ? "" : " on"));
sw.type = "button";
sw.setAttribute("role", "switch");
sw.setAttribute("aria-checked", String(!g.disabled));
sw.setAttribute("aria-label", g.name);
sw.title = g.disabled ? t("Off: agents aren't offered {name} and a request to it is turned away. Click to switch it on", { name: g.name })
: t("On: agents may pick {name}. Click to switch it off and keep it as it is", { name: g.name });
sw.append(el("i"));
sw.onclick = async (e) => {
e.stopPropagation(); // the card opens the editor; this switches
const held = document.activeElement === sw;
await groupAction("switch", { id: g.id, on: !!g.disabled }, t(g.disabled ? "{name} switched on" : "{name} switched off", { name: g.name }));
if (held) gList.querySelector(`.rt-group[data-id="${CSS.escape(g.id)}"] .rt-gon`)?.focus({ preventScroll: true });
};
row.append(sw);
}
return row;
}
// groupHandle is a group row's logos, which are also its handle, as an
@@ -2842,6 +2862,40 @@
const list = el("div", "fbl");
const addBtn = el("button", "rt-gadd");
addBtn.append(svg(PLUS, 11, 1.8), el("span", "", t("Add a model")));
// moveMember puts the member at `from` at `to` among those named — a
// pattern's follow them, in the catalog's order — and keeps the
// keyboard on its handle when it was there
const named = () => d.members.filter((x) => !d.matched.includes(x)).length;
const moveMember = (from, to, keep) => {
if (to < 0 || to >= named() || to === from) return;
d.members.splice(to, 0, d.members.splice(from, 1)[0]);
draw();
if (keep) list.querySelector(`.rt-mhandle[data-at="${to}"]`)?.focus({ preventScroll: true });
};
// memberHandle is a named member's place in the order, which is also
// its handle (PAMI on Discord: only Up, one step a click): drag it to
// move the model, or Alt+↑/↓ from the keyboard; Up and Down stay
// beside it. The grip that says so shows only on hover, as on the
// groups' cards.
const memberHandle = (id, i, row) => {
const b = el("button", "i ag-handle rt-mhandle", String(i + 1));
b.type = "button";
b.dataset.at = i;
b.setAttribute("aria-label", t("Move {name}", { name: memberName(id) }));
b.title = t("Drag to reorder · Alt+↑/↓ to move");
b.onkeydown = (e) => {
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
e.preventDefault();
e.stopPropagation();
moveMember(i, i + (e.key === "ArrowUp" ? -1 : 1), true);
};
b.onclick = (e) => { e.stopPropagation(); delete b.dataset.dragged; };
b.onpointerdown = (e) => {
const rows = [...list.children].filter((r) => r.querySelector(".rt-mhandle"));
dragRows(e, b, row, list, rows, (to) => moveMember(i, to));
};
return b;
};
const draw = () => {
list.replaceChildren();
d.members.forEach((id, i) => {
@@ -2865,7 +2919,7 @@
sw.append(el("i"));
sw.onclick = () => { d.off = off ? d.off.filter((x) => x !== id) : [...d.off, id]; draw(); };
if (off) row.classList.add("muted");
row.append(sw, el("span", "i", String(i + 1)), memberIcon(id), n, el("span", "grow"));
row.append(sw, matched ? el("span", "i", String(i + 1)) : memberHandle(id, i, row), memberIcon(id), n, el("span", "grow"));
// the reasoning the model is sent at in this group: the group's
// (blank), or one of its own whatever the agent asks. A group in
// it reasons as it says.
@@ -2911,7 +2965,8 @@
list.append(row);
return;
}
if (i) { const up = el("button", "text", t("Up")); up.onclick = () => { d.members.splice(i - 1, 0, d.members.splice(i, 1)[0]); draw(); }; row.append(up); }
if (i) { const up = el("button", "text", t("Up")); up.onclick = () => moveMember(i, i - 1); row.append(up); }
if (i < named() - 1) { const down = el("button", "text", t("Down")); down.onclick = () => moveMember(i, i + 1); row.append(down); }
const rm = el("button", "text", t("Remove"));
rm.onclick = () => { d.members.splice(i, 1); rematch(); draw(); drawRules(); };
row.append(rm);
@@ -2919,15 +2974,26 @@
});
addBtn.querySelector("span").textContent = t(d.members.length ? "Add another model" : "Add a model");
};
// the picker stays open for as many models as are wanted, each ticked
// once in, and says which other groups a model is in already (PAMI on
// Discord: a group was made one model at a time, blind)
const inGroups = (id) => groups.groups.filter((x) => !x.hidden && x.id !== g?.id && x.members.some((m) => m === id || splitMember(m)[0] === id)).map((x) => x.name);
const noted = (note, id) => {
const gs = inGroups(id);
return gs.length ? [note, t("in {groups}", { groups: gs.join(", ") })].filter(Boolean).join(" · ") : note;
};
addBtn.onclick = (ev) => {
// a group in it may be any other, but never one it is in already:
// that would put it in itself
const subs = groups.groups.filter((x) => !x.hidden && x.id !== g?.id && !(g && x.holds?.includes(g.id)) && !d.members.includes("group/" + x.id))
.map((x) => ({ value: "group/" + x.id, label: x.name, note: "group/" + x.id, icons: groupIcons(x), group: ROUTING_GROUPS, ref: "group/" + x.id }));
const options = [...subs, ...groups.models.filter((x) => !d.members.includes(x.id))
.map((x) => ({ value: x.id, label: x.name || x.id, note: x.providerName, icon: x.icon, group: x.providerName, ref: x.id, context: x.context }))];
openPicker({ id: "", name: "", fields: [] }, { key: "member", label: "model", value: "", options, onPick: (id) => {
if (id && !d.members.includes(id)) d.members.push(id);
const subs = groups.groups.filter((x) => !x.hidden && x.id !== g?.id && !(g && x.holds?.includes(g.id)))
.map((x) => ({ value: "group/" + x.id, label: x.name, note: noted("group/" + x.id, "group/" + x.id), icons: groupIcons(x), group: ROUTING_GROUPS, ref: "group/" + x.id }));
const options = [...subs, ...groups.models
.map((x) => ({ value: x.id, label: x.name || x.id, note: noted(x.providerName, x.id), icon: x.icon, group: x.providerName, ref: x.id, context: x.context }))];
openPicker({ id: "", name: "", fields: [] }, { key: "member", label: "model", value: "", options, multi: true, picked: (id) => d.members.includes(id), onPick: (id) => {
if (!id) return;
if (!d.members.includes(id)) d.members.splice(named(), 0, id); // after those named, before a pattern's
else if (d.matched.includes(id)) return status(t("In the group by a pattern: switch it off to send it nothing"), "warn");
else { d.members.splice(d.members.indexOf(id), 1); rematch(); }
draw(); drawRules();
} }, addBtn, ev);
};
+3
View File
@@ -287,6 +287,9 @@ func groupRoutes(mux *http.ServeMux) {
}
case "show":
err = provider.ShowGroup(in.ID)
case "switch":
// a group of the user's on or off, kept as it is (PAMI on Discord)
err = provider.SwitchGroup(in.ID, body.On)
case "arrange":
err = provider.SetGroupOrder(body.Order)
default:
@@ -0,0 +1,145 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A group's models are put in order by dragging (PAMI on Discord: only Up,
// one step a click). In the editor a named member's number is its handle,
// with a grip beside it drawn only while the row is under the pointer;
// dragging it moves the model, Alt+↑/↓ moves it from the keyboard, which
// stays on it, and Up and Down stay beside it. A pattern's members follow
// the named ones and have no handle. Saved, the members go in the new
// order. No click or drag moves the page. In English and Chinese, Chromium
// and WebKit.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const models = [
{ id: "a/one", name: "one", providerName: "A", icon: "generic" },
{ id: "a/two", name: "two", providerName: "A", icon: "generic" },
{ id: "b/three", name: "three", providerName: "B", icon: "generic" },
{ id: "c/free:free", name: "free:free", providerName: "C", icon: "generic" },
];
const members = ["a/one", "a/two", "b/three", "c/free:free"];
const groups = () => ({
models, pools: [],
groups: [{ id: "g", name: "G", members, match: ["c/*:free"], matched: ["c/free:free"], off: [], routing: "order", ready: true,
memberInfo: members.map((id) => ({ id, ready: true })) }],
});
const words = {
en: { edit: "Edit", save: "Save", up: "Up", down: "Down", move: "Move two" },
zh: { edit: "编辑", save: "保存", up: "上移", down: "下移", move: "移动 two" },
};
function serve(lang, posts) {
const state = { agents: [{ id: "codex", name: "Codex", path: "/test/codex", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
return async (r) => {
const url = new URL(r.request().url());
const json = (data) => r.fulfill({ json: data });
if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes
return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
}
if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
if (url.pathname === "/api/groups") return json(groups());
if (url.pathname.startsWith("/api/groups/")) {
posts.push({ path: url.pathname, body: JSON.parse(r.request().postData() || "{}") });
return json(groups());
}
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await r.fulfill({ body: await fs.readFile(file), contentType });
};
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: a group's models are dragged into order`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 }, reducedMotion: "reduce" })).newPage();
t.after(() => browser.close());
page.setDefaultTimeout(5000);
const errors = [], posts = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, posts));
await page.goto("http://magpie.test/?view=routing");
const card = page.locator(".rt-group", { hasText: "G" }).first();
await card.waitFor();
await card.locator("button", { hasText: w.edit }).click();
const ed = page.locator(".rt-gedit");
await ed.locator(".fbrow").first().waitFor();
const order = () => ed.locator(".fbl").first().locator(".fbrow .n > span:first-child").allTextContents();
const row = (name) => ed.locator(".fbrow", { has: page.locator(".n > span:first-child", { hasText: new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`) }) });
const handle = (name) => row(name).locator(".rt-mhandle");
// every named member has a handle with its number; the pattern's none
assert.deepEqual(await ed.locator(".fbrow .rt-mhandle").allTextContents(), ["1", "2", "3"]);
assert.equal(await handle("free:free").count(), 0, "a pattern's member has a handle");
assert.equal(await handle("two").getAttribute("aria-label"), w.move);
// Up on all but the first named, Down on all but the last named
const btns = async (name) => row(name).locator("button.text").allTextContents();
assert(!(await btns("one")).includes(w.up) && (await btns("one")).includes(w.down));
assert((await btns("three")).includes(w.up) && !(await btns("three")).includes(w.down));
// the grip shows on the row under the pointer only
await page.mouse.move(1, 1);
const grip = (name) => handle(name).evaluate((h) => getComputedStyle(h, "::before").opacity);
assert.equal(await grip("two"), "0");
await row("two").locator(".n").hover();
await page.waitForFunction(() => [...document.querySelectorAll(".rt-mhandle")].map((h) => getComputedStyle(h, "::before").opacity).join() === "0,1,0");
await ed.evaluate((x) => x.scrollIntoView({ block: "center" })); // by the test, as the reader would
await page.waitForTimeout(200);
const at = () => page.evaluate(() => [...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => [e.id || e.className, e.scrollTop]).join(";") + "|" + Math.round(document.querySelector(".rt-gedit").getBoundingClientRect().top));
const before = await at();
// drag "three" above "one"
const hb = await handle("three").boundingBox(), top = await row("one").boundingBox();
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
await page.mouse.down();
for (let i = 1; i <= 8; i++) await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2 - ((hb.y - top.y + 6) * i) / 8);
await page.mouse.up();
assert.deepEqual(await order(), ["three", "one", "two", "free:free"], "the drag moved it");
assert.equal(await page.locator(".pop:not([hidden])").count(), 0, "the drag's click opened something");
// Alt+↓ from the keyboard, which stays on the handle
await handle("one").focus();
await page.keyboard.press("Alt+ArrowDown");
assert.deepEqual(await order(), ["three", "two", "one", "free:free"]);
assert.equal(await page.evaluate(() => document.activeElement?.closest(".fbrow")?.querySelector(".n > span")?.textContent), "one", "the keyboard left the moved model");
// it never goes past the pattern's
await page.keyboard.press("Alt+ArrowDown");
assert.deepEqual(await order(), ["three", "two", "one", "free:free"]);
// Down and Up still move it a step
await row("three").locator("button.text", { hasText: w.down }).click();
assert.deepEqual(await order(), ["two", "three", "one", "free:free"]);
await row("one").locator("button.text", { hasText: w.up }).click();
assert.deepEqual(await order(), ["two", "one", "three", "free:free"]);
assert.equal(await at(), before, "a click or drag moved the page");
const b = ed.locator("button.primary", { hasText: w.save });
await page.mouse.move(550, 600);
for (let i = 0; i < 10 && (await b.boundingBox()).y > 1400 - 160; i++) {
await page.mouse.wheel(0, 300);
await page.waitForTimeout(150);
}
const bb = await b.boundingBox();
await page.mouse.click(bb.x + bb.width / 2, bb.y + bb.height / 2);
await page.waitForFunction(() => !document.querySelector(".rt-gedit"));
const sent = posts.find((p) => p.path === "/api/groups/save");
assert.deepEqual(sent.body.members.slice(0, 3), ["a/two", "a/one", "b/three"]);
const missing = await page.evaluate(() => ["Down", "Move {name}", "Drag to reorder · Alt+↑/↓ to move"].filter((k) => !I18N.zh[k] || !I18N.ja[k] || !I18N.de[k]));
assert.deepEqual(missing, [], "every string has its Chinese, Japanese and German");
assert.deepEqual(errors, []);
});
}
}
@@ -0,0 +1,151 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// PAMI on Discord: a routing group could not be switched off as a whole,
// and a group was made one model at a time, not seeing which groups a
// model was in already.
// - A group of the user's has a switch on its card: off, it posts
// groups/switch with on false, the card is dimmed but for the switch and
// says "switched off", and the keyboard stays on the switch; on again
// posts on true. A group magpie found has no switch (it is removed).
// - The editor's Add a model picker stays open: each model clicked is added
// and ticked, clicked again it is taken out; each says the other groups
// it is in. Saved, the members are the ones picked, in the order picked.
// No click moves the page. In English and Chinese, Chromium and WebKit.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const models = [
{ id: "a/one", name: "one", providerName: "A", icon: "generic" },
{ id: "a/two", name: "two", providerName: "A", icon: "generic" },
{ id: "b/three", name: "three", providerName: "B", icon: "generic" },
];
const words = {
en: { edit: "Edit", save: "Save", add: "Add another model", off: "switched off", in: "in Other", offSaid: "G switched off", onSaid: "G switched on" },
zh: { edit: "编辑", save: "保存", add: "再添加一个模型", off: "已关闭", in: "已在 Other", offSaid: "已关闭 G", onSaid: "已打开 G" },
};
function serve(lang, posts) {
let disabled = false;
const groups = () => ({
models, pools: [],
groups: [
{ id: "g", name: "G", members: ["a/one"], off: [], routing: "order", ready: true, disabled, memberInfo: [{ id: "a/one", ready: true }] },
{ id: "other", name: "Other", members: ["b/three:high"], off: [], routing: "order", ready: true, memberInfo: [{ id: "b/three:high", ready: true }] },
{ id: "auto-one", name: "Found one", members: ["a/one"], auto: true, ready: true, memberInfo: [{ id: "a/one", ready: true }] },
],
});
const state = { agents: [{ id: "codex", name: "Codex", path: "/test/codex", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
return async (r) => {
const url = new URL(r.request().url());
const json = (data) => r.fulfill({ json: data });
if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes
return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
}
if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
if (url.pathname === "/api/groups") return json(groups());
if (url.pathname.startsWith("/api/groups/")) {
const body = JSON.parse(r.request().postData() || "{}");
posts.push({ path: url.pathname, body });
if (url.pathname === "/api/groups/switch") disabled = !body.on;
return json(groups());
}
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await r.fulfill({ body: await fs.readFile(file), contentType });
};
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: a group switched off, and models added several at a time`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 }, reducedMotion: "reduce" })).newPage();
t.after(() => browser.close());
page.setDefaultTimeout(5000);
const errors = [], posts = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, posts));
await page.goto("http://magpie.test/?view=routing");
const card = (id) => page.locator(`.rt-group[data-id="${id}"]`);
await card("g").waitFor();
await page.locator(".rt-gsec").evaluate((x) => x.scrollIntoView({ block: "center" })); // as the reader would
await page.waitForTimeout(200);
const at = () => page.evaluate(() => [...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => [e.id || e.className, e.scrollTop]).join(";"));
const before = await at();
// the switch: on a group of the user's, none on a found one
assert.equal(await card("auto-one").locator(".rt-gon").count(), 0, "a found group has a switch");
const sw = () => card("g").locator("button.rt-gon");
assert.equal(await sw().getAttribute("aria-checked"), "true");
await sw().focus();
await page.keyboard.press("Space");
await page.waitForFunction(() => document.querySelector('.rt-group[data-id="g"]')?.classList.contains("disabled"));
assert.deepEqual(posts.at(-1), { path: "/api/groups/switch", body: { id: "g", on: false } });
assert.equal(await sw().getAttribute("aria-checked"), "false");
assert((await card("g").locator(".tags").textContent()).includes(w.off), "the card says it is off");
await page.waitForFunction((m) => document.querySelector("#status").textContent.includes(m), w.offSaid);
assert.equal(await page.evaluate(() => document.activeElement?.classList.contains("rt-gon")), true, "the keyboard left the switch");
const dim = await card("g").evaluate((r) => [...r.children].map((c) => [c.classList.contains("rt-gon"), getComputedStyle(c).opacity]));
for (const [isSw, o] of dim) assert.equal(o, isSw ? "1" : "0.5");
assert.equal(await page.locator(".rt-gedit").count(), 0, "the switch opened the editor");
await sw().click();
await page.waitForFunction(() => !document.querySelector('.rt-group[data-id="g"]')?.classList.contains("disabled"));
assert.deepEqual(posts.at(-1), { path: "/api/groups/switch", body: { id: "g", on: true } });
assert.equal(await at(), before, "the switch moved the page");
// Add a model: several in one go
await card("g").locator("button", { hasText: w.edit }).click();
const ed = page.locator(".rt-gedit");
await ed.locator("button.rt-gadd", { hasText: w.add }).click();
const pop = page.locator("#pop");
await pop.locator("li", { hasText: "two" }).first().waitFor();
const opt = (name) => pop.locator("li", { has: page.locator(".v", { hasText: new RegExp(`^${name}$`) }) }).first();
assert.equal(await opt("one").evaluate((li) => li.classList.contains("cur")), true, "the member in it is not ticked");
assert((await opt("three").textContent()).includes(w.in), "the picker doesn't say which group three is in");
await opt("two").click();
await opt("three").click();
assert.equal(await pop.isHidden(), false, "the picker closed after one");
assert.equal(await opt("two").evaluate((li) => li.classList.contains("cur")), true, "two is not ticked");
const names = () => ed.locator(".fbl").first().locator(".fbrow .n > span:first-child").allTextContents();
assert.deepEqual(await names(), ["one", "two", "three"]);
// clicked again, taken out
await opt("two").click();
assert.deepEqual(await names(), ["one", "three"]);
assert.equal(await opt("two").evaluate((li) => li.classList.contains("cur")), false);
await page.keyboard.press("Escape");
await pop.waitFor({ state: "hidden" });
const b = ed.locator("button.primary", { hasText: w.save });
await page.mouse.move(550, 600);
for (let i = 0; i < 10 && (await b.boundingBox()).y > 1400 - 160; i++) {
await page.mouse.wheel(0, 300);
await page.waitForTimeout(150);
}
const bb = await b.boundingBox();
await page.mouse.click(bb.x + bb.width / 2, bb.y + bb.height / 2);
await page.waitForFunction(() => !document.querySelector(".rt-gedit"));
const sent = posts.find((p) => p.path === "/api/groups/save");
assert.deepEqual(sent.body.members, ["a/one", "b/three"]);
const missing = await page.evaluate(() => [
"in {groups}", "switched off", "{name} switched on", "{name} switched off",
"Off: agents aren't offered {name} and a request to it is turned away. Click to switch it on",
"On: agents may pick {name}. Click to switch it off and keep it as it is",
].filter((k) => !I18N.zh[k] || !I18N.ja[k] || !I18N.de[k]));
assert.deepEqual(missing, [], "every string has its Chinese, Japanese and German");
assert.deepEqual(errors, []);
});
}
}
@@ -0,0 +1,67 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A page that runs longer than the window shows a scrollbar (PAMI on
// Discord: no page of the Mac app had one). The pages' scrollbar was
// hidden everywhere; now on the Mac (body.mac) a page has a thin thumb of
// 8px, and elsewhere the 10px one every other list has. In WebKit, which
// the Mac app is, and Chromium.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
function serve() {
const models = Array.from({ length: 40 }, (_, i) => ({ id: `a/m${i}`, name: `m${i}`, providerName: "A", icon: "generic" }));
const groups = Array.from({ length: 40 }, (_, i) => ({ id: `g${i}`, name: `Group ${i}`, members: [`a/m${i}`], routing: "order", ready: true, memberInfo: [{ id: `a/m${i}`, ready: true }] }));
const state = { agents: [{ id: "codex", name: "Codex", path: "/test/codex", fields: [] }], profiles: [], settings: { lang: "en", theme: "light" } };
return async (r) => {
const url = new URL(r.request().url());
const json = (data) => r.fulfill({ json: data });
if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"en",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes
return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] });
}
if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
if (url.pathname === "/api/groups") return json({ models, pools: [], groups });
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
await r.fulfill({ body: await fs.readFile(file), contentType });
};
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(`${engine}: a long page shows a scrollbar, on the Mac too`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 900, height: 500 }, reducedMotion: "reduce" })).newPage();
t.after(() => browser.close());
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve());
await page.goto("http://magpie.test/?view=routing");
await page.locator(".rt-group").nth(30).waitFor();
const bar = () => page.evaluate(() => {
const v = [...document.querySelectorAll(".view")].find((x) => x.offsetParent && x.scrollHeight > x.clientHeight);
return v ? v.offsetWidth - v.clientWidth - parseFloat(getComputedStyle(v).borderLeftWidth) - parseFloat(getComputedStyle(v).borderRightWidth) : -1;
});
assert.equal(await bar(), 10, "a long page has no scrollbar off the Mac");
// the Mac app's body is .mac from the start; WebKit keeps a scrollbar it
// has drawn, so the page's is drawn anew
await page.evaluate(() => {
document.body.classList.add("mac");
for (const v of document.querySelectorAll(".view")) { v.style.display = "none"; v.offsetWidth; v.style.display = ""; }
});
assert.equal(await bar(), 8, "a long page has no scrollbar on the Mac");
const thumb = await page.evaluate(() => getComputedStyle(document.querySelector("#view-routing"), "::-webkit-scrollbar-thumb").backgroundColor);
assert.notEqual(thumb, "rgba(0, 0, 0, 0)", "the thumb is drawn in no colour");
assert.deepEqual(errors, []);
});
}
+61 -5
View File
@@ -174,6 +174,12 @@ type Group struct {
Auto bool `json:"auto,omitempty"`
// Hidden is stored for a found group the user removed.
Hidden bool `json:"hidden,omitempty"`
// Disabled is a group of the user's switched off (PAMI on Discord):
// kept on the Routing page as it is, but not offered to agents, a
// request to it turned away with a word of why, and skipped where
// another group has it in it, until switched on again (SwitchGroup).
// Only SwitchGroup changes it: a save keeps it as it was.
Disabled bool `json:"disabled,omitempty"`
}
// Member is one of a group's models as it resolves now.
@@ -478,7 +484,7 @@ func GroupFinder() func(id string) (Group, []Member, bool) {
entries, read = providerEntries(), true
all = groupsIn(entries)
}
if g, ok := groupOf(all, gid); ok {
if g, ok := groupOf(all, gid); ok && !g.Disabled {
return g, membersIn(entries, all, g), true
}
return Group{}, nil, false
@@ -509,8 +515,8 @@ func membersIn(entries []Entry, all []Group, g Group) []Member {
at := append(slices.Clone(path), id)
if gid, ok := strings.CutPrefix(id, GroupPrefix); ok {
sub, ok := groupOf(all, gid)
if !ok || slices.Contains(in, gid) || len(via) >= maxNest {
continue // gone, a loop, or deeper than anyone nests
if !ok || sub.Disabled || slices.Contains(in, gid) || len(via) >= maxNest {
continue // gone, switched off, a loop, or deeper than anyone nests
}
walk(sub, at, append(slices.Clone(via), sub.Live()), append(slices.Clone(in), gid))
continue
@@ -544,7 +550,7 @@ func groupEntries(entries []Entry) []Entry {
var out []Entry
all := groupsIn(entries)
for _, g := range all {
if g.Hidden {
if g.Hidden || g.Disabled {
continue
}
ms := membersIn(entries, all, g)
@@ -801,8 +807,10 @@ func SaveGroup(g Group) error {
g.Classifier = "" // nothing to ask it
}
if gid, ok := strings.CutPrefix(g.Classifier, GroupPrefix); ok {
if _, ok := groupOf(groupsIn(providerEntries()), gid); !ok {
if c, ok := groupOf(groupsIn(providerEntries()), gid); !ok {
return fmt.Errorf("magpie has no group %q to classify with", gid)
} else if c.Disabled {
return fmt.Errorf("%s is switched off: switch it on to classify with it", c.Name)
}
} else if g.Classifier != "" {
if _, _, ok := Resolve(g.Classifier); !ok {
@@ -811,8 +819,10 @@ func SaveGroup(g Group) error {
}
g.Auto, g.Hidden = false, false
g.Members = own // what the patterns match is found again as it is read
g.Disabled = false
for i := range f.Groups {
if f.Groups[i].ID == g.ID {
g.Disabled = f.Groups[i].Disabled && !f.Groups[i].Hidden
f.Groups[i] = g
return store(f)
}
@@ -1007,6 +1017,52 @@ func DeleteGroups(ids []string) error {
return nil
}
// SwitchGroup switches a group of the user's on or off (Group.Disabled).
// A group magpie found is the catalog's, so it is removed rather than
// switched off; and one that classifies for another stays on, or that
// group's rules would have no one to ask.
func SwitchGroup(id string, on bool) error {
f, err := read()
if err != nil {
return err
}
i := slices.IndexFunc(f.Groups, func(g Group) bool { return g.ID == id && !g.Hidden })
if i < 0 {
if g, ok := groupOf(groupsIn(providerEntries()), id); ok && g.Auto {
return fmt.Errorf("%s was found by magpie: remove it, or change it to make it yours, to switch it off", g.Name)
}
return fmt.Errorf("magpie has no group %q", id)
}
if f.Groups[i].Disabled == !on {
return nil
}
if !on {
for _, o := range f.Groups {
if !o.Hidden && !o.Disabled && o.ID != id && o.Classifier == GroupPrefix+id {
return fmt.Errorf("%s classifies %s's requests: pick another classifier there first", f.Groups[i].Name, o.Name)
}
}
}
f.Groups[i].Disabled = !on
return store(f)
}
// DisabledGroup is the group of the user's a model id names ("group/<id>",
// or a model's as GroupFor takes it) when it is switched off.
func DisabledGroup(id string) (Group, bool) {
gid, ok := strings.CutPrefix(strings.TrimSuffix(strings.TrimSpace(id), "[1m]"), GroupPrefix)
if !ok {
if gid, ok = GroupFor(id); !ok {
return Group{}, false
}
gid = strings.TrimPrefix(gid, GroupPrefix)
}
if g, ok := groupOf(Groups(), gid); ok && g.Disabled {
return g, true
}
return Group{}, false
}
// RemovedGroups are the found groups the user removed, whether or not
// magpie finds them now: each is a record in providers.json ({"id", "hidden":
// true}) that keeps it removed when two providers serve its model again.
+111
View File
@@ -0,0 +1,111 @@
package provider
import (
"strings"
"testing"
)
// A group switched off as a whole (PAMI on Discord) is kept as it is, but
// agents aren't offered it, it routes nowhere, a group that has it in it
// skips it, and a save leaves it off; switched on again it is all back.
func TestSwitchGroup(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
for _, id := range []string{"a", "b"} {
if err := Save(Provider{ID: id, Name: id, Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"x"}}); err != nil {
t.Fatal(err)
}
}
if err := SaveGroup(Group{Name: "Inner", Members: []string{"a/x"}}); err != nil {
t.Fatal(err)
}
if err := SaveGroup(Group{Name: "Outer", Members: []string{"group/inner", "b/x"}, Routing: Ordered}); err != nil {
t.Fatal(err)
}
listed := func(id string) bool {
for _, e := range Catalog() {
if e.ID == id {
return true
}
}
return false
}
outer := func() string {
t.Helper()
_, ms, ok := FindGroup("group/outer")
if !ok {
return "-"
}
var out []string
for _, m := range ms {
out = append(out, m.Provider.ID+"/"+m.Model)
}
return strings.Join(out, " ")
}
if s := outer(); s != "a/x b/x" || !listed("group/inner") {
t.Fatalf("on: %s, listed %v", s, listed("group/inner"))
}
if err := SwitchGroup("inner", false); err != nil {
t.Fatal(err)
}
if _, _, ok := FindGroup("group/inner"); ok {
t.Fatal("a group switched off still routes")
}
if listed("group/inner") {
t.Fatal("a group switched off is still offered to agents")
}
if s := outer(); s != "b/x" {
t.Fatalf("the group that has it in it: %s", s)
}
if g, ok := DisabledGroup("group/inner"); !ok || g.Name != "Inner" {
t.Fatalf("DisabledGroup: %v %+v", ok, g)
}
if _, ok := DisabledGroup("inner"); !ok {
t.Fatal("DisabledGroup by the id without group/")
}
if _, ok := DisabledGroup("group/outer"); ok {
t.Fatal("a group on is no switched-off one")
}
// kept on the page, as it was
var inner Group
for _, g := range Groups() {
if g.ID == "inner" {
inner = g
}
}
if !inner.Disabled || len(inner.Members) != 1 {
t.Fatalf("listed: %+v", inner)
}
// a save from the editor (which sends no "disabled") leaves it off
inner.Disabled = false
inner.Members = append(inner.Members, "b/x")
if err := SaveGroup(inner); err != nil {
t.Fatal(err)
}
if _, ok := DisabledGroup("group/inner"); !ok {
t.Fatal("a save switched it on")
}
// it classifies for no one while off
if err := SaveGroup(Group{Name: "Ruled", Members: []string{"b/x"}, Classifier: "group/inner", Effort: EffortAuto}); err == nil || !strings.Contains(err.Error(), "switched off") {
t.Fatalf("classifier switched off: %v", err)
}
if err := SwitchGroup("inner", true); err != nil {
t.Fatal(err)
}
if s := outer(); s != "a/x b/x" || !listed("group/inner") {
t.Fatalf("on again: %s, listed %v", s, listed("group/inner"))
}
// one classifying for another stays on
if err := SaveGroup(Group{Name: "Ruled", Members: []string{"b/x"}, Classifier: "group/inner", Effort: EffortAuto}); err != nil {
t.Fatal(err)
}
if err := SwitchGroup("inner", false); err == nil || !strings.Contains(err.Error(), "classifier") {
t.Fatalf("classifier: %v", err)
}
if err := SwitchGroup("nope", false); err == nil {
t.Fatal("a group magpie hasn't")
}
}