638 lines
21 KiB
Go
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
|
|
}
|