A card's logo is its handle. A grip shows by it only while the pointer is on the card, as on Agents and Providers. The card is dragged across the grid: dragCards is dragRows in two directions, the slot nearest its middle is where it goes, and the cards between move a slot over. Alt+arrows move it from the keyboard and keep the focus on it. The order is settings usageOrder, by provider id, saved by POST /api/usage/arrange. It is apart from the Providers order, which is also the order providers are tried in. POST /api/settings keeps it. A card it doesn't name follows in magpie's own order, and an order naming one not on the page now keeps it after them. The tray panel's Usage tab follows the same order. usage-arrange.test.cjs covers the hover-only grip, a drag across the grid, Alt+arrows, a click that moves nothing, the order after a reload, and the panel. It passes in Chromium and WebKit, en and zh. TestSettingsSaveKeepsWhatItDoesNotSend covers usageOrder. go test ./internal/gui ./internal/settings under a temp HOME, go vet, and builds (darwin, GOOS=linux, GOOS=windows) pass. Also tried on a sandbox magpie web with faked quotas: the drag, the keyboard and a reload kept the order in WebKit and Chromium. The other Usage/panel UI tests pass, except credit-days.test.cjs, which fails about 1 run in 4 on main too.
1174 lines
41 KiB
Go
1174 lines
41 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 (
|
||
"bytes"
|
||
"cmp"
|
||
"context"
|
||
"crypto/sha256"
|
||
"embed"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io/fs"
|
||
"mime"
|
||
"net/http"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"regexp"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
"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"
|
||
"github.com/yetone/magpie/internal/update"
|
||
)
|
||
|
||
// cliBehind is the terminal's magpie command when it's a copied file rather
|
||
// than the installer's link — a copy can't follow updates (#531's lesson) —
|
||
// told once, and only until the user dismisses it for this version. The
|
||
// check is a bare Lstat: telling a *stale* copy from a current one would
|
||
// mean running the old binary, and `magpie version` isn't read-only — it
|
||
// migrates the user's settings first, with the old build's migrations. Only
|
||
// `magpie update`, which the user started, reads versions (update.StaleCLI).
|
||
// "" when the command is the link or absent, this isn't the Mac, or the
|
||
// advice was dismissed for this version.
|
||
func cliBehind() string {
|
||
cliBehindOnce.Do(func() {
|
||
cli := update.CopiedCLI()
|
||
if cli == "" {
|
||
return
|
||
}
|
||
if cliQuiet() {
|
||
return
|
||
}
|
||
cliBehindMu.Lock()
|
||
cliBehindVal = tilde(cli)
|
||
cliBehindMu.Unlock()
|
||
})
|
||
cliBehindMu.Lock()
|
||
defer cliBehindMu.Unlock()
|
||
return cliBehindVal
|
||
}
|
||
|
||
// cliQuiet is whether the advice was dismissed for this version; the
|
||
// dismiss is kept per version, so the next app update asks once more.
|
||
// cliQuietFile holds the version it was dismissed at.
|
||
const cliQuietFile = "cli-behind-quiet"
|
||
|
||
func cliQuiet() bool {
|
||
b, err := os.ReadFile(filepath.Join(settings.Dir(), cliQuietFile))
|
||
return err == nil && strings.TrimSpace(string(b)) == Version
|
||
}
|
||
|
||
// setCLIQuiet dismisses the advice until the next version.
|
||
func setCLIQuiet() error {
|
||
if err := os.MkdirAll(settings.Dir(), 0o755); err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(filepath.Join(settings.Dir(), cliQuietFile), []byte(Version+"\n"), 0o644)
|
||
}
|
||
|
||
// cliBehindRoutes serves the advice's "Hide until the next version".
|
||
func cliBehindRoutes(mux *http.ServeMux) {
|
||
mux.HandleFunc("POST /api/cli-behind/quiet", func(rw http.ResponseWriter, r *http.Request) {
|
||
if err := setCLIQuiet(); err != nil {
|
||
fail(rw, err)
|
||
return
|
||
}
|
||
cliBehindMu.Lock()
|
||
cliBehindVal = "" // kept away until the next version; the Once already ran
|
||
cliBehindMu.Unlock()
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
})
|
||
}
|
||
|
||
var cliBehindOnce = new(sync.Once) // a var so tests can ask again
|
||
var cliBehindMu sync.Mutex // the dismiss handler writes cliBehindVal while state() reads it on other goroutines
|
||
var cliBehindVal string
|
||
|
||
// 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"`
|
||
// Wired: magpie is in its config, which its menu's Disconnect takes out
|
||
Wired bool `json:"wired,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"
|
||
// CLIBehind is the terminal's magpie command, told to the user when
|
||
// it's a copied file rather than the installer's link: a copy can't
|
||
// follow updates (#531's lesson). "" when it's the link or absent, or
|
||
// the advice was dismissed for this version.
|
||
CLIBehind string `json:"cliBehind,omitempty"`
|
||
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 is the rate known now, never waited for: a stale one is asked
|
||
// for behind it and shown on the next look (#541: the state waited up to
|
||
// 4s on the network after each TTL, at every morning's start-up).
|
||
func currentFX() fxJSON {
|
||
r := fx.Soon()
|
||
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
|
||
// Dir is the data folder beside a portable magpie (#508)
|
||
Portable bool `json:"portable,omitempty"`
|
||
// 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"`
|
||
// the providers Settings' Searcher may name, the one magpie picks when
|
||
// it names none, why the one it names isn't used (gateway.Searcher*),
|
||
// and the relays said to search that are never asked to (#359)
|
||
SearchChoices []searchChoiceJSON `json:"searchChoices"`
|
||
SearchAuto string `json:"searchAuto,omitempty"`
|
||
SearchUnused string `json:"searchUnused,omitempty"`
|
||
SearchRelays []string `json:"searchRelays,omitempty"`
|
||
// the GitHub token the library asks GitHub with, masked, and where it
|
||
// is from ("settings", GITHUB_TOKEN or GH_TOKEN); never the token
|
||
GitHubTokenMask string `json:"githubTokenMask,omitempty"`
|
||
GitHubTokenFrom string `json:"githubTokenFrom,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"`
|
||
}
|
||
|
||
// searchChoiceJSON is a provider that can search for a model that can't,
|
||
// with the model it searches with when none is named, and its models.
|
||
type searchChoiceJSON struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Icon string `json:"icon,omitempty"`
|
||
Small string `json:"small"`
|
||
Models []modelRef `json:"models"`
|
||
}
|
||
|
||
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()
|
||
s.SearchAuto, s.SearchUnused = gateway.AutoSearcher(), gateway.SearcherUnused()
|
||
s.SearchChoices = []searchChoiceJSON{}
|
||
for _, c := range gateway.Searchers() {
|
||
p := c.Provider
|
||
j := searchChoiceJSON{ID: p.ID, Name: p.Name, Icon: p.Icon, Small: c.Small, Models: []modelRef{}}
|
||
for _, m := range c.Models {
|
||
j.Models = append(j.Models, modelRef{ID: p.ID + "/" + m.ID, Name: cmp.Or(m.Name, m.ID), Provider: p.ID, PName: p.Name, Icon: p.Icon})
|
||
}
|
||
s.SearchChoices = append(s.SearchChoices, j)
|
||
}
|
||
for _, p := range gateway.RelaysSaidToSearch() {
|
||
s.SearchRelays = append(s.SearchRelays, p.Name)
|
||
}
|
||
}
|
||
|
||
func settingsState() settingsJSON {
|
||
s := settingsJSON{Settings: settings.Load(), Version: Version, Dir: tilde(settings.Dir()), Portable: settings.Portable() != "", Gateway: gateway.URL()}
|
||
s.LANKey = "" // the retained credential belongs on disk, not in UI state
|
||
// the GitHub token, masked, and where the library's requests take one
|
||
// from: Settings, or the environment variable named
|
||
s.GitHubToken = ""
|
||
if tok, from := library.GitHubToken(); tok != "" {
|
||
s.GitHubTokenMask, s.GitHubTokenFrom = provider.Mask(tok), from
|
||
}
|
||
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", "MAGPIE_OTEL_BODIES", "MAGPIE_OTEL_BODIES_WHOLE", "MAGPIE_OTEL_SESSIONS"} {
|
||
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.DecideOnly() {
|
||
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.
|
||
//
|
||
// The page itself names each of its scripts and styles with its content's
|
||
// hash (app.js?v=…), so a cache that kept a file from before these headers
|
||
// were sent, and goes on serving it whatever magpie says now, is never asked
|
||
// for it again: a Docker user behind an HTTPS proxy had v0.1.630's page run
|
||
// v0.1.582's app.js and routing.js (incognito and a hard refresh alike),
|
||
// whose first lines looked for an element the page no longer had, and the
|
||
// page was blank under its tabs.
|
||
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"
|
||
}
|
||
b, err := fs.ReadFile(staticFS(), name)
|
||
if err != nil {
|
||
next.ServeHTTP(rw, r)
|
||
return
|
||
}
|
||
if r.URL.Path == "/" {
|
||
b = versionedPage(b)
|
||
}
|
||
sum := sha256.Sum256(b)
|
||
rw.Header().Set("ETag", `"`+hex.EncodeToString(sum[:12])+`"`)
|
||
rw.Header().Set("Cache-Control", "no-cache")
|
||
if r.URL.Path == "/" {
|
||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
http.ServeContent(rw, r, "index.html", time.Time{}, bytes.NewReader(b))
|
||
return
|
||
}
|
||
next.ServeHTTP(rw, r)
|
||
})
|
||
}
|
||
|
||
// pageFile is a script or stylesheet of the page's own, named in it.
|
||
var pageFile = regexp.MustCompile(`(src|href)="([A-Za-z0-9_.-]+\.(?:js|css))"`)
|
||
|
||
// versionedPage is the page with each of its own scripts and stylesheets
|
||
// named with its content's hash; one not among the page's files (boot.js,
|
||
// which the API writes) keeps its name.
|
||
func versionedPage(page []byte) []byte {
|
||
return pageFile.ReplaceAllFunc(page, func(m []byte) []byte {
|
||
g := pageFile.FindSubmatch(m)
|
||
b, err := fs.ReadFile(staticFS(), string(g[2]))
|
||
if err != nil {
|
||
return m
|
||
}
|
||
sum := sha256.Sum256(b)
|
||
return []byte(fmt.Sprintf(`%s="%s?v=%s"`, g[1], g[2], hex.EncodeToString(sum[:6])))
|
||
})
|
||
}
|
||
|
||
// 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()
|
||
case "disconnect":
|
||
err = a.Disconnect()
|
||
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)
|
||
cliBehindRoutes(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.GitHubToken = cur.GitHubToken // set on its own (github-token below), never sent to the page
|
||
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
|
||
in.UsageOrder = cur.UsageOrder // the Usage page's, dragged there
|
||
// how agents' lists name models, set on its own for the agents to be told
|
||
in.PlainNames, in.PlainOwnNames = cur.PlainNames, cur.PlainOwnNames
|
||
in.CodexAgentsV1 = cur.CodexAgentsV1
|
||
in.ChinaMirror = cur.ChinaMirror // the Plugins page's, set on its own
|
||
// 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 v := strings.TrimSpace(in.Searcher); v != "" && v != cur.Searcher {
|
||
id, _, _ := strings.Cut(v, "/")
|
||
if !slices.ContainsFunc(gateway.Searchers(), func(c gateway.SearcherChoice) bool { return c.Provider.ID == id }) {
|
||
fail(rw, fmt.Errorf("%s can't search the web for other models", id))
|
||
return
|
||
}
|
||
}
|
||
if err := settings.Save(in); err != nil {
|
||
fail(rw, err)
|
||
return
|
||
}
|
||
// whether agents are told every model takes images follows Vision
|
||
// (provider.Described)
|
||
if strings.TrimSpace(in.Vision) != strings.TrimSpace(cur.Vision) {
|
||
catalog.Touched()
|
||
}
|
||
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 Codex's OpenAI models say multi-agent V1 (#141): Codex's lists
|
||
// are written again and asked for again
|
||
mux.HandleFunc("POST /api/settings/codex-agents-v1", 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 := provider.SetCodexAgentsV1(in.On); 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())
|
||
})
|
||
// the Usage page's order of its cards, in magpie's settings
|
||
mux.HandleFunc("POST /api/usage/arrange", func(rw http.ResponseWriter, r *http.Request) {
|
||
var in struct{ Order []string }
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
fail(rw, err)
|
||
return
|
||
}
|
||
s := settings.Load()
|
||
s.UsageOrder = in.Order
|
||
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())
|
||
})
|
||
// the GitHub token the library's requests to GitHub carry: set, or
|
||
// taken away with ""
|
||
mux.HandleFunc("POST /api/settings/github-token", func(rw http.ResponseWriter, r *http.Request) {
|
||
var in struct {
|
||
Token string `json:"token"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
fail(rw, err)
|
||
return
|
||
}
|
||
tok := strings.TrimSpace(in.Token)
|
||
if strings.ContainsFunc(tok, func(c rune) bool { return c <= ' ' || c == 0x7f }) {
|
||
fail(rw, fmt.Errorf("a GitHub token is one word, without spaces"))
|
||
return
|
||
}
|
||
s := settings.Load()
|
||
s.GitHubToken = tok
|
||
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 held(mux)
|
||
}
|
||
|
||
// held has a page's reads share one build of the catalog, which every row
|
||
// resolving its model rebuilt (provider.Hold): /api/state took 4s with a
|
||
// few hundred models. Anything else may have written, and drops it.
|
||
func held(h http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||
if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/") {
|
||
defer provider.Hold()()
|
||
} else {
|
||
defer provider.Changed()
|
||
}
|
||
h.ServeHTTP(rw, r)
|
||
})
|
||
}
|
||
|
||
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()
|
||
s.CLIBehind = cliBehind()
|
||
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()
|
||
aj.Wired = a.Wired()
|
||
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
|
||
}
|