Files
magpie/internal/gui/api.go
T
yetone 4216d2b338 redact: a gateway's key in "key": "oc_sk_…" is masked, and Settings → Privacy takes the user's own masking rules (#195) — an OpenCode Go key in providers.json went to the vendor as it was: the API key rules wanted sk- with a hyphen, and a field called key alone was none of the secret field's names. Now a key of a gateway or relay, two to four letters and _sk_ (oc_sk_, or_sk_) with digits in it, is an API_KEY wherever it is, and a field named key and nothing more (not sort_key, monkey or obj.key) is a SECRET when its value is 16 or more letters and digits both, so a map's "key": "value" and key = "someIdentifier" are left alone; \"key\": \"…\" in JSON inside a string is found too. The secret field's names and its markers come from one list, so they can't drift apart, and of two rules finding the same value the first keeps it, so its placeholder no longer changes between an API_KEY and a SECRET from one request to the next. Masking rules, next to 脱敏词: a name for the placeholders (upper case letters, digits and _, 24 at most) and a prefix (then 8 or more key characters) or an RE2 regular expression, group 1 the value if it has one, masked with the secrets while 脱敏密钥 is on and put back as the others are, the user's name winning where one of magpie's finds the same value. They are set on their own: a pattern that doesn't compile, matches nothing at all, or is too long (300), a prefix under 3 or over 64, both at once, or more than 32 rules, is refused with the reason under the add row and what was typed kept. Tested: every row of the issue's table and the false positives, the rules' tidying, errors, stable placeholders, restore, overlaps, and kinds with digits in a stream; the settings file and the gateway's count_tokens with a rule; the page in a stub magpie web (add, a bad regex, remove, zh dark)
2026-09-29 15:26:01 +08:00

571 lines
18 KiB
Go

// Package gui hosts the desktop app: a tray panel and a regular window that
// share one small web UI. The UI talks to Go over a tiny JSON API served by
// the same handler that serves the static assets, so no binding generator or
// bundler is involved.
package gui
import (
"context"
"embed"
"encoding/json"
"fmt"
"mime"
"net/http"
"os"
"strings"
"time"
"github.com/yetone/magpie/internal/agent"
"github.com/yetone/magpie/internal/autostart"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/library"
"github.com/yetone/magpie/internal/netproxy"
"github.com/yetone/magpie/internal/profile"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/redact"
"github.com/yetone/magpie/internal/settings"
)
// Version is the build's version string, shown in Settings.
var Version = "dev"
//go:embed assets
var assets embed.FS
// Windows is what the API needs from the host application.
type Windows interface {
HidePanel()
// ShowMain brings the window up, on the named tab when view is set.
ShowMain(view string)
Quit()
// OpenURL hands a link to the system browser.
OpenURL(url string)
// OpenFolder shows a folder in the system file manager.
OpenFolder(path string) error
// ChooseFolder asks for a folder in the system's picker: "" when the
// user cancels it.
ChooseFolder(title string) (string, error)
// Copy puts text on the system clipboard, which the page's own
// navigator.clipboard can't always reach from inside the app.
Copy(text string) bool
// FitPanel asks for the panel to be tall enough for its content.
FitPanel(height int, g Glide)
// TintPanel paints the panel's tint behind the page, where the system
// keeps up with the panel's size; false when it can't, for the page to
// go on painting it itself.
TintPanel(rgba [4]uint8, ms int) bool
}
type fieldJSON struct {
Key string `json:"key"`
Label string `json:"label"`
Value string `json:"value"`
Options []agent.Option `json:"options"`
}
type agentJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Path string `json:"path"`
Fields []fieldJSON `json:"fields"`
// Drift: its config no longer does what magpie set, and how to set it again
Drift *agent.Drift `json:"drift,omitempty"`
// Import: an app that takes magpie by its own link (Cindy), and
// whether it has magpie already
Import string `json:"import,omitempty"`
Added bool `json:"added,omitempty"`
}
// clientJSON is an agent, or another client the gateway knows, as a
// request from it is drawn.
type clientJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
}
type profileJSON struct {
Name string `json:"name"`
Summary string `json:"summary"`
// Library is what the profile gives out from the library, for one
// saved with its setup
Library *profileLibraryJSON `json:"library,omitempty"`
}
type profileLibraryJSON struct {
Servers int `json:"servers"` // given to at least one agent
Skills int `json:"skills"` // given to at least one agent
Instructions bool `json:"instructions"` // some agent gets them
}
type stateJSON struct {
Agents []agentJSON `json:"agents"`
Clients []clientJSON `json:"clients"` // who a request may come from, by id
Profiles []profileJSON `json:"profiles"`
Catalog string `json:"catalog"`
Notice string `json:"notice,omitempty"` // advice after a change, e.g. "restart Codex"
Settings settings.Settings `json:"settings"`
}
// settingsJSON is the Settings page: the two choices plus the facts it shows.
type settingsJSON struct {
settings.Settings
Version string `json:"version"`
Dir string `json:"dir"` // where magpie keeps its files, as shown
Gateway string `json:"gateway"` // the local endpoint
// the proxy vendor requests go through now, and where it came from:
// settings, environment, system, off or none
ProxyNow string `json:"proxyNow"`
ProxySource string `json:"proxySource"`
// whether magpie opens at login: the system's record, not a setting
Login bool `json:"login"`
// the model that describes images when Vision names none, and those
// that can be named
VisionAuto string `json:"visionAuto,omitempty"`
VisionModels []modelRef `json:"visionModels"`
// the model magpie's generate_image tool draws with when ImageGen
// names none, and those that can be named
ImageGenAuto string `json:"imageGenAuto,omitempty"`
ImageGenModels []modelRef `json:"imageGenModels"`
// where other machines reach the gateway while it is shared
LANURLs []string `json:"lanURLs,omitempty"`
// when the Codex warm-up last started an account's window
CodexWarmed *time.Time `json:"codexWarmed,omitempty"`
// and the Claude warm-up
ClaudeWarmed *time.Time `json:"claudeWarmed,omitempty"`
// whether a WorkBuddy (China) account is signed in, and each one's
// last daily check-in
WorkBuddy bool `json:"workbuddy"`
WorkBuddyCheckins []provider.WorkBuddyCheckin `json:"workbuddyCheckins,omitempty"`
}
func settingsState() settingsJSON {
s := settingsJSON{Settings: settings.Load(), Version: Version, Dir: tilde(settings.Dir()), Gateway: gateway.URL()}
s.ProxyNow, s.ProxySource = netproxy.Describe()
s.Login = autostart.Enabled()
if s.LAN {
s.LANURLs = gateway.LANURLs()
}
s.CodexWarmed, s.ClaudeWarmed = latest(provider.CodexWarmed()), latest(provider.ClaudeWarmed())
s.WorkBuddy, s.WorkBuddyCheckins = provider.HasWorkBuddy(), provider.WorkBuddyCheckins()
s.VisionAuto, s.VisionModels = gateway.AutoVision(), []modelRef{}
for _, e := range provider.Served() {
if e.Images && (e.ImageInput == nil || *e.ImageInput) && (e.Group != "" || e.Provider.Ready()) {
m := modelRef{ID: e.ID, Name: e.Name, Provider: e.Provider.ID, PName: e.Provider.Name, Icon: e.Provider.Icon}
if e.Group != "" {
m.Provider, m.PName = "", e.Group
}
s.VisionModels = append(s.VisionModels, m)
}
}
s.ImageGenAuto, s.ImageGenModels = gateway.AutoDrawer(), []modelRef{}
for _, p := range provider.All() {
if !p.On() || p.Decides() {
continue
}
for _, m := range gateway.Drawers(p) {
name := m.Name
if name == "" {
name = m.ID
}
s.ImageGenModels = append(s.ImageGenModels, modelRef{ID: p.ID + "/" + m.ID, Name: name, Provider: p.ID, PName: p.Name, Icon: p.Icon})
}
}
return s
}
// latest is the latest of ts, nil when there is none.
func latest(ts map[string]time.Time) *time.Time {
var out *time.Time
for _, t := range ts {
if out == nil || t.After(*out) {
out = &t
}
}
return out
}
// onDock puts the app in the Mac's Dock or takes it out as the settings say,
// when the Settings page changes them; set by the process that has the app.
var onDock func(settings.Settings)
// assetTypes are the page's own files' types. The file server takes them
// from Windows' registry, which another program may have changed — an SVG
// served as something else draws no icon.
var assetTypes = map[string]string{".svg": "image/svg+xml", ".png": "image/png", ".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8", ".html": "text/html; charset=utf-8"}
func init() {
for ext, t := range assetTypes {
mime.AddExtensionType(ext, t)
}
}
// Handler serves the embedded UI and the JSON API.
// gw is the gateway this process serves, or nil when another magpie has it
// (for now: see startBackend).
func Handler(w Windows, gw *gateway.Server) http.Handler {
if gw != nil {
served.Store(gw)
}
mux := http.NewServeMux()
mux.Handle("/", devPage(http.FileServer(http.FS(staticFS()))))
devRoutes(mux)
// boot.js hands the page the saved language and theme before it paints:
// they came only with the settings, so the tabs showed English first
mux.HandleFunc("GET /boot.js", func(rw http.ResponseWriter, r *http.Request) {
s := settings.Load()
b, _ := json.Marshal(map[string]any{"lang": s.Lang, "theme": s.Theme, "web": isWeb(w)})
rw.Header().Set("Content-Type", "text/javascript; charset=utf-8")
rw.Header().Set("Cache-Control", "no-store")
rw.Write(append(append([]byte("window.bootPrefs = "), b...), ";\n"...))
})
mux.HandleFunc("GET /api/state", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, state())
})
mux.HandleFunc("POST /api/set", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Agent, Field, Value string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
a, err := agent.Find(in.Agent)
if err != nil {
fail(rw, err)
return
}
f := a.Field(in.Field)
if f == nil {
http.Error(rw, "unknown field", http.StatusBadRequest)
return
}
if err := a.Apply(f.Key, strings.TrimSpace(in.Value)); err != nil {
fail(rw, err)
return
}
s := state()
if a.Notice != nil {
s.Notice = a.Notice()
}
writeJSON(rw, s)
})
// reapply sets again what magpie set on an agent something else
// rewrote; keep takes the agent as it is now
// what drifted, per agent: cheap enough to ask while the window is up,
// so a config rewritten elsewhere shows without a reload
mux.HandleFunc("GET /api/drift", func(rw http.ResponseWriter, r *http.Request) {
out := map[string]*agent.Drift{}
for _, a := range agent.Detected() {
if d := a.Drift(); d != nil {
out[a.ID] = d
}
}
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 {
fail(rw, err)
return
}
switch r.PathValue("action") {
case "reapply":
err = a.Reapply()
case "keep":
a.Keep()
default:
http.NotFound(rw, r)
return
}
if err != nil {
fail(rw, err)
return
}
s := state()
if a.Notice != nil {
s.Notice = a.Notice()
}
writeJSON(rw, s)
})
mux.HandleFunc("POST /api/profile/{action}", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Name string }
_ = json.NewDecoder(r.Body).Decode(&in)
in.Name = strings.TrimSpace(in.Name)
var err error
var applied profile.Applied
switch r.PathValue("action") {
case "save":
var p profile.Profile
if p, err = profile.Snapshot(); err == nil {
err = profile.Save(in.Name, p)
}
case "use":
var ps map[string]profile.Profile
if ps, err = profile.Load(); err == nil {
applied, err = profile.Apply(ps[in.Name])
}
if applied.Library != nil {
lastProblems.Lock()
lastProblems.p = applied.Library.Problems
lastProblems.Unlock()
}
case "delete":
err = profile.Delete(in.Name)
default:
http.NotFound(rw, r)
return
}
if err != nil {
fail(rw, err)
return
}
s := state()
writeJSON(rw, struct {
stateJSON
Changed int `json:"changed"`
// Library is what bringing the profile's library setup back did
Library *library.Result `json:"library,omitempty"`
}{s, applied.Changed, applied.Library})
})
mux.HandleFunc("POST /api/sync", func(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := catalog.Sync(ctx); err != nil {
fail(rw, err)
return
}
for _, p := range provider.All() {
if p.Ready() {
c, cancel := context.WithTimeout(ctx, 8*time.Second)
p.Fetch(c)
cancel()
}
}
writeJSON(rw, state())
})
providerRoutes(mux, w)
importRoutes(mux)
usageRoutes(mux)
sessionRoutes(mux, w)
backupRoutes(mux, w)
libraryRoutes(mux, w)
updateRoutes(mux, w)
mux.HandleFunc("GET /api/settings", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, settingsState())
})
mux.HandleFunc("POST /api/settings", func(rw http.ResponseWriter, r *http.Request) {
var in settings.Settings
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
// the Settings page sends its own choices; how the agents are
// arranged is the Agents page's, and the window's size its own; both stay as they are
cur := settings.Load()
in.AgentOrder, in.AgentsHidden, in.AgentsShown = cur.AgentOrder, cur.AgentsHidden, cur.AgentsShown
in.Window = cur.Window // the window's own, as it was last resized
// and what other pages keep here: the models' names, levels and
// who sees them, and sharing on the network, set on its own
in.Visible, in.ModelNames, in.ModelEfforts = cur.Visible, cur.ModelNames, cur.ModelEfforts
in.LAN, in.LANKey = cur.LAN, cur.LANKey
in.RedactRules = cur.RedactRules // the masking rules, set on their own
// used or left is the Usage page's toggle as much as Settings', set on its own
in.QuotaLeft = cur.QuotaLeft
if v := strings.TrimSpace(in.Vision); v != "" && v != "off" && v != cur.Vision {
if _, _, ok := provider.Resolve(v); !ok {
fail(rw, fmt.Errorf("no model %s to describe images", v))
return
}
}
if v := strings.TrimSpace(in.ImageGen); v != "" && v != "off" && v != cur.ImageGen {
if _, _, ok := provider.Resolve(v); !ok {
fail(rw, fmt.Errorf("no model %s to generate images", v))
return
}
}
if err := settings.Save(in); err != nil {
fail(rw, err)
return
}
if (in.Dock != cur.Dock || in.DockWindow != cur.DockWindow) && onDock != nil {
onDock(in)
}
if (in.TrayUsage != cur.TrayUsage || in.TrayUsageEvery != cur.TrayUsageEvery) && onTrayUsage != nil {
onTrayUsage()
}
writeJSON(rw, settingsState())
})
// whether usage reads as used or left: the Usage page's toggle and
// Settings', for every meter and the menu bar alike (#122)
mux.HandleFunc("POST /api/settings/quota-left", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ On bool }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
s := settings.Load()
changed := s.QuotaLeft != in.On
s.QuotaLeft = in.On
if err := settings.Save(s); err != nil {
fail(rw, err)
return
}
if changed && onTrayUsage != nil {
onTrayUsage()
}
writeJSON(rw, settingsState())
})
// the Agents page's order and what it folds away, in magpie's settings
mux.HandleFunc("POST /api/agents/arrange", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Order, Hidden, Shown []string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
s := settings.Load()
s.AgentOrder, s.AgentsHidden, s.AgentsShown = in.Order, in.Hidden, in.Shown
if err := settings.Save(s); err != nil {
fail(rw, err)
return
}
writeJSON(rw, settingsState())
})
mux.HandleFunc("POST /api/settings/login", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ On bool }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
if err := autostart.Set(in.On); err != nil {
fail(rw, err)
return
}
writeJSON(rw, settingsState())
})
// sharing the gateway on the local network: on makes its key the first
// time, new asks for another (the old one stops working)
mux.HandleFunc("POST /api/settings/lan", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ On, NewKey bool }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
s := settings.Load()
s.LAN = in.On
if s.LANKey == "" || in.NewKey {
s.LANKey = gateway.NewLANKey()
}
if err := settings.Save(s); err != nil {
fail(rw, err)
return
}
if gw := served.Load(); gw != nil {
if err := gw.Relisten(); err != nil {
fail(rw, err)
return
}
}
writeJSON(rw, settingsState())
})
// the user's own masking rules, all of them each time: set on their own,
// so a pattern that doesn't compile is said and the rest are kept (#195)
mux.HandleFunc("POST /api/settings/redact-rules", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Rules []redact.Rule }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
s := settings.Load()
s.RedactRules = in.Rules
if err := settings.Save(s); err != nil {
fail(rw, err)
return
}
writeJSON(rw, settingsState())
})
// the config folder only: the page names no path, so it can't open others
mux.HandleFunc("POST /api/settings/reveal", func(rw http.ResponseWriter, r *http.Request) {
if err := w.OpenFolder(settings.Dir()); err != nil {
fail(rw, err)
return
}
rw.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("POST /api/window/{action}", func(rw http.ResponseWriter, r *http.Request) {
switch r.PathValue("action") {
case "hide":
w.HidePanel()
case "main":
w.ShowMain(r.URL.Query().Get("view"))
case "quit":
w.Quit()
case "fit":
if h, g, ok := parseFit(r.URL.Query()); ok {
w.FitPanel(h, g)
}
case "tint":
if c, ms, ok := parseTint(r.URL.Query()); ok && w.TintPanel(c, ms) {
writeJSON(rw, map[string]bool{"ok": true})
return
}
}
rw.WriteHeader(http.StatusNoContent)
})
devListen(mux)
return mux
}
func state() stateJSON {
s := stateJSON{Agents: []agentJSON{}, Profiles: []profileJSON{}, Catalog: catalog.Source(), Settings: settings.Load()}
for _, a := range agent.Clients() {
s.Clients = append(s.Clients, clientJSON{ID: a.ID, Name: a.Name, Icon: a.Icon})
}
for _, a := range agent.Detected() {
vals := a.Values()
aj := agentJSON{ID: a.ID, Name: a.Name, Icon: a.Icon, Path: tilde(a.Path), Fields: []fieldJSON{}}
for _, f := range a.Fields {
opts := f.Options(vals)
if opts == nil {
opts = []agent.Option{}
}
aj.Fields = append(aj.Fields, fieldJSON{Key: f.Key, Label: f.Label, Value: vals[f.Key], Options: opts})
}
aj.Drift = a.Drift()
if a.Import != nil {
aj.Import, aj.Added = a.Import(), a.Added != nil && a.Added()
}
s.Agents = append(s.Agents, aj)
}
if ps, err := profile.Load(); err == nil {
for _, n := range profile.Names(ps) {
pj := profileJSON{Name: n, Summary: profile.Summary(ps[n])}
if l := ps[n].Library; l != nil {
servers, skills := l.On()
pj.Library = &profileLibraryJSON{Servers: servers, Skills: skills, Instructions: l.GivesInstructions()}
}
s.Profiles = append(s.Profiles, pj)
}
}
return s
}
func writeJSON(rw http.ResponseWriter, v any) {
rw.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(rw).Encode(v)
}
func fail(rw http.ResponseWriter, err error) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(rw).Encode(map[string]string{"error": err.Error()})
}
func tilde(p string) string {
if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}