gui: the panel's reads build the catalog once per request (owner: 这个应该是 magpie API 慢, on a Discord report that the window renders slowly) — provider.Resolve rebuilt every provider's catalog (24 providers, 270 models: 35-90ms) for each model it was asked about, and /api/state and /api/groups resolve one for every agent and row; plugin.Cached re-read the plugins' files for each PluginOf, and resolveIn's fallbacks read All() and every provider's Available() again. A GET under /api/ now holds what it builds (provider.Hold: the entries, the plugins, All, which providers list a model) for the request, at most 1s while requests overlap; a save drops it (catalog.Touched → catalog.Forget, plugin.OnChange, setMigration, and any non-GET request). Outside a request nothing is held, so the gateway and tests behave as before. On the owner's config: state 4.2s → 0.42s, groups 6.7s → 0.26s, providers 1.4s → 0.33s. Sandbox (fixed build, GETs looping): a model saved through /api/provider/save is in providers, groups and state at once and gone after the restore. TestHoldBuildsOnce, TestHoldSeesWrites (fails with Forget unset). go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-03 12:36:15 +08:00
1 parent b41090f8fa
commit dff14ff465
8 files changed
+220 -15

No files matched your search

+8 -1
View File
@@ -127,8 +127,15 @@ func SaveLive(provider, base string, models []Model) error {
// of their own up to date.
var Changed func()
// Touched tells Changed, if set.
// Forget, when set, is told so first: the provider package drops the
// catalog a request holds (provider.Hold).
var Forget func()
// Touched tells Forget and Changed, if set.
func Touched() {
if Forget != nil {
Forget()
}
if Changed != nil {
Changed()
}
+15 -1
View File
@@ -988,7 +988,21 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
})
agentModelsAPI(mux)
devListen(mux)
return 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 {
+98
View File
@@ -0,0 +1,98 @@
package provider
import (
"slices"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/plugin"
)
// The catalog is rebuilt from every provider's files on each Resolve (tens
// of milliseconds with a few hundred models), and a page of the GUI
// resolves a model for each row: /api/state and /api/groups took 4s and
// 7s with 24 providers. A request that only reads holds the catalog it
// built first for all its look-ups.
var held struct {
sync.Mutex
holds int
gen uint64
// what was built, and when, by what it is
built map[string]heldValue
}
type heldValue struct {
v any
at time.Time
}
func init() {
catalog.Forget = Changed
// a plugin's sign-in saved or renewed, a plugin added
plugin.OnChange(Changed)
}
// heldFor is how long a held catalog is used before it is built again,
// while one request after another keeps it held.
const heldFor = time.Second
// Hold keeps the catalog built once until release is called, for a
// request that only reads; a write meanwhile (Changed) drops it.
func Hold() (release func()) {
held.Lock()
held.holds++
held.Unlock()
return sync.OnceFunc(func() {
held.Lock()
if held.holds--; held.holds == 0 {
held.built = nil
}
held.Unlock()
})
}
// Changed drops what is held: a provider, an account, a plugin or a
// setting was written.
func Changed() {
held.Lock()
held.gen++
held.built = nil
held.Unlock()
}
// heldOf is what build gives, built once while a request holds it.
func heldOf[T any](key string, build func() T) T {
held.Lock()
if held.holds == 0 {
held.Unlock()
return build()
}
if h, ok := held.built[key]; ok && time.Since(h.at) < heldFor {
held.Unlock()
return h.v.(T)
}
gen := held.gen
held.Unlock()
v := build()
held.Lock()
if held.holds > 0 && held.gen == gen {
if held.built == nil {
held.built = map[string]heldValue{}
}
held.built[key] = heldValue{v, time.Now()}
}
held.Unlock()
return v
}
func heldEntries(build func() []Entry) []Entry {
// clipped: a caller's append copies
return heldOf("entries", func() []Entry { return slices.Clip(build()) })
}
// heldPlugins is plugin.Cached, which reads the plugins' list and
// sign-ins each time, and PluginOf asks for every model.
func heldPlugins() []plugin.Provider {
return heldOf("plugins", func() []plugin.Provider { return slices.Clip(plugin.Cached()) })
}
+64
View File
@@ -0,0 +1,64 @@
package provider
import (
"slices"
"testing"
)
// A request that only reads builds the catalog once for all its look-ups
// (/api/state took 4s, resolving each agent's models anew); outside one,
// and after a write, it is built again.
func TestHoldBuildsOnce(t *testing.T) {
n := 0
build := func() int { n++; return n }
heldOf("t", build)
heldOf("t", build)
if n != 2 {
t.Fatalf("not held: built %d times, want 2", n)
}
release := Hold()
heldOf("t", build)
heldOf("t", build)
if n != 3 {
t.Fatalf("held: built %d times, want 3", n)
}
Changed()
if heldOf("t", build); n != 4 {
t.Fatalf("after a write: built %d times, want 4", n)
}
release()
release() // a second release is no second one
if heldOf("t", build); n != 5 {
t.Fatalf("released: built %d times, want 5", n)
}
if held.holds != 0 {
t.Fatalf("holds left: %d", held.holds)
}
}
// A provider saved while a request holds the catalog is in it at once.
func TestHoldSeesWrites(t *testing.T) {
if err := Save(Provider{ID: "held", Name: "Held", Chat: "https://held.example/v1", Key: "k", Models: []string{"a"}}); err != nil {
t.Fatal(err)
}
defer Hold()()
ids := func() []string {
var out []string
for _, e := range Catalog() {
out = append(out, e.ID)
}
return out
}
if !slices.Contains(ids(), "held/a") || slices.Contains(ids(), "held/b") {
t.Fatalf("before: %v", ids())
}
if err := Save(Provider{ID: "held", Name: "Held", Chat: "https://held.example/v1", Key: "k", Models: []string{"a", "b"}}); err != nil {
t.Fatal(err)
}
if !slices.Contains(ids(), "held/b") {
t.Fatalf("a model picked meanwhile is missing: %v", ids())
}
if p, m, ok := Resolve("held/b"); !ok || p.ID != "held" || m != "b" {
t.Fatalf("Resolve: %v %q %v", p.ID, m, ok)
}
}
+1
View File
@@ -354,6 +354,7 @@ func setMigration(id string, f func(m *Migration)) error {
if err != nil {
return err
}
defer Changed()
return writePrivate(migrationsPath(), append(b, '\n'))
}
+28 -10
View File
@@ -910,6 +910,10 @@ func Unlisted() []Entry {
// providerEntries is the catalog without its groups.
func providerEntries() []Entry {
return heldEntries(buildEntries)
}
func buildEntries() []Entry {
var out []Entry
s := settings.Load()
for _, p := range All() {
@@ -1043,7 +1047,9 @@ func resolveIn(entries []Entry, id string) (Provider, string, bool) {
}
}
if pid, model, ok := strings.Cut(id, "/"); ok {
if p, err := Find(pid); err == nil && p.On() {
// the providers a request holds: an agent's value of a model not
// listed lands here, and All is read anew each time
if p, err := findIn(heldOf("all", All), pid); err == nil && p.On() {
return *p, model, true
}
}
@@ -1058,15 +1064,9 @@ func resolveIn(entries []Entry, id string) (Provider, string, bool) {
}
// not exposed, but some provider lists it
var found []Provider
for _, p := range All() {
if !p.On() || p.DecidesModel(id) {
continue
}
for _, m := range p.Available() {
if m.ID == id {
found = append(found, p)
break
}
for _, p := range heldOf("listed", listedBy)[id] {
if !p.DecidesModel(id) {
found = append(found, p)
}
}
if len(found) == 1 {
@@ -1075,6 +1075,24 @@ func resolveIn(entries []Entry, id string) (Provider, string, bool) {
return Provider{}, "", false
}
// listedBy is the providers on that list each model, in order, each once.
func listedBy() map[string][]Provider {
out := map[string][]Provider{}
for _, p := range All() {
if !p.On() {
continue
}
seen := map[string]bool{}
for _, m := range p.Available() {
if !seen[m.ID] {
seen[m.ID] = true
out[m.ID] = append(out[m.ID], p)
}
}
}
return out
}
// IDs lists the catalog ids, for error messages.
func IDs() []string {
var out []string
+2 -2
View File
@@ -70,7 +70,7 @@ func (p Provider) PluginProvider() string {
// PluginOf is the plugin provider magpie's provider id is, when it is one.
func PluginOf(id string) (plugin.Provider, bool) {
for _, pp := range plugin.Cached() {
for _, pp := range heldPlugins() {
if PluginID(pp.ID) == id {
return pp, true
}
@@ -82,7 +82,7 @@ func PluginOf(id string) (plugin.Provider, bool) {
// first account.
func pluginAccounts() []Provider {
var out []Provider
for _, pp := range plugin.Cached() {
for _, pp := range heldPlugins() {
if movingNow(pp.ID) {
continue // shown once the move is through (migrate.go)
}
+4 -1
View File
@@ -351,8 +351,11 @@ func find(ps []Provider, id string) (Provider, bool) {
// Find looks a provider up by id (or name, case-insensitively).
func Find(id string) (*Provider, error) {
return findIn(All(), id)
}
func findIn(all []Provider, id string) (*Provider, error) {
q := strings.ToLower(strings.TrimSpace(id))
all := All()
// an id before a name: a provider of the user's called WorkBuddy isn't
// the workbuddy subscription
if i := slices.IndexFunc(all, func(p Provider) bool { return p.ID == q }); i >= 0 {