From 685fa5dc61a7046bd6c4e73f5262cd960ab9c656 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 8 Oct 2026 03:42:22 +0800 Subject: [PATCH] =?UTF-8?q?gui:=20a=20routing=20group's=20rule=20by=20inte?= =?UTF-8?q?nt=20has=20its=20own=20entry,=20"Route=20by=20what=20it=20asks?= =?UTF-8?q?=20for"=20beside=20Add=20a=20rule,=20and=20a=20group=20whose=20?= =?UTF-8?q?effort=20Jev=20picks=20offers=20it=20under=20the=20classifier?= =?UTF-8?q?=20(CherryL1quor=20on=20X:=20=E6=A8=A1=E5=9E=8B=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E8=83=BD=E4=B8=8D=E8=83=BD=E7=94=A8=20jev=EF=BC=8C?= =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E5=8F=AA=E6=9C=89=E6=80=9D=E8=80=83=E5=BC=BA?= =?UTF-8?q?=E5=BA=A6=E8=83=BD=E7=94=A8=20jev)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/gui/assets/i18n.js | 6 + internal/gui/assets/routing.css | 6 + internal/gui/assets/routing.js | 45 +++++- .../gui/tests/routing-intent-entry.test.cjs | 152 ++++++++++++++++++ 4 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 internal/gui/tests/routing-intent-entry.test.cjs diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 0407b095..9fe1f73d 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index 10ca6ef7..7270f686 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -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; } diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 14d21e50..ca2d504a 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -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); diff --git a/internal/gui/tests/routing-intent-entry.test.cjs b/internal/gui/tests/routing-intent-entry.test.cjs new file mode 100644 index 00000000..1d3912e0 --- /dev/null +++ b/internal/gui/tests/routing-intent-entry.test.cjs @@ -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, []); + }); + } + } +}