Files
magpie/internal/gui/api.go
T
yetone 04dc3bca72 panel: a Routing tab shows the gateway's latest requests, and the allowances' tab is named Allowances (v on Discord #feedback: the panel's Usage tab and the window's Usage page mean different things, and the panel had no routing) — the tray panel had Agents, Usage and Profiles, its Usage being each subscription's allowance while the window's Usage counts requests and Routing shows where they went. The panel's tab now reads Allowances (额度; the window keeps Usage/用量, and the tab's id and remembered choice are unchanged), and a new Routing tab (路由) lists the gateway's last 8 requests as they come, from the trace the Routing page already long-polls (/api/gateway/trace, which routing.js polled in the panel too; no new endpoint): each a two-line row with the agent's dot, icon and name and the model it asked for, the time, then where it went (provider · account or key), the model that try sent and, when the reply named another model (the new served/swapped fields), the amber "served gpt-6-luna" (实际 …) tag of the Requests list, beside the tokens and time taken; a failed one says its status and why (429 · rate limited), one under way who is answering. Over them, today's calls and tokens (the Usage page's /api/usage?period=today, read when the tab is shown and after a request ends, at most every 5 s) and Open Routing. A click on a request opens the window's Routing page on it: POST /api/window/main takes req beside view=routing (mainView lets only a positive id through), and the page, loaded so, picks that request as a click in its list would (its story told) and drops req from the address. The panel's height counts the tab as its tallest, as the others; the tab redraws keeping the panel's scroll, the click moves nothing, no border stripe. Tested with a new internal/gui/tests/panel-routing.test.cjs in Chromium and WebKit, en and zh: the Routing and Allowances labels, four fixture requests listed newest first with agent, model, "Relay · team" and "Claude · ann@example.com", the served tag on the swapped one only (not on a dated name), the 429 row, times, no row overflowing, today's 42 calls · 1.2M tokens, a click with the panel scrolled asking for view=routing&req=100 and leaving the scroll as it was, Open Routing, and the window opened on ?view=routing&req=101 with that request picked and its story shown; and TestMainView. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... and the whole GUI suite (102 tests) pass. Not tried in the real tray panel or with a live gateway: the window opening on the request is tested in the page, not through Wails.
2026-09-29 20:22:19 +08:00

638 lines
21 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/fx"
"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"`
// Launch: the command that starts an agent taking the gateway only
// from its environment (agy) on magpie, to copy
Launch string `json:"launch,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"`
// FX is the dollar-to-yuan rate the cny currency choice shows costs at,
// here too (not only in settingsJSON) so a cost drawn before the reader
// ever opens Settings already converts, if cny was chosen last time.
FX fxJSON `json:"fx"`
}
// fxJSON is a USD→CNY rate as the UI shows it: the number a cost is
// multiplied by, when it was last learned (unset for Fallback, never
// learned from anywhere), and whether that's stale — the Settings page's
// currency row puts these in its tooltip.
type fxJSON struct {
Rate float64 `json:"rate"`
At *time.Time `json:"at,omitempty"`
Stale bool `json:"stale"`
}
// currentFX asks internal/fx for the rate, bounded so a slow or absent
// network never holds up a page's worth of state; its own cache makes this
// return at once except right after each TTL (see internal/fx).
func currentFX() fxJSON {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
r := fx.Get(ctx)
out := fxJSON{Rate: r.CNYPerUSD, Stale: r.Stale()}
if !r.At.IsZero() {
at := r.At
out.At = &at
}
return out
}
// 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"`
// FX is the dollar-to-yuan rate the cny currency choice shows costs at
FX fxJSON `json:"fx"`
}
func settingsState() settingsJSON {
s := settingsJSON{Settings: settings.Load(), Version: Version, Dir: tilde(settings.Dir()), Gateway: gateway.URL()}
s.FX = currentFX()
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)
})
// the agents' CLIs: their versions and the newest (#202), as far as
// they're known within a moment — the rest are asked on meanwhile, and
// pending says to ask again soon
mux.HandleFunc("GET /api/agents/cli", func(rw http.ResponseWriter, r *http.Request) {
clis, pending := agent.CLIs(3 * time.Second)
writeJSON(rw, map[string]any{"agents": clis, "pending": pending})
})
// updates one the way it was installed; what it is afterwards comes
// back with an error too
mux.HandleFunc("POST /api/agents/cli/{id}", func(rw http.ResponseWriter, r *http.Request) {
a, err := agent.Find(r.PathValue("id"))
if err != nil {
fail(rw, err)
return
}
c, err := a.UpdateCLI()
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, c)
})
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(mainView(r.URL.Query()))
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()}
// state() is asked for after nearly every click, so the rate — a
// network fetch once every TTL — is only worth its rare latency when
// cny is actually chosen; usd never looks at it
if s.Settings.Currency == "cny" {
s.FX = currentFX()
}
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()
}
if a.Launch != nil {
aj.Launch = a.Launch()
}
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
}