Files
magpie/internal/gui/api.go
T
yetone f22218a092 gui: after an update the window shows what changed since the version last run (a Discord user: 点更新后能弹个窗显示更新了啥吗, to see whether their issue was fixed) — magpie updated itself silently, and its release notes were only on GitHub. The version last run is now kept in magpie's own folder (last-version); started on a newer release, the window or magpie web's page asks usemagpie.ai's new /api/notes (beside /api/latest; site/worker.js lists the releases' notes from GitHub, cached as the feed is) for every release since, newest first, without their Install section, and shows them once in a What's new dialog. A fresh install, the same version, a downgrade or a build from source shows nothing; a magpie from before the version was kept, already in use, shows its own version's notes; offline nothing is shown and nothing waits on it; until the site is deployed, the update feed's newest notes stand in when they are the current version's. The tray's panel shows no dialog. The notes are drawn as text, never as HTML: headings, bullets, bold, code, http(s) links, and #NNN links to the issue, opened in the browser as the app's other links are. Settings has a What's new row under Version that opens them again, with a waiting update's notes first, marked Not installed yet; /api/update's notes leave out the Install section too. TestShowNotes, TestStripInstall, TestBetween, TestNotesBetween, TestWhatsNewAfterUpgrade, TestWhatsNewFromBefore, TestUpdateNotesWithoutInstall and whats-new.test.cjs (Chromium, WebKit, en, zh) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit, update-check, update-hide pass.
2026-10-02 03:57:55 +08:00

934 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
"mime"
"net/http"
"os"
"path"
"slices"
"strings"
"time"
"github.com/yetone/magpie/internal/access"
"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
// TintTitleBar paints the window's title bar the page's colour, where
// the system draws one (Windows); false where there is none to paint.
TintTitleBar(rgba [4]uint8, dark bool) bool
// SetTextSize zooms the window's and the panel's pages to percent
// (settings.TextSizes), the panel's size with them.
SetTextSize(percent int)
}
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"`
// Models: how many of the catalog its lists show, for an agent that
// picks among it (agent_models.go)
Models *modelCountJSON `json:"models,omitempty"`
}
// clientJSON is an agent, or another client the gateway knows, as a
// 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"`
// Agents is what it holds, by agent, to be read before it is applied
// (#467); a value that reads as a key or a token is left out
Agents []profile.Group `json:"agents"`
}
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"`
// Unlisted are the models kept for routing groups, which the pickers
// don't offer: a filter that finds one of them says why it isn't there
Unlisted []unlistedJSON `json:"unlisted,omitempty"`
}
// unlistedJSON is a model of a provider kept for routing groups, and the
// groups ("group/<id>") it is used through, none when it is in no group.
type unlistedJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Icon string `json:"icon,omitempty"`
Groups []string `json:"groups"`
}
// unlistedModels lists provider.Unlisted for the page.
func unlistedModels() []unlistedJSON {
es := provider.Unlisted()
if len(es) == 0 {
return nil
}
in := provider.MemberGroups()
out := make([]unlistedJSON, 0, len(es))
for _, e := range es {
gs := in[e.ID]
if gs == nil {
gs = []string{}
}
out = append(out, unlistedJSON{ID: e.ID, Name: e.Name, Provider: e.Provider.Name, Icon: e.Provider.Icon, Groups: gs})
}
return out
}
// 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
// Mac apps that explicitly handle .command files, for resumed sessions.
TerminalApps []terminalChoice `json:"terminalApps,omitempty"`
TerminalDefault string `json:"terminalDefault,omitempty"`
OTelEnv bool `json:"otelEnv,omitempty"`
// 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"`
// the web search APIs a model's search goes to when no provider can
// search (#419), their keys masked; the ones that can be added; and
// the provider that searches first, if one does
SearchAPIs []searchAPIJSON `json:"searchAPIs"`
SearchVendors []searchVendorJSON `json:"searchVendors"`
SearchProvider string `json:"searchProvider,omitempty"`
// where other machines reach the gateway while it is shared
LANURLs []string `json:"lanURLs,omitempty"`
// LANURLs are a container's own addresses, not the host's: the page
// offers the one it was opened at instead, or says how to set it
LANContainer bool `json:"lanContainer,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"`
// NotifyProblem is why a usage alert set wouldn't be seen: "denied"
// (notifications turned off for magpie) or "unavailable"
NotifyProblem string `json:"notifyProblem,omitempty"`
}
// searchAPIJSON is a search API as the Settings page shows it.
type searchAPIJSON struct {
Vendor string `json:"vendor"`
Name string `json:"name"`
Key string `json:"key,omitempty"` // masked
URL string `json:"url,omitempty"`
Ready bool `json:"ready"`
}
type searchVendorJSON struct {
ID string `json:"id"`
Name string `json:"name"`
KeysURL string `json:"keysURL,omitempty"`
NeedURL bool `json:"needURL,omitempty"` // one the user runs
}
func searchState(s *settingsJSON) {
s.SearchAPIs, s.SearchVendors = []searchAPIJSON{}, []searchVendorJSON{}
for _, a := range provider.StoredSearchAPIs() {
j := searchAPIJSON{Vendor: a.Vendor, Name: a.Name(), URL: a.URL, Ready: a.Ready()}
if a.Key != "" {
j.Key = provider.Mask(a.Key)
}
s.SearchAPIs = append(s.SearchAPIs, j)
}
for _, v := range provider.SearchVendors {
s.SearchVendors = append(s.SearchVendors, searchVendorJSON{ID: v.ID, Name: v.Name, KeysURL: v.KeysURL, NeedURL: v.Base == ""})
}
s.SearchProvider = gateway.Searcher()
}
func settingsState() settingsJSON {
s := settingsJSON{Settings: settings.Load(), Version: Version, Dir: tilde(settings.Dir()), Gateway: gateway.URL()}
s.LANKey = "" // the retained credential belongs on disk, not in UI state
if found, err := discoverTerminals(); err == nil {
for _, app := range found.Apps {
s.TerminalApps = append(s.TerminalApps, terminalChoice{ID: app.ID, Name: app.Name})
}
s.TerminalDefault = found.Default
}
s.FX = currentFX()
if (s.UsageAlert > 0 || s.BalanceAlert > 0) && notifyProblem != nil {
s.NotifyProblem = notifyProblem()
}
s.ProxyNow, s.ProxySource = netproxy.Describe()
for _, name := range []string{"MAGPIE_OTEL_ENABLED", "MAGPIE_OTEL_ENDPOINT", "MAGPIE_OTEL_HEADERS", "MAGPIE_OTEL_METRICS"} {
if _, ok := os.LookupEnv(name); ok {
s.OTelEnv = true
}
}
s.Login = autostart.Enabled()
if s.LAN {
s.LANURLs, s.LANContainer = gateway.LANURLs(), gateway.ContainerAddrs()
}
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)
}
}
searchState(&s)
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)
}
}
// revalidated serves the page's own files to be asked for again each time,
// by their content's hash: an embedded file has no date, so they went out
// with nothing to check them by, and a cache in front of `magpie web` (a
// proxy, a CDN, a tunnel's) could keep an older version's app.js under the
// new index.html after an update: a page without what that version added,
// such as the request archive switch (Jorben on Discord). An unchanged
// file is a 304.
func revalidated(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "" {
name = "index.html"
}
if b, err := fs.ReadFile(staticFS(), name); err == nil {
sum := sha256.Sum256(b)
rw.Header().Set("ETag", `"`+hex.EncodeToString(sum[:12])+`"`)
rw.Header().Set("Cache-Control", "no-cache")
}
next.ServeHTTP(rw, r)
})
}
// 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(revalidated(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()
// and the text size, which the Mac's header measures against the
// traffic lights
boot := map[string]any{"lang": s.Lang, "theme": s.Theme, "textSize": s.TextSize, "web": isWeb(w)}
// on Omarchy the page takes its theme's look before it paints
if th, ok := omarchyTheme(); ok {
boot["omarchy"] = th
}
b, _ := json.Marshal(boot)
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"...))
})
// the Omarchy theme as it is now, asked again every few seconds so a
// theme picked in Omarchy's menu reaches the page at once; null off Omarchy
mux.HandleFunc("GET /api/omarchy", func(rw http.ResponseWriter, r *http.Request) {
if th, ok := omarchyTheme(); ok {
writeJSON(rw, th)
return
}
writeJSON(rw, nil)
})
omarchyRoutes(mux, w)
mux.HandleFunc("GET /api/state", func(rw http.ResponseWriter, r *http.Request) {
// the panel and its model picker load from here: an account still
// without its vendor's list (one whose try at start-up failed) is
// asked again in the background, not only from the Providers page
provider.FetchNewSoon(8 * time.Second)
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, w)
callerKeyRoutes(mux)
sessionRoutes(mux, w)
sessionManageRoutes(mux, w)
backupRoutes(mux, w)
archiveRoutes(mux)
libraryRoutes(mux, w)
updateRoutes(mux, w)
whatsNewRoutes(mux)
mux.HandleFunc("GET /api/settings", func(rw http.ResponseWriter, r *http.Request) {
access.MigrateLegacyLANKeyBestEffort()
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: which models an agent is shown, and
// everything the user said of a model anywhere else in the app, set on
// its own. The per-model maps are carried whole rather than named one
// by one, so a map added later is not silently dropped here.
//
// HiddenModels is the other way round — keyed by agent, not by
// "<provider>/<model>" — so it is not one of them, and belongs to the
// Agents page.
in.Visible, in.HiddenModels = cur.Visible, cur.HiddenModels
settings.CarryPerModel(&in, &cur)
in.LAN, in.LANKey = cur.LAN, cur.LANKey
in.LANKeyID = cur.LANKeyID
in.RequestArchive = cur.RequestArchive // the Gateway page's, set on its own
in.RequestArchiveMaxMB = cur.RequestArchiveMaxMB // in settings.json only
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
// how agents' lists name models, set on its own for the agents to be told
in.PlainNames, in.PlainOwnNames = cur.PlainNames, cur.PlainOwnNames
// which Codex accounts spend a reset by themselves, set on the Usage card
in.CodexAutoReset = cur.CodexAutoReset
// and the text size, which the keyboard changes too (text-size below)
in.TextSize = cur.TextSize
// the version the Update pill was hidden for, set from the pill
in.UpdateSkip = cur.UpdateSkip
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)
}
// the cards the menu bar shows, any of them (TrayUsage is only the first),
// how often, and with their logos or not
if (!slices.Equal(settings.Load().TrayUsages, cur.TrayUsages) || in.TrayUsageEvery != cur.TrayUsageEvery ||
in.TrayNoLogos != cur.TrayNoLogos) && onTrayUsage != nil {
onTrayUsage()
}
// an alert turned on or moved is looked at now, the Mac asked for its
// leave to notify as it is turned on (#368)
if (in.UsageAlert != cur.UsageAlert || in.BalanceAlert != cur.BalanceAlert) &&
(in.UsageAlert > 0 || in.BalanceAlert > 0) && onAlerts != nil {
onAlerts()
}
// the tray menu follows the page's language (#301)
if in.Lang != cur.Lang && onLang != nil {
onLang()
}
// an update check that failed, without the proxy set just now, is
// tried again through it, not in six hours (#294)
if strings.TrimSpace(in.Proxy) != strings.TrimSpace(cur.Proxy) && updates.json().State == "error" {
go updates.check()
}
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 version the header's Update pill is hidden for, until a newer one
// is out: set from the pill, cleared ("") from Settings
mux.HandleFunc("POST /api/settings/update-skip", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
s := settings.Load()
s.UpdateSkip = strings.TrimSpace(in.Version)
if err := settings.Save(s); err != nil {
fail(rw, err)
return
}
writeJSON(rw, settingsState())
})
// whether the agents' lists name a model with its provider's after it or
// alone (#335): their files are written again, and Codex asks again
mux.HandleFunc("POST /api/settings/plain-names", func(rw http.ResponseWriter, r *http.Request) {
// Mode is on, own (#92: not on the names the user gave) or off; a
// body of On alone is the two-way switch's, On meaning plain
var in struct {
On bool
Mode string
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
set := func() error { return provider.SetPlainNames(in.On) }
if in.Mode != "" {
set = func() error { return provider.SetSuffixMode(in.Mode) }
}
if err := set(); err != nil {
fail(rw, err)
return
}
writeJSON(rw, settingsState())
})
// whether a Codex account spends one of its resets by itself once its
// week is used up, the Usage card's toggle, set on its own
mux.HandleFunc("POST /api/settings/codex-auto-reset", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
User string
On bool
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
if strings.TrimSpace(in.User) == "" {
fail(rw, fmt.Errorf("which Codex account?"))
return
}
if err := provider.SetCodexAutoReset(in.User, in.On); err != nil {
fail(rw, err)
return
}
writeJSON(rw, settingsState())
})
// how large the window and the panel are drawn: Settings' choice and
// Ctrl/Cmd +, − and 0 in either, set on its own so a key pressed while
// the Settings page saves something else is never undone by it
mux.HandleFunc("POST /api/settings/text-size", func(rw http.ResponseWriter, r *http.Request) {
var in struct{ Size int }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
s := settings.Load()
s.TextSize = in.Size
if err := settings.Save(s); err != nil {
fail(rw, err)
return
}
if w != nil {
w.SetTextSize(in.Size)
}
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 controls exposure; the gateway's named caller keys authenticate
// remote clients just as they do local ones.
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
}
if err := access.ConfigureLAN(in.On, in.NewKey); 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())
})
// a web search API added, given a new key or address, or taken away
mux.HandleFunc("POST /api/settings/search-api", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Vendor, Key, URL string
Remove bool
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
fail(rw, err)
return
}
var err error
if in.Remove {
err = provider.RemoveSearchAPI(in.Vendor)
} else {
err = provider.SetSearchAPI(provider.SearchAPI{Vendor: in.Vendor, Key: in.Key, URL: in.URL})
}
if 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
}
case "titlebar":
if c, _, ok := parseTint(r.URL.Query()); ok && w.TintTitleBar(c, r.URL.Query().Get("dark") == "1") {
writeJSON(rw, map[string]bool{"ok": true})
return
}
}
rw.WriteHeader(http.StatusNoContent)
})
agentModelsAPI(mux)
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()
}
s.Unlisted = unlistedModels()
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: agentFields(a, vals)}
aj.Models = agentModelCount(a.ID, aj.Fields)
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]), Agents: profile.Details(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
}