Files
magpie/internal/agent/sync.go
T
yetone 2a4042a94d perf: magpie opens and shows the Agents page in a fraction of the time it took (Junjie Zhou on Discord: 8 个 agent 30 个 provider … 每次我打开的时候都会很慢,要好几秒。setting 页面秒进后 我切换到 agent 就有问题) — at start, writing the agents' model lists (agent.SyncCatalog) built every provider's catalog thousands of times with nothing held, each build reading migrations.json, settings and groups again: 6000+ file reads and ~8.5s of CPU in the first 8s with the owner's 30-odd providers, all competing with the first /api/state and the Agents page. Now SyncCatalog, magpieModels and main's start-up moves run under provider.Hold, migrations, groups, settings and a provider's fetched models are held for a second like the rest, and filememo.Hold lets a GET /api/* not stat a file it confirmed unchanged within the last second (any magpie write, Changed() or a recent mtime still reads it again). A big ~/.codex/config.toml parsed in quadratic time (the TOML parser's Shape counts newlines from the start for every key): a 3 MB one took 5.4s, now 0.04s (tomlShaper). theInstalled reads through edit.Read's memo. Measured with a copy of the owner's magpie config and agent configs under a sandbox HOME, the real server in WebKit: CPU in the first 8s 8.59s → 0.39s, page up 475 → 75ms, Agents rows drawn 775 → 325ms after start; the agents' written model files are unchanged (pi byte for byte, opencode the same models). TestTOMLShaperMatchesParser, TestTOMLBigFileParsesInLinearTime (fails on the old code), TestHold (filememo), TestHoldReadsMigrationsOnce. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
2026-10-05 03:02:40 +08:00

157 lines
3.9 KiB
Go

package agent
import (
"bytes"
"encoding/json"
"io"
"reflect"
"strings"
"sync"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/provider"
"gopkg.in/yaml.v3"
)
// Most agents can't ask the gateway which models it has: magpie writes the
// catalog into a file of the agent's own (Pi's models.json, OpenCode's
// provider block, Codex's model catalog …) when a model is picked through
// magpie. That list was the catalog as it was then, so a provider added
// later never reached the agent, and one removed stayed listed. SyncCatalog
// rewrites each such list whenever the catalog may have changed.
var syncing struct {
sync.Mutex
running, again bool
}
// SyncCatalog brings every model list magpie wrote into an agent's files up
// to the catalog as it is now. It is what catalog.Changed is set to, so a
// provider saved or removed, or a vendor's list fetched anew, reaches the
// agents; one already running asks for another round rather than waiting.
// Errors are left for the picker: a file magpie can't write now is one the
// next change, or a pick, writes.
func SyncCatalog() {
syncing.Lock()
if syncing.running {
syncing.again = true
syncing.Unlock()
return
}
syncing.running = true
syncing.Unlock()
for {
// the catalog built once for every agent's lists, not for each
// look-up of each (thousands at magpie's start, with 30 providers)
release := provider.Hold()
for _, a := range All() {
if a.Sync != nil {
_ = a.Sync()
}
}
release()
syncing.Lock()
if !syncing.again {
syncing.running = false
syncing.Unlock()
return
}
syncing.again = false
syncing.Unlock()
}
}
// syncJSON rewrites the value at key of a JSON file, if the file has one
// there and it differs: an agent that watches its files isn't told of a
// change that isn't one.
func syncJSON(path, key string, value func() any) error {
cur, ok := edit.GetJSON(path, key)
if !ok {
return nil
}
v := value()
if sameJSON(cur, v) {
return nil
}
return edit.SetJSON(path, edit.KV{Path: key, Value: v})
}
// syncJSONInOrder is syncJSON for a block whose keys' order the agent reads
// (OpenCode lists a model's variants in theirs): one that says the same in
// another order is rewritten too, as an older magpie wrote the variants
// alphabetically (#713).
func syncJSONInOrder(path, key string, value func() any) error {
cur, ok := edit.GetJSON(path, key)
if !ok {
return nil
}
v := value()
if sameJSON(cur, v) && sameOrder(cur, v) {
return nil
}
return edit.SetJSON(path, edit.KV{Path: key, Value: v})
}
// sameOrder reports whether raw JSON and what v marshals to read alike token
// by token, keys in the same order; whitespace and escapes aside.
func sameOrder(raw string, v any) bool {
b, err := json.Marshal(v)
if err != nil {
return false
}
x, y := json.NewDecoder(strings.NewReader(raw)), json.NewDecoder(bytes.NewReader(b))
x.UseNumber()
y.UseNumber()
for {
a, errA := x.Token()
c, errC := y.Token()
if errA != nil || errC != nil {
return errA == io.EOF && errC == io.EOF
}
if a != c {
return false
}
}
}
// sameJSON reports whether raw JSON says what v marshals to.
func sameJSON(raw string, v any) bool {
b, err := json.Marshal(v)
if err != nil {
return false
}
var x, y any
if json.Unmarshal([]byte(raw), &x) != nil || json.Unmarshal(b, &y) != nil {
return false
}
return reflect.DeepEqual(x, y)
}
// syncYAML is syncJSON for a YAML file.
func syncYAML(path, key string, value func() any) error {
raw, err := edit.Read(path)
if err != nil || raw == nil {
return nil
}
var doc any
if yaml.Unmarshal(raw, &doc) != nil {
return nil
}
for _, k := range strings.Split(key, ".") {
m, _ := doc.(map[string]any)
if doc = m[k]; doc == nil {
return nil
}
}
v := value()
b, err := yaml.Marshal(v)
if err != nil {
return err
}
var want any
if yaml.Unmarshal(b, &want) == nil && reflect.DeepEqual(doc, want) {
return nil
}
return edit.SetYAML(path, edit.KV{Path: key, Value: v})
}