gui: a routing group's rule by intent has its own entry, "Route by what it asks for" beside Add a rule, and a group whose effort Jev picks offers it under the classifier (CherryL1quor on X: 模型路由能不能用 jev,现在只有思考强度能用 jev)

An intent was one of a rule's seven conditions, so a reader who had Jev
picking the effort took it for that alone. "Route by what it asks for"
adds a rule with its intent box focused. Jev is picked as the classifier
when there is one, as the effort's Picked per turn already did, and the
classifier row shows at once, before anything is typed. Typing an intent
into any rule picks Jev too when no classifier is set. A group whose
effort Jev picks, with no intent yet, says under the classifier "It can
pick the model too: add a rule with an intent", which adds the same rule.
Translated in zh, ja and de.

routing-intent-entry.test.cjs (Chromium and WebKit, en and zh, 1100 and
440 wide) checks that both buttons stay inside the editor and that the
clicked one stays under the pointer as the rule comes in above it. It
also checks the focus, that Jev is picked, the save as
{use: b/flash, intent} with classifier typesafe/jev-latest, and the offer
under an effort-only Jev. It fails on the old assets (no such button).
gui-ja, gui-de, i18n-keys, click-scroll, routing-compact, -time, -save,
-save-blank, -effort-row, -effort-change, -levels, -days-fit and every
group-* suite pass (159 tests). Not tried with a real TypeSafe key.
This commit is contained in:
yetone committed 2026-10-08 03:42:22 +08:00
1 parent b0d456c3b2
commit 685fa5dc61
4 files changed
+203 -6

No files matched your search

+6
View File
@@ -2513,6 +2513,8 @@ const I18N = {
"Couldn't save the group: {error}": "分组没能保存:{error}",
"Rules": "规则",
"Add a rule": "添加规则",
"Route by what it asks for": "按意图分流",
"It can pick the model too: add a rule with an intent": "它也能挑模型:添加一条带意图的规则",
"When": "当",
"has an image": "带图片",
"compacting": "压缩上下文时",
@@ -6102,6 +6104,8 @@ const I18N = {
"Couldn't save the group: {error}": "グループを保存できませんでした:{error}",
"Rules": "ルール",
"Add a rule": "ルールを追加",
"Route by what it asks for": "インテントで振り分け",
"It can pick the model too: add a rule with an intent": "モデルも選べます:インテント付きのルールを追加",
"When": "条件",
"has an image": "画像あり",
"compacting": "コンパクト化中",
@@ -9698,6 +9702,8 @@ const I18N = {
"Couldn't save the group: {error}": "Gruppe konnte nicht gespeichert werden: {error}",
"Rules": "Regeln",
"Add a rule": "Regel hinzufügen",
"Route by what it asks for": "Nach Absicht leiten",
"It can pick the model too: add a rule with an intent": "Es kann auch das Modell wählen: Regel mit Absicht hinzufügen",
"When": "Wenn",
"has an image": "enthält ein Bild",
"compacting": "beim Kompaktieren",
+6
View File
@@ -453,6 +453,9 @@ button.rt-session { cursor: pointer; }
}
.rt-gadd:hover, .rt-gadd.open { color: var(--fg); border-color: var(--muted); background: var(--card); }
.rt-gadd svg { flex: none; }
/* Add a rule, and beside it a rule by what the message asks for */
.rt-gadds { display: flex; flex-wrap: wrap; gap: 6px; }
.rt-gadds > [hidden] { display: none; }
/* a group's rules: when, and the model it sends that turn to first */
.rt-rules .fbl { gap: 6px; }
.rt-rule { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; align-items: center; gap: 4px 8px; padding: 6px 0; border-top: 1px solid var(--line); font-size: 12.5px; }
@@ -487,6 +490,9 @@ button.rt-session { cursor: pointer; }
.rt-classifier[hidden] { display: none; }
.rt-classifier .w { color: var(--muted); font-size: 12px; }
.rt-classifier .hint { flex-basis: 100%; }
/* the effort's classifier offering to pick the model too: a line of its
own, wrapping rather than running past a narrow window */
.rt-classifier .rt-cls-more { margin-left: -7px; white-space: normal; text-align: left; color: var(--accent); }
/* the levels a group names for agents (#295): one toggle each */
.rt-levels { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin-top: 6px; }
.rt-levels[hidden] { display: none; }
+39 -6
View File
@@ -3407,11 +3407,21 @@
const rlist = el("div", "fbl");
const rAdd = el("button", "rt-gadd");
rAdd.append(svg(PLUS, 11, 1.8), el("span", "", t("Add a rule")));
// a rule by what the message asks for, offered beside it: told only as
// one of a rule's conditions, an intent went unseen (CherryL1quor on X
// took Jev for effort alone)
const rIntent = el("button", "rt-gadd");
rIntent.append(svg(PLUS, 11, 1.8), el("span", "", t("Route by what it asks for")));
const rAdds = el("div", "rt-gadds");
rAdds.append(rAdd, rIntent);
// the rule just added by intent, its intent not yet typed: the
// classifier shows for it all the same, so it is seen to be needed
let asking = null;
const rHint2 = el("div", "hint");
const drawRules = () => {
drawCtx(); // the members' windows changed with them
rlist.replaceChildren();
rAdd.hidden = d.members.length < 2;
rAdd.hidden = rIntent.hidden = d.members.length < 2;
rHint2.textContent = t(d.members.length < 2 ? "With two models or more, a rule can send some turns to one of them first."
: d.routing === "manual" ? "The rules wait while you pick the model by hand."
: "Checked top first when you send a message: the first that matches sends that turn to its model first; the rest stay behind it if it fails. A turn under way is never moved.");
@@ -3454,7 +3464,13 @@
it.onclick = () => ii.focus();
const ii = input(r.intent || "", t("what it asks for, e.g. writing tests"));
ii.maxLength = 200;
ii.oninput = () => { r.intent = ii.value; it.classList.toggle("on", !!r.intent.trim()); drawClassifier(); };
ii.oninput = () => {
r.intent = ii.value; it.classList.toggle("on", !!r.intent.trim());
// Jev, when there is one, as the effort picked per turn takes it
if (r.intent.trim() && !d.classifier && deciders.length) d.classifier = deciders[0].id;
drawClassifier();
};
if (r === asking) focusIntent = ii;
ii.onkeydown = (e) => e.stopPropagation();
it.append(el("span", "", t("asks for")), ii);
// compacting: the agent summarizing its conversation (/compact),
@@ -3532,8 +3548,19 @@
rlist.append(row);
});
drawClassifier();
focusIntent?.focus({ preventScroll: true });
focusIntent = null;
};
rAdd.onclick = () => { d.rules.push({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "", compact: false, time: null }); drawRules(); };
let focusIntent = null;
const newRule = () => ({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "", compact: false, time: null });
rAdd.onclick = () => { d.rules.push(newRule()); drawRules(); };
const addIntentRule = () => {
asking = newRule();
d.rules.push(asking);
if (!d.classifier && deciders.length) d.classifier = deciders[0].id;
drawRules();
};
rIntent.onclick = addIntentRule;
// the classifier, once a rule has an intent or the effort is picked
// per turn: the model asked which intent a turn's message is and how
// hard it is. Jev (a decision provider's model) answers both in one
@@ -3549,10 +3576,10 @@
const isJev = (id) => deciders.some((x) => x.id === id);
const drawClassifier = () => {
const auto = d.effort === "auto";
const on = auto || d.rules.some((r) => r.intent?.trim());
const intents = d.rules.some((r) => r.intent?.trim() || (r === asking && d.rules.includes(r)));
const on = auto || intents;
cls.hidden = cw.hidden = clabel.hidden = !on;
if (!on) return;
const intents = d.rules.some((r) => r.intent?.trim());
clabel.textContent = t(auto && !intents ? "Decided by" : "Intent told by");
// a model, or another group: its models are asked in turn, failing
// over as any request to it does (never this group: it would ask
@@ -3578,8 +3605,14 @@
: auto
? "As a turn begins, this model is asked which of the intents the message is and how hard the turn is, each once; a small, fast one without reasoning is best. If it can't say, no intent matches and the turn reasons as the agent asked. Its calls show in the usage as magpie’s own."
: "As a turn begins, this model is asked which of the intents the message is — once; a small, fast one without reasoning is best. If it fails or can't say, no intent matches. Its calls show in the usage as magpie’s own.")));
// picking the effort alone, the classifier can pick the model too
if (!intents && d.members.length > 1) {
const more = el("button", "text rt-cls-more", t("It can pick the model too: add a rule with an intent"));
more.onclick = addIntentRule;
cls.append(more);
}
};
rbox.append(rlist, rAdd);
rbox.append(rlist, rAdds);
const rw2 = el("div");
rw2.append(rbox, rHint2);
ed.append(el("label", "", t("Rules")), rw2);
@@ -0,0 +1,152 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A routing group's rule by intent has an entry of its own (CherryL1quor on
// X took Jev for picking the effort only: the intent was one of a rule's
// seven conditions). Beside "Add a rule", "Route by what it asks for" adds a
// rule with its intent box focused and Jev picked as the classifier when
// there is one, the button kept under the pointer as the rule comes in
// above it; it saves as {use, intent} with the classifier. A group whose effort Jev picks offers, under the classifier,
// to add a rule with an intent. At a narrow window both buttons stay inside
// the editor. In English and Chinese.
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/opus", name: "opus", providerName: "A", icon: "generic", context: 1000000, ready: true },
{ id: "b/flash", name: "flash", providerName: "B", icon: "generic", context: 128000, ready: true },
];
const member = (id, context) => ({ id, ready: true, context });
const groups = () => ({
models,
deciders: [{ id: "typesafe/jev-latest", name: "Jev", providerName: "TypeSafe", icon: "generic" }],
groups: [
{ id: "main", name: "Main", members: ["a/opus", "b/flash"], routing: "order", ready: true, memberInfo: [member("a/opus", 1000000), member("b/flash", 128000)] },
{ id: "hard", name: "Hard", members: ["a/opus", "b/flash"], routing: "order", ready: true, effort: "auto", classifier: "typesafe/jev-latest", memberInfo: [member("a/opus", 1000000), member("b/flash", 128000)] },
],
pools: [],
});
const words = {
en: { edit: "Edit", add: "Add a rule", intent: "Route by what it asks for", more: "It can pick the model too: add a rule with an intent", told: "Intent told by", save: "Save" },
zh: { edit: "编辑", add: "添加规则", intent: "按意图分流", more: "它也能挑模型:添加一条带意图的规则", told: "意图判断模型", save: "保存" },
};
function serve(lang, posts) {
const state = { agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", 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/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 });
};
}
// wheel down to a control, as the reader would (the page keeps still for
// any other scroll), then click it with the mouse
async function press(page, loc, height) {
await page.mouse.move(Math.min(550, (page.viewportSize().width / 2) | 0), 300);
for (let i = 0; i < 12 && (await loc.boundingBox()).y > height - 160; i++) {
await page.mouse.wheel(0, 300);
await page.waitForTimeout(150);
}
const b = await loc.boundingBox();
await page.mouse.click(b.x + b.width / 2, b.y + b.height / 2);
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
for (const width of [1100, 440]) {
test(`${engine} ${lang} ${width}: a rule by intent has its own entry`, async (t) => {
const height = 1400;
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width, height } })).newPage();
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 group = page.locator(".rt-group").first();
await group.waitFor();
await press(page, group.locator("button", { hasText: w.edit }), height);
const ed = page.locator(".rt-gedit");
const add = ed.locator(".rt-gadds button", { hasText: w.add });
const byIntent = ed.locator(".rt-gadds button", { hasText: w.intent });
await byIntent.waitFor();
assert(await add.isVisible(), "Add a rule is there too");
// both inside the editor, however narrow
const eb = await ed.boundingBox();
for (const b of [await add.boundingBox(), await byIntent.boundingBox()]) assert(b.x >= eb.x - 0.5 && b.x + b.width <= eb.x + eb.width + 0.5, `${JSON.stringify(b)} in ${JSON.stringify(eb)}`);
// no classifier yet: no rule has an intent
assert.equal(await ed.locator(".rt-classifier").isVisible(), false);
// a rule, its intent box focused, Jev picked; nothing scrolled
await page.mouse.move(Math.min(550, width / 2), 300);
for (let i = 0; i < 12 && (await byIntent.boundingBox()).y > height - 160; i++) {
await page.mouse.wheel(0, 300);
await page.waitForTimeout(150);
}
const bb = await byIntent.boundingBox();
await page.mouse.click(bb.x + bb.width / 2, bb.y + bb.height / 2);
const rule = ed.locator(".rt-rule").first();
await rule.waitFor();
await page.waitForTimeout(300);
// what was clicked stays under the pointer as the rule comes in
// above it, and the rule shows in the window
const after = await byIntent.boundingBox();
assert(Math.abs(after.y - bb.y) < 1, `the button moved from ${bb.y} to ${after.y}`);
const rb = await rule.boundingBox();
assert(rb.y >= 0 && rb.y + rb.height <= height, `the rule in view: ${JSON.stringify(rb)}`);
const box = rule.locator(".rt-cond.in input");
assert(await box.evaluate((b) => b === document.activeElement), "the intent box has the focus");
const cls = ed.locator(".rt-classifier");
assert(await cls.isVisible(), "the classifier shows for the rule");
assert.match(await cls.locator("button.rt-cond").first().textContent(), /Jev/);
assert(await ed.evaluate((e, s) => e.innerText.includes(s), w.told), "labelled as telling the intent");
await page.keyboard.type("a quick question");
await press(page, ed.locator("button.primary", { hasText: w.save }), height);
await page.waitForFunction(() => !document.querySelector(".rt-gedit"));
const save = posts.find((p) => p.path === "/api/groups/save");
assert(save, `saved: ${JSON.stringify(posts)}`);
assert.deepEqual(save.body.rules.map((r) => [r.use, r.intent]), [["b/flash", "a quick question"]]);
assert.equal(save.body.classifier, "typesafe/jev-latest");
// Jev picking the effort alone offers to pick the model too
const hard = page.locator(".rt-group").nth(1);
await press(page, hard.locator("button", { hasText: w.edit }), height);
await ed.waitFor();
const more = ed.locator(".rt-classifier button", { hasText: w.more });
await more.waitFor();
const mb = await more.boundingBox(), eb2 = await ed.boundingBox();
assert(mb.x + mb.width <= eb2.x + eb2.width + 0.5, "the offer wraps inside the editor");
await press(page, more, height);
await ed.locator(".rt-rule").first().waitFor();
assert(await ed.locator(".rt-rule .rt-cond.in input").first().evaluate((b) => b === document.activeElement), "its intent box has the focus");
assert.equal(await more.count(), 0, "offered no more once a rule has an intent");
assert.deepEqual(errors, []);
});
}
}
}