diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 321637e4..fee06268 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -4351,6 +4351,13 @@ function cancelEdit(keep) { editing = null; draft = null; importing = null; importingApps = null; renderProviders(); } +// modelPrefsOfDraft: the names, levels and images staged in the editor's +// Names & levels, for its Save, or nothing when none changed. +function modelPrefsOfDraft() { + const x = draft?.modelPrefs; + return x && Object.keys(x).length ? x : undefined; +} + // proxyPicker: the proxy one provider's requests go through (#237) — the // one in Settings, none, or its own — so Codex can go through a proxy // while a vendor at home goes direct. The draft keeps the choice as @@ -5072,7 +5079,7 @@ function drawEditor(p, presetID) { } const maxConcurrency = concurrencyOfDraft(); if (maxConcurrency === undefined) return concurrencyError(ed); - saveBtn.classList.add("busy"); providerAction("save", { id: p.id, models: chosenIds(), unlisted: draft.unlisted, fallback: draft.fallback, contexts: cx.map, proxy, accountProxies: own.map, maxConcurrency }, t("{name} saved", { name: p.name })); }; + saveBtn.classList.add("busy"); providerAction("save", { id: p.id, models: chosenIds(), unlisted: draft.unlisted, fallback: draft.fallback, contexts: cx.map, proxy, accountProxies: own.map, maxConcurrency, modelPrefs: modelPrefsOfDraft() }, t("{name} saved", { name: p.name })); }; bar.append(cancel, saveBtn); ed.append(bar); return ed; @@ -5361,7 +5368,7 @@ function drawEditor(p, presetID) { if (isNew && draft.copyOf) body.copyOf = draft.copyOf; if (decides) body.decide = (draft.decide || "").trim(); if (custom) { body.icon = draft.icon || "generic"; body.balanceURL = (draft.balanceURL || "").trim(); body.balancePath = (draft.balancePath || "").trim(); body.modelsURL = (draft.modelsURL || "").trim(); } - if (p) { body.fallback = draft.fallback; body.unlisted = draft.unlisted; } + if (p) { body.fallback = draft.fallback; body.unlisted = draft.unlisted; body.modelPrefs = modelPrefsOfDraft(); } body.searches = !!draft.searches && searchable(); const cx = parseContexts(draft.contexts || ""); if (cx.error) return editorError(t("Context window: {v} is not a length like 128k or 1m", { v: cx.error }), "warn"); @@ -5902,67 +5909,100 @@ function renderModels(p) { lost.append(b); } }; - // the names and reasoning levels of the models agents see: saved at once, - // apart from the editor's Save, as they change nothing but what is shown + // the names and reasoning levels of the models agents see, and whether + // they see images: staged in draft.modelPrefs and made with the editor's + // Save, as the rest of it is, Cancel dropping them. Each tick used to be + // saved on its own, every one rewriting the agents' files, so picking a + // model's levels lagged a click behind (ARNO on Discord). const drawNames = () => { names.replaceChildren(); names.hidden = naming !== p.id; if (names.hidden) return; const ids = draft.chosen.length ? draft.chosen : p.models.filter((m) => m.on).map((m) => m.id); if (!ids.length) { names.append(el("span", "hint", t("Pick a model first."))); return; } + const prefs = draft.modelPrefs = draft.modelPrefs || {}; for (const id of ids) { const m = p.models.find((x) => x.id === id) || { id, name: id }; const own = m.default || m.name || m.id; + // what is staged for it, and what it is saved as + const pref = () => prefs[id] = prefs[id] || {}; + const savedName = m.default ? m.name : ""; + const levels = m.efforts || []; + const savedKept = m.kept?.length || m.given ? m.kept || [] : levels; + const nameNow = () => prefs[id]?.name ?? savedName; + const keptNow = () => prefs[id]?.efforts ? (prefs[id].efforts.length || m.given ? prefs[id].efforts : levels) : savedKept; + const imagesNow = () => prefs[id]?.ownImages ? !!m.ownImages : prefs[id]?.images ?? !!m.images; const row = el("div", "mname"); - const name = input(m.default ? m.name : "", own); + const name = input(nameNow(), own); name.title = t("The name agents and magpie show for {id}; empty for its own", { id: m.id }); - const save = () => { + name.onchange = () => { const v = name.value.trim(); - if (v === (m.default ? m.name : "")) return; - accountAction("provider/name", { id: p.id, model: m.id, modelName: v }, v ? t("{id} is called {name}", { id: m.id, name: v }) : t("{id} has its own name again", { id: m.id })); + if (v === savedName) delete pref().name; else pref().name = v; + drawReset(); }; - name.onchange = save; - name.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") name.blur(); else if (e.key === "Escape") { name.value = m.default ? m.name : ""; name.blur(); } }; + name.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") name.blur(); else if (e.key === "Escape") { name.value = nameNow(); name.blur(); } }; const who = el("div", "mwho"); who.append(name, el("code", "", m.id)); row.append(who); - const [img, imgCb] = tick(t("Accepts images"), !!m.images); + const [img, imgCb] = tick(t("Accepts images"), imagesNow()); img.title = t("Whether agents are told {id} can see images", { id: m.id }); - imgCb.onchange = () => accountAction("provider/images", { id: p.id, model: m.id, images: imgCb.checked }, - imgCb.checked ? t("{id} accepts images", { id: m.id }) : t("{id} does not accept images", { id: m.id })); + imgCb.onchange = () => { + const x = pref(); + delete x.ownImages; + if (imgCb.checked === !!m.images) delete x.images; + else x.images = imgCb.checked; + drawReset(); + }; row.append(img); - const levels = m.efforts || []; // a model whose levels aren't known (m.given) can be given any // of them, and none again + const boxes = []; if (levels.length > 1) { const lv = el("div", "mlevels"); lv.title = t(m.given ? "Its reasoning levels aren't known: tick the ones it takes" : "Reasoning levels agents are offered"); - const kept = m.kept?.length || m.given ? m.kept || [] : levels; for (const l of levels) { - const [tk, cb] = tick(t(l), kept.includes(l)); + const [tk, cb] = tick(t(l), keptNow().includes(l)); cb.onchange = () => { + const kept = keptNow(); const next = levels.filter((x) => x === l ? cb.checked : kept.includes(x)); if (!next.length && !m.given) { cb.checked = true; status(t("Keep at least one level"), "err"); return; } - accountAction("provider/efforts", { id: p.id, model: m.id, efforts: next.length === levels.length && !m.given ? [] : next }, - next.length ? t("{id}: {levels}", { id: m.id, levels: next.map((x) => t(x)).join(", ") }) : t("{id} is as its provider has it again", { id: m.id })); + const all = next.length === levels.length && !m.given; + if (all ? !m.kept?.length : next.join() === savedKept.join()) delete pref().efforts; + else pref().efforts = all ? [] : next; + drawReset(); }; + boxes.push([l, cb]); lv.append(tk); } row.append(lv); } - if (m.default || m.kept?.length || m.imageSet) { - const reset = el("button", "text action", t("Restore default")); - reset.title = t("Its own name, every reasoning level it has, and whether it sees images"); - reset.onclick = async () => { - reset.classList.add("busy"); - try { - if (m.default) await api("provider/name", { id: p.id, model: m.id, modelName: "" }); - if (m.imageSet) await api("provider/images", { id: p.id, model: m.id, images: null }); - } catch (e) { status(e.message, "err"); reset.classList.remove("busy"); return; } - accountAction("provider/efforts", { id: p.id, model: m.id, efforts: [] }, t("{id} is as its provider has it again", { id: m.id })); - }; - row.append(reset); - } + // what the Save will make of it differs from what it is: said beside + // it, so a change not yet saved isn't taken for one made + const unsaved = el("span", "hint munsaved", t("unsaved")); + unsaved.title = t("Made when the provider is saved; Cancel drops it"); + const reset = el("button", "text action", t("Restore default")); + reset.title = t("Its own name, every reasoning level it has, and whether it sees images"); + reset.onclick = () => { + prefs[id] = {}; + if (m.default) prefs[id].name = ""; + if (m.kept?.length) prefs[id].efforts = []; + if (m.imageSet) prefs[id].ownImages = true; + name.value = nameNow(); + imgCb.checked = imagesNow(); + for (const [l, cb] of boxes) cb.checked = keptNow().includes(l); + drawReset(); + }; + const drawReset = () => { + const x = prefs[id]; + if (x && !Object.keys(x).length) delete prefs[id]; + unsaved.hidden = !prefs[id]; + // staged back to its own already, there is nothing to restore + const images = prefs[id]?.ownImages ? false : prefs[id]?.images !== undefined ? prefs[id].images !== !!m.ownImages : !!m.imageSet; + const custom = nameNow() !== "" || (prefs[id]?.efforts ? prefs[id].efforts.length > 0 : !!m.kept?.length) || images; + reset.hidden = !custom; + }; + row.append(unsaved, reset); + drawReset(); names.append(row); } }; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index f80356f7..2a332c12 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -869,6 +869,8 @@ const I18N = { "Restore default": "恢复默认", "Its own name, every reasoning level it has, and whether it sees images": "恢复默认名称、全部推理档位,以及是否支持图片", "{id} is as its provider has it again": "{id} 已恢复为供应商的默认设置", + "unsaved": "未保存", + "Made when the provider is saved; Cancel drops it": "保存供应商时生效;取消则放弃", // gateway "running": "运行中", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 5c73013b..32f9731c 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -32,6 +32,7 @@ type modelJSON struct { Given bool `json:"given,omitempty"` // its levels aren't known: Efforts are those it can be given, Kept those it was Images bool `json:"images"` // agents are told it can see images ImageSet bool `json:"imageSet,omitempty"` // the user said so, rather than its vendor + Own bool `json:"ownImages,omitempty"` // its vendor's answer, which a staged Restore default shows On bool `json:"on"` // exposed to agents Context int `json:"context,omitempty"` Max int `json:"max,omitempty"` // the most its context may be set to, above Context @@ -377,9 +378,10 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { if m.ImageInput != nil { images = *m.ImageInput } + own := images images, _ = provider.ApplyImage(p.ID, m.ID, images, m.ImageInput) _, imageSet := provider.ImageOverride(p.ID, m.ID) - j := modelJSON{ID: m.ID, Name: m.Name, Efforts: provider.EffortsOf(m), On: on, Context: m.Context, Max: m.MaxContext, Free: m.Free, Images: images, ImageSet: imageSet} + j := modelJSON{ID: m.ID, Name: m.Name, Efforts: provider.EffortsOf(m), On: on, Context: m.Context, Max: m.MaxContext, Free: m.Free, Images: images, ImageSet: imageSet, Own: own} if i := slices.IndexFunc(most, func(c catalog.Model) bool { return c.ID == m.ID }); j.Max == 0 && i >= 0 { j.Max = most[i].MaxContext } @@ -632,6 +634,10 @@ func providerRoutes(mux *http.ServeMux, w Windows) { // Images, for images: whether the model takes images. Nil // gives the vendor's answer back. Images *bool `json:"images"` + // ModelPrefs, for save: the names, levels and images the + // editor's Names & levels changed, by model id, made with the + // rest of the Save and not a click at a time + ModelPrefs map[string]provider.ModelPref `json:"modelPrefs"` // Test, for test: models to send a request each, in place of // one per endpoint Test []string `json:"test"` @@ -797,6 +803,12 @@ func providerRoutes(mux *http.ServeMux, w Windows) { in.ID = to } } + if len(req.ModelPrefs) > 0 { + if err := provider.SetModelPrefs(in.ID, req.ModelPrefs); err != nil { + fail(rw, err) + return + } + } provider.ForgetBalances() // a new key means a new vendor list is worth a try; keep it short if p, err := provider.Find(in.ID); err == nil && p.Ready() && (old == nil || old.Key != p.Key) { diff --git a/internal/gui/providers_modelprefs_test.go b/internal/gui/providers_modelprefs_test.go new file mode 100644 index 00000000..3dcadac1 --- /dev/null +++ b/internal/gui/providers_modelprefs_test.go @@ -0,0 +1,45 @@ +package gui + +import ( + "net/http" + "net/http/httptest" + "os" + "slices" + "strings" + "testing" + + "github.com/yetone/magpie/internal/settings" +) + +// A provider's Save carries what its editor's Names & levels changed +// (modelPrefs), and makes it with the rest (ARNO on Discord: each level +// ticked was applied on its own, before the Save). +func TestProviderSaveModelPrefs(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", os.Getenv("HOME")) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + mux := http.NewServeMux() + providerRoutes(mux, nil) + post := func(body string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest("POST", "/api/provider/save", strings.NewReader(body))) + return w + } + if w := post(`{"id":"relay","name":"Relay","key":"k","chat":"http://127.0.0.1:1/v1","models":["sol"],"new":true}`); w.Code != 200 { + t.Fatalf("%d %s", w.Code, w.Body) + } + if w := post(`{"id":"relay","from":"relay","name":"Relay","chat":"http://127.0.0.1:1/v1","models":["sol"], + "modelPrefs":{"sol":{"name":"My Sol","efforts":["low","high"],"images":true}}}`); w.Code != 200 { + t.Fatalf("%d %s", w.Code, w.Body) + } + s := settings.Load() + if s.ModelNames["relay/sol"] != "My Sol" || !slices.Equal(s.ModelEfforts["relay/sol"], []string{"low", "high"}) || !s.ModelImages["relay/sol"] { + t.Fatalf("names %v, efforts %v, images %v", s.ModelNames, s.ModelEfforts, s.ModelImages) + } + // one it refuses fails the Save, saying why + if w := post(`{"id":"relay","from":"relay","name":"Relay","chat":"http://127.0.0.1:1/v1","models":["sol"],"modelPrefs":{"sol":{"efforts":["loud"]}}}`); w.Code == 200 || !strings.Contains(w.Body.String(), "loud") { + t.Fatalf("%d %s", w.Code, w.Body) + } +} diff --git a/internal/gui/tests/provider-levels-save.test.cjs b/internal/gui/tests/provider-levels-save.test.cjs new file mode 100644 index 00000000..6142efe3 --- /dev/null +++ b/internal/gui/tests/provider-levels-save.test.cjs @@ -0,0 +1,94 @@ +// A model's reasoning levels, name and images, picked in the provider +// editor's Names & levels, are staged and made with the editor's Save, as +// the rest of the editor is; Cancel drops them. They used to be posted, and +// the agents' files rewritten, at each tick, so picking levels lagged (ARNO +// on Discord). +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const levels = ["none", "low", "medium", "high"]; + +function serve(lang, posts) { + const state = { agents: [], profiles: [], settings: { lang, theme: "light" } }; + const models = Array.from({ length: 3 }, (_, i) => ({ id: `model-${i + 1}`, name: `Model ${i + 1}`, on: true, efforts: levels, images: false })); + models[2] = { ...models[2], kept: ["low", "high"] }; + const provider = { id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example/v1", responses: "", anthropic: "", models, agents: [], key: { set: true, masked: "sk-…1234" }, ready: true }; + const providers = { providers: [provider], presets: [], excluded: [], gateway: { running: true, window: true } }; + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:${JSON.stringify(lang)},theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/providers") return json(providers); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname.startsWith("/api/provider/")) { + posts.push({ path: url.pathname, body: route.request().postDataJSON() }); + return json(providers); + } + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + await route.fulfill({ body: await fs.readFile(file), contentType: { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)] }); + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: a model's levels are made with the provider's Save, not at each tick`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 900, height: 720 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts)); + await page.goto("http://magpie.test/?view=providers"); + const zh = lang === "zh"; + const L = { names: zh ? "名称与推理档位" : "Names & levels", images: zh ? "支持图片输入" : "Accepts images", low: zh ? "低" : "low", high: zh ? "高" : "high", unsaved: zh ? "未保存" : "unsaved", save: zh ? "保存" : "Save", cancel: zh ? "取消" : "Cancel", reset: zh ? "恢复默认" : "Restore default" }; + const open = async () => { + await page.locator(".row.provider").click(); + // it stays open from one opening of the editor to the next + if (!(await page.locator(".mnames:not([hidden])").count())) await page.getByRole("button", { name: L.names, exact: true }).click(); + }; + const row = (id) => page.locator(".mname", { has: page.locator("code", { hasText: id }) }); + await open(); + const list = await page.locator(".mnames:not([hidden])").elementHandle(); + const y = await page.evaluate(() => scrollY); + // ticks: nothing sent, nothing redrawn, the boxes as ticked + await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).uncheck(); + await row("model-1").getByRole("checkbox", { name: L.high, exact: true }).uncheck(); + await row("model-1").getByRole("checkbox", { name: L.images, exact: true }).check(); + await row("model-2").locator(".mwho input").first().fill("Two"); + await row("model-2").locator(".mwho input").first().press("Enter"); + await row("model-3").getByRole("button", { name: L.reset }).click(); + await page.waitForTimeout(200); + assert.deepEqual(posts, [], "nothing is sent before the Save"); + assert(await list.evaluate((e) => e.isConnected), "the list is not redrawn"); + assert.equal(await page.evaluate(() => scrollY), y, "a tick never scrolls the page"); + assert.equal(await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).isChecked(), false); + assert.equal(await row("model-1").getByRole("checkbox", { name: L.high, exact: true }).isChecked(), false); + for (const id of ["model-1", "model-2", "model-3"]) assert(await row(id).getByText(L.unsaved, { exact: true }).isVisible(), id + " says it is unsaved"); + assert.equal(await row("model-3").getByRole("checkbox", { name: zh ? "中" : "medium", exact: true }).isChecked(), true, "Restore default ticks every level"); + // Cancel drops them; opened again, the editor is as saved + await page.getByRole("button", { name: L.cancel, exact: true }).click(); + await open(); + assert.equal(await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).isChecked(), true); + assert.equal(await row("model-1").getByText(L.unsaved, { exact: true }).isVisible(), false); + assert.deepEqual(posts, []); + // picked again and saved: one Save carries them all + await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).uncheck(); + await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).check(); // back as it was: nothing for it + await row("model-1").getByRole("checkbox", { name: L.high, exact: true }).uncheck(); + await row("model-3").getByRole("checkbox", { name: zh ? "中" : "medium", exact: true }).check(); + await page.getByRole("button", { name: L.save, exact: true }).click(); + await page.waitForFunction(() => !document.querySelector(".mnames")); + assert.deepEqual(posts.map((p) => p.path), ["/api/provider/save"]); + assert.deepEqual(posts[0].body.modelPrefs, { "model-1": { efforts: ["none", "low", "medium"] }, "model-3": { efforts: ["low", "medium", "high"] } }); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/gui/tests/provider-levels-scroll.test.cjs b/internal/gui/tests/provider-levels-scroll.test.cjs index 6bc78703..3511b8d2 100644 --- a/internal/gui/tests/provider-levels-scroll.test.cjs +++ b/internal/gui/tests/provider-levels-scroll.test.cjs @@ -1,5 +1,6 @@ -// A saved reasoning level redraws the provider editor without moving its -// scrollable model list back to the first model. +// A reasoning level ticked leaves the provider editor's scrollable model +// list where it is: it is staged for the editor's Save, with nothing sent +// and nothing redrawn (provider-levels-save.test.cjs). const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -55,15 +56,13 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const fifth = page.locator(".mname", { has: page.locator("code", { hasText: "model-5" }) }); const old = await list.elementHandle(); await fifth.getByRole("checkbox", { name: "low" }).uncheck(); - await page.waitForFunction((e) => !e.isConnected, old); - assert.deepEqual(posts.at(-1), { id: "relay", model: "model-5", efforts: ["none", "medium", "high"] }); + assert(await old.evaluate((e) => e.isConnected)); assert.equal(await fifth.getByRole("checkbox", { name: "low" }).isChecked(), false); assert.equal(await list.evaluate((e) => e.scrollTop), before); const next = page.locator(".mname", { has: page.locator("code", { hasText: "model-6" }) }); - const current = await list.elementHandle(); await next.getByRole("checkbox", { name: "high" }).uncheck(); - await page.waitForFunction((e) => !e.isConnected, current); - assert.deepEqual(posts.at(-1), { id: "relay", model: "model-6", efforts: ["none", "low", "medium"] }); + assert.equal(await next.getByRole("checkbox", { name: "high" }).isChecked(), false); + assert.deepEqual(posts, []); assert.equal(await list.evaluate((e) => e.scrollTop), before); assert.deepEqual(errors, []); }); diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index 791b39c6..3948e2e8 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -3,6 +3,7 @@ package provider import ( "errors" "fmt" + "maps" "slices" "strings" @@ -62,21 +63,33 @@ func splitRef(ref string) (*Provider, string, error) { // their own are told (catalog.Touched), as for any other change of the // catalog. func SetModelName(ref, name string) error { + return touchedIf(setModelName(ref, name)) +} + +// touchedIf tells the agents of a change to the catalog that was made. +func touchedIf(changed bool, err error) error { + if changed { + catalog.Touched() + } + return err +} + +func setModelName(ref, name string) (bool, error) { p, model, err := splitRef(ref) if err != nil { - return err + return false, err } name = strings.Join(strings.Fields(name), " ") if len([]rune(name)) > 80 { - return errors.New("a model's name is at most 80 characters") + return false, errors.New("a model's name is at most 80 characters") } if name != "" && !p.serves(model) { - return fmt.Errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID) + return false, fmt.Errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID) } s := settings.Load() key := p.ID + "/" + model if s.ModelNames[key] == name { - return nil + return false, nil } if name == "" { delete(s.ModelNames, key) @@ -87,10 +100,9 @@ func SetModelName(ref, name string) error { s.ModelNames[key] = name } if err := settings.Save(s); err != nil { - return err + return false, err } - catalog.Touched() - return nil + return true, nil } // SetModelPrice is what a provider's model costs the user, in USD per million @@ -229,9 +241,13 @@ var Levels = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max" // levels aren't known, any of Levels, which it is given; none takes them // away. func SetModelEfforts(ref string, efforts []string) error { + return touchedIf(setModelEfforts(ref, efforts)) +} + +func setModelEfforts(ref string, efforts []string) (bool, error) { p, model, err := splitRef(ref) if err != nil { - return err + return false, err } all := p.Known(model) var keep []string @@ -241,10 +257,10 @@ func SetModelEfforts(ref string, efforts []string) error { continue } if len(all) == 0 && !slices.Contains(Levels, e) { - return fmt.Errorf("%q is not a reasoning level (they are %s)", e, strings.Join(Levels, ", ")) + return false, fmt.Errorf("%q is not a reasoning level (they are %s)", e, strings.Join(Levels, ", ")) } if len(all) > 0 && !slices.Contains(all, e) { - return fmt.Errorf("%s/%s has no reasoning level %q (it has %s)", p.ID, model, e, strings.Join(all, ", ")) + return false, fmt.Errorf("%s/%s has no reasoning level %q (it has %s)", p.ID, model, e, strings.Join(all, ", ")) } if !slices.Contains(keep, e) { keep = append(keep, e) @@ -257,7 +273,7 @@ func SetModelEfforts(ref string, efforts []string) error { s := settings.Load() key := p.ID + "/" + model if slices.Equal(s.ModelEfforts[key], keep) { - return nil + return false, nil } if len(keep) == 0 { delete(s.ModelEfforts, key) @@ -268,10 +284,9 @@ func SetModelEfforts(ref string, efforts []string) error { s.ModelEfforts[key] = keep } if err := settings.Save(s); err != nil { - return err + return false, err } - catalog.Touched() - return nil + return true, nil } // ModelEfforts are the reasoning levels the user kept of the provider's @@ -307,12 +322,16 @@ func effortsKept(all, kept []string) []string { // SetModelImage says whether a provider's model takes images, in place // of what its vendor's list says. nil gives that answer back. func SetModelImage(ref string, images *bool) error { + return touchedIf(setModelImage(ref, images)) +} + +func setModelImage(ref string, images *bool) (bool, error) { p, model, err := splitRef(ref) if err != nil { - return err + return false, err } if images != nil && !p.serves(model) { - return fmt.Errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID) + return false, fmt.Errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID) } if images != nil { if vendor, known := vendorSees(p, model); known && *images == vendor { @@ -323,12 +342,12 @@ func SetModelImage(ref string, images *bool) error { key := p.ID + "/" + model if images == nil { if _, ok := s.ModelImages[key]; !ok { - return nil + return false, nil } delete(s.ModelImages, key) } else { if cur, ok := s.ModelImages[key]; ok && cur == *images { - return nil + return false, nil } if s.ModelImages == nil { s.ModelImages = map[string]bool{} @@ -336,10 +355,58 @@ func SetModelImage(ref string, images *bool) error { s.ModelImages[key] = *images } if err := settings.Save(s); err != nil { + return false, err + } + return true, nil +} + +// ModelPref is what the provider editor's Names & levels changed of one +// model, sent with its Save: each part left nil is as it was. Name "" gives +// the model its own name back, Efforts [] all its levels, and OwnImages the +// vendor's answer for whether it sees images. +type ModelPref struct { + Name *string `json:"name,omitempty"` + Efforts *[]string `json:"efforts,omitempty"` + Images *bool `json:"images,omitempty"` + OwnImages bool `json:"ownImages,omitempty"` +} + +// SetModelPrefs makes the changes to a provider's models, by model id, as +// SetModelName, SetModelEfforts and SetModelImage do, and tells the agents +// once, after them all, rather than once a change. It stops at the first +// that fails, telling the agents of those made before it. +func SetModelPrefs(pid string, prefs map[string]ModelPref) error { + changed := false + set := func(c bool, err error) error { + changed = changed || c return err } - catalog.Touched() - return nil + err := func() error { + for _, model := range slices.Sorted(maps.Keys(prefs)) { + m, ref := prefs[model], pid+"/"+model + if m.Name != nil { + if err := set(setModelName(ref, *m.Name)); err != nil { + return err + } + } + if m.Efforts != nil { + if err := set(setModelEfforts(ref, *m.Efforts)); err != nil { + return err + } + } + if m.Images != nil || m.OwnImages { + images := m.Images + if m.OwnImages { + images = nil + } + if err := set(setModelImage(ref, images)); err != nil { + return err + } + } + } + return nil + }() + return touchedIf(changed, err) } // ImageOverride is the user's answer for whether pid's model takes images. diff --git a/internal/provider/modelprefs_save_test.go b/internal/provider/modelprefs_save_test.go new file mode 100644 index 00000000..eeaa9fab --- /dev/null +++ b/internal/provider/modelprefs_save_test.go @@ -0,0 +1,59 @@ +package provider + +import ( + "slices" + "strings" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/settings" +) + +// The provider editor's Names & levels are made with its Save, all at +// once: each is made as SetModelName, SetModelEfforts and SetModelImage +// make it, and the agents' files are rewritten once, not once a change +// (ARNO on Discord: every reasoning level ticked was applied on its own, +// so picking them lagged). +func TestSetModelPrefs(t *testing.T) { + prefsHome(t) + touched := 0 + catalog.Changed = func() { touched++ } + t.Cleanup(func() { catalog.Changed = nil }) + name, yes := "My Sol", true + efforts := []string{"low", "high"} + if err := SetModelPrefs("a", map[string]ModelPref{"sol": {Name: &name, Efforts: &efforts, Images: &yes}}); err != nil { + t.Fatal(err) + } + if touched != 1 { + t.Fatalf("agents told %d times, want once", touched) + } + s := settings.Load() + if s.ModelNames["a/sol"] != "My Sol" || !slices.Equal(s.ModelEfforts["a/sol"], efforts) || !s.ModelImages["a/sol"] { + t.Fatalf("names %v, efforts %v, images %v", s.ModelNames, s.ModelEfforts, s.ModelImages) + } + if _, ok := s.ModelNames["b/sol"]; ok { + t.Fatal("b's sol was named too") + } + // nothing changed: nothing to tell + if err := SetModelPrefs("a", map[string]ModelPref{"sol": {Name: &name}}); err != nil || touched != 1 { + t.Fatalf("%v, told %d times", err, touched) + } + // Restore default: its own name, every level, the vendor's answer + own, all := "", []string{} + if err := SetModelPrefs("a", map[string]ModelPref{"sol": {Name: &own, Efforts: &all, OwnImages: true}}); err != nil { + t.Fatal(err) + } + s = settings.Load() + if len(s.ModelNames)+len(s.ModelEfforts)+len(s.ModelImages) != 0 || touched != 2 { + t.Fatalf("names %v, efforts %v, images %v, told %d times", s.ModelNames, s.ModelEfforts, s.ModelImages, touched) + } + // a level it hasn't is refused, the change before it still told + bad := []string{"xhigh"} + err := SetModelPrefs("a", map[string]ModelPref{"sol": {Name: &name, Efforts: &bad}}) + if err == nil || !strings.Contains(err.Error(), "xhigh") { + t.Fatalf("got %v", err) + } + if settings.Load().ModelNames["a/sol"] != "My Sol" || touched != 3 { + t.Fatalf("names %v, told %d times", settings.Load().ModelNames, touched) + } +}