provider: a routing group can be duplicated, from its menu on the Routing page or with magpie group copy (lc on Discord)
provider.CopyGroup(id, name) saves a copy of the group as it is now: its
models and patterns, routing, stays, rules, classifier, effort, context,
levels, and fast and off members. The copy is deep, so its rules' time
windows aren't shared. Its name is the one given, or "<name> copy", with a
number added past a name in use. Its id comes from the name, numbered past
any id in use, removed ones included. It is listed right after the group
it copies. The copy is a group of the user's and switched on, even when
it copies a found group or a switched-off one. The original is unchanged.
Surfaces:
- GUI: a group row's menu (right-click, or a click on its logos) has
Duplicate under Move up and Move down. It posts /api/groups/copy with
the id and t("{name} copy"), so the name is in the reader's language.
It asks before discarding an editor with unsaved changes. The status
then says "Duplicated {name}". No click scrolls the page.
- CLI: magpie group copy <id> [name]; duplicate, dup and clone work too.
- zh, zh-TW, ja and de strings. docs/reference.md and the site's start
page (en, zh, ja) list the command.
Run:
- TestGroupCopy (internal/gui): the copy has the models, pattern,
routing, rule and off member, is on, and is listed after the original.
The original stays off and unchanged. A second copy is "Fast copy 2" /
fast-copy-2. An unknown id is refused.
- TestGroupCopyCLI (root): the default name, a given name, a found
group's copy as the user's, and the errors.
- group-duplicate.test.cjs: Chromium and WebKit, en/zh/zh-TW/ja/de at
420px, 10/10. It fails 10/10 on the old routing.js. Siblings
group-arrange, removed-groups-menu-scroll, group-bulk-remove and
click-scroll pass 36/36.
- Sandbox CLI (sandbox HOME): group copy and duplicate made "DupTest copy"
and "DupTest copy 2", listed after DupTest.
- gofmt -l clean. go vet darwin/linux/windows on the root, provider and
gui packages: ok.
This commit is contained in:
1 parent
8b56cf2d57
commit
ddd4f43144
12 files changed
+388
No files matched your search
@@ -869,6 +869,7 @@ magpie group add "Opus anywhere" models=claude/claude-opus-5-5,copilot/claude-op
|
||||
magpie group opus-anywhere # one group, its models in order
|
||||
magpie group set opus-anywhere models+=openrouter/anthropic/claude-opus-5.5 routing=usage
|
||||
magpie group set opus-anywhere models-=copilot/claude-opus-5.5
|
||||
magpie group copy opus-anywhere "Opus test" # the same models, routing and rules, to change on its own
|
||||
magpie group rm opus-anywhere # one magpie found is hidden; magpie group restore <id> brings it back
|
||||
magpie claude group/opus-anywhere # use it
|
||||
```
|
||||
|
||||
@@ -36,6 +36,8 @@ const groupUsage = `usage:
|
||||
fast=<m1>[,m2…] (the models sent in their vendor's fast mode; empty for none)
|
||||
magpie group pick <id> <model> route the group manually, every request to that one of its models
|
||||
(as clicking it on the group's card in the Routing view does)
|
||||
magpie group copy <id> [name] duplicate a group with its models, routing and rules, listed after it
|
||||
(name: "<its name> copy" when left out; also: magpie group duplicate)
|
||||
magpie group rm <id>… remove groups (one magpie found is hidden instead)
|
||||
magpie group restore <id> bring back a group magpie found that you removed
|
||||
magpie group auto [on|off] whether magpie finds groups on its own (on by default); off, none is
|
||||
@@ -589,6 +591,23 @@ func groupCmd(args []string) error {
|
||||
}
|
||||
fmt.Println(green.Render("✓"), bold.Render(g.Name), "sends every request to", bold.Render(g.Picked()), muted.Render("· routing manual"))
|
||||
return showGroup(g)
|
||||
case "copy", "duplicate", "dup", "clone":
|
||||
if len(rest) < 1 {
|
||||
return fmt.Errorf("magpie group copy <id> [name]")
|
||||
}
|
||||
src, err := findGroup(rest[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if src.Hidden {
|
||||
return fmt.Errorf("%s was removed: magpie group restore %s brings it back first", src.ID, src.ID)
|
||||
}
|
||||
g, err := provider.CopyGroup(src.ID, strings.Join(rest[1:], " "))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green.Render("✓"), "copied", bold.Render(src.Name), "to", bold.Render(g.Name), muted.Render("· agents pick it as "+provider.GroupPrefix+g.ID))
|
||||
return showGroup(g)
|
||||
case "rule", "rules":
|
||||
return ruleCmd(rest)
|
||||
case "show":
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// magpie group copy <id> [name] duplicates a group (lc on Discord): its
|
||||
// models and routing under "<name> copy", or the name given, and says what
|
||||
// agents pick it as. A found group copies too, as one of the user's; a
|
||||
// removed one is brought back first.
|
||||
func TestGroupCopyCLI(t *testing.T) {
|
||||
groupsHome(t)
|
||||
if err := provider.SaveGroup(provider.Group{ID: "fast", Name: "Fast", Members: []string{"a/m", "a/only-a"}, Routing: provider.Ordered}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := stdoutOf(t, func() error { return groupCmd([]string{"group", "copy", "fast"}) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "Fast copy") || !strings.Contains(out, "group/fast-copy") {
|
||||
t.Fatalf("copy said %q", out)
|
||||
}
|
||||
c, err := findGroup("fast-copy")
|
||||
if err != nil || c.Name != "Fast copy" || c.Routing != provider.Ordered || !slices.Equal(c.Members, []string{"a/m", "a/only-a"}) {
|
||||
t.Fatalf("%v %+v", err, c)
|
||||
}
|
||||
|
||||
if _, err := stdoutOf(t, func() error { return groupCmd([]string{"group", "duplicate", "Fast", "Fast", "B"}) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, err := findGroup("fast-b"); err != nil || c.Name != "Fast B" {
|
||||
t.Fatalf("named copy: %v %+v", err, c)
|
||||
}
|
||||
|
||||
if _, err := stdoutOf(t, func() error { return groupCmd([]string{"group", "copy", "auto-m", "Mine"}) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, err := findGroup("mine"); err != nil || c.Auto || len(c.Members) != 2 {
|
||||
t.Fatalf("found group's copy: %v %+v", err, c)
|
||||
}
|
||||
|
||||
if err := groupCmd([]string{"group", "copy", "nope"}); err == nil {
|
||||
t.Error("copied a group that isn't")
|
||||
}
|
||||
if err := groupCmd([]string{"group", "copy"}); err == nil || !strings.Contains(err.Error(), "magpie group copy <id>") {
|
||||
t.Errorf("no id: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1695,6 +1695,8 @@ const I18N = {
|
||||
"Duplicate": "复制",
|
||||
"A new provider with {name}'s URLs, key, headers, models and balance settings, to change before adding": "新建供应商,沿用 {name} 的地址、Key、请求头、模型和余额设置,添加前可修改",
|
||||
"{name} copy": "{name} 副本",
|
||||
"Duplicated {name}": "已复制 {name}",
|
||||
"A copy of {name} with its models, routing and rules, listed after it": "复制 {name},连同其模型、路由方式和规则,排在它之后",
|
||||
"Copy of {name}": "{name} 的副本",
|
||||
"{masked} · {name}'s key, or paste another": "{masked} · 沿用 {name} 的 Key,或粘贴新的",
|
||||
"e.g. {name} · Work": "例如 {name} · Work",
|
||||
@@ -5660,6 +5662,8 @@ const I18N = {
|
||||
"Duplicate": "複製",
|
||||
"A new provider with {name}'s URLs, key, headers, models and balance settings, to change before adding": "新增供應商,沿用 {name} 的位址、Key、請求頭、模型和餘額設定,新增前可修改",
|
||||
"{name} copy": "{name} 副本",
|
||||
"Duplicated {name}": "已複製 {name}",
|
||||
"A copy of {name} with its models, routing and rules, listed after it": "複製 {name},連同其模型、路由方式與規則,排在它之後",
|
||||
"Copy of {name}": "{name} 的副本",
|
||||
"{masked} · {name}'s key, or paste another": "{masked} · 沿用 {name} 的 Key,或貼上新的",
|
||||
"e.g. {name} · Work": "例如 {name} · Work",
|
||||
@@ -9603,6 +9607,8 @@ const I18N = {
|
||||
"Duplicate": "複製",
|
||||
"A new provider with {name}'s URLs, key, headers, models and balance settings, to change before adding": "{name} の URL、キー、ヘッダー、モデル、残高設定を引き継いだ新しいプロバイダです。追加前に変更できます",
|
||||
"{name} copy": "{name} のコピー",
|
||||
"Duplicated {name}": "{name} を複製しました",
|
||||
"A copy of {name} with its models, routing and rules, listed after it": "{name} をモデル・ルーティング・ルールごと複製し、そのすぐ後に並べます",
|
||||
"Copy of {name}": "{name} のコピー",
|
||||
"{masked} · {name}'s key, or paste another": "{masked} · {name} のキー、または別のキーを貼り付け",
|
||||
"e.g. {name} · Work": "例:{name} · 仕事",
|
||||
@@ -13565,6 +13571,8 @@ const I18N = {
|
||||
"Duplicate": "Duplizieren",
|
||||
"A new provider with {name}'s URLs, key, headers, models and balance settings, to change before adding": "Ein neuer Anbieter mit URLs, Schlüssel, Headern, Modellen und Guthaben-Einstellungen von {name}, vor dem Hinzufügen änderbar",
|
||||
"{name} copy": "{name} Kopie",
|
||||
"Duplicated {name}": "{name} dupliziert",
|
||||
"A copy of {name} with its models, routing and rules, listed after it": "Eine Kopie von {name} mit ihren Modellen, ihrem Routing und ihren Regeln, direkt darunter",
|
||||
"Copy of {name}": "Kopie von {name}",
|
||||
"{masked} · {name}'s key, or paste another": "{masked} · Schlüssel von {name} oder einen anderen einfügen",
|
||||
"e.g. {name} · Work": "z. B. {name} · Arbeit",
|
||||
|
||||
@@ -3391,6 +3391,10 @@
|
||||
openRowMenu(anchor, [
|
||||
{ name: "Move up", icon: MOVE_UP, key: ALT + "↑", off: up < 0, run: () => moveGroup(g.id, up, true) },
|
||||
{ name: "Move down", icon: MOVE_DOWN, key: ALT + "↓", off: down < 0, run: () => moveGroup(g.id, down, true) },
|
||||
// the group as it is, models, routing and rules, listed after it, to
|
||||
// change without touching the one agents use (lc on Discord)
|
||||
{ name: "Duplicate", icon: COPY_ICON, sep: true, tip: t("A copy of {name} with its models, routing and rules, listed after it", { name: g.name }),
|
||||
run: async () => { if (!groupDirty() || await confirmDiscard()) groupAction("copy", { id: g.id, name: t("{name} copy", { name: g.name }) }, t("Duplicated {name}", { name: g.name })); } },
|
||||
]);
|
||||
}
|
||||
// nextTo: where a group moved one up (-1) or down goes, among them all:
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// The Routing view's Duplicate (lc on Discord) makes a group of the
|
||||
// user's that is the group as it is — its models, patterns, routing,
|
||||
// rules, off members — under the name asked, numbered past one in use,
|
||||
// listed right after it, switched on; the group it copies is unchanged.
|
||||
func TestGroupCopy(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("USERPROFILE", os.Getenv("HOME"))
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
if err := provider.Save(provider.Provider{ID: "a", Name: "a", Key: "ka", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1", "m2", "m3"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, g := range []provider.Group{
|
||||
{ID: "fast", Name: "Fast", Members: []string{"a/m1", "a/m2"}, Match: []string{"a/m3*"}, Routing: provider.Ordered,
|
||||
Off: []string{"a/m2"}, Rules: []provider.Rule{{Use: "a/m2", Agents: []string{"codex"}}}},
|
||||
{ID: "slow", Name: "Slow", Members: []string{"a/m3"}},
|
||||
} {
|
||||
if err := provider.SaveGroup(g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := provider.SwitchGroup("fast", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
groupRoutes(mux)
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/groups/copy", strings.NewReader(body)))
|
||||
return w
|
||||
}
|
||||
ids := func() []string {
|
||||
var out []string
|
||||
for _, g := range provider.Groups() {
|
||||
if !g.Auto {
|
||||
out = append(out, g.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
if w := post(`{"id":"fast","name":"Fast copy"}`); w.Code != 200 {
|
||||
t.Fatalf("%d %s", w.Code, w.Body)
|
||||
}
|
||||
c, _, ok := provider.FindGroup("group/fast-copy")
|
||||
if !ok {
|
||||
t.Fatalf("no copy: %v", ids())
|
||||
}
|
||||
if c.Name != "Fast copy" || c.Routing != provider.Ordered || c.Disabled || c.Auto ||
|
||||
!slices.Equal(c.Off, []string{"a/m2"}) || !slices.Equal(c.Match, []string{"a/m3*"}) ||
|
||||
len(c.Rules) != 1 || c.Rules[0].Use != "a/m2" || !slices.Equal(c.Rules[0].Agents, []string{"codex"}) ||
|
||||
!slices.Contains(c.Members, "a/m1") || !slices.Contains(c.Members, "a/m3") {
|
||||
t.Fatalf("copy: %+v", c)
|
||||
}
|
||||
if got := ids(); !slices.Equal(got, []string{"fast", "fast-copy", "slow"}) {
|
||||
t.Errorf("order: %v", got)
|
||||
}
|
||||
if i := slices.IndexFunc(provider.Groups(), func(g provider.Group) bool { return g.ID == "fast" }); i < 0 || !provider.Groups()[i].Disabled || provider.Groups()[i].Name != "Fast" {
|
||||
t.Errorf("original changed: %v", provider.Groups())
|
||||
}
|
||||
|
||||
// again: the name is in use, so it is numbered, and so is the id
|
||||
if w := post(`{"id":"fast","name":"Fast copy"}`); w.Code != 200 {
|
||||
t.Fatalf("%d %s", w.Code, w.Body)
|
||||
}
|
||||
if c, _, ok := provider.FindGroup("group/fast-copy-2"); !ok || c.Name != "Fast copy 2" {
|
||||
t.Fatalf("second copy: %v %+v (%v)", ok, c, ids())
|
||||
}
|
||||
if got := ids(); !slices.Equal(got, []string{"fast", "fast-copy-2", "fast-copy", "slow"}) {
|
||||
t.Errorf("order: %v", got)
|
||||
}
|
||||
if w := post(`{"id":"nope"}`); w.Code < 400 {
|
||||
t.Errorf("copied a group that isn't: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -326,6 +326,9 @@ func groupRoutes(mux *http.ServeMux) {
|
||||
}
|
||||
case "show":
|
||||
err = provider.ShowGroup(in.ID)
|
||||
case "copy":
|
||||
// a group as it is, under name (provider.CopyGroup)
|
||||
_, err = provider.CopyGroup(in.ID, in.Name)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// lc on Discord: a routing group can be duplicated. Its row's menu (right-
|
||||
// click, or a click on its logos) has Duplicate under Move up and Move
|
||||
// down, which posts groups/copy with the group's id and "<name> copy" in
|
||||
// the reader's language; the list is drawn as the answer has it, the copy
|
||||
// after the group it copies, and the status says it was duplicated. No
|
||||
// click moves the page or opens the editor. At 420px, in every language,
|
||||
// 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/m", name: "m", providerName: "A", icon: "generic" },
|
||||
{ id: "a/x", name: "x", providerName: "A", icon: "generic" },
|
||||
];
|
||||
const info = (ids) => ids.map((id) => ({ id, ready: true }));
|
||||
const group = (id, name, members, more = {}) => ({ id, name, members, ready: true, memberInfo: info(members), ...more });
|
||||
|
||||
const words = {
|
||||
en: { dup: "Duplicate", copy: "One copy", done: "Duplicated One" },
|
||||
zh: { dup: "复制", copy: "One 副本", done: "已复制 One" },
|
||||
"zh-TW": { dup: "複製", copy: "One 副本", done: "已複製 One" },
|
||||
ja: { dup: "複製", copy: "One のコピー", done: "One を複製しました" },
|
||||
de: { dup: "Duplizieren", copy: "One Kopie", done: "One dupliziert" },
|
||||
};
|
||||
const KEYS = ["Duplicate", "{name} copy", "Duplicated {name}", "A copy of {name} with its models, routing and rules, listed after it"];
|
||||
|
||||
function serve(lang, posts) {
|
||||
let list = [
|
||||
group("one", "One", ["a/x", "a/m"], { routing: "order", rules: [{ use: "a/m", agents: ["codex"] }] }),
|
||||
group("two", "Two", ["a/m"]),
|
||||
];
|
||||
const groups = () => ({ models, pools: [], deciders: [], found: true, groups: list });
|
||||
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/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/gateway/trace") {
|
||||
if (url.searchParams.get("wait")) return new Promise(() => {});
|
||||
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 === "/api/groups/copy") {
|
||||
const body = JSON.parse(r.request().postData() || "{}");
|
||||
posts.push(body);
|
||||
const src = list.find((g) => g.id === body.id);
|
||||
const copy = { ...structuredClone(src), id: "one-copy", name: body.name };
|
||||
list = [list[0], copy, ...list.slice(1)];
|
||||
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 });
|
||||
};
|
||||
}
|
||||
|
||||
const listed = (page) => page.locator(".rt-groups > .rt-group").evaluateAll((rs) => rs.map((r) => r.dataset.id));
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of Object.keys(words)) {
|
||||
const w = words[lang];
|
||||
test(`${engine} ${lang}: a routing group is duplicated from its menu`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const page = await (await browser.newContext({ viewport: { width: 420, height: 1200 }, reducedMotion: "reduce" })).newPage();
|
||||
t.after(async () => {
|
||||
if (process.env.ARTIFACT_DIR) {
|
||||
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-group-duplicate.png`) });
|
||||
}
|
||||
await 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");
|
||||
await page.locator(".rt-groups > .rt-group").nth(1).waitFor();
|
||||
// tall enough that the rows are in view unscrolled: a scroll closes
|
||||
// the menu, and the page settles its own scroll as it loads
|
||||
await page.waitForTimeout(500);
|
||||
assert.deepEqual(await listed(page), ["one", "two"]);
|
||||
const at = () => page.evaluate(() => [...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => [e.id || e.className, e.scrollTop]).join(";") + "|" + scrollX);
|
||||
const before = await at();
|
||||
|
||||
await page.locator(".rt-group[data-id=one] .main").click({ button: "right" });
|
||||
const menu = page.locator(".row-menu");
|
||||
await menu.waitFor();
|
||||
const item = menu.getByRole("menuitem", { name: w.dup });
|
||||
assert.equal(await item.isDisabled(), false);
|
||||
// the menu is whole on a narrow window
|
||||
const box = await menu.boundingBox();
|
||||
assert(box.x >= 0 && box.x + box.width <= 420, `menu off the window: ${JSON.stringify(box)}`);
|
||||
await item.click();
|
||||
await page.waitForFunction((m) => document.querySelector("#status").textContent.includes(m), w.done);
|
||||
assert.deepEqual(posts, [{ id: "one", name: w.copy }]);
|
||||
await page.waitForFunction(() => [...document.querySelectorAll(".rt-groups > .rt-group")].map((r) => r.dataset.id).join() === "one,one-copy,two");
|
||||
assert.equal(await page.locator(".rt-group[data-id=one-copy] .main").textContent().then((s) => s.includes(w.copy)), true);
|
||||
assert.equal(await page.locator(".rt-gedit").count(), 0, "duplicating opened the editor");
|
||||
assert.equal(await page.locator(".row-menu").count(), 0, "the menu stayed open");
|
||||
|
||||
// the handle's click opens the same menu
|
||||
await page.mouse.move(1, 1);
|
||||
await page.locator(".rt-group[data-id=one-copy] .rt-ghandle").click();
|
||||
await menu.waitFor();
|
||||
assert.equal(await menu.getByRole("menuitem", { name: w.dup }).count(), 1);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
assert.equal(await at(), before, "a click moved the page");
|
||||
if (lang !== "en") {
|
||||
const missing = await page.evaluate(([l, ks]) => ks.filter((k) => !I18N[l][k]), [lang, KEYS]);
|
||||
assert.deepEqual(missing, [], `every string has its ${lang}`);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CopyGroup makes a group of the user's that is the group id as it is now
|
||||
// (lc on Discord): its models and patterns, routing, rules, classifier,
|
||||
// effort, context, levels, fast and off members, under name — "<name>
|
||||
// copy" when empty, numbered past a name or id in use — listed right after
|
||||
// the one it copies. A found group's copy is the user's, with the members
|
||||
// it has now. The copy is on, whether the group was or not, and the
|
||||
// original is left as it is.
|
||||
func CopyGroup(id, name string) (Group, error) {
|
||||
all := Groups()
|
||||
i := slices.IndexFunc(all, func(g Group) bool { return g.ID == id && !g.Hidden })
|
||||
if i < 0 {
|
||||
return Group{}, fmt.Errorf("no group %q", id)
|
||||
}
|
||||
src := all[i]
|
||||
// a copy shares nothing with the group it was read from: the rules'
|
||||
// time windows are pointers, the lists slices
|
||||
raw, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
var g Group
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
g.Auto, g.Hidden, g.Disabled = false, false, false
|
||||
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = src.Name + " copy"
|
||||
}
|
||||
names, ids := map[string]bool{}, map[string]bool{}
|
||||
for _, o := range all {
|
||||
ids[o.ID] = true
|
||||
if !o.Hidden {
|
||||
names[strings.ToLower(o.Name)] = true
|
||||
}
|
||||
}
|
||||
for _, removed := range RemovedGroups() {
|
||||
ids[removed] = true
|
||||
}
|
||||
base := name
|
||||
for n := 2; names[strings.ToLower(name)]; n++ {
|
||||
name = fmt.Sprintf("%s %d", base, n)
|
||||
}
|
||||
slug := GroupSlug(name)
|
||||
if slug == "" {
|
||||
slug = "group"
|
||||
}
|
||||
g.Name, g.ID = name, slug
|
||||
for n := 2; ids[g.ID]; n++ {
|
||||
g.ID = fmt.Sprintf("%s-%d", slug, n)
|
||||
}
|
||||
if err := SaveGroup(g); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
|
||||
// listed after the one it copies, not at the end of the user's
|
||||
order := make([]string, 0, len(all)+1)
|
||||
for _, o := range all {
|
||||
order = append(order, o.ID)
|
||||
if o.ID == src.ID {
|
||||
order = append(order, g.ID)
|
||||
}
|
||||
}
|
||||
if err := SetGroupOrder(order); err != nil {
|
||||
return Group{}, err
|
||||
}
|
||||
for _, o := range Groups() {
|
||||
if o.ID == g.ID {
|
||||
return o, nil
|
||||
}
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -220,6 +220,7 @@ magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact <spa
|
||||
magpie group rule add opus-anywhere pause=copilot/claude-opus-5.5 time=14:00-18:00 days=mon-fri <span class="c"># その時間帯は外す</span>
|
||||
magpie group set coder models+=group/opus-anywhere <span class="c"># グループの中のグループ</span>
|
||||
magpie group rule opus-anywhere <span class="c"># ルール一覧。rule rm|mv <n>、rule classifier <group> <model></span>
|
||||
magpie group copy opus-anywhere <span class="s">"Opus test"</span> <span class="c"># モデル・ルーティング・ルールが同じ複製。別々に変更できる</span>
|
||||
magpie group rm opus-anywhere <span class="c"># magpie が見つけたものは非表示に。magpie group restore <id> で戻す</span>
|
||||
magpie claude group/opus-anywhere <span class="c"># これを使う</span></pre>
|
||||
</div>
|
||||
|
||||
@@ -220,6 +220,7 @@ magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact <spa
|
||||
magpie group rule add opus-anywhere pause=copilot/claude-opus-5.5 time=14:00-18:00 days=mon-fri <span class="c"># left out in those hours</span>
|
||||
magpie group set coder models+=group/opus-anywhere <span class="c"># a group in a group</span>
|
||||
magpie group rule opus-anywhere <span class="c"># its rules; rule rm|mv <n>, rule classifier <group> <model></span>
|
||||
magpie group copy opus-anywhere <span class="s">"Opus test"</span> <span class="c"># the same models, routing and rules, to change on its own</span>
|
||||
magpie group rm opus-anywhere <span class="c"># one magpie found is hidden; magpie group restore <id></span>
|
||||
magpie claude group/opus-anywhere <span class="c"># use it</span></pre>
|
||||
</div>
|
||||
|
||||
@@ -220,6 +220,7 @@ magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact <spa
|
||||
magpie group rule add opus-anywhere pause=copilot/claude-opus-5.5 time=14:00-18:00 days=mon-fri <span class="c"># 这段时间不用它</span>
|
||||
magpie group set coder models+=group/opus-anywhere <span class="c"># 路由组里放路由组</span>
|
||||
magpie group rule opus-anywhere <span class="c"># 查看规则;rule rm|mv <n>,rule classifier <组> <模型> 换判断模型</span>
|
||||
magpie group copy opus-anywhere <span class="s">"Opus test"</span> <span class="c"># 复制一份:模型、路由方式和规则都一样,可单独修改</span>
|
||||
magpie group rm opus-anywhere <span class="c"># 自动发现的组会被隐藏;magpie group restore <id> 恢复</span>
|
||||
magpie claude group/opus-anywhere <span class="c"># 使用它</span></pre>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user