Files
magpie/internal/gui/plugins.go
T
yetone 94bb3e2ca6 plugins: Check for updates asks npm now for each installed plugin's newest version, and each row says what it found (the owner: 插件的检查更新按钮还是得做) — the community's plugins update by themselves a little after magpie starts and every 6 hours, and the page knew of anyone else's new version only from what npm said within the hour (Info's cache), so a reader who heard a plugin had a new version had no way to have magpie look. The foot of Installed (beside Update all) now has Check for updates, shown when a plugin is npm's (a folder's or a git one's has no versions there; a git one keeps its own Update, which fetches it again): it says Checking… with a spinner while POST /api/plugins/check asks npm, a few at a time, past the hour's cache (plugin.CheckNow, through npmAsk, which is npmInfo telling why npm said nothing), and nothing is installed. Each row then says it: "v0.0.9 out" titled "v0.0.7 installed, v0.0.9 on npm" with its Update (the existing upgrade path, Upgrade → Add), "Up to date", or an amber "Couldn't check" chip with a dot, titled why — npm not reached (check your connection or proxy), npm turning requests away (429, try again in a few minutes), npm answering with an error, or npm not having the package (fetchJSON's answers other than 200/404 are now a statusError, so a 429 is told apart). The status line says how many have updates, or that every plugin is up to date, and how many couldn't be checked and why; npm not reached at all is "Couldn't check for updates: npm couldn't be reached — check your connection or proxy". A community plugin's Update is titled that magpie would update it by itself within a few hours and Update does it now. What npm said is kept as Info keeps it, so the rows and the market read it, and an update found for the reader (someone else's, or pinned) waits as the background look's do, putting the dot on Plugins; one npm didn't answer for keeps what waited before. Every new string has its Chinese; the click never scrolls the page. TestCheckNow (an httptest npm registry: the community's and someone else's updates found past an hour-old cache saying otherwise, up to date, 404 missing, 429 limited, 500 registry, git and folder not asked, nothing installed, the cache and the waiting list updated) and TestCheckNowOffline (npm not reached: offline, what waited still waits) are new, as is TestPluginsCheckRoute; plugin-check-updates.test.cjs (WebKit and Chromium, en and zh: Checking… while npm is asked, the rows' out / up to date / couldn't check chips and titles, the status line, Update after the check, offline said plainly, the view's scroll unmoved) fails without the change and passes with it. plugin-updates, plugin-market, plugin-progressive, plugin-git, plugin-local and click-scroll pass in WebKit and Chromium. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-02 21:58:22 +08:00

417 lines
13 KiB
Go

package gui
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"time"
"github.com/yetone/magpie/internal/plugin"
"github.com/yetone/magpie/internal/provider"
)
// OpenCode's provider plugins (internal/plugin): the providers they sign
// in to are subscriptions in the add sheet, and Settings → Plugins adds,
// updates and removes them.
// pluginSubJSON is a plugin's provider as the add sheet lists it.
type pluginSubJSON struct {
ID string `json:"id"` // magpie's
PID string `json:"pid"` // OpenCode's, which the plugin knows it by
Name string `json:"name"`
Icon string `json:"icon"`
Spec string `json:"spec"` // the plugin
Methods []plugin.Method `json:"methods"`
SignedIn bool `json:"signedIn"`
Models int `json:"models"`
}
// pluginIcon is the vendor's icon: the one the plugin gives its provider,
// else the one the plugin market gives the plugin or its provider, else
// that of the providers OpenCode names, a plain one for the rest.
func pluginIcon(pp plugin.Provider) string {
if ic := provider.PluginIcon(pp); ic != "" {
return ic
}
switch pp.ID {
case "github-copilot", "github-copilot-enterprise":
return "githubcopilot"
case "anthropic":
return "claude-color"
case "openai":
return "openai"
case "google", "google-vertex":
return "gemini-color"
case "qwen", "alibaba":
return "qwen-color"
case "moonshotai", "kimi-for-coding":
return "kimi"
}
return "generic"
}
func pluginSubs() []pluginSubJSON {
out := []pluginSubJSON{}
for _, pp := range plugin.Cached() {
out = append(out, pluginSubJSON{
ID: provider.PluginID(pp.ID), PID: pp.ID, Name: pp.Name, Icon: pluginIcon(pp), Spec: pp.Spec,
Methods: pp.Methods, SignedIn: pp.SignedIn, Models: len(pp.Models),
})
}
return out
}
// pluginEntryJSON is a plugin as Settings → Plugins lists it.
type pluginEntryJSON struct {
plugin.Entry
Error string `json:"error,omitempty"` // why it didn't load
Providers []string `json:"providers"` // the names of those it signs in to
Version string `json:"version,omitempty"` // installed
Latest string `json:"latest,omitempty"` // on npm, when the market asked
// Package is the package installed from a git repository, which its
// own package.json names
Package string `json:"package,omitempty"`
// Moved are the built-in subscriptions moved onto it, which go back
// to themselves when it is removed or turned off
Moved []string `json:"moved"`
// AutoUpdated is the update magpie made to it by itself lately
AutoUpdated *plugin.Updated `json:"autoUpdated,omitempty"`
}
// autoUpdatedFor is how long a plugin's row says magpie updated it.
const autoUpdatedFor = 3 * 24 * time.Hour
type pluginsJSON struct {
Plugins []pluginEntryJSON `json:"plugins"`
Bun bool `json:"bun"` // Bun is here; adding the first plugin downloads it otherwise
BunVer string `json:"bunVersion"`
Error string `json:"error,omitempty"` // the plugins couldn't be asked
// Picker is whether this magpie can show the system's folder picker:
// `magpie web` has none, and the page then offers no button for it
Picker bool `json:"picker"`
// Movable are the built-ins with accounts a plugin could run, which
// its card and its row offer to move
Movable []provider.MoveCandidate `json:"movable"`
}
func pluginsState(ctx context.Context, w Windows) pluginsJSON {
s := pluginsJSON{Plugins: []pluginEntryJSON{}, Bun: plugin.HasBun(), BunVer: plugin.BunInUse(), Movable: provider.MoveCandidates(), Picker: w != nil && !isWeb(w)}
l := plugin.Load()
errs := map[string]string{}
names := map[string][]string{}
if len(l.Plugins) > 0 && (plugin.Running() || plugin.HasBun()) {
loaded, err := plugin.Plugins(ctx)
if err != nil {
s.Error = err.Error()
}
for _, p := range loaded {
errs[p.Spec] = p.Error
}
if ps, err := plugin.Providers(ctx); err == nil {
for _, p := range ps {
names[p.Spec] = append(names[p.Spec], p.Name)
}
}
}
// npm's newest, as it said last: asking it again is /api/plugins/npm's
known := plugin.InfoCached(npmNames(l.Plugins))
for _, e := range l.Plugins {
j := pluginEntryJSON{Entry: e, Error: errs[e.Spec], Providers: names[e.Spec], Version: plugin.Installed(e.Spec)}
if npmPlugin(e.Spec) {
j.Latest = known[plugin.Name(e.Spec)].Version
}
if j.Providers == nil {
j.Providers = []string{}
}
if plugin.IsGit(e.Spec) {
j.Package = plugin.Name(e.Spec)
}
if u, ok := plugin.LastUpdated(plugin.Name(e.Spec), time.Now().Add(-autoUpdatedFor)); ok && !plugin.IsPath(e.Spec) {
j.AutoUpdated = &u
}
j.Moved = provider.MovedOnto(e.Spec)
if j.Moved == nil {
j.Moved = []string{}
}
s.Plugins = append(s.Plugins, j)
}
return s
}
// npmPlugin is whether npm has the plugin spec's versions: a plugin from a
// git repository is that repository's, whatever npm has under its name,
// and one from a folder is the folder's.
func npmPlugin(spec string) bool { return !plugin.IsPath(spec) && !plugin.IsGit(spec) }
func npmNames(es []plugin.Entry) []string {
out := []string{}
for _, e := range es {
if npmPlugin(e.Spec) {
out = append(out, plugin.Name(e.Spec))
}
}
return out
}
// pluginListings are the plugins magpie suggests, each with what npm said
// of it last (asking npm again is /api/plugins/npm's), so Discover is drawn
// without waiting on npm.
func pluginListings(ctx context.Context) []pluginListingJSON {
ls := plugin.Market(ctx)
names := make([]string, 0, len(ls))
for _, l := range ls {
names = append(names, l.Package)
}
known := plugin.InfoCached(names)
out := make([]pluginListingJSON, 0, len(ls))
for _, l := range ls {
j := pluginListingJSON{Listing: l}
if n, ok := known[l.Package]; ok {
j.NPM = &n
}
out = append(out, j)
}
return out
}
// pluginMarketJSON is the plugin market: the plugins magpie suggests, what npm
// says of each, and those added.
type pluginMarketJSON struct {
Listings []pluginListingJSON `json:"listings"`
State pluginsJSON `json:"state"`
}
type pluginListingJSON struct {
plugin.Listing
NPM *plugin.NPM `json:"npm,omitempty"` // none while npm hasn't been asked
}
func pluginMarketState(ctx context.Context, w Windows) pluginMarketJSON {
var ls []plugin.Listing
var st pluginsJSON
done := make(chan struct{})
go func() { st = pluginsState(ctx, w); close(done) }()
ls = plugin.Market(ctx)
names := []string{}
for _, l := range ls {
names = append(names, l.Package)
}
<-done
for _, e := range st.Plugins {
if npmPlugin(e.Spec) {
names = append(names, plugin.Name(e.Spec))
}
}
info := plugin.Info(ctx, names)
m := pluginMarketJSON{Listings: []pluginListingJSON{}, State: st}
for _, l := range ls {
n := info[l.Package]
m.Listings = append(m.Listings, pluginListingJSON{Listing: l, NPM: &n})
}
for i, e := range m.State.Plugins {
if !plugin.IsGit(e.Spec) {
m.State.Plugins[i].Latest = info[plugin.Name(e.Spec)].Version
}
}
return m
}
func pluginRoutes(mux *http.ServeMux, w Windows) {
// the updates waiting for the reader, which put a dot on Plugins
mux.HandleFunc("GET /api/plugins/updates", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, plugin.PendingUpdates())
})
// the market in parts, as the page draws it (#488): the plugins
// suggested, at once, then what npm says of the packages named
mux.HandleFunc("GET /api/plugins/listings", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, map[string]any{"listings": pluginListings(r.Context())})
})
mux.HandleFunc("GET /api/plugins/npm", func(rw http.ResponseWriter, r *http.Request) {
names := []string{}
for _, n := range strings.Split(r.URL.Query().Get("names"), ",") {
if n = strings.TrimSpace(n); n != "" && len(names) < 100 {
names = append(names, n)
}
}
writeJSON(rw, map[string]any{"npm": plugin.Info(r.Context(), names)})
})
// the whole market at once, npm's answers and all
mux.HandleFunc("GET /api/plugins/market", func(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
writeJSON(rw, pluginMarketState(ctx, w))
})
mux.HandleFunc("GET /api/plugins/search", func(rw http.ResponseWriter, r *http.Request) {
hits, err := plugin.Search(r.Context(), r.URL.Query().Get("q"))
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]any{"hits": hits})
})
mux.HandleFunc("GET /api/plugins/page", func(rw http.ResponseWriter, r *http.Request) {
p, err := plugin.Readme(r.Context(), r.URL.Query().Get("name"))
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, p)
})
mux.HandleFunc("GET /api/plugins", func(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
writeJSON(rw, pluginsState(ctx, w))
})
// a folder on this computer, from the system's picker; "" when the
// user cancels it
mux.HandleFunc("POST /api/plugins/choose", func(rw http.ResponseWriter, r *http.Request) {
dir, err := w.ChooseFolder("Choose a plugin folder")
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]string{"dir": dir})
})
// Check for updates: npm asked now for each plugin's newest version,
// with the list as it then stands; nothing installed
mux.HandleFunc("POST /api/plugins/check", func(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
c := plugin.CheckNow(ctx)
writeJSON(rw, map[string]any{"at": c.At, "plugins": c.Plugins, "state": pluginsState(ctx, w)})
})
// add, remove, update, turn on or off: each answers with the list
mux.HandleFunc("POST /api/plugins/{op}", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Spec string
Off bool
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil && err != io.EOF {
fail(rw, err)
return
}
// a first plugin downloads Bun, and npm installs it
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
var err error
switch r.PathValue("op") {
case "add":
_, err = plugin.Add(ctx, in.Spec)
case "remove":
err = provider.RemovePlugin(ctx, in.Spec)
case "update":
err = plugin.Update(ctx)
case "upgrade":
err = plugin.Upgrade(ctx, plugin.Name(in.Spec))
case "off":
err = provider.SetPluginOff(ctx, in.Spec, in.Off)
default:
http.NotFound(rw, r)
return
}
if err != nil {
fail(rw, err)
return
}
// a deprecated built-in the plugin now serves is the plugin's, so
// it isn't listed twice; one with accounts moves in the background
// loop (provider.KeepRetiringMoved)
if op := r.PathValue("op"); op == "add" || op == "update" || op == "upgrade" {
provider.HandOver(ctx, false)
}
writeJSON(rw, pluginsState(ctx, w))
})
// signing in to a plugin's provider: the method's questions one at a
// time, then a browser (OAuth) or a key
mux.HandleFunc("POST /api/plugin-signin/prompt", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Provider string
Method int
Inputs map[string]string
Key string // the question answered, with Value: checked first
Value string
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
fail(rw, err)
return
}
if in.Inputs == nil {
in.Inputs = map[string]string{}
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
if in.Key != "" {
msg, err := plugin.Validate(ctx, in.Provider, in.Method, in.Key, in.Value)
if err != nil {
fail(rw, err)
return
}
if msg != "" {
writeJSON(rw, map[string]any{"error": msg})
return
}
in.Inputs[in.Key] = in.Value
}
q, err := plugin.NextPrompt(ctx, in.Provider, in.Method, in.Inputs)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, map[string]any{"prompt": q, "inputs": in.Inputs})
})
mux.HandleFunc("POST /api/plugin-signin", func(rw http.ResponseWriter, r *http.Request) {
var in struct {
Provider string
Method int
Inputs map[string]string
Key string // an "api" method's key
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
fail(rw, err)
return
}
pp, ok := pluginProviderByID(in.Provider)
if !ok || in.Method < 0 || in.Method >= len(pp.Methods) {
fail(rw, errNoPluginMethod)
return
}
if pp.Methods[in.Method].Type == "api" {
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
id, err := provider.PluginAPIKey(ctx, in.Provider, in.Method, in.Inputs, in.Key)
if err != nil {
fail(rw, err)
return
}
writeJSON(rw, provider.SignInState{Agent: id, State: "done", User: pp.Name})
return
}
st, err := provider.StartPluginSignIn(in.Provider, in.Method, in.Inputs)
if err != nil {
fail(rw, err)
return
}
if st.URL != "" && w != nil {
w.OpenURL(st.URL)
}
writeJSON(rw, st)
})
}
type pluginErr string
func (e pluginErr) Error() string { return string(e) }
const errNoPluginMethod = pluginErr("the plugin has no such way to sign in; reopen the add sheet")
func pluginProviderByID(id string) (plugin.Provider, bool) {
for _, pp := range plugin.Cached() {
if pp.ID == id {
return pp, true
}
}
return plugin.Provider{}, false
}