From 55be06ea242563d761e216c71c4ff88677fa1c46 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 17:22:01 +0800 Subject: [PATCH] =?UTF-8?q?gui:=20whether=20"=C2=B7=20routing=20group"=20f?= =?UTF-8?q?ollows=20a=20group's=20name=20is=20set=20over=20the=20groups=20?= =?UTF-8?q?on=20Routing,=20for=20all=20of=20them,=20not=20in=20one=20group?= =?UTF-8?q?'s=20editor=20(PAMI=20on=20Discord)=20=E2=80=94=20#868=20put=20?= =?UTF-8?q?the=20agents'=20lists=20names=20setting=20(Settings'=20Provider?= =?UTF-8?q?=20in=20model=20names)=20in=20each=20group's=20editor=20too,=20?= =?UTF-8?q?where=20it=20read=20as=20that=20group's=20alone=20though=20it?= =?UTF-8?q?=20is=20one=20setting=20for=20every=20group=20and=20every=20pro?= =?UTF-8?q?vider's=20models.=20It=20now=20sits=20over=20the=20groups,=20un?= =?UTF-8?q?der=20Find=20groups=20on=20their=20own,=20with=20its=20three=20?= =?UTF-8?q?choices=20and=20what=20it=20sets;=20a=20group's=20editor=20says?= =?UTF-8?q?=20only=20what=20its=20group=20will=20be=20called=20and=20that?= =?UTF-8?q?=20"=C2=B7=20routing=20group"=20is=20set=20above,=20and=20follo?= =?UTF-8?q?ws=20a=20pick=20there=20with=20its=20unsaved=20name=20kept.=20P?= =?UTF-8?q?rovider=20Names=20&=20levels=20keeps=20its=20choices.=20name-su?= =?UTF-8?q?ffix-where-named=20(WebKit,=20en=20and=20zh)=20fails=20without?= =?UTF-8?q?=20the=20change;=20group-found-off=20and=20group-fold=20follow?= =?UTF-8?q?=20the=20new=20row.=20go=20vet,=20go=20build=20(darwin,=20GOOS?= =?UTF-8?q?=3Dlinux=20nogui,=20GOOS=3Dwindows)=20and=20go=20test=20-tags?= =?UTF-8?q?=20nogui=20./...=20under=20a=20temp=20HOME=20pass=20(internal/p?= =?UTF-8?q?rovider=20crashed=20once=20on=20a=20concurrent=20map=20write=20?= =?UTF-8?q?in=20TestCloudflareDecideModelsTested's=20test=20server,=20and?= =?UTF-8?q?=20passes=20alone).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gui/assets/app.js | 5 +- internal/gui/assets/i18n.js | 9 ++++ internal/gui/assets/routing.css | 11 +++-- internal/gui/assets/routing.js | 27 ++++++++--- internal/gui/tests/README.md | 11 +++++ internal/gui/tests/group-fold.test.cjs | 3 +- internal/gui/tests/group-found-off.test.cjs | 3 +- .../tests/name-suffix-where-named.test.cjs | 48 +++++++++++++------ 8 files changed, 87 insertions(+), 30 deletions(-) diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index c10c4986..47149bca 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -16184,8 +16184,9 @@ const IN_USE = "|*"; // (or "routing group") after it, all but the names the user gave models // and the groups they made (#92), or none (#335). Set on its own, so the // agents are told. Offered on Settings and where names are given, the -// provider's Names & levels and a group's editor (#868: an agent's narrow -// menu cut "· routing group" short, and the setting wasn't found); +// provider's Names & levels and over Routing's groups (#868: an agent's +// narrow menu cut "· routing group" short, and the setting wasn't found; +// in one group's editor it read as that group's, PAMI on Discord); // drawn(mode) redraws the place it was picked in. function suffixMode(s = prefs || {}) { return s.plainNames ? "off" : s.plainOwnNames ? "own" : "on"; } function suffixSegs(drawn) { diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 6648b503..aa8749d8 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -470,6 +470,9 @@ const I18N = { "Not on names I set": "自定义名称不带供应商", "Agents’ lists show “{label}”": "Agent 的模型列表里显示为「{label}」", "In agents’ lists": "Agent 列表里的名称", + "Names in agents’ lists": "Agent 列表里的名称", + "Whether “· routing group” follows a group’s name in the agents’ lists: one setting for every group, and for the provider after each model’s name, as on Settings.": "Agent 的模型列表里,路由组名后面是否带「· routing group」。这是所有路由组共用的一个设置,也决定模型名后面是否带供应商,和「设置」里的是同一个。", + "“· routing group” is set for every group above": "是否带「· routing group」在上方统一设置,对所有路由组生效", "Codex subagents on any model": "Codex 子代理可用任意模型", "Long conversations": "长对话", "Codex and Claude Code compact a conversation at 272K tokens, or the size typed here, on a model with a larger window, unless its provider has a Compact at of its own; in Claude Code a Claude model runs to its own window, 1M for a [1m] one, as Anthropic runs it. The model’s full window lets any conversation grow further, with every turn slower and costlier": "模型窗口超过 272K(或这里填的大小)时,Codex 和 Claude Code 在该处压缩对话,供应商设了自己的「压缩阈值」时以供应商的为准;Claude Code 中的 Claude 模型按自身窗口([1m] 为 1M),与 Anthropic 官方一致。使用完整窗口可让对话更长,但每轮更慢、更贵", @@ -3673,6 +3676,9 @@ const I18N = { "Not on names I set": "自分で付けた名前には付けない", "Agents’ lists show “{label}”": "エージェントの一覧では「{label}」と表示されます", "In agents’ lists": "エージェントの一覧での名前", + "Names in agents’ lists": "エージェントの一覧での名前", + "Whether “· routing group” follows a group’s name in the agents’ lists: one setting for every group, and for the provider after each model’s name, as on Settings.": "エージェントの一覧でグループ名の後に「· routing group」を付けるかどうか。すべてのグループに共通の設定で、モデル名の後のプロバイダ名にも効きます(「設定」と同じものです)。", + "“· routing group” is set for every group above": "「· routing group」を付けるかは上で全グループ共通に設定します", "Codex subagents on any model": "Codex のサブエージェントで任意のモデル", "Long conversations": "長い会話", "Codex and Claude Code compact a conversation at 272K tokens, or the size typed here, on a model with a larger window, unless its provider has a Compact at of its own; in Claude Code a Claude model runs to its own window, 1M for a [1m] one, as Anthropic runs it. The model’s full window lets any conversation grow further, with every turn slower and costlier": "ウィンドウが大きいモデルでは、Codex と Claude Code は 272K トークン(またはここに入力したサイズ)で会話を圧縮します。プロバイダーに独自の「圧縮しきい値」があればそちらが優先されます。Claude Code の Claude モデルは Anthropic と同じく自身のウィンドウ([1m] なら 1M)まで使います。モデルのウィンドウ全体を使うとどの会話もさらに伸ばせますが、ターンごとに遅く、高くなります", @@ -6861,6 +6867,9 @@ const I18N = { "Not on names I set": "Nicht bei eigenen Namen", "Agents’ lists show “{label}”": "In den Listen der Agenten: „{label}“", "In agents’ lists": "In den Listen der Agenten", + "Names in agents’ lists": "Namen in den Listen der Agenten", + "Whether “· routing group” follows a group’s name in the agents’ lists: one setting for every group, and for the provider after each model’s name, as on Settings.": "Ob in den Listen der Agenten „· routing group“ auf den Namen einer Gruppe folgt: eine Einstellung für alle Gruppen, und für den Anbieter nach jedem Modellnamen, wie in den Einstellungen.", + "“· routing group” is set for every group above": "„· routing group“ wird oben für alle Gruppen eingestellt", "Codex subagents on any model": "Codex-Subagenten auf jedem Modell", "Long conversations": "Lange Unterhaltungen", "Codex and Claude Code compact a conversation at 272K tokens, or the size typed here, on a model with a larger window, unless its provider has a Compact at of its own; in Claude Code a Claude model runs to its own window, 1M for a [1m] one, as Anthropic runs it. The model’s full window lets any conversation grow further, with every turn slower and costlier": "Codex und Claude Code komprimieren eine Unterhaltung bei einem Modell mit größerem Kontextfenster ab 272K Token oder der hier eingegebenen Größe, sofern der Anbieter keinen eigenen Wert unter „Komprimieren ab“ hat; in Claude Code nutzt ein Claude-Modell sein eigenes Fenster, 1M bei einem [1m]-Modell, wie Anthropic es betreibt. Mit dem vollen Fenster des Modells wächst jede Unterhaltung weiter, wobei jede Runde langsamer und teurer wird", diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index 5bd380f0..da016947 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -328,10 +328,13 @@ button.rt-session { cursor: pointer; } .rt-pick.off { opacity: .55; } .rt-pick:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); } .rt-gnone { padding: 14px; font-size: 12px; line-height: 1.5; } -.rt-gfound { display: flex; align-items: center; gap: 12px; padding: 2px 4px 4px; } -.rt-gfound .txt { flex: 1; min-width: 0; display: grid; gap: 2px; } -.rt-gfound b { font-size: 12.5px; font-weight: 600; } -.rt-gfound small { font-size: 11.5px; line-height: 1.45; color: var(--muted); } +.rt-gfound, .rt-gnames { display: flex; align-items: center; gap: 12px; padding: 2px 4px 4px; } +.rt-gfound .txt, .rt-gnames .txt { flex: 1; min-width: 0; display: grid; gap: 2px; } +.rt-gfound b, .rt-gnames b { font-size: 12.5px; font-weight: 600; } +.rt-gfound small, .rt-gnames small { font-size: 11.5px; line-height: 1.45; color: var(--muted); } +/* the names setting's three choices go under its words when it is narrow */ +.rt-gnames { flex-wrap: wrap; } +.rt-gnames .txt { flex-basis: 220px; } .rt-ghidden { display: flex; align-items: center; padding: 4px 12px 4px 8px; } .rt-ghidden .rt-gremoved { display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--faint); } .rt-ghidden .rt-gremoved:hover, .rt-ghidden .rt-gremoved.open { color: var(--fg-2); } diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 8a40a1ac..ca6b11f4 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -2323,7 +2323,11 @@ // whether magpie finds groups on its own, by the list it fills (蓝猫 on // Discord: they could only be removed one at a time) const gFound = el("div", "rt-gfound"); - gsec.append(gHead, gFound, gList, pHead, pList); + // how the agents' lists name every group, one setting for them all (and + // for the providers' models), so it sits over the groups rather than in + // one group's editor (PAMI on Discord) + const gNames = el("div", "rt-gnames"); + gsec.append(gHead, gFound, gNames, gList, pHead, pList); // after the requests: a request picked in the list plays on the stage, // so the list sits right under it more.append(gsec); @@ -2467,6 +2471,7 @@ } gHead.replaceChildren(...head); drawFound(); + drawNames(); const rows = []; if (gSel) rows.push(selectBar(shown)); if (gEdit && !gEdit.id) rows.push(groupEditor(null)); @@ -2593,6 +2598,12 @@ : t("Off: only the groups you made or changed are listed and served. An agent set to a found group is moved to its model from one provider, and a request still naming one goes there too."))); gFound.replaceChildren(txt, s); } + function drawNames() { + const txt = el("div", "txt"); + txt.append(el("b", "", t("Names in agents’ lists")), el("small", "", t("Whether “· routing group” follows a group’s name in the agents’ lists: one setting for every group, and for the provider after each model’s name, as on Settings."))); + // an open editor says what its group will be called: drawn again with it + gNames.replaceChildren(txt, suffixSegs(() => renderGroups())); + } async function setFound(on, s) { s.classList.toggle("on", on); s.setAttribute("aria-checked", String(on)); @@ -2829,12 +2840,14 @@ idHint.textContent = t("Agents pick it as {id}", { id: "group/" + idOf() }) + (g && idOf() !== g.id ? " · " + t("an agent set to {id} needs setting again", { id: "group/" + g.id }) : ""); }; - // what the agents' lists will call it, and the setting that says - // whether "· routing group" follows the name (#868: an agent's narrow - // menu cut it to "· rou…"); a group saved here is one the user made + // what the agents' lists will call it (#868: an agent's narrow menu + // cut it to "· rou…"); whether "· routing group" follows the name is + // one setting for every group, over the list (drawNames), not one in + // here (PAMI on Discord); a group saved here is one the user made const sfxSaid = el("span", "hint"); - const sayLabel = (mode) => { - sfxSaid.textContent = t("Agents’ lists show “{label}”", { label: (d.name.trim() || t("New group")) + ((mode || suffixMode()) === "on" ? " · routing group" : "") }); + const sayLabel = () => { + sfxSaid.textContent = t("Agents’ lists show “{label}”", { label: (d.name.trim() || t("New group")) + (suffixMode() === "on" ? " · routing group" : "") }) + + " · " + t("“· routing group” is set for every group above"); }; name.oninput = () => { d.name = name.value; showId(); sayLabel(); }; const nw = el("div"); @@ -2842,7 +2855,7 @@ if (!g) nw.append(idHint); ed.append(el("label", "", t("Name")), nw); const sw = el("div", "gsuffix"); - sw.append(suffixSegs(sayLabel), sfxSaid); + sw.append(sfxSaid); sayLabel(); ed.append(el("label", "", t("In agents’ lists")), sw); if (idIn) { diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 1226e8e5..ea06af36 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -368,6 +368,17 @@ and the status line says which agent was moved to the model from one provider; clicked on, the found group is back. Neither click moves the page, the switch has no left-border accent, and every string has its Chinese. +`name-suffix-where-named.test.cjs` checks the setting that says whether a +provider's name or "· routing group" follows a name in the agents' lists (#868) +where names are given, in Chromium and WebKit, English and Chinese. The +provider editor's Names & levels has its three choices and says what the lists +show. For the groups it is one setting for all of them, so it sits over the +groups on Routing (PAMI on Discord: in one group's editor it read as that +group's): a group's editor has no choices, only what its group will be called +and that it is set above; a pick there posts settings/plain-names once and the +open editor's line follows it with its unsaved name. No click scrolls, nothing +native, no left border, and every string has its Chinese, Japanese and German. + `routing-manual.test.cjs` routes a group by hand in Chromium and WebKit, English and Chinese (#317: pick the model, as CC Switch picks a provider). A manual group's card lists its models, the one every request goes to marked, and diff --git a/internal/gui/tests/group-fold.test.cjs b/internal/gui/tests/group-fold.test.cjs index e7bef957..5d1d849b 100644 --- a/internal/gui/tests/group-fold.test.cjs +++ b/internal/gui/tests/group-fold.test.cjs @@ -64,7 +64,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", test(`${engine} ${lang}: an open group folds back from its heading`, async (t) => { const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); // tall enough for the whole editor, its Cancel clicked where it is - const page = await (await browser.newContext({ viewport: { width: 1100, height: 1500 }, reducedMotion: "reduce" })).newPage(); + // (under the names setting over the groups) + const page = await (await browser.newContext({ viewport: { width: 1100, height: 1600 }, reducedMotion: "reduce" })).newPage(); t.after(() => browser.close()); page.setDefaultTimeout(5000); const errors = [], posts = []; diff --git a/internal/gui/tests/group-found-off.test.cjs b/internal/gui/tests/group-found-off.test.cjs index 93928be9..e40595e0 100644 --- a/internal/gui/tests/group-found-off.test.cjs +++ b/internal/gui/tests/group-found-off.test.cjs @@ -93,7 +93,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const s = document.querySelector(".rt-gsec"); return [...s.children].map((c) => c.className); }); - assert.deepEqual(order.slice(0, 3), ["row-head", "rt-gfound", "list rt-groups"]); + // (the names in agents' lists, for every group, between them) + assert.deepEqual(order.slice(0, 4), ["row-head", "rt-gfound", "rt-gnames", "list rt-groups"]); const border = await sw.evaluate((e) => { const c = getComputedStyle(e); return c.borderLeftWidth !== c.borderRightWidth || c.borderLeftColor !== c.borderRightColor; }); assert.equal(border, false, "no left-border accent"); diff --git a/internal/gui/tests/name-suffix-where-named.test.cjs b/internal/gui/tests/name-suffix-where-named.test.cjs index 5ce5ec1f..bbcd2074 100644 --- a/internal/gui/tests/name-suffix-where-named.test.cjs +++ b/internal/gui/tests/name-suffix-where-named.test.cjs @@ -3,10 +3,14 @@ // narrow model menu cut "Long name · routing group" short, and the setting // that drops the suffix (Settings › Provider in model names, #335) wasn't // found. It is now offered where names are given too: the provider editor's -// Names & levels and a routing group's editor each have the three choices, -// say what the agents' lists will show ("My Sol · Relay", "Fast · routing -// group"), and a pick posts settings/plain-names once, the line following -// it and the page not scrolled by the click. English and Chinese. +// Names & levels has the three choices and says what the agents' lists will +// show ("My Sol · Relay"). For the routing groups it is one setting for them +// all, so it sits over the groups on Routing, not in one group's editor (PAMI +// on Discord: in there it read as that group's alone); the editor only says +// what its group will be called ("Fast · routing group") and where that is +// set. A pick posts settings/plain-names once, an open editor's line follows +// it (its unsaved name kept), and the click doesn't scroll the page. English +// and Chinese. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -16,9 +20,9 @@ const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); const words = { en: { names: "Names & levels", edit: "Edit", own: "Not on names I set", on: "On", off: "Off", label: "In agents’ lists", - shows: (l) => `Agents’ lists show “${l}”` }, + shows: (l) => `Agents’ lists show “${l}”`, above: " · “· routing group” is set for every group above", setting: "Names in agents’ lists" }, zh: { names: "名称与推理档位", edit: "编辑", own: "自定义名称不带供应商", on: "开启", off: "关闭", label: "Agent 列表里的名称", - shows: (l) => `Agent 的模型列表里显示为「${l}」` }, + shows: (l) => `Agent 的模型列表里显示为「${l}」`, above: " · 是否带「· routing group」在上方统一设置,对所有路由组生效", setting: "Agent 列表里的名称" }, }; function serve(lang, posts) { @@ -100,30 +104,44 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.deepEqual(errors, []); }); - test(`${engine} ${lang}: a group's editor says what agents' lists show and drops "· routing group" there`, async (t) => { + test(`${engine} ${lang}: "· routing group" is set over the groups, for all of them, and a group's editor says what it shows`, async (t) => { const posts = []; const { page, errors } = await launch(t, engine, lang, posts); await page.goto("http://magpie.test/?view=routing"); const card = page.locator(".rt-group", { hasText: "Fast" }); await card.waitFor(); + // the setting is over the groups, before the first card + const set = page.locator(".rt-gnames"); + await set.waitFor(); + assert.equal((await set.locator("b").textContent()).trim(), w.setting); + assert.deepEqual((await set.locator(".segs .opt").allTextContents()).map((s) => s.trim()), [w.off, w.own, w.on]); + assert.equal((await set.locator(".opt.on").textContent()).trim(), w.on); + assert.ok(await set.evaluate((s) => !!(s.compareDocumentPosition(document.querySelector(".rt-group")) & Node.DOCUMENT_POSITION_FOLLOWING)), "the setting is over the groups"); await card.locator("button", { hasText: w.edit }).click(); const ed = page.locator(".rt-gedit"); const row = ed.locator("label", { hasText: new RegExp(`^${w.label}$`) }).locator("xpath=following-sibling::div[1]"); await row.waitFor(); - assert.equal(await row.locator(".hint").textContent(), w.shows("Fast · routing group")); + // not in here: one group's editor has no choices of its own + assert.equal(await row.locator(".segs").count(), 0, "the group's editor has the setting for every group"); + assert.equal(await row.locator(".hint").textContent(), w.shows("Fast · routing group") + w.above); // the name typed is the name shown await ed.locator("input").first().fill("Fast lane"); - assert.equal(await row.locator(".hint").textContent(), w.shows("Fast lane · routing group")); + assert.equal(await row.locator(".hint").textContent(), w.shows("Fast lane · routing group") + w.above); const before = await scrolls(page); - await row.locator(".opt", { hasText: w.own }).click(); - await row.locator(".opt.on", { hasText: w.own }).waitFor(); + await set.locator(".opt", { hasText: w.own }).click(); + await page.locator(".rt-gnames .opt.on", { hasText: w.own }).waitFor(); assert.deepEqual(posts, [{ mode: "own" }]); - assert.equal(await row.locator(".hint").textContent(), w.shows("Fast lane")); + // the open editor follows it, its unsaved name kept + const row2 = page.locator(".rt-gedit label", { hasText: new RegExp(`^${w.label}$`) }).locator("xpath=following-sibling::div[1]"); + assert.equal(await row2.locator(".hint").textContent(), w.shows("Fast lane") + w.above); assert.deepEqual(await scrolls(page), before, "the click scrolled the page"); // nothing native, no stripe down the side - assert.equal(await ed.locator("select").count(), 0); - assert.equal(await row.evaluate((e) => getComputedStyle(e).borderLeftWidth), "0px"); - const missing = await page.evaluate(() => ["Agents’ lists show “{label}”", "In agents’ lists", "Not on names I set", "Provider in model names"] + assert.equal(await page.locator("#view-routing select").count(), 0); + assert.equal(await set.evaluate((e) => getComputedStyle(e).borderLeftWidth), "0px"); + assert.equal(await row2.evaluate((e) => getComputedStyle(e).borderLeftWidth), "0px"); + const missing = await page.evaluate(() => ["Agents’ lists show “{label}”", "In agents’ lists", "Not on names I set", "Provider in model names", + "Names in agents’ lists", "“· routing group” is set for every group above", + "Whether “· routing group” follows a group’s name in the agents’ lists: one setting for every group, and for the provider after each model’s name, as on Settings."] .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, []);