From 3df75514b99e0f7f00a2a1d14b25d66e39e38e77 Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 30 Sep 2026 12:42:49 +0800 Subject: [PATCH] =?UTF-8?q?agents:=20each=20agent's=20own=20model=20list?= =?UTF-8?q?=20=E2=80=94=20under=20an=20agent's=20name=20on=20the=20Agents?= =?UTF-8?q?=20page,=20"Showing=205=20/=2032=20models"=20(or=20"All=2041=20?= =?UTF-8?q?models")=20opens=20a=20list=20to=20take=20models=20out=20of=20t?= =?UTF-8?q?hat=20agent's=20picker=20one=20by=20one=20and=20put=20them=20ba?= =?UTF-8?q?ck=20(@ChongkaiX=20on=20X:=20=E5=9C=A8=20magpie=20=E9=87=8C?= =?UTF-8?q?=E9=80=89=E5=93=AA=E4=BA=9B=E6=A8=A1=E5=9E=8B=E5=87=BA=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=20Codex=20=E7=9A=84=E6=A8=A1=E5=9E=8B=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E9=87=8C)=20=E2=80=94=20until=20now=20an=20agent's=20?= =?UTF-8?q?list=20could=20only=20be=20narrowed=20by=20whole=20providers=20?= =?UTF-8?q?or=20families=20(Visible).=20settings.HiddenModels=20holds,=20b?= =?UTF-8?q?y=20agent,=20the=20catalog=20entries=20taken=20out,=20after=20V?= =?UTF-8?q?isible:=20a=20deny=20list,=20so=20a=20new=20model=20is=20shown.?= =?UTF-8?q?=20provider.CatalogFor=20drops=20them,=20so=20the=20gateway's?= =?UTF-8?q?=20/models,=20the=20agents'=20files=20and=20pickers=20and=20Cod?= =?UTF-8?q?ex's=20catalog=20all=20follow=20in=20one=20place,=20and=20catal?= =?UTF-8?q?og.Touched=20rewrites=20the=20agents'=20files;=20Codex's=20own?= =?UTF-8?q?=20ChatGPT=20models=20are=20dropped=20from=20the=20backend's=20?= =?UTF-8?q?list=20too=20(CodexNativeHidden),=20and=20the=20list's=20ETag?= =?UTF-8?q?=20and=20models=5Fcache=20tag=20carry=20them=20(CodexListTag)?= =?UTF-8?q?=20so=20Codex=20asks=20again.=20The=20model=20an=20agent=20is?= =?UTF-8?q?=20set=20to=20is=20marked=20=E5=9C=A8=E7=94=A8=20and=20never=20?= =?UTF-8?q?taken=20out,=20on=20the=20server=20as=20well.=20The=20list=20gr?= =?UTF-8?q?oups=20routing=20groups=20first,=20then=20providers=20with=20th?= =?UTF-8?q?eir=20logos=20and=20on/total,=20a=20long=20group=20folded;=20se?= =?UTF-8?q?arch,=20=E5=85=A8=E9=83=A8/=E5=B7=B2=E6=98=BE=E7=A4=BA,=20a=20g?= =?UTF-8?q?roup's=20=E5=85=A8=E9=83=A8=E6=98=BE=E7=A4=BA/=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E9=9A=90=E8=97=8F=20on=20hover,=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E6=98=BE=E7=A4=BA;=20a=20click=20on=20a=20ro?= =?UTF-8?q?w=20applies=20at=20once,=20Esc=20or=20a=20click=20elsewhere=20c?= =?UTF-8?q?loses=20it,=20and=20nothing=20scrolls.=20A=20renamed=20provider?= =?UTF-8?q?'s=20hidden=20models=20follow=20it.=20Tested=20with=20agent-mod?= =?UTF-8?q?els.test.cjs=20in=20Chromium=20and=20WebKit,=20English=20and=20?= =?UTF-8?q?Chinese;=20TestHiddenModels=20(provider),=20TestCodexModelListH?= =?UTF-8?q?idesOnAgentsPage=20(gateway)=20and=20TestAgentModelsAPI=20(gui)?= =?UTF-8?q?=20in=20sandbox=20HOMEs.=20click-scroll,=20panel-fold,=20old-we?= =?UTF-8?q?bkit,=20model-pick,=20menu-scroll,=20add-button=20pass;=20GOOS?= =?UTF-8?q?=3Dlinux=20go=20vet,=20linux=20and=20windows=20builds,=20go=20t?= =?UTF-8?q?est=20-tags=20nogui=20./...=20pass.=20Not=20tried=20in=20the=20?= =?UTF-8?q?real=20Wails=20window=20or=20against=20a=20real=20Codex.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + internal/agent/codex.go | 2 +- internal/gateway/codex_backend.go | 16 +- internal/gateway/codex_backend_test.go | 54 +++++ internal/gui/agent_models.go | 136 +++++++++++++ internal/gui/agent_models_test.go | 77 ++++++++ internal/gui/api.go | 7 + internal/gui/assets/app.css | 57 ++++++ internal/gui/assets/app.js | 241 ++++++++++++++++++++++- internal/gui/assets/i18n.js | 14 ++ internal/gui/tests/README.md | 10 + internal/gui/tests/agent-models.test.cjs | 166 ++++++++++++++++ internal/provider/codex_models.go | 31 +++ internal/provider/hidden_models_test.go | 86 ++++++++ internal/provider/modelprefs.go | 10 +- internal/provider/visible.go | 70 ++++++- internal/settings/settings.go | 4 + 17 files changed, 975 insertions(+), 10 deletions(-) create mode 100644 internal/gui/agent_models.go create mode 100644 internal/gui/agent_models_test.go create mode 100644 internal/gui/tests/agent-models.test.cjs create mode 100644 internal/provider/hidden_models_test.go diff --git a/README.md b/README.md index a4879224..735fb605 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ and there is a terminal version (`magpie tui`) and a plain CLI. background once it goes stale. Choose which models each provider exposes, or expose them all — a model released this morning is in the picker on the next refresh. +- **Each agent's own model list.** Under an agent's name on the Agents page, + "Showing 5 / 32 models" opens its list: click a model to take it out of + that agent's picker (Codex's `/model` included, its ChatGPT models too) or + put it back; other agents still use it, and a new model is shown. - **Profiles.** Snapshot every agent's settings under a name and switch all of them back in one move. - **Real logos, no framework.** Plain HTML over the system webview; brand diff --git a/internal/agent/codex.go b/internal/agent/codex.go index 16c514c1..e0479186 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -284,7 +284,7 @@ func codexIn(at place) *Agent { } } case viaBase(): - if err := codexStaleCache(filepath.Join(dir, "models_cache.json"), codexcat.Tag(provider.CodexListed())); err != nil { + if err := codexStaleCache(filepath.Join(dir, "models_cache.json"), provider.CodexListTag()); err != nil { return err } default: diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index fe847a46..d1b9d2f2 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -524,9 +524,21 @@ func (s *Server) codexModels(w http.ResponseWriter, r *http.Request) { } own = kept } + // and the ones taken out of Codex's list on the Agents page + if off := provider.CodexNativeHidden(); len(off) > 0 { + kept := own[:0] + for _, m := range own { + o, _ := m.(map[string]any) + if slug, _ := o["slug"].(string); off[slug] { + continue + } + kept = append(kept, m) + } + own = kept + } ms := provider.CodexListed() // the list is the backend's and magpie's, and so is its ETag - w.Header().Set("ETag", codexcat.WithTag(etag, codexcat.Tag(ms))) + w.Header().Set("ETag", codexcat.WithTag(etag, provider.CodexListTag())) writeJSON(w, 200, map[string]any{"models": append(own, codexcat.Entries(ms, len(own)+100)...)}) } @@ -535,7 +547,7 @@ func (s *Server) codexModels(w http.ResponseWriter, r *http.Request) { // change to either has Codex ask for the list again. func modelsEtag(h http.Header) { if v := h.Get("X-Models-Etag"); v != "" { - h.Set("X-Models-Etag", codexcat.WithTag(v, codexcat.Tag(provider.CodexListed()))) + h.Set("X-Models-Etag", codexcat.WithTag(v, provider.CodexListTag())) } } diff --git a/internal/gateway/codex_backend_test.go b/internal/gateway/codex_backend_test.go index e2703320..9d39eedb 100644 --- a/internal/gateway/codex_backend_test.go +++ b/internal/gateway/codex_backend_test.go @@ -395,6 +395,60 @@ func TestCodexModelListNarrowedByPicks(t *testing.T) { } } +// A native model taken out of Codex's list on the Agents page is dropped +// from the backend's list too, and the ETag changes so Codex asks again. +func TestCodexModelListHidesOnAgentsPage(t *testing.T) { + codexSignedIn(t) + if err := provider.Save(provider.Provider{ID: "codex", Models: []string{"gpt-5.5", "gpt-5-codex"}}); err != nil { + t.Fatal(err) + } + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("ETag", `"v1"`) + io.WriteString(w, `{"models":[{"slug":"gpt-5.5","priority":1},{"slug":"gpt-5-codex","priority":2}]}`) + })) + defer up.Close() + was := provider.CodexBase + provider.CodexBase = up.URL + "/backend-api/codex" + defer func() { provider.CodexBase = was }() + list := func() ([]string, string) { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", CodexPath+"/models", nil) + req.Header.Set("Authorization", "Bearer chatgpt-token") + New().Handler().ServeHTTP(rec, req) + var got struct { + Models []map[string]any `json:"models"` + } + json.Unmarshal(rec.Body.Bytes(), &got) + var native []string + for _, m := range got.Models { + if slug, _ := m["slug"].(string); strings.HasPrefix(slug, "gpt-") { + native = append(native, slug) + } + } + return native, rec.Header().Get("ETag") + } + all, before := list() + if len(all) != 2 { + t.Fatalf("native models %v", all) + } + var id string + for _, e := range provider.Catalog() { + if acc := e.Provider.Account; acc != nil && acc.Agent == "codex" && e.Model == "gpt-5-codex" { + id = e.ID + } + } + if id == "" { + t.Fatal("no catalog entry for the account's gpt-5-codex") + } + if err := provider.SetHiddenModels("codex", []string{id}); err != nil { + t.Fatal(err) + } + native, after := list() + if len(native) != 1 || native[0] != "gpt-5.5" || after == before { + t.Errorf("native %v, ETag %q then %q", native, before, after) + } +} + // A reply's X-Models-Etag carries magpie's list too: Codex refetches its // model list on a new one, and only on the backend's it never would when a // provider was added. diff --git a/internal/gui/agent_models.go b/internal/gui/agent_models.go new file mode 100644 index 00000000..910dfa9c --- /dev/null +++ b/internal/gui/agent_models.go @@ -0,0 +1,136 @@ +package gui + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/yetone/magpie/internal/agent" + "github.com/yetone/magpie/internal/provider" +) + +// Which of the catalog an agent's lists show, picked one model at a time +// on the Agents page (settings.HiddenModels): the line under an agent's +// name counts them, and opens the list to pick in. + +// modelCountJSON is how many models an agent's lists show, of those its +// visibility gives it. +type modelCountJSON struct { + Shown int `json:"shown"` + Listed int `json:"listed"` +} + +// agentModelJSON is a model an agent may be shown, as the list draws it. +type agentModelJSON struct { + ID string `json:"id"` + Name string `json:"name"` + Group string `json:"group"` // its provider's name, or "Routing groups" + Icon string `json:"icon,omitempty"` + Icons []string `json:"icons,omitempty"` + Context int `json:"context,omitempty"` + Hidden bool `json:"hidden,omitempty"` + // InUse: the agent is set to it, so it can't be taken out + InUse bool `json:"inUse,omitempty"` +} + +// takesCatalog reports whether an agent picks among magpie's catalog: some +// option of a field of its is a catalog entry. +func takesCatalog(fields []fieldJSON) bool { + for _, f := range fields { + for _, o := range f.Options { + if o.Ref != "" { + return true + } + } + } + return false +} + +func modelCount(id string) *modelCountJSON { + listed, _ := provider.ListedFor(id) + off := provider.HiddenModels(id) + c := &modelCountJSON{Listed: len(listed)} + for _, e := range listed { + if !off[e.ID] { + c.Shown++ + } + } + return c +} + +// usedBy reports whether one of the agent's values is the entry: its id, +// with a prefix the agent's files give it (magpie/…), or, for the agent's +// own account, the vendor's model id alone. +func usedBy(a *agent.Agent, vals map[string]string, e provider.Entry) bool { + for _, v := range vals { + if v == "" { + continue + } + if v == e.ID || strings.HasSuffix(v, "/"+e.ID) { + return true + } + if acc := e.Provider.Account; e.Group == "" && acc != nil && acc.Agent == a.ID && v == e.Model { + return true + } + } + return false +} + +func agentModelList(a *agent.Agent) []agentModelJSON { + listed, _ := provider.ListedFor(a.ID) + off := provider.HiddenModels(a.ID) + vals := a.Values() + out := []agentModelJSON{} + for _, e := range listed { + m := agentModelJSON{ID: e.ID, Name: e.Name, Group: e.Provider.Name, Icon: e.Provider.Icon, Context: e.Context, + Hidden: off[e.ID], InUse: usedBy(a, vals, e)} + if m.Name == "" { + m.Name = e.Model + } + if e.Group != "" { + m.Group, m.Icons = agent.RoutingGroups, e.Icons + } + out = append(out, m) + } + return out +} + +func agentModelsAPI(mux *http.ServeMux) { + mux.HandleFunc("GET /api/agent-models/{id}", func(rw http.ResponseWriter, r *http.Request) { + a, err := agent.Find(r.PathValue("id")) + if err != nil { + fail(rw, err) + return + } + writeJSON(rw, map[string]any{"models": agentModelList(a)}) + }) + // hidden is every entry to take out of the agent's lists; the others + // are shown, and one the agent is set to is kept in whatever is asked + mux.HandleFunc("POST /api/agent-models/{id}", func(rw http.ResponseWriter, r *http.Request) { + var in struct{ Hidden []string } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + fail(rw, err) + return + } + a, err := agent.Find(r.PathValue("id")) + if err != nil { + fail(rw, err) + return + } + used := map[string]bool{} + for _, m := range agentModelList(a) { + used[m.ID] = m.InUse + } + var hidden []string + for _, id := range in.Hidden { + if !used[id] { + hidden = append(hidden, id) + } + } + if err := provider.SetHiddenModels(a.ID, hidden); err != nil { + fail(rw, err) + return + } + writeJSON(rw, map[string]any{"models": agentModelList(a), "count": modelCount(a.ID)}) + }) +} diff --git a/internal/gui/agent_models_test.go b/internal/gui/agent_models_test.go new file mode 100644 index 00000000..ffa60d24 --- /dev/null +++ b/internal/gui/agent_models_test.go @@ -0,0 +1,77 @@ +package gui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" + "github.com/yetone/magpie/internal/settings" +) + +// The Agents page's model list for an agent: every model it may be shown, +// the one it's set to marked and never taken out. +func TestAgentModelsAPI(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("CODEX_HOME", filepath.Join(home, ".codex")) + os.MkdirAll(filepath.Join(home, ".codex"), 0o755) + os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("model = \"magpie/relay/m1\"\n"), 0o600) + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1", "m2", "m3"}}); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + agentModelsAPI(mux) + call := func(method, body string) (out struct { + Models []agentModelJSON + Count *modelCountJSON + }) { + t.Helper() + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(method, "/api/agent-models/codex", strings.NewReader(body))) + if w.Code != 200 { + t.Fatalf("%s %d %s", method, w.Code, w.Body) + } + json.Unmarshal(w.Body.Bytes(), &out) + return out + } + find := func(ms []agentModelJSON, id string) agentModelJSON { + for _, m := range ms { + if m.ID == id { + return m + } + } + t.Fatalf("no %s in %+v", id, ms) + return agentModelJSON{} + } + got := call("GET", "") + if m := find(got.Models, "relay/m1"); !m.InUse || m.Hidden || m.Group != "Relay" { + t.Fatalf("%+v", m) + } + if m := find(got.Models, "relay/m2"); m.InUse || m.Hidden { + t.Fatalf("%+v", m) + } + // the one in use stays whatever is asked + got = call("POST", `{"hidden":["relay/m1","relay/m2"]}`) + if saved := settings.Load().HiddenModels["codex"]; !slices.Equal(saved, []string{"relay/m2"}) { + t.Fatalf("saved %v", saved) + } + if !find(got.Models, "relay/m2").Hidden || find(got.Models, "relay/m1").Hidden || + got.Count == nil || got.Count.Shown != got.Count.Listed-1 { + t.Fatalf("%+v %+v", got.Models, got.Count) + } + if c := modelCount("codex"); *c != *got.Count { + t.Fatalf("count %+v, answered %+v", c, got.Count) + } + got = call("POST", `{"hidden":[]}`) + if got.Count.Shown != got.Count.Listed { + t.Fatalf("%+v", got.Count) + } +} diff --git a/internal/gui/api.go b/internal/gui/api.go index 54c24252..871ef009 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -86,6 +86,9 @@ type agentJSON struct { // Launch: the command that starts an agent taking the gateway only // from its environment (agy) on magpie, to copy Launch string `json:"launch,omitempty"` + // Models: how many of the catalog its lists show, for an agent that + // picks among it (agent_models.go) + Models *modelCountJSON `json:"models,omitempty"` } // clientJSON is an agent, or another client the gateway knows, as a @@ -630,6 +633,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { } rw.WriteHeader(http.StatusNoContent) }) + agentModelsAPI(mux) devListen(mux) return mux } @@ -655,6 +659,9 @@ func state() stateJSON { } aj.Fields = append(aj.Fields, fieldJSON{Key: f.Key, Label: f.Label, Value: vals[f.Key], Options: opts}) } + if takesCatalog(aj.Fields) { + aj.Models = modelCount(a.ID) + } aj.Drift = a.Drift() if a.Import != nil { aj.Import, aj.Added = a.Import(), a.Added != nil && a.Added() diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index d2d50af3..ed1a375a 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -1810,3 +1810,60 @@ body.panel .modal { padding: 16px 12px; } .status.ok, .status.err, .status.warn { background: var(--bg); } .status.lift { background: var(--card); } } + +/* ---- an agent's model list ------------------------------------------------ */ +/* the line under an agent's name: quiet words, a pill only on hover */ +body.window .row.agent .who.with-models { flex-wrap: wrap; row-gap: 0; } +.ag-models-line { flex: 0 0 100%; min-width: 0; display: flex; } +.ag-models { min-width: 0; display: inline-flex; align-items: center; gap: 4px; height: 20px; margin: 1px 0 -2px -6px; padding: 0 6px; border: 0; border-radius: 6px; background: none; color: var(--muted); font: inherit; font-size: 11.5px; line-height: 1; white-space: nowrap; cursor: pointer; transition: background-color .15s, color .15s; } +.ag-models b { font-weight: 600; color: var(--fg-2); font-variant-numeric: tabular-nums; } +.ag-models .chev { display: inline-flex; color: var(--faint); transition: transform .2s var(--ease, ease); } +.ag-models:hover, .ag-models.open { background: var(--pill); color: var(--fg-2); } +.ag-models.open .chev { transform: rotate(90deg); } +.ag-models:focus-visible { outline: 1.5px solid var(--accent); outline-offset: 1px; } + +.pop.am-pop { overflow: visible; max-height: 560px; padding: 14px 6px 8px; border-radius: 14px; background: var(--pop-bg); } +/* the arrow at the line it grows from */ +.pop.am-pop::before { content: ""; position: absolute; left: calc(var(--ox, 24px) - 6px); top: -5px; width: 12px; height: 12px; background: var(--pop-bg); transform: rotate(45deg); border-radius: 2px 0 0 0; box-shadow: -.5px -.5px 0 color-mix(in srgb, var(--fg) 10%, transparent); } +.pop.am-pop.up::before { top: auto; bottom: -5px; border-radius: 0 0 2px 0; box-shadow: .5px .5px 0 color-mix(in srgb, var(--fg) 10%, transparent); } +.am-head { padding: 0 12px; } +.am-t { font-size: 14px; font-weight: 600; letter-spacing: -.01em; color: var(--fg); } +.am-d { margin-top: 3px; font-size: 12px; line-height: 1.45; color: var(--muted); } +.am-tools { display: flex; align-items: center; gap: 8px; margin: 12px 12px 6px; } +.am-search { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; height: 28px; padding: 0 9px; border-radius: 8px; background: var(--pill); color: var(--faint); } +.pop.am-pop .am-search input { flex: 1; min-width: 0; height: 28px; border: 0; outline: none; background: none; color: var(--fg); font: inherit; font-size: 12.5px; } +.am-search:focus-within { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--accent) 55%, transparent); } +.am-seg { display: inline-flex; flex: none; padding: 2px; border-radius: 8px; background: var(--seg-track); } +.am-seg button { height: 24px; padding: 0 9px; border: 0; border-radius: 6px; background: none; color: var(--muted); font: inherit; font-size: 12px; cursor: pointer; transition: background-color .15s, color .15s; } +.am-seg button.on { background: var(--seg-thumb); color: var(--fg); box-shadow: var(--seg-edge); } +.am-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-bottom: 2px; } +.am-gh { display: flex; align-items: center; margin: 6px 2px 0; } +.am-fold { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; border: 0; border-radius: 8px; background: none; color: var(--muted); font: inherit; font-size: 12px; text-align: left; cursor: pointer; } +.am-fold > .ic, .am-fold > svg { width: 14px; height: 14px; flex: none; } +.am-fold .tw { display: inline-flex; width: 10px; color: var(--faint); } +.am-fold .tw svg { transition: transform .2s var(--ease, ease); } +.am-g.shut .am-fold .tw svg { transform: rotate(-90deg); } +.am-fold .gn { color: var(--fg-2); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.am-fold .c { font-variant-numeric: tabular-nums; } +.am-all { flex: none; height: 24px; margin-right: 6px; padding: 0 6px; border: 0; border-radius: 6px; background: none; color: var(--accent); font: inherit; font-size: 11.5px; cursor: pointer; opacity: 0; transition: opacity .15s, background-color .15s; } +.am-gh:hover .am-all { opacity: 1; } +.am-all:focus-visible { opacity: 1; } +.am-all:hover { background: var(--accent-soft); } +@media (hover: none) { .am-all { opacity: 1; } } +.am-mr { display: flex; align-items: center; gap: 10px; width: calc(100% - 4px); height: 32px; margin: 0 2px; padding: 0 12px; border: 0; border-radius: 8px; background: none; color: var(--fg); font: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: background-color .12s; } +.am-mr:hover { background: var(--pill); } +.am-mr:focus-visible { outline: 1.5px solid var(--accent); outline-offset: -1.5px; } +.am-mr .n { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color .15s; } +.am-mr .x { flex: none; font-size: 11.5px; color: var(--faint); font-variant-numeric: tabular-nums; } +.am-mr .ck { flex: none; display: inline-flex; justify-content: center; width: 16px; color: var(--accent); } +.am-mr.off .n { color: var(--faint); } +.am-mr.lock { cursor: default; } +.am-mr.lock:hover { background: none; } +.am-mr.lock .ck { opacity: .45; } +.am-tag { margin-left: 6px; padding: 1px 6px; border-radius: 5px; background: var(--accent-soft); color: var(--accent); font-size: 10.5px; font-weight: 500; } +.am-none { padding: 18px 12px; color: var(--muted); font-size: 12.5px; text-align: center; } +.am-foot { display: flex; align-items: center; gap: 8px; margin: 8px 8px 0; padding: 10px 6px 2px; border-top: 1px solid var(--line-2); font-size: 11.5px; color: var(--muted); } +.am-foot .sp { flex: 1; } +.am-reset { border: 0; background: none; padding: 2px 4px; border-radius: 5px; color: var(--fg-2); font: inherit; font-size: 11.5px; cursor: pointer; } +.am-reset:hover:not(:disabled) { color: var(--accent); } +.am-reset:disabled { color: var(--faint); cursor: default; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index f1e2e11d..b2e59452 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -363,6 +363,13 @@ function renderAgents() { // the CLI's version, and an update when one is out (#202); the panel's // name column has no room for it if (mode !== "panel") who.append(cliTag(a)); + // which of magpie's models its lists show, on a line under the name + if (mode !== "panel" && a.models) { + const line = el("div", "ag-models-line"); + line.append(modelsEntry(a)); + who.append(line); + who.classList.add("with-models"); + } row.append(agentHandle(a, row, inFold), who); if (sum) row.append(sum, ...(openBox ? [openBox] : [])); else row.append(fields); @@ -783,6 +790,235 @@ function dragAgent(e, handle, row) { handle.addEventListener("pointercancel", up); } +// ---------- an agent's model list ---------- + +// modelsEntry: the line under an agent's name counting the models its +// lists show ("All 41 models", or "Showing 5 / 32 models"); it opens the +// list to take models out of them and put them back. +function modelsEntry(a) { + const b = el("button", "ag-models"); + b.type = "button"; + fillModelsEntry(b, a); + b.onclick = (ev) => openAgentModels(a, b, ev); + // the rows drawn again while its list is open: the list stays, held to + // the new line + if (agentModels?.a.id === a.id) { + b.classList.add("open"); + agentModels.anchor = b; + if (agentModels.count) { a.models = agentModels.count; fillModelsEntry(b, a); } + } + return b; +} +function fillModelsEntry(b, a) { + const c = a.models; + b.replaceChildren(); + // the words in one box, so the flex line keeps the spaces round the number + const words = el("span"); + if (c.shown >= c.listed) words.append(t("All {n} models", { n: c.listed })); + else { + const [pre, post] = t("Showing {shown} / {listed} models", { listed: c.listed }).split("{shown}"); + words.append(pre, el("b", "", String(c.shown)), post || ""); + } + b.append(words); + const ch = el("span", "chev"); + ch.append(svg(CHEV_R, 10, 1.6)); + b.append(ch); + b.title = t("Pick which models {agent} lists", { agent: a.name }); +} + +const ctxShort = (n) => !n ? "" : n >= 1e6 ? (n % 1e6 ? (n / 1e6).toFixed(1) : n / 1e6) + "M" : Math.round(n / 1e3) + "K"; + +let agentModels = null; +function closeAgentModels() { + const m = agentModels; + if (!m) return; + agentModels = null; + m.anchor.classList.remove("open"); + popGhost(m.box); + m.box.remove(); + document.removeEventListener("mousedown", m.outside, true); + document.removeEventListener("keydown", m.keys, true); + document.removeEventListener("scroll", m.scrolled, true); + removeEventListener("resize", closeAgentModels); + // the agents' pickers list what's left once the writes are in + if (m.changed) m.saving.then(async () => { state = await api("state"); renderAgents(); }).catch(() => {}); +} + +async function openAgentModels(a, anchor, ev) { + ev.stopPropagation(); + const again = agentModels?.a.id === a.id; + closeAgentModels(); + closePicker(); + if (again) return; + let models; + try { models = (await api("agent-models/" + encodeURIComponent(a.id))).models; } + catch (e) { status(e.message, "err"); return; } + if (!anchor.isConnected) return; + const box = el("div", "pop am-pop"); + box.setAttribute("role", "dialog"); + box.setAttribute("aria-label", t("{agent}'s model list", { agent: a.name })); + const head = el("div", "am-head"); + head.append(el("div", "am-t", t("{agent}'s model list", { agent: a.name })), + el("div", "am-d", t("Models turned off don't show in {agent}'s model picker; other agents can still use them.", { agent: a.name }))); + const tools = el("div", "am-tools"); + const search = el("label", "am-search"); + search.innerHTML = ''; + const q = el("input"); + q.type = "text"; + q.spellcheck = false; + q.autocomplete = "off"; + q.placeholder = t("Search models"); + search.append(q); + const seg = el("div", "am-seg"); + const segAll = el("button", "on", t("All")), segOn = el("button", "", t("Shown")); + segAll.type = segOn.type = "button"; + seg.append(segAll, segOn); + tools.append(search, seg); + const list = el("div", "am-list"); + const foot = el("div", "am-foot"); + const reset = el("button", "am-reset", t("Show all again")); + reset.type = "button"; + foot.append(el("span", "", t("New models are shown")), el("span", "sp"), reset); + box.append(head, tools, list, foot); + + // groups as the catalog has them, routing groups first; a long one + // starts folded, unless the agent is set to a model in it + const groups = []; + for (const m of models) { + let g = groups.find((x) => x.name === m.group); + if (!g) groups.push(g = { name: m.group, icon: m.icon, models: [] }); + g.models.push(m); + } + groups.sort((x, y) => (y.name === ROUTING_GROUPS) - (x.name === ROUTING_GROUPS)); + const shut = new Set(groups.filter((g) => groups.length > 1 && g.models.length > 8 && !g.models.some((m) => m.inUse)).map((g) => g.name)); + // under "Shown", one just turned off stays until the view changes + let onlyShown = false, kept = new Set(); + + const me = agentModels = { a, anchor, box, saving: Promise.resolve(), changed: false }; + const save = () => { + me.changed = true; + const hidden = models.filter((m) => m.hidden).map((m) => m.id); + const count = { shown: models.length - hidden.length, listed: models.length }; + me.count = a.models = count; + fillModelsEntry(me.anchor, a); + me.saving = me.saving.then(() => api("agent-models/" + encodeURIComponent(a.id), { hidden })) + .catch((e) => status(e.message, "err")); + }; + const draw = () => { + const top = list.scrollTop; + list.replaceChildren(); + const words = q.value.trim().toLowerCase(); + for (const g of groups) { + const rows = g.models.filter((m) => (!onlyShown || !m.hidden || kept.has(m.id)) && + (!words || [m.name, m.id, g.name].some((s) => s.toLowerCase().includes(words)))); + if (!rows.length) continue; + const on = g.models.filter((m) => !m.hidden).length; + const folded = !words && shut.has(g.name); + const sec = el("section", "am-g" + (folded ? " shut" : "")); + const gh = el("div", "am-gh"); + const fold = el("button", "am-fold"); + fold.type = "button"; + fold.setAttribute("aria-expanded", String(!folded)); + const tw = el("span", "tw"); + tw.append(svg(CHEV, 10, 1.7)); + fold.append(tw, g.name === ROUTING_GROUPS ? svg(FAN, 14, 1.5) : icon(g.icon || "generic"), + el("span", "gn", g.name === ROUTING_GROUPS ? t(g.name) : g.name), el("span", "c", `${on} / ${g.models.length}`)); + fold.onclick = () => { shut.has(g.name) ? shut.delete(g.name) : shut.add(g.name); draw(); }; + const free = g.models.filter((m) => !m.inUse); + const allOn = free.every((m) => !m.hidden); + const all = el("button", "am-all", allOn ? t("Hide all") : t("Show all")); + all.type = "button"; + all.hidden = !free.length; + all.onclick = () => { + for (const m of free) { m.hidden = allOn; if (allOn) kept.add(m.id); } + save(); + draw(); + }; + gh.append(fold, all); + sec.append(gh); + if (!folded) { + const body = el("div", "am-rows"); + for (const m of rows) { + const r = el("button", "am-mr" + (m.hidden ? " off" : "") + (m.inUse ? " lock" : "")); + r.type = "button"; + r.setAttribute("role", "menuitemcheckbox"); + r.setAttribute("aria-checked", String(!m.hidden)); + r.title = m.inUse ? t("{agent} is set to it, so it stays", { agent: a.name }) : m.id; + const n = el("span", "n", m.name); + if (m.inUse) n.append(el("span", "am-tag", t("Current"))); + const ck = el("span", "ck"); + if (!m.hidden) ck.append(svg("m3.5 8.5 3 3 6-7", 12, 1.9)); + r.append(n, el("span", "x", ctxShort(m.context)), ck); + if (m.inUse) r.setAttribute("aria-disabled", "true"); + else r.onclick = () => { + m.hidden = !m.hidden; + if (m.hidden) kept.add(m.id); + save(); + draw(); + }; + body.append(r); + } + sec.append(body); + } + list.append(sec); + } + if (!list.childNodes.length) list.append(el("div", "am-none", t("No matches."))); + list.scrollTop = top; + reset.disabled = !models.some((m) => m.hidden); + }; + q.oninput = () => { list.scrollTop = 0; draw(); }; + const view = (shown) => { + onlyShown = shown; + kept = new Set(); + segAll.classList.toggle("on", !shown); + segOn.classList.toggle("on", shown); + list.scrollTop = 0; + draw(); + }; + segAll.onclick = () => view(false); + segOn.onclick = () => view(true); + reset.onclick = () => { + for (const m of models) m.hidden = false; + save(); + draw(); + }; + draw(); + + document.body.append(box); + // under the line, or over it where there's no room; the list scrolls + const r = anchor.getBoundingClientRect(), pad = 8; + const w = Math.min(380, innerWidth - pad * 2); + box.style.width = w + "px"; + const x = Math.max(pad, Math.min(r.left - 10, innerWidth - w - pad)); + box.style.left = x + "px"; + box.style.setProperty("--ox", Math.max(18, Math.min(w - 18, r.left + Math.min(r.width, 60) / 2 - x)) + "px"); + const below = innerHeight - r.bottom - 8 - pad, above = r.top - 8 - pad; + const want = Math.min(560, box.scrollHeight); + if (want > below && above > below) { + box.classList.add("up"); + box.style.bottom = innerHeight - r.top + 8 + "px"; + box.style.maxHeight = Math.min(560, above) + "px"; + } else { + box.style.top = r.bottom + 8 + "px"; + box.style.maxHeight = Math.min(560, below) + "px"; + } + anchor.classList.add("open"); + me.outside = (e) => { if (!box.contains(e.target) && !me.anchor.contains(e.target)) closeAgentModels(); }; + me.keys = (e) => { + if (e.key !== "Escape") return; + e.preventDefault(); + e.stopPropagation(); + closeAgentModels(); + me.anchor.focus({ preventScroll: true }); + }; + me.scrolled = (e) => { if (!box.contains(e.target)) closeAgentModels(); }; + document.addEventListener("mousedown", me.outside, true); + document.addEventListener("keydown", me.keys, true); + document.addEventListener("scroll", me.scrolled, true); + addEventListener("resize", closeAgentModels); + q.focus({ preventScroll: true }); +} + let agentMenu = null; function closeAgentMenu() { if (!agentMenu) return; @@ -4667,7 +4903,7 @@ function renderAccounts(a) { } row.append(dot, el("span", "n", l.user), el("span", "plan", accountPlan({ agent: a.agent, plan: l.plan })), el("span", "grow")); if (l.active) { - row.append(el("span", "using", l.paused ? t("Paused") : several ? t("First") : t("In use"))); + row.append(el("span", "using", l.paused ? t("Paused") : several ? t("First") : t("Current"))); if (a.agent === "qoder" || l.own) { const forget = el("button", "text quiet", t("Remove")); if (l.own) forget.title = forgetOwnTitle(a); @@ -5083,7 +5319,7 @@ function renderKeyAccounts(p) { rm.onclick = () => accountAction("keys/remove", { id: p.id, ref: k.id }, t("Key removed")); row.append(rm); } - if (k.active) row.append(el("span", "using", several ? t("First") : t("In use"))); + if (k.active) row.append(el("span", "using", several ? t("First") : t("Current"))); else if (k.on) { const first = el("button", "text", t("Make first")); first.onclick = () => { first.classList.add("busy"); accountAction("keys/use", { id: p.id, ref: k.id }, t("{name} tries {key} first", { name: p.name, key: k.name || k.masked })); }; @@ -8739,6 +8975,7 @@ function show(v) { const back = () => backToReader($("#view-" + v)); requestAnimationFrame(back); closePicker(); + closeAgentModels(); if (v !== "providers" && editing !== null) cancelEdit(); if (v === "providers" || v === "gateway" || v === "routing") loadProviders().then(back, (e) => status(e.message, "err")); if (v === "usage") loadUsage().then(back, (e) => status(e.message, "err")); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 4789f243..a3589002 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -22,6 +22,20 @@ const I18N = { "Install Claude Code, Codex, Gemini CLI, OpenCode… and magpie will list them here.": "安装 Claude Code、Codex、Gemini CLI、OpenCode…… 之后,magpie 会在这里列出它们。", "Show {n} more": "显示其余 {n} 个", "Show less": "收起", + // an agent's model list + "All {n} models": "全部 {n} 个模型", + "Showing {shown} / {listed} models": "显示 {shown} / {listed} 个模型", + "Pick which models {agent} lists": "选择 {agent} 列出哪些模型", + "{agent}'s model list": "{agent} 的模型列表", + "Models turned off don't show in {agent}'s model picker; other agents can still use them.": "关掉的模型不会出现在 {agent} 的模型列表里,别的 Agent 照常能用。", + "Search models": "搜索模型", + "Shown": "已显示", + "Show all": "全部显示", + "Hide all": "全部隐藏", + "Current": "在用", + "{agent} is set to it, so it stays": "{agent} 正在用它,所以不能隐藏", + "New models are shown": "新模型默认显示", + "Show all again": "恢复全部显示", "{n} hidden": "已隐藏 {n} 个", "{n} hidden agents": "已隐藏的 Agent({n} 个)", "Show {n} more ({h} hidden)": "显示其余 {n} 个(含 {h} 个已隐藏)", diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 1f959b2d..9cc1c65b 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -29,6 +29,16 @@ scrolled to its end, in the tray panel (one agent open) and in the window: them and never back up; "Show less", and a row opened and closed, leave what was clicked where it is on every frame. +`agent-models.test.cjs` opens Codex's model list from the line under its +name, in Chromium and WebKit, English and Chinese: the line reads "Showing 8 / +31 models" under the name, opening it moves nothing and puts it on the screen +whole, routing groups come first and OpenRouter's 24 start folded, the model +Codex is set to can't be taken out, a click takes one out at once with the +whole list sent and the count under the name following, a search opens a +folded group, "Shown" keeps one just turned off till the view changes, a +group's "Show all" comes on hover, "Show all again" sends none, and Esc or a +click elsewhere closes it. + `gateway-fold.test.cjs` folds Connect on the Gateway page with the view scrolled: its fields hide, the head keeps the base URL and a copy button, the head stays where it was, and the fold is remembered across a reload. diff --git a/internal/gui/tests/agent-models.test.cjs b/internal/gui/tests/agent-models.test.cjs new file mode 100644 index 00000000..eb0a0b64 --- /dev/null +++ b/internal/gui/tests/agent-models.test.cjs @@ -0,0 +1,166 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// An agent's model list (@ChongkaiX on X: pick which models show in Codex's +// /model): the line under its name counts them and opens the list, where a +// click on a row takes a model out or puts it back, at once. +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: "group:fast", name: "fast", group: "Routing groups", icons: ["openai", "anthropic"] }, + ...["gpt-5.5", "gpt-5.5-mini", "gpt-5.4", "gpt-5.3-codex", "o4", "o4-mini"].map((m, i) => + ({ id: "openai/" + m, name: m.toUpperCase(), group: "OpenAI", icon: "openai", context: i ? 400e3 : 1e6, inUse: i === 0 })), + ...Array.from({ length: 24 }, (_, i) => ({ id: "openrouter/m" + i, name: "Router Model " + i, group: "OpenRouter", icon: "openrouter", context: 128e3, hidden: i > 0 })), +]; + +function fixture(lang) { + const list = models(); + const posts = []; + const count = () => ({ shown: list.filter((m) => !m.hidden).length, listed: list.length }); + const state = () => ({ + agents: [{ + id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", + fields: [{ key: "model", label: "model", value: "magpie/openai/gpt-5.5", options: [{ value: "magpie/openai/gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }] }], + models: count(), + }], + profiles: [], settings: { lang, theme: "light" }, + }); + async function serve(route) { + const req = route.request(); + const url = new URL(req.url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state()); + if (url.pathname === "/api/agent-models/codex") { + if (req.method() === "POST") { + const { hidden } = req.postDataJSON(); + posts.push(hidden); + for (const m of list) m.hidden = hidden.includes(m.id) && !m.inUse; + return json({ models: list, count: count() }); + } + return json({ models: list }); + } + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } }); + 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 route.fulfill({ body: await fs.readFile(file), contentType }); + } + return { serve, posts }; +} + +const W = { + en: { entry: "Showing 8 / 31 models", after: "Showing 7 / 31 models", all: "All 31 models", title: "Codex's model list", current: "Current", shown: "Shown", reset: "Show all again", showAll: "Show all" }, + zh: { entry: "显示 8 / 31 个模型", after: "显示 7 / 31 个模型", all: "全部 31 个模型", title: "Codex 的模型列表", current: "在用", shown: "已显示", reset: "恢复全部显示", showAll: "全部显示" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: an agent's model list`, async (t) => { + const w = W[lang]; + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const page = await browser.newPage({ viewport: { width: 1000, height: 700 } }); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + const fx = fixture(lang); + await page.route("**/*", fx.serve); + 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}-models.png`) }); + } + await browser.close(); + }); + await page.goto("http://magpie.test/"); + const entry = page.locator('.row.agent[data-id="codex"] .ag-models'); + await entry.waitFor(); + assert.equal((await entry.innerText()).trim(), w.entry); + // under the name, not beside it + const name = await page.locator('.row.agent[data-id="codex"] .who .name').boundingBox(); + const eb = await entry.boundingBox(); + assert(eb.y >= name.y + name.height - 2, "the line is under the name"); + + const scroll = () => page.evaluate(() => [scrollY, document.scrollingElement.scrollTop, $("#view-agents").scrollTop]); + const was = await scroll(); + await entry.click(); + const pop = page.locator(".am-pop:not(.leaving)"); + await pop.waitFor(); + assert.deepEqual(await scroll(), was, "opening it moves nothing"); + assert.equal(await pop.locator(".am-t").innerText(), w.title); + // on the screen whole + const pb = await pop.boundingBox(); + assert(pb.x >= 0 && pb.y >= 0 && pb.x + pb.width <= 1000 && pb.y + pb.height <= 700, JSON.stringify(pb)); + assert(await page.evaluate(() => document.activeElement?.closest(".am-search") !== null), "the search has the keys"); + + // routing groups first; the long OpenRouter folded, OpenAI open + const groups = await pop.locator(".am-fold .gn").allInnerTexts(); + assert.equal(groups.length, 3); + assert.equal(groups[1], "OpenAI"); + assert.equal(groups[2], "OpenRouter"); + assert.equal(await pop.locator(".am-g").nth(2).locator(".am-mr").count(), 0, "OpenRouter's 24 start folded"); + assert.equal(await pop.locator(".am-g").nth(1).locator(".am-mr").count(), 6); + assert.equal(await pop.locator(".am-g").nth(2).locator(".c").innerText(), "1 / 24"); + + // the one Codex is set to stays + const cur = pop.locator(".am-mr", { hasText: "GPT-5.5" }).first(); + assert.equal(await cur.locator(".am-tag").innerText(), w.current); + await cur.click({ force: true }); // aria-disabled: Playwright waits for it to be enabled + assert.equal(fx.posts.length, 0, "the model in use can't be taken out"); + assert.equal(await cur.locator(".ck svg").count(), 1); + + // a click takes one out at once, the count under the name with it + const mini = pop.locator(".am-mr", { hasText: "O4-MINI" }); + await mini.click(); + await page.waitForFunction(() => document.querySelector(".ag-models")?.innerText.includes("7")); + assert.equal((await entry.innerText()).trim(), w.after); + await page.waitForTimeout(150); + assert.equal(fx.posts.length, 1); + assert(fx.posts[0].includes("openai/o4-mini") && fx.posts[0].includes("openrouter/m5"), "the whole list goes"); + assert.equal(await mini.locator(".ck svg").count(), 0); + assert(await mini.evaluate((e) => e.classList.contains("off"))); + assert.equal(await pop.locator(".am-g").nth(1).locator(".c").innerText(), "5 / 6"); + + // a search opens a folded group to what it finds + const q = pop.locator(".am-search input"); + await q.fill("router model 7"); + assert.equal(await pop.locator(".am-mr").count(), 1); + await q.fill(""); + + // "Shown": only those shown, one turned off there stays till it changes + await pop.locator(".am-seg button", { hasText: w.shown }).click(); + assert.equal(await pop.locator(".am-mr").count(), 1 + 5 + 0, "routing group, OpenAI's 5; OpenRouter folded"); + await pop.locator(".am-mr", { hasText: "O4" }).first().click(); + assert.equal(await pop.locator(".am-mr").count(), 6); + + // the group's "show all" comes on hover + const gh = pop.locator(".am-gh").nth(2); + assert.equal(await gh.locator(".am-all").evaluate((e) => getComputedStyle(e).opacity), "0"); + await gh.hover(); + await page.waitForTimeout(250); + assert.equal(await gh.locator(".am-all").evaluate((e) => getComputedStyle(e).opacity), "1"); + assert.equal(await gh.locator(".am-all").innerText(), w.showAll); + + // back to all shown + await pop.locator(".am-reset").click(); + await page.waitForTimeout(150); + assert.deepEqual(fx.posts.at(-1), []); + assert.equal((await entry.innerText()).trim(), w.all); + assert(await pop.locator(".am-reset").isDisabled()); + + // Esc closes it; so does a click elsewhere + await page.keyboard.press("Escape"); + await pop.waitFor({ state: "detached" }); + await entry.click(); + await pop.waitFor(); + await page.mouse.click(900, 650); + await pop.waitFor({ state: "detached" }); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/codex_models.go b/internal/provider/codex_models.go index 6c0c43ef..8fd76c5f 100644 --- a/internal/provider/codex_models.go +++ b/internal/provider/codex_models.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "net/http" "net/url" "os" @@ -19,6 +20,7 @@ import ( "time" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/codexcat" "github.com/yetone/magpie/internal/proc" ) @@ -276,6 +278,35 @@ func CodexListed() []catalog.Model { }) } +// CodexNativeHidden is the ChatGPT account's own model slugs the user took +// out of Codex's list (HiddenModels): the backend lists them, and the +// gateway drops them from its /models answer as it does the ones not picked. +func CodexNativeHidden() map[string]bool { + off := HiddenModels("codex") + if len(off) == 0 { + return nil + } + out := map[string]bool{} + for _, e := range Catalog() { + if off[e.ID] && e.Group == "" && e.Provider.Account != nil && e.Provider.Account.Agent == "codex" { + out[e.Model] = true + } + } + return out +} + +// CodexListTag names the list Codex is handed, for its ETag: magpie's models +// and the account's own taken out of it, so either changing has Codex ask +// for the list again. +func CodexListTag() string { + ms := CodexListed() + off := slices.Sorted(maps.Keys(CodexNativeHidden())) + for _, slug := range off { + ms = append(ms, catalog.Model{ID: "-" + slug}) + } + return codexcat.Tag(ms) +} + // CodexNativePicked is the set of the ChatGPT account's own model slugs the // user kept, and whether they narrowed that list at all. The backend lists // every model the account can reach; when the user has picked among them on diff --git a/internal/provider/hidden_models_test.go b/internal/provider/hidden_models_test.go new file mode 100644 index 00000000..776fea68 --- /dev/null +++ b/internal/provider/hidden_models_test.go @@ -0,0 +1,86 @@ +package provider + +import ( + "slices" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/settings" +) + +func ids(es []Entry) []string { + var out []string + for _, e := range es { + out = append(out, e.ID) + } + return out +} + +// Models taken out of one agent's lists one by one: gone from its catalog, +// every other agent's untouched, a new model shown, and the agents told. +func TestHiddenModels(t *testing.T) { + isolate(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + for _, id := range []string{"relay", "other"} { + if err := Save(Provider{ID: id, Name: "My " + id, Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1", "m2"}}); err != nil { + t.Fatal(err) + } + } + told := 0 + was := catalog.Changed + catalog.Changed = func() { told++ } + t.Cleanup(func() { catalog.Changed = was }) + + all, _ := CatalogFor("codex") + if err := SetHiddenModels("Codex", []string{" relay/m2 ", "other/m1", "relay/m2", ""}); err != nil { + t.Fatal(err) + } + if got := settings.Load().HiddenModels["codex"]; !slices.Equal(got, []string{"other/m1", "relay/m2"}) { + t.Fatalf("saved %v", got) + } + shown, hidden := CatalogFor("codex") + if slices.Contains(ids(shown), "relay/m2") || slices.Contains(ids(shown), "other/m1") || + len(shown) != len(all)-2 || !slices.Equal(slices.Sorted(slices.Values(ids(hidden))), []string{"other/m1", "relay/m2"}) { + t.Fatalf("shown %v hidden %v", ids(shown), ids(hidden)) + } + // what it may pick among is still every one + if listed, _ := ListedFor("codex"); len(listed) != len(all) { + t.Fatalf("listed %v", ids(listed)) + } + if other, _ := CatalogFor("claude"); len(other) != len(all) { + t.Fatalf("another agent's list narrowed: %v", ids(other)) + } + if told != 1 { + t.Fatalf("agents told %d times", told) + } + // the same again changes nothing and tells no one + if err := SetHiddenModels("codex", []string{"relay/m2", "other/m1"}); err != nil || told != 1 { + t.Fatalf("%v told %d", err, told) + } + // a model new to a provider is shown + if err := Save(Provider{ID: "relay", Name: "My relay", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1", "m2", "m3"}}); err != nil { + t.Fatal(err) + } + if shown, _ := CatalogFor("codex"); !slices.Contains(ids(shown), "relay/m3") { + t.Fatalf("new model not shown: %v", ids(shown)) + } + // a renamed provider's hidden models follow it + if err := Rename("relay", "fast"); err != nil { + t.Fatal(err) + } + if got := settings.Load().HiddenModels["codex"]; !slices.Contains(got, "fast/m2") || slices.Contains(got, "relay/m2") { + t.Fatalf("after rename %v", got) + } + // none puts every one back + if err := SetHiddenModels("codex", nil); err != nil { + t.Fatal(err) + } + if _, ok := settings.Load().HiddenModels["codex"]; ok { + t.Fatal("empty list kept") + } + if _, hidden := CatalogFor("codex"); len(hidden) != 0 { + t.Fatalf("still hidden %v", ids(hidden)) + } +} diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index bd11ea5a..7f879ba3 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -252,7 +252,15 @@ func vendorSees(p *Provider, model string) (bool, bool) { func renameModelPrefs(s *settings.Settings, from, to string) bool { named := renameKeys(s.ModelNames, from, to) imaged := renameKeys(s.ModelImages, from, to) - return renameKeys(s.ModelEfforts, from, to) || named || imaged + hidden := false + for _, ids := range s.HiddenModels { + for i, id := range ids { + if rest, ok := strings.CutPrefix(id, from+"/"); ok { + ids[i], hidden = to+"/"+rest, true + } + } + } + return renameKeys(s.ModelEfforts, from, to) || named || imaged || hidden } func renameKeys[V any](m map[string]V, from, to string) bool { diff --git a/internal/provider/visible.go b/internal/provider/visible.go index 7d840153..ca0af7a0 100644 --- a/internal/provider/visible.go +++ b/internal/provider/visible.go @@ -1,9 +1,11 @@ package provider import ( + "errors" "slices" "strings" + "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/settings" ) @@ -44,8 +46,24 @@ func Shows(names []string, e Entry) bool { } // CatalogFor is the catalog as agent is shown it, and what is kept from it -// (none when its lists aren't narrowed). +// (none when its lists aren't narrowed): its visibility's, less the models +// taken out of its lists one by one (HiddenModels). func CatalogFor(agent string) (shown, hidden []Entry) { + listed, hidden := ListedFor(agent) + off := HiddenModels(agent) + for _, e := range listed { + if off[e.ID] { + hidden = append(hidden, e) + } else { + shown = append(shown, e) + } + } + return shown, hidden +} + +// ListedFor is the catalog agent's visibility gives it, the models it may +// pick to show or not among, and what the visibility keeps from it. +func ListedFor(agent string) (listed, kept []Entry) { all := Catalog() names, ok := VisibleTo(agent) if !ok { @@ -53,12 +71,56 @@ func CatalogFor(agent string) (shown, hidden []Entry) { } for _, e := range all { if Shows(names, e) { - shown = append(shown, e) + listed = append(listed, e) } else { - hidden = append(hidden, e) + kept = append(kept, e) } } - return shown, hidden + return listed, kept +} + +// HiddenModels are the ids of the entries taken out of agent's lists. +func HiddenModels(agent string) map[string]bool { + ids := settings.Load().HiddenModels[strings.ToLower(agent)] + out := make(map[string]bool, len(ids)) + for _, id := range ids { + out[id] = true + } + return out +} + +// SetHiddenModels takes these entries out of agent's lists, and puts back +// every other; none shows it every model its visibility gives it. The +// agents that keep the models in files of their own are told. +func SetHiddenModels(agent string, ids []string) error { + agent = strings.ToLower(strings.TrimSpace(agent)) + if agent == "" { + return errors.New("no agent") + } + var keep []string + for _, id := range ids { + if id = strings.TrimSpace(id); id != "" && !slices.Contains(keep, id) { + keep = append(keep, id) + } + } + slices.Sort(keep) + s := settings.Load() + if slices.Equal(s.HiddenModels[agent], keep) { + return nil + } + if len(keep) == 0 { + delete(s.HiddenModels, agent) + } else { + if s.HiddenModels == nil { + s.HiddenModels = map[string][]string{} + } + s.HiddenModels[agent] = keep + } + if err := settings.Save(s); err != nil { + return err + } + catalog.Touched() + return nil } // Families are the families providers and groups are tagged with, sorted. diff --git a/internal/settings/settings.go b/internal/settings/settings.go index d3547bee..8dbed49c 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -118,6 +118,10 @@ type Settings struct { // families (the tag a provider or group is given), provider ids and // group ids its lists hold. An agent it doesn't name is shown them all. Visible map[string][]string `json:"visible,omitempty"` + // HiddenModels are the catalog entries (their ids, "/" + // or a group's) taken out of an agent's lists one by one, by agent id, + // after Visible: a model not named here, a new one among them, is shown. + HiddenModels map[string][]string `json:"hiddenModels,omitempty"` // ModelNames are the names the user gave models, by "/": agents, the gateway's model list and magpie itself // show them for the vendor's (see provider.SetModelName).