agents: the 「接入」 boards filled in — the opened row as designed, real accounts tried
- The opened row follows the boards: what to do in the agent, running copies with the old list, Claude Code's tiers (each with its own effort beside its model again, #536), the model list by whose models they are with what's hidden, the model new sessions start on, what connecting costs (Gemini's Google sign-in), and the files put back on disconnect, shown line by line. - Disconnecting asks first and shows the lines it puts back. Secrets are masked, and a config of tens of thousands of lines diffs in a fraction of a second. - Connecting prefers the agent's own subscription when magpie serves it (Claude, Codex). - The dry run goes through proc. - Every model hidden still keeps the row connected, with the way back to the list (#356). - Routing groups are named in Chinese. - Phone widths: the pickers and squares wrap inside the opened row, and the name stays beside its logo. GUI tests moved to the new rows: connected fixtures carry refs and open the row from its link. tier-effort and mobile-web each found a real gap and now guard the fix. Tried in a sandbox HOME with the owner's real accounts: - Claude Code: connected, a request answered, and disconnect restored the settings exactly. - OpenCode: a request answered. - Gemini CLI: connected. - Codex: connected. - Model list picks were saved. go vet, builds (darwin, GOOS=windows, GOOS=linux -tags nogui) and go test -tags nogui ./... under a temp HOME pass. The agent GUI tests pass except cli-update's tight-row case, menu-scroll and old-webkit, which fail on main too.
This commit is contained in:
1 parent
4c7ab667e5
commit
d0d098c69f
39 files changed
+1860
-242
No files matched your search
@@ -55,6 +55,9 @@ type Option struct {
|
||||
|
||||
// own: served on the agent's own sign-in (viaMagpie), for Same
|
||||
own bool
|
||||
// sub: served on a subscription signed in in magpie, not a key; a
|
||||
// Claude account only for Claude Code, which alone may use it safely
|
||||
sub bool
|
||||
}
|
||||
|
||||
// Field is one tunable setting of an agent. Set with an empty value puts
|
||||
|
||||
+98
-21
@@ -295,32 +295,91 @@ func (a *Agent) Connect() error {
|
||||
if len(a.Fields) == 0 || a.Wired() {
|
||||
return nil
|
||||
}
|
||||
f := a.Fields[0]
|
||||
if f.Options == nil {
|
||||
return fmt.Errorf("%s can't be connected to magpie", a.Name)
|
||||
}
|
||||
// the field magpie is picked in: the one listing magpie's models (for
|
||||
// Gemini CLI its model, its first field being how it signs in), else one
|
||||
// whose value is magpie itself
|
||||
vals := a.Values()
|
||||
cur, _, _ := a.split(vals[f.Key])
|
||||
if i := strings.LastIndex(cur, "/"); i >= 0 {
|
||||
cur = cur[i+1:]
|
||||
}
|
||||
pick, same := "", ""
|
||||
for _, o := range f.Options(vals) {
|
||||
switch {
|
||||
case o.Value == magpieID:
|
||||
return a.Apply(f.Key, magpieID)
|
||||
case o.Ref == "" || o.Group == RoutingGroups:
|
||||
case pick == "":
|
||||
pick = o.Value
|
||||
fallthrough
|
||||
case same == "" && cur != "" && strings.HasSuffix(o.Ref, "/"+cur):
|
||||
if strings.HasSuffix(o.Ref, "/"+cur) {
|
||||
same = o.Value
|
||||
var f *Field
|
||||
var opts []Option
|
||||
for pass := 0; pass < 2 && f == nil; pass++ {
|
||||
for i := range a.Fields {
|
||||
if a.Fields[i].Options == nil {
|
||||
continue
|
||||
}
|
||||
list := a.Fields[i].Options(vals)
|
||||
for _, o := range list {
|
||||
if pass == 0 && o.Ref != "" || pass == 1 && o.Value == magpieID {
|
||||
f, opts = &a.Fields[i], list
|
||||
break
|
||||
}
|
||||
}
|
||||
if f != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if same != "" {
|
||||
if f == nil {
|
||||
return fmt.Errorf("%s can't be connected to magpie", a.Name)
|
||||
}
|
||||
cur := connectWas(vals[f.Key], opts)
|
||||
if cur == "" && f.Key != a.Fields[0].Key {
|
||||
cur = connectWas(a.Values()[a.Fields[0].Key], nil)
|
||||
}
|
||||
if cur == "" {
|
||||
// on its default: the first of its own models
|
||||
for _, o := range opts {
|
||||
if o.Ref == "" && o.Group == a.Name && o.Value != magpieID {
|
||||
cur = connectWas(o.Value, opts)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// the model it is on now, through magpie (or of its family, for an
|
||||
// alias): on the account it is signed in to (its vendor's) first, then
|
||||
// on a subscription, before a key's; else its own vendor's; else a
|
||||
// subscription's; else the first
|
||||
var pick, same, alike, own, sub string
|
||||
sameRank, alikeRank := -1, -1
|
||||
for _, o := range opts {
|
||||
if o.Value == magpieID {
|
||||
return a.Apply(f.Key, magpieID)
|
||||
}
|
||||
if o.Ref == "" || o.Group == RoutingGroups {
|
||||
continue
|
||||
}
|
||||
rank := 0
|
||||
if o.own || o.Group == a.Name {
|
||||
rank = 2
|
||||
} else if o.sub {
|
||||
rank = 1
|
||||
}
|
||||
if pick == "" {
|
||||
pick = o.Value
|
||||
}
|
||||
id := o.Ref[strings.LastIndex(o.Ref, "/")+1:]
|
||||
if cur != "" && (id == cur || strings.TrimSuffix(id, "[1m]") == cur) && rank > sameRank {
|
||||
same, sameRank = o.Value, rank
|
||||
}
|
||||
// an alias (opus) no label read: the model of that family
|
||||
if cur != "" && !strings.Contains(cur, "-") && strings.Contains(id+"-", "-"+cur+"-") && rank > alikeRank {
|
||||
alike, alikeRank = o.Value, rank
|
||||
}
|
||||
if own == "" && (o.own || o.Group == a.Name) {
|
||||
own = o.Value
|
||||
}
|
||||
if sub == "" && o.sub {
|
||||
sub = o.Value
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case same != "":
|
||||
pick = same
|
||||
case alike != "":
|
||||
pick = alike
|
||||
case own != "":
|
||||
pick = own
|
||||
case sub != "":
|
||||
pick = sub
|
||||
}
|
||||
if pick == "" {
|
||||
return fmt.Errorf("magpie has no models %s can use: add a subscription or a provider first", a.Name)
|
||||
@@ -328,6 +387,24 @@ func (a *Agent) Connect() error {
|
||||
return a.Apply(f.Key, pick)
|
||||
}
|
||||
|
||||
// connectWas is the model an agent is on before it is connected, as a
|
||||
// model id: its own option's alias read through the label that names the
|
||||
// model ("opus · claude-opus-5-5"), a provider's prefix taken off.
|
||||
func connectWas(v string, opts []Option) string {
|
||||
for _, o := range opts {
|
||||
if o.Value == v && o.Ref == "" {
|
||||
if _, id, ok := strings.Cut(o.Label, " · "); ok {
|
||||
v = strings.TrimSpace(id)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if i := strings.LastIndex(v, "/"); i >= 0 {
|
||||
v = v[i+1:]
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// Disconnect takes magpie out of the agent's config and puts back what the
|
||||
// user had, the Agents page's "Disconnect from magpie" (Fate on Discord:
|
||||
// picking the agent's default did it, but nothing said so). Unwire first,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/edit"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
// The 「接入」 switch keeps an agent on the model it was on, now through
|
||||
// magpie: Claude Code on its opus alias goes to the Claude Opus magpie
|
||||
// serves, not to the first model of the first provider.
|
||||
func TestConnectKeepsTheModel(t *testing.T) {
|
||||
home, _ := codexHome(t, "", "")
|
||||
if err := provider.Save(provider.Provider{ID: "aaa", Name: "AAA", Chat: "https://a.example/v1", Key: "k", Models: []string{"first", "claude-opus-5-5"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(home, ".claude", "settings.json")
|
||||
writeFile(t, path, `{"model": "opus"}`)
|
||||
c := claude(home)
|
||||
if err := c.Connect(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m, _ := edit.GetJSON(path, "model"); m != "aaa/claude-opus-5-5" {
|
||||
t.Fatalf("connected on %q:\n%s", m, readFile(path))
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini CLI is connected through its model (its first field is how it
|
||||
// signs in), and Disconnect leaves its settings.json as they were, with
|
||||
// no "model": {} behind.
|
||||
func TestGeminiConnectRoundTrip(t *testing.T) {
|
||||
home, _ := codexHome(t, "", "")
|
||||
if err := provider.Save(provider.Provider{ID: "aaa", Name: "AAA", Chat: "https://a.example/v1", Key: "k", Models: []string{"m1"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Join(home, ".gemini")
|
||||
const was = `{"security": {"auth": {"selectedType": "oauth-personal"}}}`
|
||||
writeFile(t, filepath.Join(dir, "settings.json"), was)
|
||||
g := gemini(home)
|
||||
if err := g.Connect(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !g.Wired() {
|
||||
t.Fatalf("not connected:\n%s", readFile(filepath.Join(dir, "settings.json")))
|
||||
}
|
||||
if err := g.Disconnect(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok := edit.GetJSON(filepath.Join(dir, "settings.json"), "model"); ok {
|
||||
t.Fatalf("model left: %q", v)
|
||||
}
|
||||
if a, _ := edit.GetJSON(filepath.Join(dir, "settings.json"), "security.auth.selectedType"); a != "oauth-personal" {
|
||||
t.Fatalf("sign-in %q", a)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".env")); err == nil {
|
||||
if k, _ := edit.GetEnvFile(filepath.Join(dir, ".env"), "GEMINI_API_KEY"); k != "" {
|
||||
t.Fatalf("magpie's key left: %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Of the providers serving the model the agent is on, Connect takes the
|
||||
// account it is signed in to, then a subscription, over a relay listed
|
||||
// first; on no model of its own, a subscription's before the first.
|
||||
func TestConnectPrefersTheSubscription(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"))
|
||||
for _, c := range []struct {
|
||||
cur string
|
||||
opts []Option
|
||||
want string
|
||||
}{
|
||||
{"claude-opus-5-5", []Option{
|
||||
{Value: "group/auto", Ref: "group/auto", Group: RoutingGroups},
|
||||
{Value: "relay/claude-opus-5-5[1m]", Ref: "relay/claude-opus-5-5[1m]", Group: "Relay"},
|
||||
{Value: "other/claude-opus-5-5", Ref: "other/claude-opus-5-5", Group: "Other", sub: true},
|
||||
{Value: "claude/claude-opus-5-5", Ref: "claude/claude-opus-5-5", Group: "Claude", sub: true, own: true},
|
||||
}, "claude/claude-opus-5-5"},
|
||||
{"opus", []Option{
|
||||
{Value: "relay/claude-opus-5-5[1m]", Ref: "relay/claude-opus-5-5", Group: "Relay"},
|
||||
{Value: "claude/claude-opus-5-5[1m]", Ref: "claude/claude-opus-5-5", Group: "X"},
|
||||
}, "claude/claude-opus-5-5[1m]"},
|
||||
{"claude-opus-5-5", []Option{
|
||||
{Value: "relay/claude-opus-5-5", Ref: "relay/claude-opus-5-5", Group: "Relay"},
|
||||
{Value: "other/claude-opus-5-5", Ref: "other/claude-opus-5-5", Group: "Other", sub: true},
|
||||
}, "other/claude-opus-5-5"},
|
||||
{"", []Option{
|
||||
{Value: "stepfun/step-1", Ref: "stepfun/step-1", Group: "StepFun"},
|
||||
{Value: "claude/claude-sonnet-5-5", Ref: "claude/claude-sonnet-5-5", Group: "Claude", sub: true},
|
||||
}, "claude/claude-sonnet-5-5"},
|
||||
{"", []Option{
|
||||
{Value: "stepfun/step-1", Ref: "stepfun/step-1", Group: "StepFun"},
|
||||
}, "stepfun/step-1"},
|
||||
} {
|
||||
v := c.cur
|
||||
a := &Agent{ID: "x", Name: "X", Fields: []Field{{Key: "model",
|
||||
Get: func() string { return v },
|
||||
Set: func(s string) error { v = s; return nil },
|
||||
Options: func(map[string]string) []Option { return c.opts },
|
||||
}}}
|
||||
if err := a.Connect(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v != c.want {
|
||||
t.Errorf("on %q: connected on %q, want %q", c.cur, v, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/edit"
|
||||
"github.com/yetone/magpie/internal/proc"
|
||||
)
|
||||
|
||||
// What the Agents page says beside an agent's 「接入」 switch (the owner's
|
||||
// design): before it is on, what the agent runs on now — its own
|
||||
// subscription, sign-in or providers — so the user can tell what turning
|
||||
// it on touches; once on, whether copies of it already running still have
|
||||
// the list they started with.
|
||||
|
||||
// Source is what an agent not connected to magpie runs on now: "sub" (a
|
||||
// Claude subscription), "chatgpt" (a ChatGPT sign-in), "key" (an API key
|
||||
// of its own), "providers:N" (N providers of its own); "" when unknown.
|
||||
func (a *Agent) Source() string {
|
||||
if a.WSL != "" || a.Path == "" {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Dir(a.Path)
|
||||
switch a.ID {
|
||||
case "claude":
|
||||
// signed in to claude.ai: ~/.claude.json (or the one in
|
||||
// CLAUDE_CONFIG_DIR) has the account
|
||||
for _, p := range []string{filepath.Join(dir, ".claude.json"), filepath.Join(filepath.Dir(dir), ".claude.json")} {
|
||||
if v, _ := edit.GetJSON(p, "oauthAccount.accountUuid"); v != "" {
|
||||
return "sub"
|
||||
}
|
||||
}
|
||||
if v, _ := edit.GetJSON(a.Path, "env.ANTHROPIC_API_KEY"); v != "" {
|
||||
return "key"
|
||||
}
|
||||
if v, _ := edit.GetJSON(a.Path, "apiKeyHelper"); v != "" {
|
||||
return "key"
|
||||
}
|
||||
case "codex":
|
||||
if codexChatGPT(dir) {
|
||||
return "chatgpt"
|
||||
}
|
||||
if v, _ := edit.GetJSON(filepath.Join(dir, "auth.json"), "OPENAI_API_KEY"); v != "" {
|
||||
return "key"
|
||||
}
|
||||
case "opencode", "mimocode":
|
||||
// the providers in its config, and those signed in through it
|
||||
n := map[string]bool{}
|
||||
var cfg map[string]json.RawMessage
|
||||
if v, _ := edit.GetJSON(a.Path, "provider"); json.Unmarshal([]byte(v), &cfg) == nil {
|
||||
for k := range cfg {
|
||||
n[k] = true
|
||||
}
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
var auth map[string]json.RawMessage
|
||||
if b, err := os.ReadFile(filepath.Join(home, ".local", "share", a.ID, "auth.json")); err == nil && json.Unmarshal(b, &auth) == nil {
|
||||
for k := range auth {
|
||||
n[k] = true
|
||||
}
|
||||
}
|
||||
delete(n, magpieID)
|
||||
if len(n) > 0 {
|
||||
return "providers:" + strconv.Itoa(len(n))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// startsWith is, for an agent that reads magpie's models only as it
|
||||
// starts, how to find its processes (patterns for pgrep -f) and the files
|
||||
// whose change they missed.
|
||||
func (a *Agent) startsWith() (pats []string, files []string) {
|
||||
switch a.ID {
|
||||
case "codex":
|
||||
return []string{`(^|/)codex( |$)`}, []string{a.Path, filepath.Join(filepath.Dir(a.Path), "magpie-models.json")}
|
||||
case "claude":
|
||||
return []string{`(^|/)claude( |$)`}, []string{a.Path}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Stale is how many copies of the agent are running that started before
|
||||
// magpie last changed what it reads at start: they still have the list
|
||||
// they started with until reopened. Zero where it can't be told (Windows).
|
||||
func (a *Agent) Stale() int {
|
||||
pats, files := a.startsWith()
|
||||
if len(pats) == 0 || a.WSL != "" || runtime.GOOS == "windows" {
|
||||
return 0
|
||||
}
|
||||
var changed time.Time
|
||||
for _, f := range files {
|
||||
if st, err := os.Stat(f); err == nil && st.ModTime().After(changed) {
|
||||
changed = st.ModTime()
|
||||
}
|
||||
}
|
||||
if changed.IsZero() {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, pat := range pats {
|
||||
out, _ := proc.Command("pgrep", "-f", pat).Output()
|
||||
pids := strings.Fields(string(out))
|
||||
if len(pids) == 0 {
|
||||
continue
|
||||
}
|
||||
out, _ = proc.Command("ps", "-o", "etime=", "-p", strings.Join(pids, ",")).Output()
|
||||
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if up, ok := elapsed(l); ok && time.Now().Add(-up).Before(changed.Add(-time.Second)) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// elapsed reads ps's etime, [[dd-]hh:]mm:ss.
|
||||
func elapsed(s string) (time.Duration, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
var days int
|
||||
if d, rest, ok := strings.Cut(s, "-"); ok {
|
||||
n, err := strconv.Atoi(d)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
days, s = n, rest
|
||||
}
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
return 0, false
|
||||
}
|
||||
secs := 0
|
||||
for _, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
secs = secs*60 + n
|
||||
}
|
||||
return time.Duration(days)*24*time.Hour + time.Duration(secs)*time.Second, true
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func gemini(home string) *Agent {
|
||||
if m := unstash("gemini.model"); m != "" {
|
||||
return edit.SetJSON(path, edit.KV{Path: "model.name", Value: m})
|
||||
}
|
||||
return edit.DelJSON(path, "model.name")
|
||||
return delModelName(path)
|
||||
}
|
||||
setModel := func(v string) error {
|
||||
if v == "" {
|
||||
@@ -77,9 +77,11 @@ func gemini(home string) *Agent {
|
||||
if err := edit.DelEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"); err != nil {
|
||||
return err
|
||||
}
|
||||
return edit.DelJSON(path, "security.auth.selectedType", "model.name")
|
||||
if err := edit.DelJSON(path, "security.auth.selectedType"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return edit.DelJSON(path, "model.name")
|
||||
return delModelName(path)
|
||||
}
|
||||
if isMagpie(v) {
|
||||
if !routed() {
|
||||
@@ -245,3 +247,16 @@ func gemini(home string) *Agent {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// delModelName takes model.name out of Gemini CLI's settings.json, and the
|
||||
// model it was in once nothing else is there: the "model": {} it would
|
||||
// leave was never the user's.
|
||||
func delModelName(path string) error {
|
||||
if err := edit.DelJSON(path, "model.name"); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, _ := edit.GetJSON(path, "model"); strings.TrimSpace(strings.Trim(strings.TrimSpace(v), "{}")) == "" && strings.HasPrefix(strings.TrimSpace(v), "{") {
|
||||
return edit.DelJSON(path, "model")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/agentenv"
|
||||
"github.com/yetone/magpie/internal/appdir"
|
||||
"github.com/yetone/magpie/internal/proc"
|
||||
)
|
||||
|
||||
// Before an agent is disconnected the user is shown what changes in its
|
||||
// files, line by line (the owner's 断开 design: 删除、改回、保留), not a
|
||||
// sentence about it. What Disconnect does depends on the stash, the record
|
||||
// and each agent's own unwiring, so rather than a second account of it that
|
||||
// could say otherwise, magpie runs Disconnect itself on a copy: the agent's
|
||||
// config folder and magpie's own files under a temporary home, in a process
|
||||
// of its own (DryRunArg), and compares the copy with the files as they are.
|
||||
|
||||
// DryRunArg is the command line's first word for the process that
|
||||
// disconnects an agent on a copy of its files (main.go).
|
||||
const DryRunArg = "agent-disconnect-dry-run"
|
||||
|
||||
// FileChange is how disconnecting changes one of the agent's files.
|
||||
type FileChange struct {
|
||||
Path string `json:"path"` // as the user knows it, ~ for home
|
||||
Removed bool `json:"removed,omitempty"` // the file goes
|
||||
Created bool `json:"created,omitempty"` // the file is made
|
||||
Lines []LineDiff `json:"lines,omitempty"`
|
||||
}
|
||||
|
||||
// LineDiff is a line of a file disconnecting changes: Op is "-" for a line
|
||||
// taken out, "+" for one put in, "~" for a line whose value goes back to
|
||||
// another (Was is the line now).
|
||||
type LineDiff struct {
|
||||
Op string `json:"op"`
|
||||
Text string `json:"text"`
|
||||
Was string `json:"was,omitempty"`
|
||||
}
|
||||
|
||||
// DisconnectPreview is what Disconnect would change in the agent's files,
|
||||
// found by running it on a copy. An error is a preview not to be had (the
|
||||
// agent's files outside home, a WSL agent): the dialog then says it in
|
||||
// words.
|
||||
func DisconnectPreview(a *Agent, exe string) ([]FileChange, error) {
|
||||
if a.WSL != "" || a.Path == "" {
|
||||
return nil, errors.New("no preview for this agent")
|
||||
}
|
||||
if appdir.Portable() != "" {
|
||||
return nil, errors.New("no preview for a portable magpie")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel := func(p string) (string, bool) {
|
||||
r, err := filepath.Rel(home, p)
|
||||
return r, err == nil && r != "." && !strings.HasPrefix(r, "..")
|
||||
}
|
||||
dir := filepath.Dir(a.Path)
|
||||
dirRel, ok := rel(dir)
|
||||
cfg := appdir.Config()
|
||||
cfgRel, ok2 := rel(cfg)
|
||||
if !ok || !ok2 {
|
||||
return nil, errors.New("the agent's files are outside home")
|
||||
}
|
||||
tmp, err := os.MkdirTemp("", "magpie-preview-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
tmp, _ = filepath.EvalSymlinks(tmp)
|
||||
// the agent's folder: its files, copied; its folders aren't its config
|
||||
before := map[string][]byte{}
|
||||
if err := copyTop(dir, filepath.Join(tmp, dirRel), before, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// magpie's: its files copied (the stash, the record, the providers),
|
||||
// its folders (plugins) only read, so linked
|
||||
if err := copyTop(cfg, filepath.Join(tmp, cfgRel), nil, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := previewEnv(home, tmp)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
cmd := proc.CommandContext(ctx, exe, DryRunArg, a.ID)
|
||||
cmd.Env = env
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("dry run: %v: %s", err, strings.TrimSpace(out.String()))
|
||||
}
|
||||
// what the agent's folder holds now against what it held
|
||||
var changes []FileChange
|
||||
copied := filepath.Join(tmp, dirRel)
|
||||
after := map[string][]byte{}
|
||||
entries, _ := os.ReadDir(copied)
|
||||
for _, e := range entries {
|
||||
if e.Type().IsRegular() {
|
||||
if b, err := os.ReadFile(filepath.Join(copied, e.Name())); err == nil {
|
||||
after[e.Name()] = b
|
||||
}
|
||||
}
|
||||
}
|
||||
shown := func(name string) string { return "~/" + filepath.ToSlash(filepath.Join(dirRel, name)) }
|
||||
// the agent's own file first, then the others by name
|
||||
names := []string{filepath.Base(a.Path)}
|
||||
for n := range before {
|
||||
if n != names[0] {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
for n := range after {
|
||||
if _, ok := before[n]; !ok {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
sortTail(names)
|
||||
for _, n := range names {
|
||||
was, had := before[n]
|
||||
now, has := after[n]
|
||||
switch {
|
||||
case had && !has:
|
||||
changes = append(changes, FileChange{Path: shown(n), Removed: true})
|
||||
case !had && has:
|
||||
changes = append(changes, FileChange{Path: shown(n), Created: true, Lines: diffLines("", string(now))})
|
||||
case had && has && !bytes.Equal(was, now):
|
||||
if l := diffLines(string(was), string(now)); len(l) > 0 {
|
||||
changes = append(changes, FileChange{Path: shown(n), Lines: l})
|
||||
}
|
||||
}
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// sortTail sorts all but the first name.
|
||||
func sortTail(names []string) {
|
||||
for i := 2; i < len(names); i++ {
|
||||
for j := i; j > 1 && names[j] < names[j-1]; j-- {
|
||||
names[j], names[j-1] = names[j-1], names[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copyTop copies a folder's files into to, keeping what they held in
|
||||
// before when it is given; with link, its folders are linked there.
|
||||
func copyTop(from, to string, before map[string][]byte, link bool) error {
|
||||
if err := os.MkdirAll(to, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(from)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
const most = 16 << 20
|
||||
for _, e := range entries {
|
||||
p := filepath.Join(from, e.Name())
|
||||
st, err := os.Stat(p) // a linked file is its target
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if st.IsDir() {
|
||||
if link {
|
||||
os.Symlink(p, filepath.Join(to, e.Name()))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !st.Mode().IsRegular() || st.Size() > most || notConfig(e.Name()) {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(to, e.Name()), b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if before != nil {
|
||||
before[e.Name()] = b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// notConfig is a file no agent keeps its settings in: a database, a log, a
|
||||
// history, which can be large and which Disconnect never writes.
|
||||
func notConfig(name string) bool {
|
||||
for _, ext := range []string{".sqlite", ".sqlite-wal", ".sqlite-shm", ".db", ".db-wal", ".db-shm", ".jsonl", ".log"} {
|
||||
if strings.HasSuffix(name, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// previewEnv is this process's environment with home, and the folders under
|
||||
// it, moved to the copy.
|
||||
func previewEnv(home, tmp string) []string {
|
||||
moved := map[string]bool{"HOME": true, "USERPROFILE": true, "XDG_CONFIG_HOME": true, "XDG_DATA_HOME": true, "XDG_STATE_HOME": true, "APPDATA": true, "LOCALAPPDATA": true}
|
||||
for _, k := range agentenv.Vars {
|
||||
moved[k] = true
|
||||
}
|
||||
var out []string
|
||||
for _, kv := range os.Environ() {
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
if !moved[k] {
|
||||
out = append(out, kv)
|
||||
continue
|
||||
}
|
||||
if k == "HOME" || k == "USERPROFILE" {
|
||||
continue
|
||||
}
|
||||
// a folder under home is the same one under the copy's; one
|
||||
// elsewhere isn't copied, so it is left out
|
||||
if r, err := filepath.Rel(home, v); err == nil && !strings.HasPrefix(r, "..") {
|
||||
out = append(out, k+"="+filepath.Join(tmp, r))
|
||||
}
|
||||
}
|
||||
return append(out, "HOME="+tmp, "USERPROFILE="+tmp)
|
||||
}
|
||||
|
||||
// diffLines is the lines that differ between two texts, in the order of the
|
||||
// new one: a line taken out, one put in, and a line whose key stays with its
|
||||
// value changed (model = "x" → model = "y") as one going back. Values that
|
||||
// read as secrets are masked. Between the lines both start and end with,
|
||||
// texts too far apart to compare line by line (OpenCode's table of
|
||||
// thousands of magpie models taken out) keep the lines of the new one
|
||||
// found in order in the old.
|
||||
func diffLines(a, b string) []LineDiff {
|
||||
x, y := splitLines(a), splitLines(b)
|
||||
pre := 0
|
||||
for pre < len(x) && pre < len(y) && x[pre] == y[pre] {
|
||||
pre++
|
||||
}
|
||||
suf := 0
|
||||
for suf < len(x)-pre && suf < len(y)-pre && x[len(x)-1-suf] == y[len(y)-1-suf] {
|
||||
suf++
|
||||
}
|
||||
keepX, keepY := make([]bool, len(x)), make([]bool, len(y))
|
||||
for i := 0; i < pre; i++ {
|
||||
keepX[i], keepY[i] = true, true
|
||||
}
|
||||
for i := 1; i <= suf; i++ {
|
||||
keepX[len(x)-i], keepY[len(y)-i] = true, true
|
||||
}
|
||||
mx, my := x[pre:len(x)-suf], y[pre:len(y)-suf]
|
||||
if kx, ky, ok := sameLines(mx, my, 4000); ok {
|
||||
copy(keepX[pre:], kx)
|
||||
copy(keepY[pre:], ky)
|
||||
} else if len(mx)*len(my) <= 50_000_000 {
|
||||
// each line of the one, in order, kept where the other has it
|
||||
// next: what is left of a file once a block is taken out of it
|
||||
// (past that, all of it taken out and put in)
|
||||
i := 0
|
||||
for j := range my {
|
||||
for k := i; k < len(mx); k++ {
|
||||
if mx[k] == my[j] {
|
||||
keepX[pre+k], keepY[pre+j] = true, true
|
||||
i = k + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []LineDiff
|
||||
var gone, came []string
|
||||
flush := func() {
|
||||
// a line taken out and one put in under the same key are one
|
||||
// going back
|
||||
for _, c := range came {
|
||||
k := lineKey(c)
|
||||
paired := false
|
||||
for i, g := range gone {
|
||||
if k != "" && lineKey(g) == k {
|
||||
out = append(out, LineDiff{Op: "~", Text: mask(c), Was: mask(g)})
|
||||
gone = append(gone[:i], gone[i+1:]...)
|
||||
paired = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !paired {
|
||||
out = append(out, LineDiff{Op: "+", Text: mask(c)})
|
||||
}
|
||||
}
|
||||
for _, g := range gone {
|
||||
out = append(out, LineDiff{Op: "-", Text: mask(g)})
|
||||
}
|
||||
gone, came = nil, nil
|
||||
}
|
||||
i, j := 0, 0
|
||||
for i < len(x) || j < len(y) {
|
||||
switch {
|
||||
case i < len(x) && j < len(y) && keepX[i] && keepY[j]:
|
||||
flush()
|
||||
i++
|
||||
j++
|
||||
case i < len(x) && !keepX[i]:
|
||||
if strings.TrimSpace(x[i]) != "" {
|
||||
gone = append(gone, x[i])
|
||||
}
|
||||
i++
|
||||
default:
|
||||
if strings.TrimSpace(y[j]) != "" {
|
||||
came = append(came, y[j])
|
||||
}
|
||||
j++
|
||||
}
|
||||
}
|
||||
flush()
|
||||
// a bracket or brace left alone on its line says nothing
|
||||
var kept []LineDiff
|
||||
for _, l := range out {
|
||||
if t := strings.Trim(strings.TrimSpace(l.Text), "{}[](),"); t == "" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, l)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// sameLines is which lines of x and of y a shortest edit keeps (Myers'
|
||||
// diff: time and memory grow with the edits, not the files — an agent's
|
||||
// config can be 30,000 lines, and magpie changes a few). Not ok past most
|
||||
// edits.
|
||||
func sameLines(x, y []string, most int) (keepX, keepY []bool, ok bool) {
|
||||
n, m := len(x), len(y)
|
||||
if most > n+m {
|
||||
most = n + m
|
||||
}
|
||||
off := most + 1
|
||||
v := make([]int, 2*off+1)
|
||||
// trace[d] is v as step d found it, for k in -d-1..d+1
|
||||
var trace [][]int
|
||||
get := func(d, k int) int { return trace[d][k+d+1] }
|
||||
for d := 0; d <= most; d++ {
|
||||
trace = append(trace, append([]int(nil), v[off-d-1:off+d+2]...))
|
||||
for k := -d; k <= d; k += 2 {
|
||||
var xi int
|
||||
if k == -d || (k != d && v[off+k-1] < v[off+k+1]) {
|
||||
xi = v[off+k+1]
|
||||
} else {
|
||||
xi = v[off+k-1] + 1
|
||||
}
|
||||
yi := xi - k
|
||||
for xi < n && yi < m && x[xi] == y[yi] {
|
||||
xi++
|
||||
yi++
|
||||
}
|
||||
v[off+k] = xi
|
||||
if xi < n || yi < m {
|
||||
continue
|
||||
}
|
||||
// back from the end, marking the diagonals walked
|
||||
keepX, keepY = make([]bool, n), make([]bool, m)
|
||||
xi, yi = n, m
|
||||
for e := d; e >= 0; e-- {
|
||||
k := xi - yi
|
||||
var pk int
|
||||
if k == -e || (k != e && get(e, k-1) < get(e, k+1)) {
|
||||
pk = k + 1
|
||||
} else {
|
||||
pk = k - 1
|
||||
}
|
||||
px := get(e, pk)
|
||||
py := px - pk
|
||||
for xi > px && yi > py && xi > 0 && yi > 0 {
|
||||
keepX[xi-1], keepY[yi-1] = true, true
|
||||
xi--
|
||||
yi--
|
||||
}
|
||||
xi, yi = px, py
|
||||
}
|
||||
return keepX, keepY, true
|
||||
}
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(strings.TrimRight(strings.ReplaceAll(s, "\r\n", "\n"), "\n"), "\n")
|
||||
}
|
||||
|
||||
// lineKey is what a line sets: TOML's and .env's key before =, JSON's and
|
||||
// YAML's before :, "" for a line that sets nothing.
|
||||
var keyRe = regexp.MustCompile(`^\s*(?:export\s+)?"?([A-Za-z0-9_.\-]+)"?\s*[:=]`)
|
||||
|
||||
func lineKey(l string) string {
|
||||
if m := keyRe.FindStringSubmatch(l); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
if t := strings.TrimSpace(l); strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var secretKey = regexp.MustCompile(`(?i)(key|token|secret|password|auth)`)
|
||||
|
||||
// mask hides the value of a line whose key reads as a secret's; magpie's
|
||||
// own token, which is no secret, stays.
|
||||
func mask(l string) string {
|
||||
k := lineKey(l)
|
||||
if k == "" || !secretKey.MatchString(k) {
|
||||
return l
|
||||
}
|
||||
m := keyRe.FindStringIndex(l)
|
||||
val := strings.TrimSpace(l[m[1]:])
|
||||
bare := strings.Trim(val, `"', `)
|
||||
if bare == "" || bare == "magpie" || strings.HasPrefix(bare, "{") || strings.HasPrefix(bare, "$") {
|
||||
return l
|
||||
}
|
||||
return l[:m[1]] + " ••••"
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The lines Disconnect puts back, as the disconnect dialog shows them: a
|
||||
// line taken out, one put in, a value going back; secrets masked; quick on
|
||||
// a config of tens of thousands of lines (a Codex config.toml with its
|
||||
// [projects]).
|
||||
func TestDiffLines(t *testing.T) {
|
||||
var big strings.Builder
|
||||
for i := 0; i < 30000; i++ {
|
||||
fmt.Fprintf(&big, "[projects.\"/p/%d\"]\ntrust_level = \"trusted\"\n", i)
|
||||
}
|
||||
before := "model = \"magpie/deepseek/pro\"\nmodel_provider = \"magpie\"\napi_key = \"sk-live-123\"\n" + big.String() + "[model_providers.magpie]\nname = \"magpie\"\n"
|
||||
after := "model = \"gpt-6\"\nopenai_key = \"sk-live-456\"\n" + big.String()
|
||||
start := time.Now()
|
||||
got := diffLines(before, after)
|
||||
if d := time.Since(start); d > 2*time.Second {
|
||||
t.Fatalf("took %s", d)
|
||||
}
|
||||
want := []LineDiff{
|
||||
{Op: "~", Text: `model = "gpt-6"`, Was: `model = "magpie/deepseek/pro"`},
|
||||
{Op: "+", Text: "openai_key = ••••"},
|
||||
{Op: "-", Text: `model_provider = "magpie"`},
|
||||
{Op: "-", Text: "api_key = ••••"},
|
||||
{Op: "-", Text: "[model_providers.magpie]"},
|
||||
{Op: "-", Text: `name = "magpie"`},
|
||||
}
|
||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Fatalf("got %v\nwant %v", got, want)
|
||||
}
|
||||
|
||||
if d := diffLines("a\nb\n", "a\nb\n"); len(d) != 0 {
|
||||
t.Fatalf("same texts: %v", d)
|
||||
}
|
||||
if d := diffLines("", "x = 1\n}\n"); fmt.Sprint(d) != fmt.Sprint([]LineDiff{{Op: "+", Text: "x = 1"}}) {
|
||||
t.Fatalf("from nothing, a lone brace left out: %v", d)
|
||||
}
|
||||
// a block of thousands taken out, past what is compared line by
|
||||
// line: its lines, quickly
|
||||
var table strings.Builder
|
||||
for i := 0; i < 7000; i++ {
|
||||
fmt.Fprintf(&table, " \"m%d\": {\"name\": \"M %d\"},\n", i, i)
|
||||
}
|
||||
start = time.Now()
|
||||
d := diffLines("{\n\"model\": \"magpie/x\",\n\"provider\": {\n"+table.String()+"}\n}\n", "{\n\"provider\": {\n}\n}\n")
|
||||
if time.Since(start) > 2*time.Second || len(d) != 7001 || d[0] != (LineDiff{Op: "-", Text: `"model": "magpie/x",`}) {
|
||||
t.Fatalf("a table taken out: %d lines in %s, first %v", len(d), time.Since(start), d[:min(len(d), 1)])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMask(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
` "ANTHROPIC_AUTH_TOKEN": "magpie",`: ` "ANTHROPIC_AUTH_TOKEN": "magpie",`,
|
||||
` "ANTHROPIC_API_KEY": "sk-ant-x",`: ` "ANTHROPIC_API_KEY": ••••`,
|
||||
`export OPENAI_API_KEY=sk-1`: `export OPENAI_API_KEY= ••••`,
|
||||
`env_key = "$KEY"`: `env_key = "$KEY"`,
|
||||
`model = "x"`: `model = "x"`,
|
||||
} {
|
||||
if got := mask(in); got != want {
|
||||
t.Errorf("mask(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestElapsed(t *testing.T) {
|
||||
for in, want := range map[string]time.Duration{
|
||||
" 01:02": 62 * time.Second,
|
||||
"03:00:01": 3*time.Hour + time.Second,
|
||||
"2-00:00:10": 48*time.Hour + 10*time.Second,
|
||||
"10-23:59:59": 10*24*time.Hour + 23*time.Hour + 59*time.Minute + 59*time.Second,
|
||||
} {
|
||||
if got, ok := elapsed(in); !ok || got != want {
|
||||
t.Errorf("elapsed(%q) = %s %v, want %s", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "x", "1:2:3:4", "a-01:02"} {
|
||||
if _, ok := elapsed(in); ok {
|
||||
t.Errorf("elapsed(%q) read", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,8 @@ func viaMagpie(agent, prefix string) []Option {
|
||||
note = a.User + " · via magpie"
|
||||
}
|
||||
out = append(out, Option{Value: prefix + e.ID, Label: e.Name, Note: note,
|
||||
Icon: e.Provider.Icon, Group: e.Provider.Name, Ref: e.ID, Free: e.Free, Rate: e.Rate, RateWas: e.RateWas, Context: e.Context, own: own[e.Provider.ID]})
|
||||
Icon: e.Provider.Icon, Group: e.Provider.Name, Ref: e.ID, Free: e.Free, Rate: e.Rate, RateWas: e.RateWas, Context: e.Context, own: own[e.Provider.ID],
|
||||
sub: e.Provider.Account != nil && (e.Provider.Account.Agent != "claude" || agent == "claude")})
|
||||
}
|
||||
return append(groups, out...)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,18 @@ import (
|
||||
type modelCountJSON struct {
|
||||
Shown int `json:"shown"`
|
||||
Listed int `json:"listed"`
|
||||
// By is the models shown by whose they are, a connected agent's
|
||||
// expanded row's chips (OpenAI 6 · OpenRouter 24)
|
||||
By []modelGroupJSON `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// modelGroupJSON is how many of the models shown one provider (or the
|
||||
// routing groups) gives.
|
||||
type modelGroupJSON struct {
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Icons []string `json:"icons,omitempty"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
|
||||
// agentModelJSON is a model an agent may be shown, as the list draws it.
|
||||
@@ -84,10 +96,23 @@ func modelCount(id string) *modelCountJSON {
|
||||
listed, _ := provider.ListedFor(id)
|
||||
off := provider.HiddenModels(id)
|
||||
c := &modelCountJSON{Listed: len(listed)}
|
||||
at := map[string]int{}
|
||||
for _, e := range listed {
|
||||
if !off[e.ID] {
|
||||
c.Shown++
|
||||
if off[e.ID] {
|
||||
continue
|
||||
}
|
||||
c.Shown++
|
||||
g := modelGroupJSON{Name: e.Provider.Name, Icon: e.Provider.Icon}
|
||||
if e.Group != "" {
|
||||
g = modelGroupJSON{Name: agent.RoutingGroups, Icons: e.Icons}
|
||||
}
|
||||
i, ok := at[g.Name]
|
||||
if !ok {
|
||||
i = len(c.By)
|
||||
at[g.Name] = i
|
||||
c.By = append(c.By, g)
|
||||
}
|
||||
c.By[i].N++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -69,7 +70,7 @@ func TestAgentModelsAPI(t *testing.T) {
|
||||
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 {
|
||||
if c := modelCount("codex"); !reflect.DeepEqual(c, got.Count) {
|
||||
t.Fatalf("count %+v, answered %+v", c, got.Count)
|
||||
}
|
||||
got = call("POST", `{"hidden":[]}`)
|
||||
|
||||
@@ -166,6 +166,11 @@ type agentJSON struct {
|
||||
// Models: how many of the catalog its lists show, for an agent that
|
||||
// picks among it (agent_models.go)
|
||||
Models *modelCountJSON `json:"models,omitempty"`
|
||||
// Source: what an agent not connected runs on now (agent.Source), and
|
||||
// Stale: copies of a connected one still running on the list they
|
||||
// started with (agent.Stale), for the line under its name
|
||||
Source string `json:"source,omitempty"`
|
||||
Stale int `json:"stale,omitempty"`
|
||||
}
|
||||
|
||||
// clientJSON is an agent, or another client the gateway knows, as a
|
||||
@@ -645,6 +650,27 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
||||
}
|
||||
writeJSON(rw, c)
|
||||
})
|
||||
// what disconnecting an agent changes in its files, line by line, for
|
||||
// the dialog asking it (agent.DisconnectPreview); its files when the
|
||||
// preview can't be had
|
||||
mux.HandleFunc("GET /api/agents/preview/{id}", func(rw http.ResponseWriter, r *http.Request) {
|
||||
a, err := agent.Find(r.PathValue("id"))
|
||||
if err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
changes, err := agent.DisconnectPreview(a, exe)
|
||||
out := map[string]any{"changes": changes}
|
||||
if err != nil {
|
||||
out["error"] = err.Error()
|
||||
}
|
||||
writeJSON(rw, out)
|
||||
})
|
||||
mux.HandleFunc("POST /api/agents/{action}/{id}", func(rw http.ResponseWriter, r *http.Request) {
|
||||
a, err := agent.Find(r.PathValue("id"))
|
||||
if err != nil {
|
||||
@@ -1195,6 +1221,11 @@ func state() stateJSON {
|
||||
aj.Models = agentModelCount(a.ID, aj.Fields)
|
||||
aj.Drift = a.Drift()
|
||||
aj.Wired = a.Wired()
|
||||
if aj.Wired {
|
||||
aj.Stale = a.Stale()
|
||||
} else {
|
||||
aj.Source = a.Source()
|
||||
}
|
||||
if a.Import != nil {
|
||||
aj.Import, aj.Added = a.Import(), a.Added != nil && a.Added()
|
||||
}
|
||||
|
||||
@@ -2643,11 +2643,103 @@ body.window .row.agent .who.with-models { flex-wrap: wrap; row-gap: 0; }
|
||||
(claude-direct.test.cjs); the page holds the reader itself */
|
||||
#view-agents { overflow-anchor: none; }
|
||||
body.window .row.agent > .ag-conn { margin-left: 12px; align-self: center; cursor: pointer; }
|
||||
.ag-conn-line { flex: 0 0 100%; min-width: 0; font-size: 11.5px; line-height: 18px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ag-conn-line.on { color: var(--muted); }
|
||||
.ag-conn-line.on::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: var(--green); vertical-align: 1px; }
|
||||
.row.agent.unplugged > .fields { opacity: .55; transition: opacity .15s; }
|
||||
.row.agent.unplugged:hover > .fields { opacity: 1; }
|
||||
.ag-conn-row { display: flex; align-items: center; gap: 8px; min-height: 28px; }
|
||||
.ag-conn-row .ag-conn-said { flex: 1; min-width: 0; font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ag-conn-row .lib-switch { cursor: pointer; }
|
||||
|
||||
/* 「接入」 in the window: a row says whether the agent goes through magpie —
|
||||
a line under its name, a link that opens it, its switch */
|
||||
.ag-lead { margin: 0 2px 12px; font-size: 12.5px; color: var(--fg-2); }
|
||||
.ag-lead b { font-weight: 600; color: var(--fg); }
|
||||
.ag-lead.welcome { margin: 0 0 12px; padding: 16px 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); }
|
||||
.ag-lead .ag-lead-h { display: block; font-size: 14px; margin-bottom: 4px; }
|
||||
.ag-lead p { margin: 0; line-height: 1.55; color: var(--muted); }
|
||||
.ag-lead .text.primary { margin-top: 12px; }
|
||||
body.window .row.agent.conn { flex-wrap: wrap; min-height: 56px; }
|
||||
.ag-st { flex: 0 0 100%; min-width: 0; display: flex; align-items: center; gap: 6px; font-size: 12px; line-height: 18px; color: var(--muted); }
|
||||
.ag-st .ag-st-t { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ag-st .ag-st-t b { font-weight: 600; color: var(--fg-2); }
|
||||
.ag-dot { width: 6px; height: 6px; flex: none; border-radius: 50%; background: var(--faint); }
|
||||
.ag-st.on .ag-dot { background: var(--green); }
|
||||
.ag-st.wait .ag-dot, .ag-warn .ag-dot { background: var(--amber); }
|
||||
.ag-st.wait .ag-st-t { color: var(--amber); }
|
||||
.ag-st.bad .ag-dot { background: var(--red); }
|
||||
.ag-st.bad .ag-st-t { color: var(--red); }
|
||||
.ag-st .ag-fix { flex: none; }
|
||||
.ag-link, .ag-quiet { flex: none; display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px; border: 0; border-radius: 6px; background: none; color: var(--fg-2); font: inherit; font-size: 12px; white-space: nowrap; cursor: pointer; transition: background-color .15s, color .15s; }
|
||||
.ag-link:hover, .ag-quiet:hover, .ag-link.open { background: var(--pill); color: var(--fg); }
|
||||
.ag-link .chev, .ag-quiet .chev { display: inline-flex; color: var(--faint); transition: transform .2s ease; }
|
||||
.ag-quiet.open .chev { transform: rotate(90deg); }
|
||||
.ag-quiet.red { color: var(--red); }
|
||||
.ag-quiet.red:hover { background: color-mix(in srgb, var(--red) 10%, transparent); color: var(--red); }
|
||||
.ag-quiet.accent { color: var(--accent); }
|
||||
.ag-link:focus-visible, .ag-quiet:focus-visible, .ag-add:focus-visible { outline: 1.5px solid var(--accent); outline-offset: 1px; }
|
||||
.row.agent.conn > .ag-link { margin-left: auto; }
|
||||
.row.agent.conn > .fields + .ag-link { margin-left: 0; }
|
||||
.row.agent.conn > .ag-conn { margin-left: 4px; align-self: center; cursor: pointer; }
|
||||
.row.agent.conn > .ag-link + .ag-conn { margin-left: 4px; }
|
||||
.row.agent.conn > .who + .ag-conn, .row.agent.conn > .who + .ag-add, .row.agent.conn > .who + .ag-cant { margin-left: auto; }
|
||||
.row.agent.conn > .ag-conn:disabled { opacity: .4; cursor: default; }
|
||||
.ag-add { flex: none; height: 24px; padding: 0 10px; border: 0; border-radius: 6px; background: var(--pill); color: var(--fg); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; }
|
||||
.ag-add:hover { background: var(--pill-hover); }
|
||||
.ag-cant { flex: none; font-size: 12px; color: var(--faint); }
|
||||
.row.agent.conn.unplugged .ag-handle .ic { opacity: .7; }
|
||||
/* opened: the settings as named lines on the row's quieter ground */
|
||||
.ag-exp { flex: 0 0 calc(100% + 26px); margin: 7px -12px -7px -14px; padding: 4px 16px 14px 50px; border-top: 1px solid var(--line-2); background: var(--card-2); cursor: default; }
|
||||
.ag-kv { display: flex; align-items: flex-start; gap: 16px; padding: 11px 0; border-top: 1px solid var(--line-2); }
|
||||
.ag-kv:first-child { border-top: 0; }
|
||||
.ag-k { width: 112px; flex: none; padding-top: 3px; font-size: 12px; color: var(--muted); }
|
||||
.ag-v { flex: 1; min-width: 0; font-size: 12.5px; color: var(--fg-2); display: grid; gap: 4px; }
|
||||
.ag-vl { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; min-height: 24px; }
|
||||
.ag-vl > .fields { margin-left: 0; }
|
||||
.ag-code, .ag-diff code, .ag-path { font-family: var(--code); font-size: 11.5px; }
|
||||
.ag-code { padding: 1px 5px; border-radius: 4px; background: var(--pill); color: var(--fg); }
|
||||
.ag-warn { gap: 8px; font-size: 12px; }
|
||||
.ag-hint { font-size: 12px; color: var(--muted); }
|
||||
.ag-tier { width: 70px; flex: none; font-size: 12px; color: var(--fg-2); }
|
||||
.ag-vl .field.ag-pick { width: 220px; justify-content: flex-start; }
|
||||
.ag-vl .field.ag-eff { flex: none; }
|
||||
.ag-vl .field.ag-pick .v { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
|
||||
.ag-exp .extras-cell { justify-content: flex-start; }
|
||||
.ag-chip { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 6px; background: var(--card); box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; color: var(--fg-2); }
|
||||
.ag-chip .ic { width: 13px !important; height: 13px !important; }
|
||||
.ag-chip .n { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.ag-foot { display: flex; align-items: center; gap: 8px; margin-top: 2px; padding-top: 12px; border-top: 1px solid var(--line-2); font-size: 12px; color: var(--muted); }
|
||||
.ag-files { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ag-path { color: var(--fg-2); }
|
||||
.ag-diff-box { margin-top: 10px; max-height: 320px; overflow: auto; }
|
||||
.ag-diff-more { display: block; margin: 2px 0 6px 28px; padding: 2px 0; border: 0; background: none; font: inherit; font-size: 11.5px; color: var(--accent); cursor: pointer; }
|
||||
/* a file's lines as disconnecting changes them */
|
||||
.ag-diff { display: grid; gap: 10px; }
|
||||
.ag-diff-file { border: 1px solid var(--line); border-radius: 8px; background: var(--card); overflow: hidden; }
|
||||
.ag-diff-path { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line-2); font-family: var(--code); font-size: 11.5px; color: var(--fg); }
|
||||
.ag-diff-path:last-child { border-bottom: 0; }
|
||||
.ag-diff-line { display: flex; align-items: baseline; gap: 8px; padding: 2px 10px; font-size: 11.5px; line-height: 1.6; }
|
||||
.ag-diff-line:first-of-type { padding-top: 6px; }
|
||||
.ag-diff-line:last-child { padding-bottom: 6px; }
|
||||
.ag-diff-line .op { width: 10px; flex: none; text-align: center; font-family: var(--code); }
|
||||
.ag-diff-line code { flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--fg-2); }
|
||||
.ag-diff-line.del .op, .ag-diff-line.del code { color: var(--red); }
|
||||
.ag-diff-line .ind { text-decoration: none; }
|
||||
.ag-diff-line.del code .t { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--red) 45%, transparent); }
|
||||
.ag-diff-line.back .op { color: var(--green); }
|
||||
.ag-tag { flex: none; padding: 0 6px; border-radius: 4px; background: var(--pill); font-family: inherit; font-size: 10.5px; line-height: 16px; color: var(--muted); }
|
||||
.ag-tag.del { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); }
|
||||
.disconnect-ask .ag-diff-wrap { max-height: min(46vh, 380px); overflow: auto; margin: 2px 0 10px; }
|
||||
.ag-diff-loading { padding: 14px 0; font-size: 12px; color: var(--muted); }
|
||||
.disconnect-ask .ag-note { margin: 0 0 10px; font-size: 12px; color: var(--muted); }
|
||||
.ag-bar-note { font-size: 12px; color: var(--muted); }
|
||||
@media (max-width: 600px) {
|
||||
body.window .row.agent.conn { display: flex; }
|
||||
/* the name beside its logo; the link and the switch wrap under it when short of room */
|
||||
body.window .row.agent.conn > .who { width: auto; flex: 1 1 160px; min-width: 0; }
|
||||
.ag-exp { padding-left: 16px; min-width: 0; }
|
||||
.ag-kv { flex-direction: column; gap: 4px; min-width: 0; }
|
||||
/* the pickers take the line, the effort and the squares wrap under them */
|
||||
.ag-vl { width: 100%; min-width: 0; }
|
||||
#agents .ag-vl > .fields { display: flex; flex-wrap: wrap; width: 100%; min-width: 0; }
|
||||
.ag-vl > .fields > .field.main, .ag-vl > .fields > .field.solo { flex: 1 1 100%; min-width: 0; }
|
||||
.ag-vl > .fields > .field.side { flex: 1 0 124px; }
|
||||
.ag-vl > .fields > .extras-cell { flex-wrap: wrap; max-width: 100%; }
|
||||
.ag-vl .field.ag-pick { flex: 1 1 160px; width: auto; min-width: 0; }
|
||||
}
|
||||
+452
-22
@@ -239,6 +239,7 @@ function renderAgents() {
|
||||
page.removeAttribute("aria-busy");
|
||||
const list = $("#agents");
|
||||
list.replaceChildren();
|
||||
agentsLead(list);
|
||||
if (!state.agents.length) {
|
||||
const e = el("div", "empty-state");
|
||||
e.append(el("b", "", t("No agents found")), el("span", "", t("Install Claude Code, Codex, Gemini CLI, OpenCode… and magpie will list them here.")));
|
||||
@@ -269,13 +270,12 @@ function renderAgents() {
|
||||
// the squares share one cell, side by side: two (Codex's subagents and
|
||||
// sign-in) wrapped the second under the first
|
||||
const extras = el("span", "extras-cell");
|
||||
for (const f of sorted) {
|
||||
if (extra(f)) { extras.append(extraField(a, f)); continue; }
|
||||
const b = el("button", "field " + (plain === 1 ? "solo" : wide(f) ? "main" : "side"));
|
||||
const fieldBtn = (f, cls) => {
|
||||
const b = el("button", "field " + cls);
|
||||
const opt = optionFor(f, f.value);
|
||||
b.title = t("{label}: {value}", { label: t(f.label), value: f.value || t("agent default") }) + (opt?.note ? ` · ${opt.note}` : "");
|
||||
if (f.value && opt?.direct) b.title += "\n" + directSaid(a, opt);
|
||||
const effort = f.key === "effort" || f.label === "effort" || f.label === "thinking";
|
||||
const effort = f.key === "effort" || f.label === "effort" || f.label === "thinking" || TIER_EFFORTS.includes(f.label);
|
||||
if (opt?.icon || opt?.icons?.length) b.append(optionIcon(opt));
|
||||
// a field with no logo of its own still leads with an icon: how much
|
||||
// effort, or the agent's own for its default, as the picker shows it
|
||||
@@ -290,7 +290,11 @@ function renderAgents() {
|
||||
b.append(c);
|
||||
b.dataset.key = f.key;
|
||||
b.onclick = (ev) => openPicker(a, f, b, ev);
|
||||
fields.append(b);
|
||||
return b;
|
||||
};
|
||||
for (const f of sorted) {
|
||||
if (extra(f)) { extras.append(extraField(a, f)); continue; }
|
||||
fields.append(fieldBtn(f, plain === 1 ? "solo" : wide(f) ? "main" : "side"));
|
||||
}
|
||||
// an agent that takes the gateway only from its environment (agy): a
|
||||
// square that copies the command starting it on magpie
|
||||
@@ -396,15 +400,17 @@ function renderAgents() {
|
||||
}
|
||||
// on the name's own line, so the row keeps its height and the pickers
|
||||
// their columns
|
||||
// a row with 「接入」 says it on its line under the name instead
|
||||
const kind = mode !== "panel" ? connectKind(a) : "";
|
||||
if (a.drift) {
|
||||
row.classList.add("drifted");
|
||||
who.append(driftFix(a));
|
||||
if (!kind) who.append(driftFix(a));
|
||||
}
|
||||
// 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) {
|
||||
if (mode !== "panel" && a.models && !kind) {
|
||||
const line = el("div", "ag-models-line");
|
||||
line.append(modelsEntry(a));
|
||||
who.append(line);
|
||||
@@ -413,10 +419,15 @@ function renderAgents() {
|
||||
// connected to magpie or not, said under the name, with where the agent
|
||||
// picks among magpie's models itself
|
||||
const sw = connectable(a) ? connectSwitch(a) : null;
|
||||
if (sw && mode !== "panel") {
|
||||
who.append(el("div", "ag-conn-line" + (a.wired ? " on" : ""), connectSaid(a)));
|
||||
who.classList.add("with-models");
|
||||
row.classList.toggle("unplugged", !a.wired);
|
||||
if (kind) {
|
||||
// Claude Code's tiers and subagents have rows of their own when opened
|
||||
if (a.id === "claude") {
|
||||
for (const f of a.fields.filter((f) => TIERS.includes(f.label) || f.label === "subagents")) extras.querySelector(`[data-key="${CSS.escape(f.key)}"]`)?.remove();
|
||||
extras.querySelector('[data-key="tiers"]')?.remove();
|
||||
}
|
||||
row.append(agentHandle(a, row, inFold), who);
|
||||
connectRow(a, row, who, { fields, extras, fieldBtn, sw, kind });
|
||||
return row;
|
||||
}
|
||||
if (sw && openBox) {
|
||||
const line = el("div", "ag-conn-row");
|
||||
@@ -711,14 +722,14 @@ function profileDetail(p, footed) {
|
||||
// driftFix is the one thing a drifted agent shows: an amber pill after its
|
||||
// name that sets magpie's settings again. What is off is its tooltip; taking
|
||||
// the config as it is now is in the row's menu.
|
||||
function driftFix(a) {
|
||||
function driftFix(a, label = "Apply again") {
|
||||
const d = a.drift, f = a.fields.find((x) => x.key === d.field);
|
||||
const want = (f && optionFor(f, d.want)?.label) || d.want;
|
||||
const fix = el("button", "ag-fix");
|
||||
fix.type = "button";
|
||||
fix.title = `${t(DRIFT_WHY[d.kind] || DRIFT_WHY.unwired, { agent: a.name, model: want })}\n${d.detail}`;
|
||||
fix.setAttribute("aria-label", t("Apply again"));
|
||||
fix.append(svg(REAPPLY, 11, 1.8), el("span", "", t("Apply again")));
|
||||
fix.setAttribute("aria-label", t(label));
|
||||
fix.append(svg(REAPPLY, 11, 1.8), el("span", "", t(label)));
|
||||
fix.onclick = (e) => { e.stopPropagation(); reapplyAgent(a, fix); };
|
||||
return fix;
|
||||
}
|
||||
@@ -740,9 +751,13 @@ const DISCONNECT_TIP = "Take out everything magpie wrote into {agent}'s config a
|
||||
// its provider and its own model list has magpie's models (agent.Connect);
|
||||
// off, magpie takes out what it wrote (askDisconnect). An agent with none of
|
||||
// magpie's models to offer (Cursor, Copilot) has no switch.
|
||||
// the field magpie is picked in: the first, or Gemini CLI's model, its
|
||||
// first being how it signs in
|
||||
const connectField = (a) => a.fields.find((f) => f.options.some((o) => o.ref)) || a.fields.find((f) => f.options.some((o) => o.value === "magpie"));
|
||||
// connectable: magpie has models for it — in its pickers, or every one
|
||||
// hidden from its lists, which leave its pickers but not the way back (#356)
|
||||
function connectable(a) {
|
||||
const f = a.fields[0];
|
||||
return !a.import && !!f && f.options.some((o) => o.ref || o.value === "magpie");
|
||||
return !a.import && (!!connectField(a) || !!a.models?.listed);
|
||||
}
|
||||
|
||||
// PICKS_IN is where an agent picks a model itself, for the words under it.
|
||||
@@ -783,6 +798,393 @@ function connectSwitch(a) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- 「接入」 in the window (the owner's design boards) ----------
|
||||
// A row there answers one thing: does the agent go through magpie. Its
|
||||
// switch stands where the model and effort pickers stood, a line under the
|
||||
// name says what it runs on and where it picks a model, and a connected
|
||||
// one opens to what else there is: when it takes effect, which models it
|
||||
// lists, what a new session starts on, the files magpie changed, and the
|
||||
// way back.
|
||||
|
||||
// agents with no way onto magpie: no switch, the reason instead
|
||||
const NATIVE_ONLY = {
|
||||
cursor: "Only Cursor's own models",
|
||||
copilot: "Only GitHub Copilot's own models",
|
||||
goose: "Only Goose's own providers",
|
||||
devin: "Only Devin's own models",
|
||||
};
|
||||
// agents that pick no model once started: the one they start on stays in
|
||||
// the row
|
||||
const NO_PICKER = new Set(["gemini", "hermes", "agy", "muse"]);
|
||||
// what turning one on costs, said in its opened row
|
||||
const CONNECT_COST = {
|
||||
gemini: "Gemini CLI's requests all go through magpie; its Google sign-in is turned off first and comes back when you disconnect",
|
||||
};
|
||||
// agents that read magpie's list only as they start: copies already
|
||||
// running keep the list they started with (agent.Stale)
|
||||
const STARTS_WITH = new Set(["codex", "claude"]);
|
||||
|
||||
let agentExpanded = null; // the connected agent opened in the window
|
||||
const staleSeen = {}; // agent id → the running copies the reader was told of
|
||||
const previews = {}; // agent id → its disconnect preview, as last read
|
||||
|
||||
const anyMagpieModels = () => state.agents.some((a) => connectField(a)?.options.some((o) => o.ref));
|
||||
|
||||
// connectKind: "ok" (a switch), "empty" (a switch for when magpie has
|
||||
// models), "no" (never), "" (the row as it was: an app with a link of its own)
|
||||
function connectKind(a) {
|
||||
if (a.import) return "";
|
||||
if (connectable(a)) return "ok";
|
||||
if (NATIVE_ONLY[a.id]) return "no";
|
||||
if (a.fields.length && !anyMagpieModels()) return "empty";
|
||||
return "";
|
||||
}
|
||||
|
||||
// the models it lists the reader hasn't seen yet: counted once, said in
|
||||
// bold for a while, then counted as seen
|
||||
const SEEN_KEY = "magpie.agentModelsSeen";
|
||||
let seenTimer = null;
|
||||
function newModels(a) {
|
||||
if (!a.wired || !a.models) return 0;
|
||||
let seen = {};
|
||||
try { seen = JSON.parse(localStorage.getItem(SEEN_KEY) || "{}") || {}; } catch { /* none kept */ }
|
||||
const was = seen[a.id], now = a.models.shown;
|
||||
const keep = () => { try { seen[a.id] = now; localStorage.setItem(SEEN_KEY, JSON.stringify(seen)); } catch { /* not kept */ } };
|
||||
if (was === undefined || now <= was) { if (was !== now) keep(); return 0; }
|
||||
clearTimeout(seenTimer);
|
||||
seenTimer = setTimeout(() => { keep(); renderAgents(); }, 12000);
|
||||
return now - was;
|
||||
}
|
||||
|
||||
const staleNow = (a) => a.wired && a.stale > 0 && staleSeen[a.id] !== a.stale;
|
||||
|
||||
// connectLine: the dot and the words under an agent's name
|
||||
function connectLine(a, kind) {
|
||||
const line = el("div", "ag-st");
|
||||
const dot = el("span", "ag-dot");
|
||||
const words = el("span", "ag-st-t");
|
||||
line.append(dot, words);
|
||||
const say = (s, cls) => { words.textContent = s; if (cls) line.classList.add(cls); };
|
||||
if (kind === "no") { say(t(NATIVE_ONLY[a.id])); return line; }
|
||||
if (kind === "empty") { say(t("Add a key or a subscription first; then there are models to connect")); return line; }
|
||||
if (!a.wired) {
|
||||
const src = a.source || "";
|
||||
if (a.id === "agy") say(t("Not connected · once connected, start it with magpie's command"));
|
||||
else if (src === "sub") say(t("Not connected · now on its Claude subscription"));
|
||||
else if (src === "chatgpt") say(t("Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected"));
|
||||
else if (src === "key") say(t("Not connected · now on an API key of its own"));
|
||||
else if (src.startsWith("providers:")) say(t("Not connected · now has {n} providers of its own", { n: src.slice(10) }));
|
||||
else say(t("Not connected · {agent} uses its own settings", { agent: a.name }));
|
||||
return line;
|
||||
}
|
||||
if (a.drift) {
|
||||
say(t(DRIFT_WHY[a.drift.kind] || DRIFT_WHY.unwired, { agent: a.name, model: a.drift.want }), "bad");
|
||||
words.title = a.drift.detail || "";
|
||||
line.append(driftFix(a, "Reconnect"));
|
||||
return line;
|
||||
}
|
||||
line.classList.add("on");
|
||||
if (staleNow(a)) {
|
||||
if (a.id === "claude") say(t("Connected · new sessions take it"));
|
||||
else say(t("Connected · takes effect once {agent} is reopened", { agent: a.name }), "wait");
|
||||
return line;
|
||||
}
|
||||
const fresh = newModels(a);
|
||||
if (fresh) {
|
||||
const [pre, post] = t("{n} models · {new} show in new {agent} sessions", { n: a.models.shown, agent: a.name }).split("{new}");
|
||||
words.append(pre, el("b", "", t("{n} new", { n: fresh })), post || "");
|
||||
return line;
|
||||
}
|
||||
const at = PICKS_IN[a.id];
|
||||
if (a.id === "claude") say(t("Connected · switch the opus · sonnet · haiku tiers in /model"));
|
||||
else if (NO_PICKER.has(a.id)) {
|
||||
const f = connectField(a), o = f && optionFor(f, f.value);
|
||||
say(o ? t("Connected · starts on {model}", { model: o.label || f.value }) : t("Connected · pick the model it starts on"));
|
||||
} else if (at && a.models) say(t("{n} models in {agent}'s {cmd}", { n: a.models.shown, agent: a.name, cmd: at }));
|
||||
else say(connectSaid(a));
|
||||
return line;
|
||||
}
|
||||
|
||||
// expandLink opens a connected agent's row, and closes it again
|
||||
function expandLink(a) {
|
||||
const open = agentExpanded === a.id;
|
||||
const b = el("button", "ag-link" + (open ? " open" : ""));
|
||||
b.type = "button";
|
||||
b.setAttribute("aria-expanded", String(open));
|
||||
b.append(el("span", "", open ? t("Collapse") : a.id === "claude" ? t("Tiers") : a.models ? t("Models {n}", { n: a.models.shown }) : t("Details")));
|
||||
if (!open) {
|
||||
const c = el("span", "chev");
|
||||
c.append(svg(CHEV_R, 10, 1.6));
|
||||
b.append(c);
|
||||
}
|
||||
b.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
agentExpanded = open ? null : a.id;
|
||||
renderAgents();
|
||||
};
|
||||
return b;
|
||||
}
|
||||
|
||||
// connectRow finishes a window row that has 「接入」
|
||||
function connectRow(a, row, who, { fields, extras, fieldBtn, sw, kind }) {
|
||||
// its list open, what was picked there until the state has it
|
||||
if (agentModels?.a.id === a.id && agentModels.count) a.models = agentModels.count;
|
||||
row.classList.add("conn");
|
||||
row.classList.toggle("unplugged", kind === "ok" && !a.wired);
|
||||
who.classList.add("with-models");
|
||||
who.append(connectLine(a, kind));
|
||||
const noPicker = NO_PICKER.has(a.id);
|
||||
if (kind === "ok") {
|
||||
if (noPicker && a.wired) row.append(fields);
|
||||
if (a.wired) row.append(expandLink(a));
|
||||
row.append(sw);
|
||||
} else if (kind === "empty") {
|
||||
const add = el("button", "ag-add", t("Add a provider"));
|
||||
add.type = "button";
|
||||
add.onclick = (e) => { e.stopPropagation(); addProviderFromAgents(); };
|
||||
const off = el("button", "lib-switch ag-conn");
|
||||
off.type = "button";
|
||||
off.disabled = true;
|
||||
off.setAttribute("role", "switch");
|
||||
off.setAttribute("aria-checked", "false");
|
||||
off.setAttribute("aria-label", t("Connect {agent} to magpie", { agent: a.name }));
|
||||
off.title = t("Add a key or a subscription first; then there are models to connect");
|
||||
off.append(el("i"));
|
||||
row.append(add, off);
|
||||
} else row.append(el("span", "ag-cant", t("Can't connect")));
|
||||
if (kind === "ok" && a.wired && agentExpanded === a.id) {
|
||||
row.classList.add("expanded");
|
||||
row.append(connectPanel(a, { fields: noPicker ? null : fields, extras, fieldBtn }));
|
||||
}
|
||||
}
|
||||
|
||||
// agentsLead: above the window's list, what 「接入」 does, said once in a
|
||||
// line; while no agent is connected, as a welcome, with a way to add a
|
||||
// provider when magpie has no models to give
|
||||
function agentsLead(list) {
|
||||
let lead = $("#agentsLead");
|
||||
if (!lead) {
|
||||
lead = el("div", "ag-lead");
|
||||
lead.id = "agentsLead";
|
||||
list.before(lead);
|
||||
}
|
||||
lead.replaceChildren();
|
||||
const kinds = mode === "panel" ? [] : state.agents.map(connectKind);
|
||||
lead.hidden = !kinds.some((k) => k === "ok" || k === "empty");
|
||||
if (lead.hidden) return;
|
||||
const first = !state.agents.some((a) => a.wired);
|
||||
lead.classList.toggle("welcome", first);
|
||||
if (!first) {
|
||||
const [pre, post] = t("Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list.").split("{magpie}");
|
||||
lead.append(pre, el("b", "", "magpie"), post || "");
|
||||
return;
|
||||
}
|
||||
lead.append(el("b", "ag-lead-h", t("Let your agents use the models you set up in magpie")));
|
||||
lead.append(el("p", "", t("Switch an agent on and your models show up as the magpie provider in its own model list. Then change models in the agent; switch it off and it goes back to how it was.")));
|
||||
if (!anyMagpieModels()) {
|
||||
const add = el("button", "text primary", t("Add a provider"));
|
||||
add.type = "button";
|
||||
add.onclick = () => addProviderFromAgents();
|
||||
lead.append(add);
|
||||
}
|
||||
}
|
||||
|
||||
function addProviderFromAgents() {
|
||||
show("providers");
|
||||
$("#addProvider")?.click();
|
||||
}
|
||||
|
||||
// connectPanel: a connected agent's row, opened
|
||||
function connectPanel(a, { fields, fieldBtn }) {
|
||||
const box = el("div", "ag-exp");
|
||||
box.onclick = (e) => e.stopPropagation();
|
||||
const kv = (k, ...v) => {
|
||||
const r = el("div", "ag-kv");
|
||||
const val = el("div", "ag-v");
|
||||
val.append(...v);
|
||||
r.append(el("div", "ag-k", k), val);
|
||||
box.append(r);
|
||||
return val;
|
||||
};
|
||||
const line = (...parts) => {
|
||||
const l = el("div", "ag-vl");
|
||||
l.append(...parts);
|
||||
return l;
|
||||
};
|
||||
const code = (s) => el("code", "ag-code", s);
|
||||
// where it picks, and when the copies running take it
|
||||
const at = PICKS_IN[a.id];
|
||||
if (at || STARTS_WITH.has(a.id)) {
|
||||
const parts = [];
|
||||
// the command in the words as code, wherever the language puts it
|
||||
const said = (s, cmd) => { const [pre, post] = t(s, { agent: a.name }).split("{cmd}"); return line(pre, code(cmd), post || ""); };
|
||||
if (a.id === "claude") parts.push(said("New sessions take it; switch the tiers in {cmd}.", "/model"));
|
||||
else if (STARTS_WITH.has(a.id)) parts.push(said("Reopen {agent}, then pick any model in {cmd}.", at));
|
||||
else parts.push(said("Pick any model in {agent}'s {cmd}.", at));
|
||||
if (staleNow(a)) {
|
||||
const w = line();
|
||||
w.classList.add("ag-warn");
|
||||
w.append(el("span", "ag-dot"), el("span", "", t(a.stale === 1 ? "1 running {agent} still has the old list" : "{n} running {agent} still have the old list", { n: a.stale, agent: a.name })));
|
||||
const later = el("button", "ag-quiet", t("Got it"));
|
||||
later.type = "button";
|
||||
later.onclick = () => { staleSeen[a.id] = a.stale; renderAgents(); };
|
||||
w.append(later);
|
||||
parts.push(w);
|
||||
}
|
||||
kv(t("In {agent}", { agent: a.name }), ...parts);
|
||||
}
|
||||
// Claude Code's tiers, each with the /model that picks it
|
||||
if (a.id === "claude") {
|
||||
const tiers = a.fields.filter((f) => TIERS.includes(f.label) && f.options.length);
|
||||
const hint = { opus: "/model opus", sonnet: "/model sonnet", haiku: "/model haiku", fable: "/model fable" };
|
||||
const also = { sonnet: " · " + t("the default tier"), haiku: " · " + t("also for small background tasks") };
|
||||
const rows = tiers.map((f) => {
|
||||
const l = line(el("span", "ag-tier", f.label[0].toUpperCase() + f.label.slice(1)), fieldBtn(f, "ag-pick"));
|
||||
// the tier's own effort (#536), while there are levels to pick;
|
||||
// unset, it runs at the effort Claude Code asks for
|
||||
const e = a.fields.find((x) => x.label === f.label + " effort" && (x.options.length || x.value));
|
||||
if (e) {
|
||||
const b = fieldBtn(e, "ag-eff");
|
||||
b.title = t("{label}: {value}", { label: t(e.label), value: e.value ? effortName(optionFor(e, e.value) || { value: e.value }) : t("the effort Claude Code asks for") });
|
||||
b.setAttribute("aria-label", b.title);
|
||||
l.append(b);
|
||||
}
|
||||
l.append(el("span", "ag-hint", (hint[f.label] || "") + (also[f.label] || "")));
|
||||
return l;
|
||||
});
|
||||
const sub = a.fields.find((f) => f.label === "subagents" && (f.options.length || f.value));
|
||||
if (sub) rows.push(line(el("span", "ag-tier", t("Subagents")), fieldBtn(sub, "ag-pick")));
|
||||
if (rows.length) kv(t("Tiers"), ...rows);
|
||||
const main = a.fields.find((f) => f.key === "model");
|
||||
const hasSub = main?.options.some((o) => (o.ref || "").startsWith("claude/"));
|
||||
if (hasSub) kv(t("Claude models"), line(t("Your Claude subscription is in magpie; picking a claude-* model goes through it as before")));
|
||||
else {
|
||||
const add = el("button", "ag-quiet accent", t("Add a Claude subscription"));
|
||||
add.type = "button";
|
||||
add.onclick = () => addProviderFromAgents();
|
||||
kv(t("Claude models"), line(t("claude-* models need a Claude subscription in magpie"), add));
|
||||
}
|
||||
}
|
||||
// which of magpie's models it lists, by whose they are
|
||||
if (a.models) {
|
||||
const chips = line();
|
||||
chips.classList.add("ag-chips");
|
||||
for (const g of a.models.by || []) {
|
||||
const c = el("span", "ag-chip");
|
||||
if (g.icons?.length) c.append(optionIcon({ icons: g.icons }));
|
||||
else if (g.icon) c.append(icon(g.icon));
|
||||
c.append(el("span", "", g.name === ROUTING_GROUPS ? t(g.name) : g.name), el("span", "n", String(g.n)));
|
||||
chips.append(c);
|
||||
}
|
||||
const hidden = a.models.listed - a.models.shown;
|
||||
if (hidden > 0) chips.append(el("span", "ag-hint", t("{n} hidden", { n: hidden })));
|
||||
const pick = el("button", "ag-quiet");
|
||||
pick.type = "button";
|
||||
pick.append(el("span", "", t("Pick")));
|
||||
const c = el("span", "chev");
|
||||
c.append(svg(CHEV_R, 10, 1.6));
|
||||
pick.append(c);
|
||||
pick.onclick = (ev) => openAgentModels(a, pick, ev);
|
||||
// drawn again while its list is open: the list stays, held to it
|
||||
if (agentModels?.a.id === a.id) {
|
||||
pick.classList.add("open");
|
||||
agentModels.anchor = pick;
|
||||
}
|
||||
chips.append(pick);
|
||||
kv(t("Model list"), chips);
|
||||
}
|
||||
// what a new session starts on: optional, the agent's own last pick unset
|
||||
if (fields && a.id !== "claude") {
|
||||
kv(t("New sessions"), line(fields), el("div", "ag-hint", t("Optional · unset, {agent} starts on its own last pick", { agent: a.name })));
|
||||
} else if (fields && a.id === "claude") {
|
||||
kv(t("New sessions"), line(fields));
|
||||
}
|
||||
if (CONNECT_COST[a.id]) kv(t("Once connected"), line(t(CONNECT_COST[a.id])));
|
||||
if (a.launch) {
|
||||
const cp = el("button", "ag-quiet", t("Copy"));
|
||||
cp.type = "button";
|
||||
cp.onclick = () => copy(a.launch, t("Launch command"), null, t("Copied — run it to start {name} on magpie", { name: a.name }));
|
||||
kv(t("Start it with"), line(code(a.launch), cp));
|
||||
}
|
||||
// the files magpie changed, and the way back
|
||||
const foot = el("div", "ag-foot");
|
||||
const files = el("span", "ag-files");
|
||||
const view = el("button", "ag-quiet");
|
||||
view.type = "button";
|
||||
view.hidden = true;
|
||||
const diff = el("div", "ag-diff-box");
|
||||
diff.hidden = true;
|
||||
view.onclick = () => {
|
||||
diff.hidden = !diff.hidden;
|
||||
view.firstChild.textContent = diff.hidden ? t("View") : t("Hide changes");
|
||||
view.classList.toggle("open", !diff.hidden);
|
||||
};
|
||||
view.append(el("span", "", t("View")));
|
||||
const vc = el("span", "chev");
|
||||
vc.append(svg(CHEV_R, 10, 1.6));
|
||||
view.append(vc);
|
||||
const off = el("button", "ag-quiet red", t("Disconnect and restore"));
|
||||
off.type = "button";
|
||||
off.onclick = () => askDisconnect(a);
|
||||
foot.append(files, view, el("span", "grow"), off);
|
||||
box.append(foot, diff);
|
||||
const fill = (changes) => {
|
||||
if (!changes?.length) return;
|
||||
files.replaceChildren(t("Changed "));
|
||||
changes.forEach((c, i) => {
|
||||
if (i) files.append(" · ");
|
||||
files.append(el("span", "ag-path", i ? c.path.split("/").pop() : c.path));
|
||||
});
|
||||
view.hidden = false;
|
||||
diff.replaceChildren(changesList(changes));
|
||||
};
|
||||
if (previews[a.id]) fill(previews[a.id]);
|
||||
loadPreview(a).then(fill, () => {});
|
||||
return box;
|
||||
}
|
||||
|
||||
async function loadPreview(a) {
|
||||
const r = await api("agents/preview/" + a.id);
|
||||
if (r?.error) throw new Error(r.error);
|
||||
previews[a.id] = r?.changes || [];
|
||||
return previews[a.id];
|
||||
}
|
||||
|
||||
// changesList: what disconnecting does to each file, line by line
|
||||
function changesList(changes) {
|
||||
const box = el("div", "ag-diff");
|
||||
for (const c of changes) {
|
||||
const f = el("div", "ag-diff-file");
|
||||
const h = el("div", "ag-diff-path");
|
||||
h.append(el("span", "", c.path));
|
||||
if (c.removed) h.append(el("span", "ag-tag del", t("Deleted")));
|
||||
if (c.created) h.append(el("span", "ag-tag", t("Created")));
|
||||
f.append(h);
|
||||
const line = (l) => {
|
||||
const r = el("div", "ag-diff-line " + (l.op === "-" ? "del" : "back"));
|
||||
// the indent shows where it was, and isn't struck through
|
||||
const code = el("code"), ind = l.text.match(/^\s*/)[0];
|
||||
code.append(el("span", "ind", ind), el("span", "t", l.text.slice(ind.length)));
|
||||
r.append(el("span", "op", l.op === "-" ? "−" : "↺"), code);
|
||||
if (l.op !== "-") r.append(el("span", "ag-tag", t("value before connecting")));
|
||||
if (l.op === "~" && l.was) r.title = t("Now: {line}", { line: l.was });
|
||||
return r;
|
||||
};
|
||||
// a list magpie wrote whole (Claude Code's modelPicker, Codex's
|
||||
// catalog) is a few lines and the rest on asking
|
||||
const lines = c.lines || [], FEW = 8;
|
||||
f.append(...lines.slice(0, FEW).map(line));
|
||||
if (lines.length > FEW + 2) {
|
||||
const rest = el("button", "ag-diff-more", t("{n} more lines", { n: lines.length - FEW }));
|
||||
rest.type = "button";
|
||||
rest.onclick = () => rest.replaceWith(...lines.slice(FEW).map(line));
|
||||
f.append(rest);
|
||||
} else f.append(...lines.slice(FEW).map(line));
|
||||
box.append(f);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
// askDisconnect takes magpie out of an agent's config once asked: every
|
||||
// field magpie set, and its endpoint and key, give way to what the agent
|
||||
// had before magpie (agent.Disconnect). Picking Default isn't that: it is
|
||||
@@ -792,9 +1194,22 @@ function askDisconnect(a) {
|
||||
const head = el("div", "ehead");
|
||||
head.append(icon(a.icon), el("b", "", t("Disconnect {agent} from magpie?", { agent: a.name })));
|
||||
ed.append(head);
|
||||
ed.append(el("p", "lib-confirm", t("magpie takes out everything it wrote into {agent}'s config — its endpoint, key, models and effort — and puts back the settings {agent} had before. magpie's providers and accounts stay as they are.", { agent: a.name })));
|
||||
ed.append(el("p", "lib-confirm", t("{agent} goes back to how it was before it was connected; these are the places put back.", { agent: a.name })));
|
||||
// what changes, line by line, read from a dry run on a copy (preview.go);
|
||||
// where none can be had, the old words
|
||||
const said = el("p", "lib-confirm", t("magpie takes out everything it wrote into {agent}'s config — its endpoint, key, models and effort — and puts back the settings {agent} had before. magpie's providers and accounts stay as they are.", { agent: a.name }));
|
||||
const body = el("div", "ag-diff-wrap");
|
||||
const show = (changes) => {
|
||||
if (changes?.length) body.replaceChildren(changesList(changes));
|
||||
else body.replaceChildren(said);
|
||||
};
|
||||
if (previews[a.id]) show(previews[a.id]);
|
||||
else body.append(el("div", "ag-diff-loading", t("Reading what changes…")));
|
||||
loadPreview(a).then(show, () => show(null));
|
||||
ed.append(body);
|
||||
if (a.id === "codex") ed.append(el("p", "ag-note", t("magpie's provider table stays, so sessions opened on magpie's models still open")));
|
||||
const bar = el("div", "bar");
|
||||
const go = el("button", "text primary danger-fill", t("Disconnect"));
|
||||
const go = el("button", "text primary danger-fill", t("Disconnect and restore"));
|
||||
go.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
go.disabled = true;
|
||||
@@ -802,6 +1217,8 @@ function askDisconnect(a) {
|
||||
try {
|
||||
state = await api("agents/disconnect/" + a.id, {});
|
||||
closeConfirmAsk();
|
||||
delete previews[a.id];
|
||||
if (agentExpanded === a.id) agentExpanded = null;
|
||||
renderAgents();
|
||||
const msg = t("{agent} no longer goes through magpie; its own settings are back", { agent: a.name });
|
||||
if (state.notice) status(`${msg}. ${t(state.notice)}`, "warn", 9000);
|
||||
@@ -814,7 +1231,7 @@ function askDisconnect(a) {
|
||||
};
|
||||
const cancel = el("button", "text", t("Cancel"));
|
||||
cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
|
||||
bar.append(el("span", "grow"), cancel, go);
|
||||
bar.append(STARTS_WITH.has(a.id) ? el("span", "ag-bar-note", t("Running {agent} copies take it once reopened", { agent: a.name })) : el("span"), el("span", "grow"), cancel, go);
|
||||
ed.append(bar);
|
||||
confirmAsk = ed;
|
||||
openModal(ed);
|
||||
@@ -1387,9 +1804,19 @@ async function openAgentModels(a, anchor, ev) {
|
||||
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 };
|
||||
// by whose they are, as the state counts them, for a connected row's chips
|
||||
const by = [];
|
||||
for (const m of models) {
|
||||
if (m.hidden) continue;
|
||||
let g = by.find((x) => x.name === m.group);
|
||||
if (!g) by.push(g = m.group === ROUTING_GROUPS ? { name: m.group, icons: m.icons, n: 0 } : { name: m.group, icon: m.icon, n: 0 });
|
||||
g.n++;
|
||||
}
|
||||
const count = { shown: models.length - hidden.length, listed: models.length, by };
|
||||
me.count = a.models = count;
|
||||
fillModelsEntry(me.anchor, a);
|
||||
// the line under the name counts them; an opened row is drawn again
|
||||
if (me.anchor.classList.contains("ag-models")) fillModelsEntry(me.anchor, a);
|
||||
else renderAgents();
|
||||
me.saving = me.saving.then(() => api("agent-models/" + encodeURIComponent(a.id), { hidden }))
|
||||
.catch((e) => status(e.message, "err"));
|
||||
};
|
||||
@@ -2533,7 +2960,10 @@ function renderEffortPicker() {
|
||||
}
|
||||
// a tier's effort, opened from the tiers' square: that square's title
|
||||
// and light follow the level
|
||||
if (TIER_EFFORTS.includes(opened.field.label)) {
|
||||
if (TIER_EFFORTS.includes(opened.field.label) && opened.anchor.classList.contains("ag-eff")) {
|
||||
opened.anchor.title = t("{label}: {value}", { label: t(opened.field.label), value: option.value ? effortName(option) : t("the effort Claude Code asks for") });
|
||||
opened.anchor.setAttribute("aria-label", opened.anchor.title);
|
||||
} else if (TIER_EFFORTS.includes(opened.field.label)) {
|
||||
const menu = tierMenu(opened.agent);
|
||||
if (menu) {
|
||||
opened.anchor.classList.toggle("set", menu.custom);
|
||||
|
||||
@@ -134,10 +134,71 @@ const I18N = {
|
||||
"Disconnect from magpie": "断开 magpie(还原配置)",
|
||||
"Take out everything magpie wrote into {agent}'s config and put back what it had before": "移除 magpie 写入 {agent} 配置的所有内容,还原成接入 magpie 之前的设置",
|
||||
"put back what {agent} had before magpie": "还原 {agent} 接入 magpie 之前的设置",
|
||||
"Disconnect {agent} from magpie?": "断开 {agent} 与 magpie 的连接?",
|
||||
"Disconnect {agent} from magpie?": "断开 {agent} 和 magpie?",
|
||||
"magpie takes out everything it wrote into {agent}'s config — its endpoint, key, models and effort — and puts back the settings {agent} had before. magpie's providers and accounts stay as they are.": "magpie 会移除它写入 {agent} 配置的所有内容(接口地址、密钥、模型和推理强度),并还原 {agent} 之前的设置。magpie 里的供应商和账号保持不变。",
|
||||
"Disconnect": "断开",
|
||||
"Not connected · {agent} uses its own settings": "未接入 · {agent} 用它自己的设置",
|
||||
"Not connected · {agent} uses its own settings": "未接入 · 用 {agent} 自己的设置",
|
||||
"Only Cursor's own models": "只能用 Cursor 自己的模型",
|
||||
"Only GitHub Copilot's own models": "只能用 GitHub Copilot 自己的模型",
|
||||
"Only Goose's own providers": "只能用 Goose 自己的供应商",
|
||||
"Only Devin's own models": "只能用 Devin 自己的模型",
|
||||
"Gemini CLI's requests all go through magpie; its Google sign-in is turned off first and comes back when you disconnect": "Gemini CLI 的请求都走 magpie,Google 登录会先关掉,断开时恢复",
|
||||
"Add a key or a subscription first; then there are models to connect": "先添加一个 key 或订阅,接入后才有模型可选",
|
||||
"Not connected · once connected, start it with magpie's command": "未接入 · 接入后要用 magpie 给的命令启动",
|
||||
"Not connected · now on its Claude subscription": "未接入 · 现在用 Claude 订阅",
|
||||
"Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected": "未接入 · 现在用 ChatGPT 登录 · 接入后 OpenAI 的模型照常可用",
|
||||
"Not connected · now on an API key of its own": "未接入 · 现在用它自己的 API key",
|
||||
"Not connected · now has {n} providers of its own": "未接入 · 现在有 {n} 个自己的供应商",
|
||||
"Reconnect": "重新接入",
|
||||
"Connected · new sessions take it": "已接入 · 新开的会话生效",
|
||||
"Connected · takes effect once {agent} is reopened": "已接入 · 重开 {agent} 后生效",
|
||||
"{n} models · {new} show in new {agent} sessions": "{n} 个模型 · {new}在新的 {agent} 会话里可见",
|
||||
"{n} new": "新加的 {n} 个",
|
||||
"Connected · switch the opus · sonnet · haiku tiers in /model": "已接入 · 在 /model 里切换 opus · sonnet · haiku 档位",
|
||||
"Connected · starts on {model}": "已接入 · 用 {model} 启动",
|
||||
"Connected · pick the model it starts on": "已接入 · 选一个启动模型",
|
||||
"{n} models in {agent}'s {cmd}": "{n} 个模型在 {agent} 的 {cmd} 里",
|
||||
"Collapse": "收起",
|
||||
"Tiers": "档位",
|
||||
"Models {n}": "模型 {n}",
|
||||
"Details": "详情",
|
||||
"Can't connect": "不能接入",
|
||||
"New sessions take it; switch the tiers in {cmd}.": "新开的会话生效,在 {cmd} 里切换档位。",
|
||||
"Reopen {agent}, then pick any model in {cmd}.": "重开 {agent} 后,在 {cmd} 里选任何一个模型。",
|
||||
"Pick any model in {agent}'s {cmd}.": "在 {agent} 的 {cmd} 里选任何一个模型。",
|
||||
"1 running {agent} still has the old list": "1 个正在运行的 {agent} 还是旧列表",
|
||||
"{n} running {agent} still have the old list": "{n} 个正在运行的 {agent} 还是旧列表",
|
||||
"Got it": "知道了",
|
||||
"In {agent}": "在 {agent} 里",
|
||||
"also for small background tasks": "也用于后台小任务",
|
||||
"the default tier": "默认档位",
|
||||
"Subagents": "子 agent",
|
||||
"Claude models": "Claude 模型",
|
||||
"Your Claude subscription is in magpie; picking a claude-* model goes through it as before": "Claude 订阅已在 magpie 里添加,选 claude-* 模型时照常走你的订阅",
|
||||
"Add a Claude subscription": "添加 Claude 订阅",
|
||||
"claude-* models need a Claude subscription in magpie": "claude-* 模型要先在 magpie 里添加 Claude 订阅",
|
||||
"Pick": "挑选",
|
||||
"Model list": "模型列表",
|
||||
"New sessions": "新会话默认",
|
||||
"Optional · unset, {agent} starts on its own last pick": "可选 · 不设就用 {agent} 自己上次的选择",
|
||||
"Once connected": "接入后",
|
||||
"Start it with": "启动命令",
|
||||
"View": "查看",
|
||||
"Hide changes": "收起",
|
||||
"Disconnect and restore": "断开并还原",
|
||||
"Changed ": "改动了 ",
|
||||
"Deleted": "删除",
|
||||
"Created": "新建",
|
||||
"value before connecting": "接入前的值",
|
||||
"Now: {line}": "现在:{line}",
|
||||
"{n} more lines": "还有 {n} 行",
|
||||
"{agent} goes back to how it was before it was connected; these are the places put back.": "{agent} 会回到接入前的样子,下面是要改回的地方。",
|
||||
"Reading what changes…": "正在读取要改回的地方…",
|
||||
"magpie's provider table stays, so sessions opened on magpie's models still open": "保留 magpie 供应商表,用 magpie 模型开过的会话还能打开",
|
||||
"Running {agent} copies take it once reopened": "正在运行的 {agent} 重开后生效",
|
||||
"Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list.": "打开「接入」,你在 magpie 配的模型会以 {magpie} 供应商出现在 agent 自己的模型列表里。",
|
||||
"Let your agents use the models you set up in magpie": "让 agent 用上你在 magpie 里配的模型",
|
||||
"Switch an agent on and your models show up as the magpie provider in its own model list. Then change models in the agent; switch it off and it goes back to how it was.": "打开一个 agent 的「接入」,你的模型就会以 magpie 供应商出现在它自己的模型列表里。之后在 agent 里换模型就行;关掉开关,它会回到原来的样子。",
|
||||
"Connected · pick magpie's models with {cmd} in {agent}": "已接入 · 在 {agent} 里用 {cmd} 选 magpie 的模型",
|
||||
"Connected · magpie's models are in {agent}'s model list": "已接入 · magpie 的模型在 {agent} 的模型列表里",
|
||||
"Connect {agent} to magpie": "将 {agent} 接入 magpie",
|
||||
|
||||
@@ -55,7 +55,7 @@ function server(lang, posts) {
|
||||
|
||||
const words = {
|
||||
en: { off: "Not connected · Codex uses its own settings", on: "Connected · pick magpie's models with /model in Codex", go: "Disconnect", done: /Codex is connected to magpie/ },
|
||||
zh: { off: "未接入 · Codex 用它自己的设置", on: "已接入 · 在 Codex 里用 /model 选 magpie 的模型", go: "断开", done: /Codex 已接入 magpie/ },
|
||||
zh: { off: "未接入 · 用 Codex 自己的设置", on: "已接入 · 在 Codex 里用 /model 选 magpie 的模型", go: "断开", done: /Codex 已接入 magpie/ },
|
||||
};
|
||||
const row = (id) => `.row.agent[data-id="${id}"]`;
|
||||
|
||||
@@ -86,7 +86,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const tops = () => page.evaluate(() => [document.scrollingElement.scrollTop, document.querySelector("#view-agents")?.scrollTop]);
|
||||
const top = await tops();
|
||||
const sw = () => page.locator(`${row("codex")} .ag-conn`);
|
||||
const said = () => page.locator(`${row("codex")} .ag-conn-line`).textContent();
|
||||
const said = () => page.locator(`${row("codex")} .ag-st`).textContent();
|
||||
|
||||
assert.equal(await page.locator(`${row("cursor")} .ag-conn`).count(), 0, "nothing of magpie's to connect it to");
|
||||
assert.equal(await sw().getAttribute("role"), "switch");
|
||||
|
||||
@@ -15,7 +15,7 @@ const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const options = ["gpt-5.4", "magpie/relay/m1"].map((m) => ({ value: m, label: "Label " + m }));
|
||||
const options = ["gpt-5.4", "magpie/relay/m1"].map((m) => ({ value: m, label: "Label " + m, ...(m.startsWith("magpie/") ? { ref: "relay/m1" } : {}) }));
|
||||
const fresh = () => ({
|
||||
agents: [
|
||||
{ id: "codex", name: "Codex", icon: "generic", path: "/fixture/codex", wired: true, fields: [{ key: "model", label: "model", value: "magpie/relay/m1", options }] },
|
||||
@@ -55,7 +55,7 @@ function server(lang, posts) {
|
||||
|
||||
const words = {
|
||||
en: { item: "Disconnect from magpie", ask: "Disconnect Codex from magpie?", go: "Disconnect", cancel: "Cancel", done: /Codex no longer goes through magpie/ },
|
||||
zh: { item: "断开 magpie(还原配置)", ask: "断开 Codex 与 magpie 的连接?", go: "断开", cancel: "取消", done: /Codex 已不再经过 magpie/ },
|
||||
zh: { item: "断开 magpie(还原配置)", ask: "断开 Codex 和 magpie?", go: "断开", cancel: "取消", done: /Codex 已不再经过 magpie/ },
|
||||
};
|
||||
const row = (id) => `.row.agent[data-id="${id}"]`;
|
||||
|
||||
@@ -111,12 +111,11 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await ask.waitFor({ state: "detached" });
|
||||
assert.deepEqual(posts, []);
|
||||
|
||||
// the model picker has it too, beside Default; Claude Code's hasn't
|
||||
await page.locator(`${row("claude")} .field[data-key="model"]`).click();
|
||||
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
|
||||
assert.equal(await page.locator("#list li", { hasText: w.item }).count(), 0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator("#pop").waitFor({ state: "hidden" });
|
||||
// the model picker, in the opened row, has it too, beside Default;
|
||||
// Claude Code, not connected, has no picker to open
|
||||
assert.equal(await page.locator(`${row("claude")} .field`).count(), 0);
|
||||
assert.equal(await page.locator(`${row("claude")} .ag-link`).count(), 0);
|
||||
await page.locator(`${row("codex")} .ag-link`).click();
|
||||
await page.locator(`${row("codex")} .field[data-key="model"]`).click();
|
||||
const pickItem = page.locator("#list li", { hasText: w.item });
|
||||
await pickItem.waitFor();
|
||||
@@ -128,7 +127,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
|
||||
await ask.locator(".bar button", { hasText: w.go }).click();
|
||||
await ask.waitFor({ state: "detached" });
|
||||
await page.waitForFunction((r) => document.querySelector(r + ' .field[data-key="model"] .v')?.textContent === "Label gpt-5.4", row("codex"));
|
||||
await page.waitForFunction((r) => document.querySelector(r + " .ag-conn")?.getAttribute("aria-checked") === "false", row("codex"));
|
||||
assert.deepEqual(posts, ["/api/agents/disconnect/codex"]);
|
||||
assert.match(await page.locator("#status").textContent(), w.done);
|
||||
await openMenu("codex");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// An agent's name must stay readable when the main window is narrow or
|
||||
// enlarged, without making the model and effort controls run past the row.
|
||||
// enlarged, without making its 「接入」 switch and the link that opens it run
|
||||
// past the row; the switches line up down the list.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -11,16 +12,16 @@ const assets = path.resolve(__dirname, "../assets");
|
||||
const state = {
|
||||
agents: [
|
||||
{
|
||||
id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color",
|
||||
id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", wired: true,
|
||||
fields: [
|
||||
{ key: "model", label: "model", value: "claude-sonnet-4.5", options: [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color" }] },
|
||||
{ key: "model", label: "model", value: "claude-sonnet-4.5", options: [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color", ref: "claude/claude-sonnet-4.5" }] },
|
||||
{ key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "High" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color",
|
||||
id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", wired: true,
|
||||
fields: [
|
||||
{ key: "model", label: "model", value: "gpt-6.1-sol", options: [{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai" }] },
|
||||
{ key: "model", label: "model", value: "gpt-6.1-sol", options: [{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai", ref: "openai/gpt-6.1-sol" }] },
|
||||
{ key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "Highest" }] },
|
||||
],
|
||||
},
|
||||
@@ -64,32 +65,27 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const row = page.locator('.row.agent[data-id="claude"]');
|
||||
const who = row.locator(".who");
|
||||
const name = row.locator(".name");
|
||||
const fields = row.locator(".fields");
|
||||
await fields.waitFor();
|
||||
for (const width of [520, 560, 600]) {
|
||||
await page.locator('.row.agent[data-id="claude"] .ag-conn').waitFor();
|
||||
const boxes = () => page.locator(".row.agent").evaluateAll((els) => els.map((el) => {
|
||||
const box = (e) => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width }; };
|
||||
return { row: box(el), who: box(el.querySelector(".who")), controls: [...el.querySelectorAll(":scope > .ag-link, :scope > .ag-conn")].map(box) };
|
||||
}));
|
||||
for (const width of [520, 560, 600, 660, 601, 960]) {
|
||||
await page.setViewportSize({ width, height: 700 });
|
||||
const [rb, wb, nb, fb] = await Promise.all([row.boundingBox(), who.boundingBox(), name.boundingBox(), fields.boundingBox()]);
|
||||
assert(wb.width >= 150, `agent identity column is too narrow at ${width}px: ${JSON.stringify({ rb, wb, nb, fb })}`);
|
||||
assert(fb.y >= wb.y + wb.height - 1, `controls should wrap below the agent name at ${width}px: ${JSON.stringify({ wb, fb })}`);
|
||||
assert(Math.abs(fb.x - wb.x) < 1, `wrapped controls should align with the name at ${width}px: ${JSON.stringify({ wb, fb })}`);
|
||||
assert(fb.x + fb.width <= rb.x + rb.width - 8, `controls must stay inside the row at ${width}px: ${JSON.stringify({ rb, fb })}`);
|
||||
const rows = await boxes();
|
||||
for (const { row, who, controls } of rows) {
|
||||
assert(who.width >= 150, `agent identity column is too narrow at ${width}px: ${JSON.stringify({ row, who })}`);
|
||||
assert.equal(controls.length, 2, "the link and the switch");
|
||||
for (const c of controls) {
|
||||
assert(c.right <= row.right - 8, `controls must stay inside the row at ${width}px: ${JSON.stringify({ row, c })}`);
|
||||
// beside the name, or on a line below it in a narrow window
|
||||
const ok = width > 600 ? c.x >= who.right && c.y < who.bottom : c.x >= who.right || c.y >= who.bottom - 1;
|
||||
assert(ok, `controls stay clear of the name at ${width}px: ${JSON.stringify({ who, c })}`);
|
||||
}
|
||||
}
|
||||
const rights = rows.map((r) => r.controls[1].right);
|
||||
assert(Math.abs(rights[0] - rights[1]) < 1, `the switches line up at ${width}px: ${JSON.stringify(rights)}`);
|
||||
if (width === 520) narrowShot = await page.screenshot();
|
||||
}
|
||||
|
||||
// The default 660px window and widths above the breakpoint keep
|
||||
// the controls beside the names and their right edges aligned.
|
||||
for (const width of [660, 601, 960]) {
|
||||
await page.setViewportSize({ width, height: 700 });
|
||||
const boxes = await page.locator(".row.agent").evaluateAll((els) => els.map((el) => {
|
||||
const whoBox = el.querySelector(".who").getBoundingClientRect();
|
||||
const fieldsBox = el.querySelector(".fields").getBoundingClientRect();
|
||||
return { who: { x: whoBox.x, y: whoBox.y, right: whoBox.right, bottom: whoBox.bottom }, fields: { x: fieldsBox.x, y: fieldsBox.y, right: fieldsBox.right } };
|
||||
}));
|
||||
assert(boxes.every(({ who, fields }) => fields.y < who.bottom && fields.x > who.right), `controls should stay beside names at ${width}px: ${JSON.stringify(boxes)}`);
|
||||
assert(Math.abs(boxes[0].fields.right - boxes[1].fields.right) < 1, `control columns should remain aligned at ${width}px: ${JSON.stringify(boxes)}`);
|
||||
if (width === 660) defaultShot = await page.screenshot();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ async function serve(route) {
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({
|
||||
agents: [{
|
||||
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color",
|
||||
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true,
|
||||
fields: [{ key: "model", label: "model", value: "magpie/openai/gpt-6", options: [{ value: "magpie/openai/gpt-6", label: "GPT-6", ref: "openai/gpt-6" }] }],
|
||||
models: { shown: models.length, listed: models.length },
|
||||
}],
|
||||
@@ -56,7 +56,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await browser.close();
|
||||
});
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator('.row.agent[data-id="codex"] .ag-models').click();
|
||||
await page.locator('.row.agent[data-id="codex"] .ag-link').click();
|
||||
await page.locator('.row.agent[data-id="codex"] .ag-exp .ag-chips .ag-quiet').click();
|
||||
const pop = page.locator(".am-pop:not(.leaving)");
|
||||
await pop.waitFor();
|
||||
const row = (name) => pop.locator(".am-mr", { hasText: name });
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Every model taken out of an agent's lists (#356, DeepSeek Harness): the
|
||||
// line under its name stays, "Showing 0 / 3 models", and opens the list to
|
||||
// put them back. Its pickers then list only the agent's own models, and the
|
||||
// line had gone with the catalog entries, leaving no way back.
|
||||
// Every model taken out of an agent's lists (#356, DeepSeek Harness): its
|
||||
// opened row still says how many are hidden, "3 hidden", and its Pick opens
|
||||
// the list to put them back. Its pickers then list only the agent's own
|
||||
// models, and the way in had gone with the catalog entries, leaving no way
|
||||
// back.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -24,7 +25,7 @@ function fixture(lang) {
|
||||
...list.filter((m) => !m.hidden).map((m) => ({ value: "magpie/" + m.id, label: m.name, ref: m.id }))];
|
||||
return {
|
||||
agents: [{
|
||||
id: "dsh", name: "DeepSeek Harness", path: "/test/.dsh/config.json", icon: "deepseek-color",
|
||||
id: "dsh", name: "DeepSeek Harness", path: "/test/.dsh/config.json", icon: "deepseek-color", wired: true,
|
||||
fields: [{ key: "model", label: "model", value: "deepseek-v4-pro", options }],
|
||||
models: count(),
|
||||
}],
|
||||
@@ -59,8 +60,8 @@ function fixture(lang) {
|
||||
}
|
||||
|
||||
const W = {
|
||||
en: { all: "All 3 models", none: "Showing 0 / 3 models", hideAll: "Hide all", showAll: "Show all" },
|
||||
zh: { all: "全部 3 个模型", none: "显示 0 / 3 个模型", hideAll: "全部隐藏", showAll: "全部显示" },
|
||||
en: { none: "3 hidden", hideAll: "Hide all", showAll: "Show all" },
|
||||
zh: { none: "已隐藏 3 个", hideAll: "全部隐藏", showAll: "全部显示" },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
@@ -82,9 +83,14 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await browser.close();
|
||||
});
|
||||
await page.goto("http://magpie.test/");
|
||||
const entry = page.locator('.row.agent[data-id="dsh"] .ag-models');
|
||||
const row = page.locator('.row.agent[data-id="dsh"]');
|
||||
await row.locator(".ag-link").click();
|
||||
const entry = row.locator(".ag-exp .ag-chips .ag-quiet");
|
||||
const hint = row.locator(".ag-exp .ag-chips .ag-hint");
|
||||
const shown = () => row.locator(".ag-st-t").innerText();
|
||||
await entry.waitFor();
|
||||
assert.equal((await entry.innerText()).trim(), w.all);
|
||||
assert.equal(await hint.count(), 0, "none hidden");
|
||||
assert.match(await shown(), /^3 /);
|
||||
const scroll = () => page.evaluate(() => [scrollY, document.scrollingElement.scrollTop, $("#view-agents").scrollTop]);
|
||||
const was = await scroll();
|
||||
|
||||
@@ -94,17 +100,19 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await pop.locator(".am-hide").click();
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal(fx.posts.at(-1).length, 3);
|
||||
assert.equal((await entry.innerText()).trim(), w.none);
|
||||
assert.equal((await hint.innerText()).trim(), w.none);
|
||||
assert.match(await shown(), /^0 /);
|
||||
assert.equal(await row.locator(".ag-chip").count(), 0, "no provider gives one");
|
||||
assert.equal(await pop.count(), 1, "the list stays open as the row is drawn again");
|
||||
|
||||
// closed, the agents are drawn again from the state: the line stays
|
||||
// closed, the agents are drawn again from the state: the way back stays
|
||||
const before = fx.stateCalls();
|
||||
await page.keyboard.press("Escape");
|
||||
await pop.waitFor({ state: "detached" });
|
||||
await page.waitForTimeout(300);
|
||||
assert(fx.stateCalls() > before, "the state is asked for again");
|
||||
await entry.waitFor();
|
||||
assert.equal((await entry.innerText()).trim(), w.none);
|
||||
assert.equal(await entry.locator("b").innerText(), "0");
|
||||
assert.equal((await hint.innerText()).trim(), w.none);
|
||||
|
||||
// and opens the list to show them all again
|
||||
await entry.click();
|
||||
@@ -114,11 +122,12 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await showAll.click();
|
||||
await page.waitForTimeout(150);
|
||||
assert.deepEqual(fx.posts.at(-1), []);
|
||||
assert.equal((await entry.innerText()).trim(), w.all);
|
||||
assert.equal(await hint.count(), 0);
|
||||
assert.equal(await row.locator(".ag-chip").innerText().then((s) => s.replace(/\s+/g, " ")), "DeepSeek 3");
|
||||
await page.keyboard.press("Escape");
|
||||
await pop.waitFor({ state: "detached" });
|
||||
await page.waitForTimeout(300);
|
||||
assert.equal((await entry.innerText()).trim(), w.all);
|
||||
assert.equal(await hint.count(), 0);
|
||||
assert.deepEqual(await scroll(), was, "no click moved the page");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ async function serve(route) {
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({
|
||||
agents: [{
|
||||
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color",
|
||||
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true,
|
||||
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: { shown: 3, listed: 3 },
|
||||
}],
|
||||
@@ -52,7 +52,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.route("**/*", serve);
|
||||
t.after(() => browser.close());
|
||||
await page.goto("http://magpie.test/");
|
||||
const entry = page.locator('.row.agent[data-id="codex"] .ag-models');
|
||||
// the list opens from Pick in the connected row, opened
|
||||
await page.locator('.row.agent[data-id="codex"] .ag-link').click();
|
||||
const entry = page.locator('.row.agent[data-id="codex"] .ag-exp .ag-chips .ag-quiet');
|
||||
await entry.waitFor();
|
||||
const boxes = () => page.locator(".am-pop:not(.leaving)").count();
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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.
|
||||
// /model): its connected row, opened, counts them by provider and those
|
||||
// hidden, and Pick 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");
|
||||
@@ -19,10 +20,18 @@ const models = () => [
|
||||
function fixture(lang) {
|
||||
const list = models();
|
||||
const posts = [];
|
||||
const count = () => ({ shown: list.filter((m) => !m.hidden).length, listed: list.length });
|
||||
const count = () => {
|
||||
const by = [];
|
||||
for (const m of list.filter((m) => !m.hidden)) {
|
||||
let g = by.find((x) => x.name === m.group);
|
||||
if (!g) by.push(g = { name: m.group, icon: m.icon, n: 0 });
|
||||
g.n++;
|
||||
}
|
||||
return { shown: list.filter((m) => !m.hidden).length, listed: list.length, by };
|
||||
};
|
||||
const state = () => ({
|
||||
agents: [{
|
||||
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color",
|
||||
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true,
|
||||
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(),
|
||||
}],
|
||||
@@ -55,8 +64,8 @@ function fixture(lang) {
|
||||
}
|
||||
|
||||
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", hideAll: "Hide all", one: "Showing 1 / 31 models", showAll: "Show all" },
|
||||
zh: { entry: "显示 8 / 31 个模型", after: "显示 7 / 31 个模型", all: "全部 31 个模型", title: "Codex 的模型列表", current: "在用", shown: "已显示", hideAll: "全部隐藏", one: "显示 1 / 31 个模型", showAll: "全部显示" },
|
||||
en: { entry: "23 hidden", after: "24 hidden", all: "", title: "Codex's model list", current: "Current", shown: "Shown", hideAll: "Hide all", one: "30 hidden", showAll: "Show all" },
|
||||
zh: { entry: "已隐藏 23 个", after: "已隐藏 24 个", all: "", title: "Codex 的模型列表", current: "在用", shown: "已显示", hideAll: "全部隐藏", one: "已隐藏 30 个", showAll: "全部显示" },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
@@ -78,13 +87,16 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await browser.close();
|
||||
});
|
||||
await page.goto("http://magpie.test/");
|
||||
const entry = page.locator('.row.agent[data-id="codex"] .ag-models');
|
||||
const row = page.locator('.row.agent[data-id="codex"]');
|
||||
await row.locator(".ag-link").click();
|
||||
const entry = row.locator(".ag-exp .ag-chips .ag-quiet");
|
||||
const hidden = async () => (await row.locator(".ag-exp .ag-chips .ag-hint").allInnerTexts()).join("").trim();
|
||||
const chips = async () => (await row.locator(".ag-chip").allInnerTexts()).map((s) => s.replace(/\s+/g, " "));
|
||||
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");
|
||||
assert.equal(await hidden(), w.entry);
|
||||
assert.deepEqual(await chips(), ["Routing groups 1", "OpenAI 6", "OpenRouter 1"].map((c) => lang === "zh" ? c.replace("Routing groups", "路由组") : c));
|
||||
// the line under the name counts them too
|
||||
assert.match(await row.locator(".who .ag-st-t").innerText(), /^8\b/);
|
||||
|
||||
const scroll = () => page.evaluate(() => [scrollY, document.scrollingElement.scrollTop, $("#view-agents").scrollTop]);
|
||||
const was = await scroll();
|
||||
@@ -117,8 +129,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
// 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.waitForFunction(() => document.querySelector(".ag-chips .ag-hint")?.innerText.includes("24"));
|
||||
assert.equal(await hidden(), w.after);
|
||||
assert.equal((await chips())[1], "OpenAI 5");
|
||||
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");
|
||||
@@ -154,7 +167,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal(fx.posts.at(-1).length, 30);
|
||||
assert(!fx.posts.at(-1).includes("openai/gpt-5.5"));
|
||||
assert.equal((await entry.innerText()).trim(), w.one);
|
||||
assert.equal(await hidden(), w.one);
|
||||
assert(await hideAll.isDisabled());
|
||||
assert.equal(await pop.locator(".am-g").nth(1).locator(".c").innerText(), "1 / 6");
|
||||
|
||||
@@ -162,7 +175,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await showAll.click();
|
||||
await page.waitForTimeout(150);
|
||||
assert.deepEqual(fx.posts.at(-1), []);
|
||||
assert.equal((await entry.innerText()).trim(), w.all);
|
||||
assert.equal(await hidden(), w.all);
|
||||
assert(await showAll.isDisabled());
|
||||
assert(!(await hideAll.isDisabled()));
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ const opt = (v) => ({ value: v, label: v });
|
||||
function agents({ ultracode = "", effort = "", tierEffort = "", subEffort = "", model = "" } = {}) {
|
||||
return [
|
||||
{
|
||||
id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color",
|
||||
id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", wired: true,
|
||||
fields: [
|
||||
{ key: "model", label: "model", value: model, options: [opt("claude-sonnet-4.5")] },
|
||||
{ key: "model", label: "model", value: model, options: [{ ...opt("claude-sonnet-4.5"), ref: "claude/claude-sonnet-4.5" }] },
|
||||
{ key: "effort", label: "effort", value: effort, options: [opt("high"), opt("xhigh")] },
|
||||
{ key: "ultracode", label: "ultracode", value: ultracode, options: [opt("on")] },
|
||||
{ key: "opus_effort", label: "opus effort", value: tierEffort, options: [opt("high")] },
|
||||
@@ -91,6 +91,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
test(`${engine}: turning ultracode on keeps every agent in view`, async (t) => {
|
||||
const { page, errors } = await open(t, engine, {});
|
||||
assert.deepEqual((await arranged(page)).folded, [], "a fresh magpie shows them all");
|
||||
// in its connected row, opened
|
||||
await page.locator('.row.agent[data-id="claude"] .ag-link').click();
|
||||
const toggle = page.locator('.row.agent[data-id="claude"] [data-key="ultracode"]');
|
||||
await toggle.click();
|
||||
await page.waitForFunction(() => document.querySelector('.row.agent[data-id="claude"] [data-key="ultracode"]')?.getAttribute("aria-pressed") === "true");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Antigravity CLI (agy) takes magpie's gateway only from its environment, so
|
||||
// its row on the Agents page has a square that copies the command starting
|
||||
// it on magpie, while it is on one of magpie's models: the command in its
|
||||
// tooltip, one click copying it (posted to /api/copy) and saying so, with the
|
||||
// it on magpie, once it is connected (its model picker stays in the row, as
|
||||
// agy picks no model once started): the command in its tooltip, one click copying it (posted to /api/copy) and saying so, with the
|
||||
// page left where it was; none on an agent without one; in the tray panel's
|
||||
// opened row too; the words in Chinese. No backend: the API is faked here.
|
||||
const assert = require("node:assert/strict");
|
||||
@@ -13,9 +13,9 @@ const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const LAUNCH = "GEMINI_API_KEY=magpie-agy GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:3425 agy --model 'magpie/deepseek/pro'";
|
||||
const models = ["magpie/deepseek/pro", "model-b"].map((m) => ({ value: m, label: m }));
|
||||
const models = [{ value: "magpie/deepseek/pro", label: "magpie/deepseek/pro", ref: "deepseek/pro" }, { value: "model-b", label: "model-b" }];
|
||||
const agent = (id, name, launch) => ({
|
||||
id, name, path: "/test/" + id, launch,
|
||||
id, name, path: "/test/" + id, launch, wired: !!launch,
|
||||
fields: [{ key: "model", label: "model", value: "magpie/deepseek/pro", options: models }],
|
||||
});
|
||||
const state = {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
// a model Claude Code asks Anthropic for itself says so: the row's tooltip
|
||||
// says it isn't through magpie and why the file names no magpie endpoint,
|
||||
// and picking one says "straight to Anthropic". A magpie model says neither.
|
||||
// No click moves the page. In English and Chinese, Chromium and WebKit.
|
||||
// The model is picked in the connected row, opened from its link. No click
|
||||
// moves the page. In English and Chinese, Chromium and WebKit.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -26,7 +27,7 @@ const filler = [{ key: "model", label: "model", value: "magpie/deepseek/pro", op
|
||||
const fresh = () => ({
|
||||
agents: [
|
||||
...Array.from({ length: 5 }, (_, i) => ({ id: "agent-" + i, name: "Agent " + i, path: "/test/" + i, fields: filler })),
|
||||
{ id: "claude", name: "Claude Code", icon: "claudecode-color", path: "~/.claude/settings.json",
|
||||
{ id: "claude", name: "Claude Code", icon: "claudecode-color", path: "~/.claude/settings.json", wired: true,
|
||||
fields: [{ key: "model", label: "model", value: "sonnet", options }] },
|
||||
...Array.from({ length: 8 }, (_, i) => ({ id: "more-" + i, name: "More " + i, path: "/test/m" + i, fields: filler })),
|
||||
],
|
||||
@@ -85,7 +86,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const sets = [];
|
||||
await page.route("**/*", server(lang, sets));
|
||||
await page.goto("http://magpie.test/");
|
||||
const field = page.locator(`${row} .field[data-key="model"]`);
|
||||
await page.locator(`${row} .ag-link`).click();
|
||||
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
|
||||
await field.waitFor();
|
||||
|
||||
// the alias, as the model it stands for, with Claude's logo
|
||||
@@ -97,7 +99,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
// the page scrolled, a pick moves nothing and says where it goes
|
||||
const view = page.locator("#view-agents");
|
||||
await page.mouse.move(400, 300);
|
||||
for (let i = 0; i < 2; i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); }
|
||||
// down to the opened row's picker, so the click has no need to scroll
|
||||
for (let i = 0; i < 8; i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); }
|
||||
await page.waitForTimeout(300);
|
||||
const top = await view.evaluate((v) => v.scrollTop);
|
||||
assert(top > 0, "the list must be scrolled");
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
// a click posts ultracode on, lights it and says the restart; the effort
|
||||
// slider's change says it too. On Opus 4.5 the slider has its three levels
|
||||
// and no ultracode; on Haiku 4.5 there is neither an effort nor ultracode.
|
||||
// The window scrolled down stays where it is through the clicks. English
|
||||
// and Chinese, Chromium and WebKit, the window and the tray panel.
|
||||
// In the window they are in a connected row, opened from its link; the
|
||||
// window scrolled down stays where it is through the clicks. English and
|
||||
// Chinese, Chromium and WebKit, the window and the tray panel.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -16,9 +17,9 @@ const { chromium, webkit } = require("playwright");
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const five = ["low", "medium", "high", "xhigh", "max"];
|
||||
const claude = (id, model, efforts, ultracode) => ({
|
||||
id, name: "Claude Code " + id, icon: "claudecode-color", path: "/test/" + id,
|
||||
id, name: "Claude Code " + id, icon: "claudecode-color", path: "/test/" + id, wired: true,
|
||||
fields: [
|
||||
{ key: "model", label: "model", value: model, options: [{ value: model, icon: "claude-color" }] },
|
||||
{ key: "model", label: "model", value: model, options: [{ value: model, icon: "claude-color", ref: "claude/" + model }] },
|
||||
{ key: "effort", label: "effort", value: "", options: efforts.map((value) => ({ value })) },
|
||||
{ key: "ultracode", label: "ultracode", value: ultracode, options: efforts.includes("xhigh") ? [{ value: "on", note: "Claude plans a workflow for each substantive task" }] : [] },
|
||||
],
|
||||
@@ -82,12 +83,19 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await t.test("the window", async () => {
|
||||
const sets = [];
|
||||
const page = await open("", sets);
|
||||
// the rows as the model has them
|
||||
assert.equal(await page.locator(`${row("o55")} .field[data-key="ultracode"]`).count(), 1);
|
||||
// the rows as the model has them, each opened in turn
|
||||
const expand = async (id) => {
|
||||
await page.locator(`${row(id)} .ag-link`).click();
|
||||
await page.locator(`${row(id)} .ag-exp`).waitFor();
|
||||
};
|
||||
await expand("o45");
|
||||
assert.equal(await page.locator(`${row("o45")} .field[data-key="ultracode"]`).count(), 0, "ultracode on a model without xhigh");
|
||||
assert.equal(await page.locator(`${row("o45")} .field[data-key="effort"]`).count(), 1);
|
||||
await expand("h45");
|
||||
assert.equal(await page.locator(`${row("h45")} .field[data-key="effort"]`).count(), 0, "an effort for Haiku 4.5");
|
||||
assert.equal(await page.locator(`${row("h45")} .field[data-key="ultracode"]`).count(), 0);
|
||||
assert.equal(await page.locator(`${row("o45")} .field[data-key="effort"]`).count(), 1);
|
||||
await expand("o55");
|
||||
assert.equal(await page.locator(`${row("o55")} .field[data-key="ultracode"]`).count(), 1);
|
||||
|
||||
const sq = page.locator(`${row("o55")} .field[data-key="ultracode"]`);
|
||||
assert.equal(await sq.getAttribute("aria-pressed"), "false");
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
// a click on it leaves the keys to the filter (focus went to the page, and
|
||||
// Esc and the arrows did nothing); a query shows the rows it finds, so an id
|
||||
// typed in full is picked with Enter (which only opened the fold); and a
|
||||
// star on a dated id still shows (its row was gone, Favorites empty).
|
||||
// star on a dated id still shows (its row was gone, Favorites empty). The
|
||||
// picker is the connected row's, opened from its link.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -37,7 +38,7 @@ const options = [
|
||||
via("claude/claude-opus-4-5-20251101", "Claude Opus 4.5", "claude/claude-opus-4-5"),
|
||||
];
|
||||
const state = (value) => ({
|
||||
agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color", path: "/test/settings.json",
|
||||
agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color", path: "/test/settings.json", wired: true,
|
||||
fields: [{ key: "model", label: "model", value, options }] }],
|
||||
profiles: [],
|
||||
});
|
||||
@@ -89,7 +90,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.route("**/*", server(lang, value, sets));
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator(row).waitFor();
|
||||
await page.locator(`${row} .field[data-key="model"]`).click();
|
||||
await page.locator(`${row} .ag-link`).click();
|
||||
await page.locator(`${row} .ag-exp .field[data-key="model"]`).click();
|
||||
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
|
||||
await page.waitForTimeout(400); // the picker grows open
|
||||
return page;
|
||||
@@ -147,7 +149,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.locator(`${row} .field[data-key="model"]`).click();
|
||||
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
|
||||
list = await rows(page);
|
||||
assert.deepEqual(list[1], { v: "Claude Opus 5.5", n: "me@example.com · via magpie", cur: true, fold: false }, JSON.stringify(list));
|
||||
// after Default and, connected, the way back
|
||||
assert.deepEqual(list[2], { v: "Claude Opus 5.5", n: "me@example.com · via magpie", cur: true, fold: false }, JSON.stringify(list));
|
||||
assert.equal(list.filter((r) => r.fold).map((r) => r.v).join(), w.one);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
@@ -111,10 +111,18 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const posts = [];
|
||||
const page = await open("en", posts);
|
||||
const view = page.locator("#view-agents");
|
||||
// the reader scrolls down a little, so a scroll would show
|
||||
await page.mouse.move(400, 300);
|
||||
for (let i = 0; i < 6; i++) { await page.mouse.wheel(0, 40); await page.waitForTimeout(20); }
|
||||
await page.waitForTimeout(300);
|
||||
// the reader scrolls down until a pill is in view, clear of the
|
||||
// footer, so a scroll would show
|
||||
const wheelTo = async (id) => {
|
||||
const pill = page.locator(`${row(id)} .ag-up`);
|
||||
await page.mouse.move(400, 300);
|
||||
for (let i = 0; i < 30 && await pill.evaluate((b) => b.getBoundingClientRect().bottom > 460); i++) {
|
||||
await page.mouse.wheel(0, 40);
|
||||
await page.waitForTimeout(60);
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
await wheelTo("codex");
|
||||
const top = await view.evaluate((v) => v.scrollTop);
|
||||
assert(top > 0, "the list must be scrolled");
|
||||
const was = await page.locator(`${row("codex")} .ag-up`).evaluate((b) => b.getBoundingClientRect().top);
|
||||
@@ -134,6 +142,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert(Math.abs(now - was) <= 3, `the row moved ${now - was}px`);
|
||||
|
||||
// one that fails says why, and can be tried again
|
||||
await wheelTo("gemini");
|
||||
await page.locator(`${row("gemini")} .ag-up`).click();
|
||||
await page.waitForTimeout(1000);
|
||||
assert.match(await page.locator("#status").textContent(), /EACCES/);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// tiers each take a model of their own from magpie (WilianWeng). Desktop has
|
||||
// no model field here for them to follow: the tiers' square says what an
|
||||
// unset one runs on instead, and its picker's reset entry is "Not set", not
|
||||
// "Same as model". In English and Chinese. No backend: the API is faked here.
|
||||
// "Same as model". Connected to magpie, the square is in the row opened from
|
||||
// its link. In English and Chinese. No backend: the API is faked here.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -11,11 +12,11 @@ const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const models = [{ value: "magpie/v/glm", label: "GLM" }, { value: "magpie/v/flash", label: "Flash" }];
|
||||
const models = [{ value: "magpie/v/glm", label: "GLM", ref: "v/glm" }, { value: "magpie/v/flash", label: "Flash", ref: "v/flash" }];
|
||||
const tiers = ["opus", "sonnet", "haiku", "fable"];
|
||||
const fresh = () => ({
|
||||
agents: [{
|
||||
id: "claude-desktop", name: "Claude Desktop", path: "/test/claude_desktop_config.json", icon: "claude-color",
|
||||
id: "claude-desktop", name: "Claude Desktop", path: "/test/claude_desktop_config.json", icon: "claude-color", wired: true,
|
||||
fields: [
|
||||
{ key: "provider", label: "provider", value: "magpie", options: [{ value: "magpie", label: "magpie" }] },
|
||||
...tiers.map((tier) => ({ key: tier, label: tier, value: tier === "haiku" ? "magpie/v/flash" : "", options: models })),
|
||||
@@ -70,8 +71,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
t.after(() => browser.close());
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator(cd).waitFor();
|
||||
await page.locator(`${cd} .ag-link`).click();
|
||||
|
||||
const square = page.locator(`${cd} .field.extra[data-key="tiers"]`);
|
||||
const square = page.locator(`${cd} .ag-exp .field.extra[data-key="tiers"]`);
|
||||
assert.equal(await square.count(), 1, "no tiers square");
|
||||
assert.ok((await square.getAttribute("aria-label")).startsWith(w.summary), await square.getAttribute("aria-label"));
|
||||
const y = await page.evaluate(() => scrollY);
|
||||
|
||||
@@ -10,8 +10,8 @@ const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const models = ["claude-sonnet-5-5", "claude-opus-5-5"].map((m) => ({ value: m, label: "Label " + m }));
|
||||
const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [{ key: "model", label: "model", value: "claude-sonnet-5-5", options: models }] }];
|
||||
const models = ["claude-sonnet-5-5", "claude-opus-5-5"].map((m) => ({ value: m, ref: "claude/" + m, label: "Label " + m }));
|
||||
const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", wired: true, fields: [{ key: "model", label: "model", value: "claude-sonnet-5-5", options: models }] }];
|
||||
|
||||
function server(lang, theme) {
|
||||
return async (route) => {
|
||||
@@ -53,7 +53,11 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.locator(`${row} .ag-sum`).click();
|
||||
await page.waitForTimeout(700);
|
||||
await page.locator(`${row} .ag-open .field[data-key="model"]`).click();
|
||||
} else await page.locator(`${row} .field[data-key="model"]`).click();
|
||||
} else {
|
||||
// the window: in the connected agent's opened row
|
||||
await page.locator(`${row} .ag-link`).click();
|
||||
await page.locator(`${row} .ag-exp .field[data-key="model"]`).click();
|
||||
}
|
||||
const li = page.locator("#pop:not([hidden]) #list li").filter({ hasText: "Label claude-opus-5-5" }).first();
|
||||
await li.waitFor();
|
||||
const star = li.locator(".favorite");
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Phone navigation and content stay usable in both languages (#391). The
|
||||
// desktop screenshots match BASE_REF within a small rendering tolerance
|
||||
// (origin/main by default); only API
|
||||
// boundaries are faked, never the page's layout or scrolling helpers.
|
||||
// (origin/main by default), but for the Agents page while BASE_REF hasn't its
|
||||
// 「接入」 rows yet; only API boundaries are faked, never the page's layout or
|
||||
// scrolling helpers.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -22,27 +23,33 @@ const usage = { ...groups[0], path: "/test/usage.jsonl", bucket: "day", agents:
|
||||
series: Array.from({ length: 30 }, (_, i) => ({ ...groups[0], label: "09-" + String(i + 1).padStart(2, "0") })) };
|
||||
// Populated rows are essential: an empty Agents list cannot expose clipped
|
||||
// model/effort controls or make the desktop comparison cover those rows.
|
||||
const modelOptions = [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color" },
|
||||
{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai" }];
|
||||
// Connected, so each opens on its pickers.
|
||||
const modelOptions = [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color", ref: "claude/claude-sonnet-4.5" },
|
||||
{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai", ref: "openai/gpt-6.1-sol" }];
|
||||
const modelField = (key, label, value = "") => ({ key, label, value, options: modelOptions });
|
||||
const agentRows = [
|
||||
{ id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", fields: [
|
||||
{ id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", wired: true, fields: [
|
||||
modelField("model", "model", "claude-sonnet-4.5"),
|
||||
{ key: "effort", label: "thinking", value: "high", options: [{ value: "high", label: "High" }] },
|
||||
] },
|
||||
{ id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", fields: [
|
||||
{ id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", wired: true, fields: [
|
||||
modelField("model", "model", "gpt-6.1-sol"),
|
||||
{ key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "High" }] },
|
||||
modelField("subagent", "subagents"),
|
||||
{ key: "signin", label: "sign-in", value: "magpie", options: [{ value: "magpie", label: "magpie" }, { value: "chatgpt", label: "ChatGPT" }] },
|
||||
] },
|
||||
{ id: "omp", name: "omp", path: "/test/omp.json", icon: "omp", fields: [
|
||||
{ id: "omp", name: "omp", path: "/test/omp.json", icon: "omp", wired: true, fields: [
|
||||
modelField("model", "model", "gpt-6.1-sol"), modelField("subagent", "subagents"),
|
||||
modelField("small", "smol"), modelField("slow", "slow"),
|
||||
] },
|
||||
];
|
||||
const views = ["agents", "providers", "gateway", "routing", "usage", "library", "plugins", "settings"];
|
||||
|
||||
function baselineFile(file) {
|
||||
if (!baseline.has(file)) baseline.set(file, execFileSync("git", ["show", `${base}:internal/gui/assets/${file}`], { cwd: assets, maxBuffer: 4 * 1024 * 1024 }));
|
||||
return baseline.get(file);
|
||||
}
|
||||
|
||||
function server(lang, original = false, opened = []) {
|
||||
return async route => {
|
||||
const url = new URL(route.request().url());
|
||||
@@ -73,10 +80,8 @@ function server(lang, original = false, opened = []) {
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
try {
|
||||
let body;
|
||||
if (original) {
|
||||
if (!baseline.has(file)) baseline.set(file, execFileSync("git", ["show", `${base}:internal/gui/assets/${file}`], { cwd: assets, maxBuffer: 4 * 1024 * 1024 }));
|
||||
body = baseline.get(file);
|
||||
} else body = await fs.readFile(path.join(assets, file));
|
||||
if (original) body = baselineFile(file);
|
||||
else body = await fs.readFile(path.join(assets, file));
|
||||
await route.fulfill({ body, contentType });
|
||||
} catch { await route.fulfill({ status: 404 }); }
|
||||
};
|
||||
@@ -131,6 +136,9 @@ async function agentsFit(page) {
|
||||
for (const agent of agentRows) {
|
||||
const row = page.locator(`.row.agent[data-id="${agent.id}"]`);
|
||||
await row.waitFor();
|
||||
// opened, its pickers in the row
|
||||
await row.locator(".ag-link").click();
|
||||
await row.locator(".ag-exp").waitFor();
|
||||
const bad = await row.evaluate(row => {
|
||||
const box = row.getBoundingClientRect(), bad = [];
|
||||
for (const field of row.querySelectorAll(".field")) {
|
||||
@@ -252,6 +260,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const original = await open(browser, lang, width, { touch: false, original: true });
|
||||
try {
|
||||
for (const view of views) {
|
||||
if (view === "agents" && !baselineFile("app.js").includes("function connectPanel")) continue;
|
||||
await go(current, view); await go(original, view);
|
||||
await current.mouse.move(0, 0); await original.mouse.move(0, 0);
|
||||
// Removing hover can repaint the tab's shadow in a later frame.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// still out (it answers with the whole state, which took seconds: Claude
|
||||
// Code's model took 5–10s to show, in the window and the tray panel alike).
|
||||
// The answer then draws what the config says; a refused pick puts the old
|
||||
// model back and says why. The page never moves. In English and Chinese.
|
||||
// model back and says why. The page never moves. In the window the picker
|
||||
// is in the connected agent's opened row. In English and Chinese.
|
||||
// No backend: the API is faked here, its /api/set held for SLOW ms.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
@@ -13,9 +14,9 @@ const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const SLOW = 2500;
|
||||
const models = ["claude-sonnet-5-5", "claude-opus-5-5", "magpie/deepseek/pro", "magpie/kimi/k3"].map((m) => ({ value: m, label: "Label " + m }));
|
||||
const models = ["claude-sonnet-5-5", "claude-opus-5-5", "magpie/deepseek/pro", "magpie/kimi/k3"].map((m) => ({ value: m, ref: m.replace(/^magpie\//, ""), label: "Label " + m }));
|
||||
const agent = (id, name) => ({
|
||||
id, name, path: "/test/" + id,
|
||||
id, name, path: "/test/" + id, wired: id === "claude",
|
||||
fields: [{ key: "model", label: "model", value: "claude-sonnet-5-5", options: models }],
|
||||
});
|
||||
const fresh = () => ({
|
||||
@@ -72,6 +73,17 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.locator(row).waitFor();
|
||||
return page;
|
||||
};
|
||||
// the window: the connected agent's row opened, and scrolled down to its
|
||||
// picker as a reader would (the page puts back any scroll that isn't
|
||||
// the reader's), by at least `least` turns of the wheel
|
||||
const toPicker = async (page, field, least = 0) => {
|
||||
const view = page.locator("#view-agents");
|
||||
await page.locator(`${row} .ag-link`).click();
|
||||
await page.mouse.move(400, 300);
|
||||
const shown = async () => { const f = await field.boundingBox(), v = await view.boundingBox(); return f.y + f.height + 8 <= v.y + v.height; };
|
||||
for (let i = 0; i < least || !(await shown()); i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(i < least ? 20 : 80); }
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
// the picker's row for a model, clicked
|
||||
const pickModel = async (page, value) => {
|
||||
const li = page.locator("#list li").filter({ hasText: "Label " + value });
|
||||
@@ -84,10 +96,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const sets = [];
|
||||
const page = await open("http://magpie.test/", lang, sets);
|
||||
const view = page.locator("#view-agents");
|
||||
const field = page.locator(`${row} .field[data-key="model"]`);
|
||||
await page.mouse.move(400, 300);
|
||||
for (let i = 0; i < 2; i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); }
|
||||
await page.waitForTimeout(300);
|
||||
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
|
||||
await toPicker(page, field, 2);
|
||||
const top = await view.evaluate((v) => v.scrollTop);
|
||||
assert(top > 0, "the list must be scrolled");
|
||||
await field.click();
|
||||
@@ -126,7 +136,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await t.test("a refused pick puts the old model back", async () => {
|
||||
const sets = [];
|
||||
const page = await open("http://magpie.test/", "en", sets, { refuse: true });
|
||||
const field = page.locator(`${row} .field[data-key="model"]`);
|
||||
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
|
||||
await toPicker(page, field);
|
||||
await field.click();
|
||||
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
|
||||
await pickModel(page, "claude-opus-5-5");
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
// through (Qwen3.8-Flash free, 0.1× struck through; 0.2× with 0.5× struck
|
||||
// through). A small grey badge on the provider editor's model chips and in
|
||||
// an agent's model picker, its title saying it; a model with no rate has
|
||||
// none. Clicking a chip picks it and scrolls nothing; no stripe. In English
|
||||
// and Chinese, Chromium and WebKit.
|
||||
// none (the picker in a connected agent's opened row). Clicking a chip
|
||||
// picks it and scrolls nothing; no stripe. In English and Chinese,
|
||||
// Chromium and WebKit.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -29,11 +30,11 @@ const qoder = {
|
||||
key: { set: true, masked: "sk-…one" }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "",
|
||||
};
|
||||
const options = [
|
||||
{ value: "magpie/qoder/qmodel_38max", label: "Qwen3.8-Max", note: "Qoder · via magpie", group: "Qoder", rate: 0.5 },
|
||||
{ value: "magpie/qoder/qfmodel", label: "Qwen3.8-Flash", note: "Qoder · via magpie", group: "Qoder", free: true, rateWas: 0.1 },
|
||||
{ value: "magpie/qoder/plain", label: "Plain", note: "Qoder · via magpie", group: "Qoder" },
|
||||
{ value: "magpie/qoder/qmodel_38max", ref: "qoder/qmodel_38max", label: "Qwen3.8-Max", note: "Qoder · via magpie", group: "Qoder", rate: 0.5 },
|
||||
{ value: "magpie/qoder/qfmodel", ref: "qoder/qfmodel", label: "Qwen3.8-Flash", note: "Qoder · via magpie", group: "Qoder", free: true, rateWas: 0.1 },
|
||||
{ value: "magpie/qoder/plain", ref: "qoder/plain", label: "Plain", note: "Qoder · via magpie", group: "Qoder" },
|
||||
];
|
||||
const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [{ key: "model", label: "model", value: "magpie/qoder/plain", options }] }];
|
||||
const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", wired: true, fields: [{ key: "model", label: "model", value: "magpie/qoder/plain", options }] }];
|
||||
|
||||
function serve(lang) {
|
||||
const providers = { providers: [qoder], presets: [], excluded: [], gateway: { running: true, window: true } };
|
||||
@@ -128,7 +129,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
|
||||
// an agent's model picker
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator('.row.agent[data-id="claude"] .field[data-key="model"]').click();
|
||||
await page.locator('.row.agent[data-id="claude"] .ag-link').click();
|
||||
await page.locator('.row.agent[data-id="claude"] .ag-exp .field[data-key="model"]').click();
|
||||
const list = page.locator("#pop:not([hidden]) #list");
|
||||
await list.locator("li").filter({ hasText: "Qwen3.8-Max" }).first().waitFor();
|
||||
const rows = await list.locator("li[data-i]").evaluateAll((ls) => ls.map((l) => {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// (#325), so each is a square after the pickers, not a picker of its own:
|
||||
// unset it says it follows the model, its picker opens on "Same as model",
|
||||
// and set it names the model it is on. OpenCode's small model, which
|
||||
// doesn't follow the model, stays a picker. In English and Chinese.
|
||||
// doesn't follow the model, stays a picker. Both read in the connected
|
||||
// agent's opened row. In English and Chinese.
|
||||
// No backend: the API is faked here.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
@@ -20,10 +21,10 @@ const field = (key, label, value) => ({ key, label, value, options });
|
||||
const fresh = () => ({
|
||||
agents: [
|
||||
{
|
||||
id: "omp", name: "omp", path: "/test/config.yml", icon: "omp",
|
||||
id: "omp", name: "omp", path: "/test/config.yml", icon: "omp", wired: true,
|
||||
fields: [field("model", "model", "magpie/deepseek/pro"), field("subagent", "subagents", ""), field("small", "smol", ""), field("slow", "slow", "magpie/deepseek/flash")],
|
||||
},
|
||||
{ id: "opencode", name: "OpenCode", path: "/test/opencode.json", fields: [field("model", "model", "magpie/deepseek/pro"), field("small", "small", "")] },
|
||||
{ id: "opencode", name: "OpenCode", path: "/test/opencode.json", wired: true, fields: [field("model", "model", "magpie/deepseek/pro"), field("small", "small", "")] },
|
||||
],
|
||||
profiles: [],
|
||||
});
|
||||
@@ -81,6 +82,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
});
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator(omp).waitFor();
|
||||
await page.locator(`${omp} .ag-link`).click();
|
||||
|
||||
// the three roles are squares in the row's squares cell; the model alone is a picker
|
||||
const square = (key) => page.locator(`${omp} .extras-cell .field.extra[data-key="${key}"]`);
|
||||
@@ -105,6 +107,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
|
||||
// OpenCode's small model doesn't follow the model: a picker, not a square
|
||||
const oc = '.row.agent[data-id="opencode"]';
|
||||
await page.locator(`${oc} .ag-link`).click();
|
||||
await page.locator(`${oc} .ag-exp`).waitFor();
|
||||
assert.equal(await page.locator(`${oc} .field.extra`).count(), 0);
|
||||
assert.equal(await page.locator(`${oc} .field[data-key="small"]`).count(), 1);
|
||||
assert.deepEqual(errors, []);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// bars in the list and the effort icon full; one between two offered lights
|
||||
// them as far as its stop. auto, none and the default light no bar, where
|
||||
// minimal lights one, and none counts for no level: Hermes at low, second
|
||||
// of five, lights one of three. In English and Chinese. No backend: the API
|
||||
// of five, lights one of three; the window's icon is read in a connected
|
||||
// agent's opened row. In English and Chinese. No backend: the API
|
||||
// is faked here. ARTIFACT_DIR gets the slider and the panel's list.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
@@ -19,8 +20,8 @@ const { chromium, webkit } = require("playwright");
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const levels = ["minimal", "low", "medium", "high", "xhigh", "max"].map((value) => ({ value }));
|
||||
const agent = (id, name, effort, options = levels) => ({
|
||||
id, name, path: "/test/" + id,
|
||||
fields: [{ key: "model", label: "model", value: "anthropic/claude-opus-5-5:max", options: [] },
|
||||
id, name, path: "/test/" + id, wired: true,
|
||||
fields: [{ key: "model", label: "model", value: "anthropic/claude-opus-5-5:max", options: [{ value: "anthropic/claude-opus-5-5:max", ref: "anthropic/claude-opus-5-5:max" }] },
|
||||
{ key: "effort", label: "thinking", value: effort, options }],
|
||||
});
|
||||
const some = (...vs) => vs.map((value) => ({ value }));
|
||||
@@ -64,8 +65,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
});
|
||||
// the page, a row opened in the panel with its slider and a click on the
|
||||
// slider's first or last stop
|
||||
const load = async (lang, sets, url) => {
|
||||
const page = await (await browser.newContext({ viewport: { width: 440, height: 560 } })).newPage();
|
||||
const load = async (lang, sets, url, viewport = { width: 440, height: 560 }) => {
|
||||
const page = await (await browser.newContext({ viewport })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", server(lang, sets));
|
||||
@@ -129,10 +130,15 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await t.test("as bars", async () => {
|
||||
const lit = (page, id) => page.locator(`${row(id)} .field[data-key="effort"] .effort-ic rect[opacity="1"]`).count();
|
||||
const ids = ["omp-listed", "pi-minimal", "codex", "kimi", "grok", "hermes"];
|
||||
const page = await load("en", [], "http://magpie.test/");
|
||||
const page = await load("en", [], "http://magpie.test/", { width: 980, height: 900 });
|
||||
await page.locator(row("hermes")).waitFor();
|
||||
const icons = [];
|
||||
for (const id of ids) icons.push(await lit(page, id));
|
||||
for (const id of ids) {
|
||||
// Hermes picks no model once started: its pickers stay in the row
|
||||
const more = page.locator(`${row(id)} .ag-link`);
|
||||
if (await more.count()) { await more.click(); await page.waitForTimeout(100); }
|
||||
icons.push(await lit(page, id));
|
||||
}
|
||||
const panel = await load("en", [], "http://magpie.test/?mode=panel");
|
||||
await panel.locator(row("hermes")).waitFor();
|
||||
const bars = [];
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
// the icon below: the browser's own tooltip came up under the pointer, and
|
||||
// Devin's name sat on ZCode's Z as if it were ZCode's (Elan on X). Hovering
|
||||
// from one icon to the next moves the name with it; leaving, a click, Esc
|
||||
// and closing the picker take it away. Keyboard focus shows it too. In
|
||||
// Chromium and WebKit, English and Chinese, light and dark.
|
||||
// and closing the picker take it away. Keyboard focus shows it too. The
|
||||
// picker is the one in a connected agent's opened row. In Chromium and
|
||||
// WebKit, English and Chinese, light and dark.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -13,9 +14,9 @@ const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const vendors = [["Grok (SuperGrok)", "xai"], ["Devin", "devin"], ["ZCode", "zcode"], ["Kimi", "kimi"]];
|
||||
const options = vendors.flatMap(([g, ic]) => [1, 2, 3].map((n) => ({ value: `${ic}/m${n}`, label: `${g} ${n}`, group: g, icon: ic })));
|
||||
const options = vendors.flatMap(([g, ic]) => [1, 2, 3].map((n) => ({ value: `${ic}/m${n}`, ref: `${ic}/m${n}`, label: `${g} ${n}`, group: g, icon: ic })));
|
||||
const state = (lang, theme) => ({
|
||||
agents: [{ id: "claude", name: "Claude Code", path: "/test/settings.json", fields: [{ key: "model", label: "model", value: "xai/m1", options }] }],
|
||||
agents: [{ id: "claude", name: "Claude Code", path: "/test/settings.json", wired: true, fields: [{ key: "model", label: "model", value: "xai/m1", options }] }],
|
||||
profiles: [], settings: { lang, theme },
|
||||
});
|
||||
|
||||
@@ -60,6 +61,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.route("http://magpie.test/**", serve(lang, theme));
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator('#nav [data-view="agents"]').click();
|
||||
// connected, its model picked in its opened row
|
||||
await page.locator("#agents .ag-link").first().click();
|
||||
await page.locator('#agents [data-key="model"]').first().click();
|
||||
await page.locator("#pickerRail .rail-item").first().waitFor();
|
||||
const item = (n) => page.locator(`#pickerRail .rail-item[aria-label="${n}"]`);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// effort, or the subagent model's own default), it opens the effort slider
|
||||
// with Default as its first stop, and a level picked is posted and lights
|
||||
// the square. Claude Code's subagents (#468) have no square
|
||||
// while there is no model to pick. In English and Chinese. No backend: the API is faked here.
|
||||
// while there is no model to pick. Connected, the squares are in the row
|
||||
// opened from its link. In English and Chinese. No backend: the API is faked here.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -13,11 +14,11 @@ const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const models = [{ value: "gpt-5.5", label: "GPT-5.5" }, { value: "gpt-5.4-mini", label: "GPT-5.4 mini" }];
|
||||
const models = [{ value: "gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }, { value: "gpt-5.4-mini", label: "GPT-5.4 mini", ref: "openai/gpt-5.4-mini" }];
|
||||
const levels = ["low", "medium", "high", "xhigh"].map((value) => ({ value }));
|
||||
const fresh = () => ({
|
||||
agents: [{
|
||||
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color",
|
||||
id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true,
|
||||
fields: [
|
||||
{ key: "model", label: "model", value: "gpt-5.5", options: models },
|
||||
{ key: "effort", label: "effort", value: "high", options: levels },
|
||||
@@ -27,8 +28,8 @@ const fresh = () => ({
|
||||
},
|
||||
// Claude Code's subagents (#468): a model of magpie's, offered once it runs
|
||||
// through magpie; before, there is nothing to pick and no square
|
||||
...[["cc-own", "opus", []], ["cc-magpie", "magpie/deepseek/pro", [{ value: "magpie/deepseek/flash", label: "DeepSeek Flash" }]]].map(([id, model, options]) => ({
|
||||
id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color",
|
||||
...[["cc-own", "opus", []], ["cc-magpie", "magpie/deepseek/pro", [{ value: "magpie/deepseek/flash", label: "DeepSeek Flash", ref: "deepseek/flash" }]]].map(([id, model, options]) => ({
|
||||
id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color", wired: options.length > 0,
|
||||
fields: [{ key: "model", label: "model", value: model, options: [{ value: model }] }, { key: "subagent", label: "subagents", value: "", options }],
|
||||
}))],
|
||||
profiles: [],
|
||||
@@ -93,16 +94,22 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
});
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator(codex).waitFor();
|
||||
|
||||
// a square beside the subagents' one; model and effort alone are pickers
|
||||
const square = page.locator(`${codex} .extras-cell .field.extra[data-key="subagent_effort"]`);
|
||||
assert.equal(await square.count(), 1);
|
||||
assert.equal(await page.locator(`${codex} .extras-cell .field.extra[data-key="subagent"]`).count(), 1);
|
||||
assert.deepEqual(await page.locator(`${codex} .field:not(.extra)`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["model", "effort"]);
|
||||
assert.equal(await square.getAttribute("aria-label"), w.unset);
|
||||
assert.equal(await square.evaluate((e) => e.classList.contains("set")), false);
|
||||
const expand = async (sel) => {
|
||||
await page.locator(`${sel} .ag-link`).click();
|
||||
await page.locator(`${sel} .ag-exp`).waitFor();
|
||||
};
|
||||
await expand('.row.agent[data-id="cc-magpie"]');
|
||||
assert.equal(await page.locator('.row.agent[data-id="cc-own"] .field.extra[data-key="subagent"]').count(), 0, "a subagents square with nothing to pick");
|
||||
assert.equal(await page.locator('.row.agent[data-id="cc-magpie"] .field.extra[data-key="subagent"]').getAttribute("aria-label"), w.follows);
|
||||
await expand(codex);
|
||||
|
||||
// a square beside the subagents' one; model and effort alone are pickers
|
||||
const square = page.locator(`${codex} .ag-exp .extras-cell .field.extra[data-key="subagent_effort"]`);
|
||||
assert.equal(await square.count(), 1);
|
||||
assert.equal(await page.locator(`${codex} .extras-cell .field.extra[data-key="subagent"]`).count(), 1);
|
||||
assert.deepEqual(await page.locator(`${codex} .ag-exp .field:not(.extra)`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["model", "effort"]);
|
||||
assert.equal(await square.getAttribute("aria-label"), w.unset);
|
||||
assert.equal(await square.evaluate((e) => e.classList.contains("set")), false);
|
||||
|
||||
// the effort slider, Default its first stop and the square's value on it
|
||||
const y = await page.evaluate(() => scrollY);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Claude Code's tiers can each run at an effort of their own (#536), as its
|
||||
// subagents can. A tier's effort is an entry under its model in the tiers'
|
||||
// square, not a picker of its own: unset it says the tier runs at the effort
|
||||
// subagents can. In the window, connected, a tier's effort sits beside its
|
||||
// model in the opened row's Tiers: unset it says the tier runs at the effort
|
||||
// Claude Code asks for, a click opens the effort slider with Default as its
|
||||
// first stop, and a level picked is posted, lights the square and is named in
|
||||
// its title. The subagents' effort is a square, as Codex's is, that says what
|
||||
// first stop, and a level picked is posted and named in its title. (The
|
||||
// tray panel keeps them as entries under the models in the tiers' square.) The subagents' effort is a square, as Codex's is, that says what
|
||||
// unset means for Claude Code. Before Claude Code goes through magpie there
|
||||
// are no levels: no effort entries and no square. No click scrolls the page.
|
||||
// In English and Chinese. No backend: the API is faked here.
|
||||
@@ -15,11 +15,11 @@ const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const models = [{ value: "magpie/v/glm", label: "GLM" }, { value: "magpie/v/flash", label: "Flash" }];
|
||||
const models = [{ value: "magpie/v/glm", label: "GLM", ref: "v/glm" }, { value: "magpie/v/flash", label: "Flash", ref: "v/flash" }];
|
||||
const levels = ["low", "medium", "high"].map((value) => ({ value }));
|
||||
const tiers = ["opus", "sonnet", "haiku", "fable"];
|
||||
const claude = (id, routed) => ({
|
||||
id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color",
|
||||
id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color", wired: routed,
|
||||
fields: [
|
||||
{ key: "model", label: "model", value: routed ? "magpie/v/glm" : "opus", options: routed ? models : [{ value: "opus" }] },
|
||||
{ key: "effort", label: "effort", value: "high", options: levels },
|
||||
@@ -61,19 +61,21 @@ function server(lang, sets) {
|
||||
const W = {
|
||||
en: {
|
||||
asks: "the effort Claude Code asks for", effort: (tier) => tier + " effort", title: "haiku effort", def: "default", low: "low", high: "high",
|
||||
sub: "subagent effort: default\nthe effort Claude Code asks for", summary: "tiers: haiku (low)",
|
||||
sub: "subagent effort: default\nthe effort Claude Code asks for", sep: (l, v) => `${l}: ${v}`,
|
||||
},
|
||||
zh: {
|
||||
asks: "跟随 Claude Code 请求的推理强度", effort: (tier) => tier + " 推理强度", title: "haiku 推理强度", def: "默认", low: "低", high: "高",
|
||||
sub: "子 agent 推理强度:默认\n跟随 Claude Code 请求的推理强度", summary: "分档:haiku (低)",
|
||||
sub: "子 agent 推理强度:默认\n跟随 Claude Code 请求的推理强度", sep: (l, v) => `${l}:${v}`,
|
||||
},
|
||||
};
|
||||
|
||||
const cc = '.row.agent[data-id="claude"]';
|
||||
// a field's title as fieldBtn words it
|
||||
const t2 = (w, label, value) => w.sep(label, value);
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
test(`${engine} ${lang}: a Claude Code tier's effort is picked from the tiers' square`, async (t) => {
|
||||
test(`${engine} ${lang}: a Claude Code tier's effort is picked beside its model`, async (t) => {
|
||||
const w = W[lang];
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 700 } });
|
||||
@@ -90,27 +92,25 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
});
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator(cc).waitFor();
|
||||
await page.locator(`${cc} .ag-link`).click();
|
||||
await page.locator(`${cc} .ag-exp`).waitFor();
|
||||
|
||||
// no tier's effort is a field of its own; the subagents' is a square
|
||||
assert.deepEqual(await page.locator(`${cc} .field[data-key$="_effort"]`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["subagent_effort"]);
|
||||
// no tier's effort is a field of the row's own; the subagents' is a square
|
||||
assert.deepEqual(await page.locator(`${cc} .field:not(.ag-eff)[data-key$="_effort"]`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["subagent_effort"]);
|
||||
assert.equal(await page.locator(`${cc} .field.extra[data-key="subagent_effort"]`).getAttribute("aria-label"), w.sub);
|
||||
// not through magpie: no levels, no square, no effort entries
|
||||
assert.equal(await page.locator('.row.agent[data-id="cc-own"] .field[data-key$="_effort"]').count(), 0);
|
||||
|
||||
const square = page.locator(`${cc} .field.extra[data-key="tiers"]`);
|
||||
assert.equal(await square.evaluate((e) => e.classList.contains("set")), true, "haiku has a model of its own");
|
||||
// each tier's row in the opened row: its model, then its effort
|
||||
const effs = page.locator(`${cc} .ag-exp .field.ag-eff`);
|
||||
assert.deepEqual(await effs.evaluateAll((es) => es.map((e) => e.dataset.key)), tiers.map((tier) => tier + "_effort"));
|
||||
for (const tier of tiers) assert.equal(await page.locator(`${cc} .ag-exp .field.ag-eff[data-key="${tier}_effort"]`).getAttribute("aria-label"), t2(w, w.effort(tier), w.asks));
|
||||
assert.equal(await effs.locator(".effort-ic").count(), 4, "each effort shows the bars");
|
||||
const haiku = page.locator(`${cc} .ag-exp .field.ag-eff[data-key="haiku_effort"]`);
|
||||
const y = await page.evaluate(() => scrollY);
|
||||
await square.click();
|
||||
await page.locator("#pop:not([hidden]) #list li").first().waitFor();
|
||||
const rows = await page.locator("#list li").evaluateAll((es) => es.map((e) => [e.querySelector(".v")?.textContent, e.querySelector(".n")?.textContent || ""]));
|
||||
assert.deepEqual(rows.map((r) => r[0]), tiers.flatMap((tier) => [tier, w.effort(tier)]));
|
||||
for (const [, note] of rows.filter((_, i) => i % 2)) assert.equal(note, w.asks);
|
||||
assert.equal(rows[4][1], "Flash", "haiku's own model");
|
||||
// each effort entry shows the bars
|
||||
assert.equal(await page.locator("#list li .effort-ic").count(), 4);
|
||||
|
||||
// haiku's effort: the slider, Default first, then its levels
|
||||
await page.locator("#list li").nth(5).click();
|
||||
await haiku.click();
|
||||
await page.locator("#effortControl:not([hidden])").waitFor();
|
||||
assert.equal(await page.evaluate(() => scrollY), y, "a click scrolled the page");
|
||||
assert.equal(await page.locator("#effortTitle").textContent(), w.title);
|
||||
@@ -124,7 +124,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal(await page.locator("#effortValue").textContent(), w.low);
|
||||
await page.waitForFunction(() => document.querySelector("#status")?.textContent);
|
||||
assert.deepEqual(sets, [{ agent: "claude", field: "haiku_effort", value: "low" }]);
|
||||
assert.ok((await square.getAttribute("aria-label")).startsWith(w.summary), await square.getAttribute("aria-label"));
|
||||
assert.equal(await haiku.getAttribute("aria-label"), t2(w, w.title, w.low));
|
||||
assert.equal(await page.evaluate(() => scrollY), y, "a pick scrolled the page");
|
||||
// the session's effort is untouched
|
||||
assert.equal(await page.locator(`${cc} .field[data-key="effort"] .v`).textContent(), w.high);
|
||||
|
||||
@@ -47,7 +47,7 @@ const words = {
|
||||
};
|
||||
|
||||
function serve(lang, posts) {
|
||||
const state = () => ({ agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [{ key: "model", label: "model", value: "magpie/other/m1", options }] }],
|
||||
const state = () => ({ agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", wired: true, fields: [{ key: "model", label: "model", value: "magpie/other/m1", options }] }],
|
||||
profiles: [], unlisted, settings: { lang, theme: "light" } });
|
||||
const groups = { groups: [], pools: [], deciders: [], models: [{ id: "hunyuan/hy4", name: "hy4", provider: "hunyuan", providerName: "Hunyuan", icon: "generic" }] };
|
||||
return async (r) => {
|
||||
@@ -101,7 +101,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
// the window's picker: hy3 is reached through Mine, picked at a click
|
||||
let posts = [];
|
||||
let page = await open("http://magpie.test/", posts);
|
||||
const field = page.locator(`${row} .field[data-key="model"]`);
|
||||
// connected: its picker is in its opened row
|
||||
await page.locator(`${row} .ag-link`).click();
|
||||
const field = page.locator(`${row} .ag-exp .field[data-key="model"]`);
|
||||
await field.waitFor();
|
||||
const view = page.locator("#view-agents");
|
||||
await filterFor(page, field, "hy3");
|
||||
|
||||
@@ -144,6 +144,16 @@ func run(args []string) error {
|
||||
if len(args) > 0 && args[0] == "healthcheck" {
|
||||
return healthcheck() // every few seconds in a container: nothing else
|
||||
}
|
||||
if len(args) == 2 && args[0] == agent.DryRunArg {
|
||||
// an agent disconnected on a copy of its files under a temporary
|
||||
// home, for the Agents page to show what disconnecting changes
|
||||
// (agent.DisconnectPreview)
|
||||
a, err := agent.Find(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.Disconnect()
|
||||
}
|
||||
makeDirs()
|
||||
settings.Migrate()
|
||||
agent.RenameLegacy()
|
||||
|
||||
Reference in new issue
Block a user