From ddd4f4314494f3002af9fec0d59e7728b3bb3950 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 10 Oct 2026 14:53:11 +0800 Subject: [PATCH] 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 " 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 [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. --- docs/reference.md | 1 + groups_cli.go | 19 +++ groups_copy_cli_test.go | 52 ++++++++ internal/gui/assets/i18n.js | 8 ++ internal/gui/assets/routing.js | 4 + internal/gui/group_copy_test.go | 88 ++++++++++++++ internal/gui/groups.go | 3 + internal/gui/tests/group-duplicate.test.cjs | 127 ++++++++++++++++++++ internal/provider/group_copy.go | 83 +++++++++++++ site/public/docs/ja/start.html | 1 + site/public/docs/start.html | 1 + site/public/docs/zh/start.html | 1 + 12 files changed, 388 insertions(+) create mode 100644 groups_copy_cli_test.go create mode 100644 internal/gui/group_copy_test.go create mode 100644 internal/gui/tests/group-duplicate.test.cjs create mode 100644 internal/provider/group_copy.go diff --git a/docs/reference.md b/docs/reference.md index 4c7c6ff7..93e0ec04 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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 brings it back magpie claude group/opus-anywhere # use it ``` diff --git a/groups_cli.go b/groups_cli.go index 40a29cdc..4a13e954 100644 --- a/groups_cli.go +++ b/groups_cli.go @@ -36,6 +36,8 @@ const groupUsage = `usage: fast=[,m2…] (the models sent in their vendor's fast mode; empty for none) magpie group pick 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 [name] duplicate a group with its models, routing and rules, listed after it + (name: " copy" when left out; also: magpie group duplicate) magpie group rm … remove groups (one magpie found is hidden instead) magpie group restore 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 [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": diff --git a/groups_copy_cli_test.go b/groups_copy_cli_test.go new file mode 100644 index 00000000..711d11b0 --- /dev/null +++ b/groups_copy_cli_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "slices" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// magpie group copy [name] duplicates a group (lc on Discord): its +// models and routing under " 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 ") { + t.Errorf("no id: %v", err) + } +} diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 1ede3d0c..b29d4e6c 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 5afaab24..a9575046 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -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: diff --git a/internal/gui/group_copy_test.go b/internal/gui/group_copy_test.go new file mode 100644 index 00000000..de2eb5d9 --- /dev/null +++ b/internal/gui/group_copy_test.go @@ -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) + } +} diff --git a/internal/gui/groups.go b/internal/gui/groups.go index d524eaa1..0a7d2277 100644 --- a/internal/gui/groups.go +++ b/internal/gui/groups.go @@ -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) diff --git a/internal/gui/tests/group-duplicate.test.cjs b/internal/gui/tests/group-duplicate.test.cjs new file mode 100644 index 00000000..b1b244c7 --- /dev/null +++ b/internal/gui/tests/group-duplicate.test.cjs @@ -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 " 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, []); + }); + } +} diff --git a/internal/provider/group_copy.go b/internal/provider/group_copy.go new file mode 100644 index 00000000..bb3ce916 --- /dev/null +++ b/internal/provider/group_copy.go @@ -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 — " +// 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 +} diff --git a/site/public/docs/ja/start.html b/site/public/docs/ja/start.html index 43857a1d..70cb197d 100644 --- a/site/public/docs/ja/start.html +++ b/site/public/docs/ja/start.html @@ -220,6 +220,7 @@ magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact # その時間帯は外す magpie group set coder models+=group/opus-anywhere # グループの中のグループ magpie group rule opus-anywhere # ルール一覧。rule rm|mv <n>、rule classifier <group> <model> +magpie group copy opus-anywhere "Opus test" # モデル・ルーティング・ルールが同じ複製。別々に変更できる magpie group rm opus-anywhere # magpie が見つけたものは非表示に。magpie group restore <id> で戻す magpie claude group/opus-anywhere # これを使う diff --git a/site/public/docs/start.html b/site/public/docs/start.html index 378f5336..a04b4e39 100644 --- a/site/public/docs/start.html +++ b/site/public/docs/start.html @@ -220,6 +220,7 @@ magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact # left out in those hours magpie group set coder models+=group/opus-anywhere # a group in a group magpie group rule opus-anywhere # its rules; rule rm|mv <n>, rule classifier <group> <model> +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> magpie claude group/opus-anywhere # use it diff --git a/site/public/docs/zh/start.html b/site/public/docs/zh/start.html index 399f3f9d..4cfef0ad 100644 --- a/site/public/docs/zh/start.html +++ b/site/public/docs/zh/start.html @@ -220,6 +220,7 @@ magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact # 这段时间不用它 magpie group set coder models+=group/opus-anywhere # 路由组里放路由组 magpie group rule opus-anywhere # 查看规则;rule rm|mv <n>,rule classifier <组> <模型> 换判断模型 +magpie group copy opus-anywhere "Opus test" # 复制一份:模型、路由方式和规则都一样,可单独修改 magpie group rm opus-anywhere # 自动发现的组会被隐藏;magpie group restore <id> 恢复 magpie claude group/opus-anywhere # 使用它