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 # 使用它