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, []); + }); + } + } +}