gui, gateway, settings: the Usage page's agent rows show their cache hit rate, and web search can go to the search APIs first (#925, xiaozhu1337; #928, hypooo)
#925: an agent's row said "9.0M cached" but not what share of its prompt that was. Each row of the Agent (and Model, Account, Key) lists now adds "hit rate N%", counted as the Cache read tile counts it: cache read over input + cache read + cache write; a row with no cache read shows none. usage-hit-rate.test.cjs is new and fails without the change. #928: with both a provider that searches and a search API set up, Settings › Web search has "Search first with": Model search (as before, the default) or Search APIs. Saved as settings.searchFirst ("api", or empty for the model first); with the APIs first, webSearch asks them before the Kimi/Google own search and the searcher's model, which search only when every API fails. The Search APIs row says they are asked first. The row is shown only when both sides are set up. TestSearchAPIsFirst and search-first.test.cjs are new and fail without the change. Test fixes on main: gui-ja/gui-de failed on ja/de strings with a second {agent} (the "is signed in to {user} now" warning) and on <name> written <名前>/<Name> in the docker-forward warning; quota-curve.test.cjs opened /blank, which its harness served as a 404 with no body, and Chromium refused to open it (ERR_HTTP_RESPONSE_CODE_FAILURE) — a harness bug, now served as an empty page; search-api.test.cjs waited for #status text that Brave's refusal had already set, so on Chromium it read the posts before the copy arrived. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass; the GUI tests above pass on Chromium and WebKit with Playwright 1.63.
This commit is contained in:
1 parent
9fb9a8ab81
commit
1e0829b125
9 files changed
+286
-11
No files matched your search
@@ -391,9 +391,18 @@ const searchSystem = "You are a web search tool. Search the web for what is aske
|
||||
// webSearch searches the web and says what it found, and on which pages:
|
||||
// for a model of a Kimi Code plan, with the plan's search service first;
|
||||
// then with the searcher's model, or, without one or when it fails, with
|
||||
// the search APIs the user set up (#419).
|
||||
// the search APIs the user set up (#419). Settings can put the search APIs
|
||||
// first (#928): the providers then search only when none of them answers.
|
||||
func (s *Server) webSearch(ctx context.Context, query string) (string, []Hit, error) {
|
||||
var errs []error
|
||||
apiFirst := settings.Load().SearchFirst == settings.SearchFirstAPI && len(provider.SearchAPIs()) > 0
|
||||
if apiFirst {
|
||||
said, hits, err := s.apiSearch(ctx, query)
|
||||
if err == nil {
|
||||
return said, hits, nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if p, ok := ownSearcher(ctx); ok {
|
||||
said, hits, err := s.ownSearch(ctx, p, query)
|
||||
if err == nil {
|
||||
@@ -410,7 +419,7 @@ func (s *Server) webSearch(ctx context.Context, query string) (string, []Hit, er
|
||||
if !errors.Is(err, errNoSearcher) || len(errs) == 0 && !apis {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if !apis {
|
||||
if !apis || apiFirst {
|
||||
return "", nil, errors.Join(errs...)
|
||||
}
|
||||
said, hits, err = s.apiSearch(ctx, query)
|
||||
|
||||
@@ -371,3 +371,59 @@ func TestSearcherFindingNothingGivesWay(t *testing.T) {
|
||||
t.Fatalf("blocks %v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
// With the search APIs put first (#928), a search goes to them before the
|
||||
// provider that can search; the provider searches only when they all fail.
|
||||
// Left as it was, the provider is asked first and the APIs not at all.
|
||||
func TestSearchAPIsFirst(t *testing.T) {
|
||||
searchSandbox(t)
|
||||
ds := newFakeDeepSeek(t)
|
||||
glm, result := newBlindModel(t)
|
||||
apis := newSearchAPIs(t)
|
||||
searchesOn(t, provider.Responses, ds.URL)
|
||||
save(t, provider.Provider{ID: "ds", Name: "DeepSeek", Key: "k", Responses: ds.URL + "/v1", Models: []string{"deepseek-flash"}},
|
||||
provider.Provider{ID: "glm", Name: "GLM", Key: "k", Chat: glm.URL, Models: []string{"glm-flash"}})
|
||||
if err := provider.SetSearchAPI(apis.api("tavily", "tvly-k")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
searchAsClaudeCode(t)
|
||||
if len(ds.searches()) != 1 || len(apis.asked) != 0 {
|
||||
t.Fatalf("by default: DeepSeek asked %q, the APIs %q", ds.searches(), apis.asked)
|
||||
}
|
||||
if r := result(); !strings.Contains(r, "22/17℃") {
|
||||
t.Fatalf("the model was told %q", r)
|
||||
}
|
||||
|
||||
st := settings.Load()
|
||||
st.SearchFirst = settings.SearchFirstAPI
|
||||
if err := settings.Save(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
searchAsClaudeCode(t)
|
||||
if len(ds.searches()) != 1 || strings.Join(apis.asked, "|") != "tavily: 今天上海天气" {
|
||||
t.Fatalf("APIs first: DeepSeek asked %q, the APIs %q", ds.searches(), apis.asked)
|
||||
}
|
||||
if r := result(); !strings.Contains(r, "go1.27.1") {
|
||||
t.Fatalf("the model was told %q", r)
|
||||
}
|
||||
|
||||
// the APIs failing, the provider searches after them
|
||||
apis.asked, apis.broken = nil, true
|
||||
searchAsClaudeCode(t)
|
||||
if len(ds.searches()) != 2 || len(apis.asked) != 1 {
|
||||
t.Fatalf("APIs down: DeepSeek asked %q, the APIs %q", ds.searches(), apis.asked)
|
||||
}
|
||||
if r := result(); !strings.Contains(r, "22/17℃") {
|
||||
t.Fatalf("the model was told %q", r)
|
||||
}
|
||||
|
||||
st.SearchFirst = "nonsense"
|
||||
if err := settings.Save(st); err == nil {
|
||||
t.Fatal("an unknown order was saved")
|
||||
}
|
||||
st.SearchFirst = settings.SearchFirstModel
|
||||
if err := settings.Save(st); err != nil || settings.Load().SearchFirst != "" {
|
||||
t.Fatalf("model first saved as %q: %v", settings.Load().SearchFirst, err)
|
||||
}
|
||||
}
|
||||
@@ -13630,7 +13630,11 @@ function renderUsage() {
|
||||
share.title = t("{n}% of tokens", { n: Math.round(100 * tokensOf(g) / total) });
|
||||
r.append(share);
|
||||
const num = el("div", "num");
|
||||
num.append(el("b", "", fmtN(tokensOf(g))), el("small", "", t("{a} in · {b} out", { a: fmtN(g.input), b: fmtN(g.output) }) + (g.cache_read ? " · " + t("{n} cached", { n: fmtN(g.cache_read) }) : "")));
|
||||
// how much of its prompt came from the cache, counted as the cache
|
||||
// read tile counts it (#925)
|
||||
const prompt = g.input + g.cache_read + (g.cache_write || 0);
|
||||
const hit = g.cache_read && prompt ? " · " + t("hit rate {p}", { p: Math.round(100 * g.cache_read / prompt) + "%" }) : "";
|
||||
num.append(el("b", "", fmtN(tokensOf(g))), el("small", "", t("{a} in · {b} out", { a: fmtN(g.input), b: fmtN(g.output) }) + (g.cache_read ? " · " + t("{n} cached", { n: fmtN(g.cache_read) }) : "") + hit));
|
||||
r.append(num);
|
||||
r.append(el("div", "cost", fmtCost(g) ? "≈" + fmtCost(g) : ""));
|
||||
box.append(r);
|
||||
@@ -17410,8 +17414,10 @@ function renderSearch(s, keep) {
|
||||
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") add.onclick(); };
|
||||
}
|
||||
draw();
|
||||
const by = s.searchProvider ? t("Now done by {who}; these come after it", { who: s.searchProvider })
|
||||
: t("No provider can search, so these are asked");
|
||||
const apiFirst = s.searchFirst === "api" && (s.searchAPIs || []).length > 0;
|
||||
const by = !s.searchProvider ? t("No provider can search, so these are asked")
|
||||
: apiFirst ? t("Asked before {who}, which searches when these fail", { who: s.searchProvider })
|
||||
: t("Now done by {who}; these come after it", { who: s.searchProvider });
|
||||
const head = row(t("Search APIs"), d.err || t("When a model can't search the web, magpie searches for it with these, in this order, and gives it what they found") + " · " + by,
|
||||
pick, key, url, get, add);
|
||||
head.classList.add("rule-row", "search-add");
|
||||
@@ -17447,6 +17453,14 @@ function renderSearch(s, keep) {
|
||||
const r = row(`${n + 1}. ${a.name}`, what, ...tools);
|
||||
r.classList.add("search-api");
|
||||
});
|
||||
// which goes first when both a provider and a search API can search
|
||||
// (#928); with only one of them there is nothing to choose
|
||||
if (s.searchProvider && (s.searchAPIs || []).length) {
|
||||
const r = row(t("Search first with"), t("The other is asked when it fails"),
|
||||
segs([["model", t("Model search")], ["api", t("Search APIs")]], apiFirst ? "api" : "model",
|
||||
(v) => savePrefs({ ...keep, searchFirst: v === "api" ? "api" : "" })));
|
||||
r.id = "searchFirstRow";
|
||||
}
|
||||
}
|
||||
|
||||
// renderSearcher: the provider that searches the web for a model that
|
||||
@@ -18020,7 +18034,7 @@ function prefsKeep(s) {
|
||||
claudeWarmup: s.claudeWarmup || "", codexWarmAt: s.codexWarmAt || "", claudeWarmAt: s.claudeWarmAt || "", workbuddyCheckin: !!s.workbuddyCheckin, traeCheckin: !!s.traeCheckin, minimaxCheckin: !!s.minimaxCheckin, noStats: !!s.noStats,
|
||||
memberModel: !!s.memberModel,
|
||||
noUpdatePill: !!s.noUpdatePill, noAutoUpdate: !!s.noAutoUpdate, updateEvery: s.updateEvery || 360,
|
||||
trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, trayNoLogos: !!s.trayNoLogos, trayNoBird: !!s.trayNoBird, vision: s.vision || "", imageGen: s.imageGen || "", searcher: s.searcher || "", currency: s.currency || "usd",
|
||||
trayUsage: s.trayUsage || "", trayUsageEvery: s.trayUsageEvery || 3, trayNoLogos: !!s.trayNoLogos, trayNoBird: !!s.trayNoBird, vision: s.vision || "", imageGen: s.imageGen || "", searcher: s.searcher || "", searchFirst: s.searchFirst || "", currency: s.currency || "usd",
|
||||
chineseUnits: !!s.chineseUnits, usageAlert: s.usageAlert || 0, balanceAlert: s.balanceAlert || 0, resetReminder: s.resetReminder || 0 };
|
||||
}
|
||||
|
||||
|
||||
@@ -591,6 +591,10 @@ const I18N = {
|
||||
"Search API": "搜索 API",
|
||||
"When a model can't search the web, magpie searches for it with these, in this order, and gives it what they found": "模型不能联网搜索时,按顺序用这些 API 代为搜索并返回结果",
|
||||
"Now done by {who}; these come after it": "目前由 {who} 代搜,这些排在它之后",
|
||||
"Asked before {who}, which searches when these fail": "先用这些搜索,都失败时再由 {who} 代搜",
|
||||
"Search first with": "优先使用",
|
||||
"The other is asked when it fails": "失败时再用另一种",
|
||||
"Model search": "模型代搜",
|
||||
"No provider can search, so these are asked": "没有能搜索的供应商,由这些 API 来搜",
|
||||
"Searches for other models": "代搜供应商",
|
||||
"When a model can't search the web directly, the selected provider searches for it and returns the results. Searches may use the service's quota or incur charges; if a search fails, magpie tries other available sources.": "当模型无法直接联网搜索时,由所选供应商代为搜索并返回结果。代搜可能消耗所用服务的额度或产生费用;失败时会尝试其他可用来源。",
|
||||
@@ -3979,6 +3983,10 @@ const I18N = {
|
||||
"Search API": "検索 API",
|
||||
"When a model can't search the web, magpie searches for it with these, in this order, and gives it what they found": "モデルがウェブ検索できないとき、magpie がこれらを上から順に使って代わりに検索し、結果をモデルに渡します",
|
||||
"Now done by {who}; these come after it": "現在は {who} が検索しています。これらはその後に使われます",
|
||||
"Asked before {who}, which searches when these fail": "まずこれらで検索し、すべて失敗したときに {who} が検索します",
|
||||
"Search first with": "優先して使う検索",
|
||||
"The other is asked when it fails": "失敗したときはもう一方を使います",
|
||||
"Model search": "モデルによる代理検索",
|
||||
"No provider can search, so these are asked": "検索できるプロバイダがないため、これらを使います",
|
||||
"Searches for other models": "他のモデルの代わりに検索",
|
||||
"When a model can't search the web directly, the selected provider searches for it and returns the results. Searches may use the service's quota or incur charges; if a search fails, magpie tries other available sources.": "モデルが直接ウェブ検索できない場合、選択したプロバイダが代わりに検索し、結果を返します。検索にはサービスの利用枠を消費したり、料金が発生したりする場合があります。検索に失敗すると、magpie は他の利用可能な検索元を試します。",
|
||||
@@ -4006,7 +4014,7 @@ const I18N = {
|
||||
"magpie signs out the accounts it keeps for {name} and forgets them; adding {name} again signs in afresh. The account itself is left as it is.": "magpie は {name} 用に保存しているアカウントからサインアウトし、削除します。{name} を再度追加するときは改めてサインインします。アカウント自体はそのままです。",
|
||||
"magpie signs out the accounts it keeps for {name}; adding it again signs in afresh": "magpie は {name} 用に保存しているアカウントからサインアウトします。再度追加するときは改めてサインインします",
|
||||
"{name} signed out": "{name} からサインアウトしました",
|
||||
"{agent} is signed in to {user} now: sign it in to another of its accounts first (Use on that account), then remove this one": "{agent} は現在 {user} でサインインしています。先に別のアカウントの「使用」で {agent} を切り替えてから、このアカウントを削除してください",
|
||||
"{agent} is signed in to {user} now: sign it in to another of its accounts first (Use on that account), then remove this one": "{agent} は現在 {user} でサインインしています。先に別のアカウントの「使用」で切り替えてから、このアカウントを削除してください",
|
||||
"Signs Codex out of this account, as codex logout does": "codex logout と同じく、Codex をこのアカウントからサインアウトします",
|
||||
"Codex is signed in to another of its accounts, and magpie forgets this one; the account itself is untouched": "Codex を別のアカウントにサインインし直してから、magpie はこのアカウントを忘れます。アカウント自体はそのままです",
|
||||
"Codex itself is signed out too, as codex logout does.": "codex logout と同じく、Codex 自体もサインアウトします。",
|
||||
@@ -4844,7 +4852,7 @@ const I18N = {
|
||||
"Restarting the gateway…": "ゲートウェイを再起動しています…",
|
||||
"Starting the gateway…": "ゲートウェイを起動しています…",
|
||||
"Port {port} is held by {path} (pid {pid}), which isn't magpie, so magpie leaves it alone. Quit it yourself, or move the gateway to another port in Settings.": "ポート {port} は {path}(pid {pid})が使用しています。magpie ではないため、magpie は触りません。ご自身で終了するか、設定でゲートウェイを別のポートに移してください。",
|
||||
"Port {port} is forwarded by {path} (pid {pid}) to a container or a VM: the magpie {v} answering there runs inside it, out of this magpie's reach. Stop that container (docker ps lists it, docker stop <name> stops it, or stop it in OrbStack or Docker Desktop), or move this magpie's gateway to another port in Settings.": "ポート {port} は {path}(pid {pid})がコンテナまたは VM に転送しています。そこで応答している magpie {v} はその中で動いており、この magpie からは止められません。そのコンテナを停止するか(docker ps で一覧表示、docker stop <名前> で停止、または OrbStack や Docker Desktop で停止)、設定でこの magpie のゲートウェイを別のポートに移してください。",
|
||||
"Port {port} is forwarded by {path} (pid {pid}) to a container or a VM: the magpie {v} answering there runs inside it, out of this magpie's reach. Stop that container (docker ps lists it, docker stop <name> stops it, or stop it in OrbStack or Docker Desktop), or move this magpie's gateway to another port in Settings.": "ポート {port} は {path}(pid {pid})がコンテナまたは VM に転送しています。そこで応答している magpie {v} はその中で動いており、この magpie からは止められません。そのコンテナを停止するか(docker ps で一覧表示、docker stop <name> で停止、または OrbStack や Docker Desktop で停止)、設定でこの magpie のゲートウェイを別のポートに移してください。",
|
||||
"magpie can't see which process holds port {port}: it runs as another user or as administrator. Quit it there.": "ポート {port} を使っているプロセスが magpie からは見えません。別のユーザーまたは管理者として実行されています。そちらで終了してください。",
|
||||
"magpie {v} (pid {pid}, {path}) runs as another user or as administrator, so this magpie can't quit it. Quit it there.": "magpie {v}(pid {pid}、{path})は別のユーザーまたは管理者として実行されているため、この magpie からは終了できません。そちらで終了してください。",
|
||||
"magpie {v} (pid {pid}) didn't quit. End it in your system's process list (Activity Monitor, Task Manager).": "magpie {v}(pid {pid})が終了しませんでした。システムのプロセス一覧(アクティビティモニタ、タスクマネージャー)で終了してください。",
|
||||
@@ -7353,6 +7361,10 @@ const I18N = {
|
||||
"Search API": "Such-API",
|
||||
"When a model can't search the web, magpie searches for it with these, in this order, and gives it what they found": "Kann ein Modell nicht im Web suchen, sucht magpie mit diesen in dieser Reihenfolge für es und übergibt die Ergebnisse",
|
||||
"Now done by {who}; these come after it": "Derzeit übernimmt das {who}; diese folgen danach",
|
||||
"Asked before {who}, which searches when these fail": "Werden vor {who} gefragt, das sucht, wenn alle scheitern",
|
||||
"Search first with": "Zuerst suchen mit",
|
||||
"The other is asked when it fails": "Scheitert es, wird das andere gefragt",
|
||||
"Model search": "Modellsuche",
|
||||
"No provider can search, so these are asked": "Kein Anbieter kann suchen, daher werden diese gefragt",
|
||||
"Searches for other models": "Sucht für andere Modelle",
|
||||
"When a model can't search the web directly, the selected provider searches for it and returns the results. Searches may use the service's quota or incur charges; if a search fails, magpie tries other available sources.": "Wenn ein Modell nicht direkt im Web suchen kann, übernimmt der ausgewählte Anbieter die Suche und liefert die Ergebnisse. Suchen können das Kontingent des Dienstes verbrauchen oder Kosten verursachen. Schlägt eine Suche fehl, versucht magpie andere verfügbare Quellen.",
|
||||
@@ -7380,7 +7392,7 @@ const I18N = {
|
||||
"magpie signs out the accounts it keeps for {name} and forgets them; adding {name} again signs in afresh. The account itself is left as it is.": "magpie meldet die für {name} gespeicherten Konten ab und vergisst sie; beim erneuten Hinzufügen von {name} neu anmelden. Das Konto selbst bleibt unverändert.",
|
||||
"magpie signs out the accounts it keeps for {name}; adding it again signs in afresh": "magpie meldet die für {name} gespeicherten Konten ab; beim erneuten Hinzufügen neu anmelden",
|
||||
"{name} signed out": "{name} abgemeldet",
|
||||
"{agent} is signed in to {user} now: sign it in to another of its accounts first (Use on that account), then remove this one": "{agent} ist gerade bei {user} angemeldet: Melden Sie {agent} zuerst bei einem anderen seiner Konten an (dort „Verwenden“) und entfernen Sie dann dieses",
|
||||
"{agent} is signed in to {user} now: sign it in to another of its accounts first (Use on that account), then remove this one": "{agent} ist gerade bei {user} angemeldet: Melden Sie es zuerst bei einem anderen seiner Konten an (dort „Verwenden“) und entfernen Sie dann dieses",
|
||||
"Signs Codex out of this account, as codex logout does": "Meldet Codex von diesem Konto ab, wie codex logout",
|
||||
"Codex is signed in to another of its accounts, and magpie forgets this one; the account itself is untouched": "Codex wird bei einem anderen seiner Konten angemeldet, und magpie vergisst dieses; das Konto selbst bleibt unberührt",
|
||||
"Codex itself is signed out too, as codex logout does.": "Codex selbst wird ebenfalls abgemeldet, wie mit codex logout.",
|
||||
@@ -8218,7 +8230,7 @@ const I18N = {
|
||||
"Restarting the gateway…": "Zugangspunkt wird neu gestartet…",
|
||||
"Starting the gateway…": "Zugangspunkt wird gestartet…",
|
||||
"Port {port} is held by {path} (pid {pid}), which isn't magpie, so magpie leaves it alone. Quit it yourself, or move the gateway to another port in Settings.": "Port {port} wird von {path} (PID {pid}) belegt. Das ist kein magpie, daher lässt magpie es in Ruhe. Beenden Sie es selbst oder legen Sie den Zugangspunkt in den Einstellungen auf einen anderen Port.",
|
||||
"Port {port} is forwarded by {path} (pid {pid}) to a container or a VM: the magpie {v} answering there runs inside it, out of this magpie's reach. Stop that container (docker ps lists it, docker stop <name> stops it, or stop it in OrbStack or Docker Desktop), or move this magpie's gateway to another port in Settings.": "Port {port} wird von {path} (PID {pid}) an einen Container oder eine VM weitergeleitet: Das magpie {v}, das dort antwortet, läuft darin, außerhalb der Reichweite dieses magpie. Stoppen Sie diesen Container (docker ps listet ihn, docker stop <Name> stoppt ihn, oder stoppen Sie ihn in OrbStack oder Docker Desktop), oder legen Sie den Zugangspunkt dieses magpie in den Einstellungen auf einen anderen Port.",
|
||||
"Port {port} is forwarded by {path} (pid {pid}) to a container or a VM: the magpie {v} answering there runs inside it, out of this magpie's reach. Stop that container (docker ps lists it, docker stop <name> stops it, or stop it in OrbStack or Docker Desktop), or move this magpie's gateway to another port in Settings.": "Port {port} wird von {path} (PID {pid}) an einen Container oder eine VM weitergeleitet: Das magpie {v}, das dort antwortet, läuft darin, außerhalb der Reichweite dieses magpie. Stoppen Sie diesen Container (docker ps listet ihn, docker stop <name> stoppt ihn, oder stoppen Sie ihn in OrbStack oder Docker Desktop), oder legen Sie den Zugangspunkt dieses magpie in den Einstellungen auf einen anderen Port.",
|
||||
"magpie can't see which process holds port {port}: it runs as another user or as administrator. Quit it there.": "magpie kann nicht sehen, welcher Prozess Port {port} belegt: Er läuft als anderer Benutzer oder als Administrator. Beenden Sie ihn dort.",
|
||||
"magpie {v} (pid {pid}, {path}) runs as another user or as administrator, so this magpie can't quit it. Quit it there.": "magpie {v} (PID {pid}, {path}) läuft als anderer Benutzer oder als Administrator, daher kann dieses magpie es nicht beenden. Beenden Sie es dort.",
|
||||
"magpie {v} (pid {pid}) didn't quit. End it in your system's process list (Activity Monitor, Task Manager).": "magpie {v} (PID {pid}) wurde nicht beendet. Beenden Sie es in der Prozessliste Ihres Systems (Aktivitätsanzeige, Task-Manager).",
|
||||
|
||||
@@ -188,6 +188,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
// turned off in the window, the tray's line goes too, and stays gone
|
||||
const win = await panel.context().newPage();
|
||||
await win.route("**/*", serve(lang, "dark", false, data));
|
||||
// a page of magpie's origin, only for its storage: served as a 404
|
||||
// with no body, Chromium refused to open it (ERR_HTTP_RESPONSE_CODE_FAILURE)
|
||||
await win.route("http://magpie.test/blank", (r) => r.fulfill({ contentType: "text/html", body: "<!doctype html><title>blank</title>" }));
|
||||
await win.goto("http://magpie.test/blank");
|
||||
await win.evaluate(() => localStorage.setItem("magpie.quotaRange", "off"));
|
||||
await panel.waitForFunction(() => !document.querySelector(".pq-spark"));
|
||||
|
||||
@@ -129,7 +129,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal(await tavily.locator("button.search-eye").innerText(), w.show);
|
||||
await click(tavily.locator("button.search-copy", { hasText: w.copy }));
|
||||
await page.waitForFunction(() => document.querySelector("#searchList .row.search-api .sub")?.innerText === "tvly…abcd");
|
||||
await page.waitForFunction(() => document.querySelector("#status")?.textContent);
|
||||
// #status may still hold Brave's refusal, so wait for the copy itself
|
||||
for (let i = 0; i < 100 && !posted.some((p) => p.copied); i++) await page.waitForTimeout(50);
|
||||
assert.deepEqual(posted.slice(sent), [{ asked: "tavily" }, { asked: "tavily" }, { copied: "tvly-dev-0123456789abcd" }]);
|
||||
assert.equal(await tavily.locator(".sub").innerText(), "tvly…abcd", "copying shows nothing");
|
||||
posted.splice(sent);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Settings › Models › Web search (#928, hypooo): with both a provider that
|
||||
// searches and a search API set up, "Search first with" picks which goes
|
||||
// first — Model search, as before, or Search APIs, the other asked when it
|
||||
// fails. A click saves searchFirst with the rest of the settings kept, the
|
||||
// Search APIs row then says they are asked first, and the page isn't moved.
|
||||
// With only one of the two the row isn't there. In English and Chinese.
|
||||
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 words = {
|
||||
en: { name: "Search first with", model: "Model search", apis: "Search APIs", before: "Asked before DeepSeek · deepseek-flash" },
|
||||
zh: { name: "优先使用", model: "模型代搜", apis: "搜索 API", before: "先用这些搜索,都失败时再由 DeepSeek · deepseek-flash 代搜" },
|
||||
};
|
||||
|
||||
function serve(lang, saved, { provider = true } = {}) {
|
||||
const settings = { lang, theme: "light", redact: true, searcher: "ds",
|
||||
searchProvider: provider ? "DeepSeek · deepseek-flash" : "",
|
||||
searchVendors: [{ id: "tavily", name: "Tavily", keysURL: "https://app.tavily.com/home" }],
|
||||
searchAPIs: [{ vendor: "tavily", name: "Tavily", key: "tvly…abcd", ready: true }],
|
||||
searchChoices: [{ id: "ds", name: "DeepSeek", icon: "deepseek", small: "deepseek-flash", models: [] }] };
|
||||
const state = { agents: [], profiles: [], settings };
|
||||
return async (r) => {
|
||||
const url = new URL(r.request().url());
|
||||
const json = (data) => r.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json(state);
|
||||
if (url.pathname === "/api/settings") {
|
||||
if (r.request().method() === "POST") {
|
||||
saved.push(JSON.parse(r.request().postData()));
|
||||
Object.assign(settings, saved.at(-1));
|
||||
}
|
||||
return json(settings);
|
||||
}
|
||||
if (url.pathname === "/api/groups") return json({ models: [], groups: [], pools: [] });
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } });
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
try { await r.fulfill({ body: await fs.readFile(file), contentType }); } catch { await r.fulfill({ status: 404 }); }
|
||||
};
|
||||
}
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const w = words[lang];
|
||||
test(`${engine} ${lang}: web search can go to the search APIs first`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await (await browser.newContext({ viewport: { width: 1100, height: 900 } })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
const saved = [];
|
||||
await page.route("**/*", serve(lang, saved));
|
||||
await page.goto("http://magpie.test/?view=settings&tab=models");
|
||||
const row = page.locator("#searchList #searchFirstRow");
|
||||
await row.waitFor();
|
||||
await row.scrollIntoViewIfNeeded();
|
||||
assert.equal(await row.locator(".name").textContent(), w.name);
|
||||
const model = row.locator(".segs button", { hasText: w.model });
|
||||
const apis = row.locator(".segs button", { hasText: w.apis });
|
||||
assert(await model.evaluate((b) => b.classList.contains("on")), "model search first at first");
|
||||
assert(!await apis.evaluate((b) => b.classList.contains("on")));
|
||||
|
||||
const before = await page.evaluate(() => [scrollX, scrollY, document.scrollingElement.scrollTop]);
|
||||
const b = await apis.boundingBox();
|
||||
await page.mouse.click(b.x + b.width / 2, b.y + b.height / 2);
|
||||
for (let i = 0; i < 50 && !saved.length; i++) await page.waitForTimeout(50);
|
||||
assert.equal(saved.length, 1, "saved once");
|
||||
assert.equal(saved[0].searchFirst, "api");
|
||||
assert.equal(saved[0].searcher, "ds", "the rest kept");
|
||||
assert.equal(saved[0].redact, true);
|
||||
await page.locator("#searchFirstRow .segs button.on", { hasText: w.apis }).waitFor();
|
||||
assert((await page.locator("#searchList .row.search-add .sub").innerText()).includes(w.before));
|
||||
assert.deepEqual(await page.evaluate(() => [scrollX, scrollY, document.scrollingElement.scrollTop]), before, "the click moved nothing");
|
||||
|
||||
// and back
|
||||
const m = await page.locator("#searchFirstRow .segs button", { hasText: w.model }).boundingBox();
|
||||
await page.mouse.click(m.x + m.width / 2, m.y + m.height / 2);
|
||||
for (let i = 0; i < 50 && saved.length < 2; i++) await page.waitForTimeout(50);
|
||||
assert.equal(saved.at(-1).searchFirst, "");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test(`${engine} ${lang}: with no provider that searches there is nothing to choose`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await (await browser.newContext({ viewport: { width: 1100, height: 900 } })).newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
await page.route("**/*", serve(lang, [], { provider: false }));
|
||||
await page.goto("http://magpie.test/?view=settings&tab=models");
|
||||
await page.locator("#searchList .row.search-add").waitFor();
|
||||
assert.equal(await page.locator("#searchFirstRow").count(), 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// #925: the Usage page's Agent list said how much each agent read from the
|
||||
// cache ("9.0M cached") but not what share of its prompt that was, so one
|
||||
// agent's caching couldn't be told from another's. Each row now adds its hit
|
||||
// rate, counted as the Cache read tile counts it: cache read over input +
|
||||
// cache read + cache write. A row with no cache read shows none; in English
|
||||
// and Chinese. No backend: the API is faked here.
|
||||
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 row = (name, input, cache_read, cache_write) => ({ name, icon: "", calls: 10, errors: 0, input, output: 1000, cache_read, cache_write, reasoning: 0, cost: 0, unpriced: 0 });
|
||||
// Pi: 9M of 10M from the cache → 90%; OpenCode: 1M read, 2M in, 1M written → 25%; omp: none
|
||||
const agents = [row("Pi", 900000, 9000000, 100000), row("OpenCode", 2000000, 1000000, 1000000), row("omp", 500000, 0, 0)];
|
||||
const sum = (k) => agents.reduce((n, a) => n + a[k], 0);
|
||||
const usage = { name: "", calls: 30, errors: 0, input: sum("input"), output: 3000, cache_read: sum("cache_read"), cache_write: sum("cache_write"), reasoning: 0, cost: 0, unpriced: 0,
|
||||
path: "/test/usage.jsonl", bucket: "day", agents, models: [], series: [] };
|
||||
|
||||
function server(lang) {
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${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 route.fulfill({ json: { agents: [], profiles: [], settings: { lang, theme: "light" } } });
|
||||
if (url.pathname === "/api/providers") return route.fulfill({ json: { providers: [], presets: [], excluded: [], gateway: { running: true, calls: [], groups: [] } } });
|
||||
if (url.pathname === "/api/groups") return route.fulfill({ json: { groups: [], pools: [], models: [] } });
|
||||
if (url.pathname === "/api/usage/quotas") return route.fulfill({ json: [] });
|
||||
if (url.pathname === "/api/usage") return route.fulfill({ json: usage });
|
||||
if (url.pathname === "/api/plugins") return route.fulfill({ json: { plugins: [] } });
|
||||
if (url.pathname.startsWith("/api/")) return route.fulfill({ json: {} });
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404 }); }
|
||||
};
|
||||
}
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const [lang, rate] of [["en", "hit rate"], ["zh", "命中率"]]) {
|
||||
test(`${engine} ${lang}: each agent row on the Usage page shows its cache hit rate`, async () => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(5000);
|
||||
await page.route("http://magpie.test/**", server(lang));
|
||||
await page.goto("http://magpie.test/?view=usage");
|
||||
await page.locator("#usageAgents .row.stat").nth(2).waitFor();
|
||||
const notes = await page.locator("#usageAgents .row.stat .num small").allTextContents();
|
||||
assert.equal(notes.length, 3, notes.join(" | "));
|
||||
assert.ok(notes[0].includes(`${rate} 90%`), notes[0]);
|
||||
assert.ok(notes[1].includes(`${rate} 25%`), notes[1]);
|
||||
assert.ok(!notes[2].includes(rate), notes[2]);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,11 @@ type Settings struct {
|
||||
// (gateway.searcher). One that is gone, off or can't search gives way
|
||||
// to magpie's pick.
|
||||
Searcher string `json:"searcher,omitempty"`
|
||||
// SearchFirst is what a model that can't search the web is searched
|
||||
// for with first, when both a provider and a search API can (#928):
|
||||
// SearchFirstAPI for the search APIs, the provider's search after them
|
||||
// when they fail; empty for the provider first, as before.
|
||||
SearchFirst string `json:"searchFirst,omitempty"`
|
||||
// TrayUsages are the subscriptions and plans whose windows are shown
|
||||
// beside the tray icon, in the order shown, each by its provider and
|
||||
// account ("claude|a@b.c"); none when empty.
|
||||
@@ -519,6 +524,13 @@ var (
|
||||
TextSizes = []int{100, 110, 125, 150}
|
||||
)
|
||||
|
||||
// SearchFirst's values: a provider's search first, saved as "", or the
|
||||
// search APIs first.
|
||||
const (
|
||||
SearchFirstModel = "model"
|
||||
SearchFirstAPI = "api"
|
||||
)
|
||||
|
||||
// DefaultTextSize is the text size of settings that have never had one
|
||||
// saved. Windows' and Linux's body text is 14px against magpie's 13px at
|
||||
// 100%, a size smaller than what is around it there, so they start at
|
||||
@@ -834,6 +846,12 @@ func Save(s Settings) error {
|
||||
return fmt.Errorf("the model for Codex's titles must be a model's id such as openai/gpt-5-mini, or off, not %q", s.CodexTitles)
|
||||
}
|
||||
s.Searcher = strings.TrimSpace(s.Searcher)
|
||||
if s.SearchFirst = strings.TrimSpace(s.SearchFirst); s.SearchFirst == SearchFirstModel {
|
||||
s.SearchFirst = ""
|
||||
}
|
||||
if s.SearchFirst != "" && s.SearchFirst != SearchFirstAPI {
|
||||
return fmt.Errorf("web search must go first to %q or %q, not %q", SearchFirstModel, SearchFirstAPI, s.SearchFirst)
|
||||
}
|
||||
s.ImageGen = strings.TrimSpace(s.ImageGen)
|
||||
if s.ImageGen != "" && s.ImageGen != "off" && !strings.Contains(s.ImageGen, "/") {
|
||||
return fmt.Errorf("the image generation model must be a model's id such as openai/gpt-image-1, or off, not %q", s.ImageGen)
|
||||
|
||||
Reference in new issue
Block a user