gui, web: gateway mode — magpie web on a server that is only a gateway shows no Agents, Sessions or Library (Player on Discord) — a magpie web run on a server or NAS for other machines' agents showed the Agents, Sessions and Library pages and the settings written into this machine's agents, none of which do anything there. In gateway mode the page has Providers, Gateway, Routing, Usage, Plugins and Settings; an address kept for a page left out opens Providers. Settings leaves out provider in model names, Codex subagents, long conversations, Codex thread titles, the usage/balance alerts and reset reminders, on top of the tray, open at login, Dock, text size and lightweight rows magpie web already hid; Language, theme, currency, proxy, redaction, vision/image models, sync, updates and the rest stay. magpie web is in it by itself when it finds no agents on its machine (the Docker image, a NAS) and with the new magpie web --gateway; Settings › General › Gateway mode (Automatic / On / Off, saying why it is on) wins over both and Off brings every page back at once. The choice is this machine's own (KeepOwn: never synced or restored from another computer), set only by POST /api/settings/gateway-mode, never by the Settings page's save. The app's own windows are never in gateway mode. boot.js tells the page before it paints. en/zh/ja/de; docs/reference.md (Docker › NAS, CLI list). TestGatewayMode (temp HOME: no agents → on, agents → off, --gateway → on, Off wins over --gateway, the Settings save leaves it, On with agents, Automatic, an unknown mode refused, the app window never told, KeepOwn) is new; TestSettingsSaveKeepsWhatItDoesNotSend sets it. gateway-mode.test.cjs (WebKit, en and zh: the tabs, ?view=agents/sessions/library open Providers, the switch and why, the seven rows hidden, Off posts "off" and brings tabs and rows back with Settings kept open and Agents opening, Automatic hides them again, the Settings save never sends it, no scroll, no select, no left border; web with agents and the app window keep every tab, the switch is magpie web's alone) fails without the change. mobile-web's desktop comparison differs from origin/main on Settings only, by the new row. settings-sections, settings-groups, click-scroll, cli-path, plain-names, codex-titles and agent-grip pass in WebKit. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
This commit is contained in:
1 parent
8339e82c08
commit
1534382ff9
12 files changed
+485
-9
No files matched your search
+11
-1
@@ -872,6 +872,16 @@ key. See [Connecting anything else](#connecting-anything-else) for the
|
||||
protocol-specific base URLs. Keep `3425` behind a firewall or VPN and do not
|
||||
expose it publicly without gateway-key authentication.
|
||||
|
||||
Such a page is in **gateway mode**: it shows Providers, Gateway, Routing,
|
||||
Usage, Plugins and Settings, with no Agents, Sessions or Library tab, and
|
||||
Settings leaves out what is written into this machine's agents (provider in
|
||||
model names, Codex subagents, long conversations, Codex thread titles) and
|
||||
the desktop's alerts and tray. `magpie web` is in it by itself when it finds
|
||||
no agents on its machine, as in the container, and with
|
||||
`magpie web --gateway`. *Settings › General › Gateway mode* picks Automatic,
|
||||
On or Off for this machine (it wins over both); Off brings every page back.
|
||||
The app's own window is never in gateway mode.
|
||||
|
||||
For a bind-mounted configuration directory, the directory must be writable by
|
||||
the non-root container user (uid 65532):
|
||||
|
||||
@@ -934,7 +944,7 @@ gateway translates.
|
||||
magpie # open the app: a window plus the menu bar icon
|
||||
magpie tray # menu bar icon only (use this in your login items)
|
||||
magpie tui # the same thing, in the terminal
|
||||
magpie web # the app's window in a browser (WSL, a server over SSH); --lan, --addr, --no-open
|
||||
magpie web # the app's window in a browser (WSL, a server over SSH); --lan, --addr, --no-open, --gateway
|
||||
# (a new key each run; MAGPIE_WEB_KEY keeps one, for a page run as a service)
|
||||
magpie ls # list every agent and its current settings
|
||||
magpie claude opus # set a model (agent names accept prefixes: cc, oc, gem …)
|
||||
|
||||
@@ -317,6 +317,11 @@ type settingsJSON struct {
|
||||
TerminalApps []terminalChoice `json:"terminalApps,omitempty"`
|
||||
TerminalDefault string `json:"terminalDefault,omitempty"`
|
||||
OTelEnv bool `json:"otelEnv,omitempty"`
|
||||
// whether the page is in gateway mode and why (gatewayMode); Web says
|
||||
// it is served by magpie web, where the mode can be set
|
||||
GatewayOn bool `json:"gatewayOn,omitempty"`
|
||||
GatewayWhy string `json:"gatewayWhy,omitempty"`
|
||||
Web bool `json:"web,omitempty"`
|
||||
// the proxy vendor requests go through now, and where it came from:
|
||||
// settings, environment, system, off or none
|
||||
ProxyNow string `json:"proxyNow"`
|
||||
@@ -452,6 +457,8 @@ func searchState(s *settingsJSON) {
|
||||
func settingsState() settingsJSON {
|
||||
s := settingsJSON{Settings: settings.Load(), Version: Version, Dir: tilde(settings.Dir()), Portable: settings.Portable() != "", Gateway: gateway.URL()}
|
||||
s.AddrEnv = os.Getenv("MAGPIE_ADDR")
|
||||
s.Web = webPage.Load()
|
||||
s.GatewayOn, s.GatewayWhy = gatewayMode(s.Web)
|
||||
s.LANKey = "" // the retained credential belongs on disk, not in UI state
|
||||
// the GitHub token, masked, and where the library's requests take one
|
||||
// from: Settings, or the environment variable named
|
||||
@@ -613,6 +620,9 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
||||
if gw != nil {
|
||||
served.Store(gw)
|
||||
}
|
||||
if isWeb(w) {
|
||||
webPage.Store(true)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/", devPage(revalidated(http.FileServer(http.FS(staticFS())))))
|
||||
devRoutes(mux)
|
||||
@@ -623,6 +633,11 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
||||
// and the text size, which the Mac's header measures against the
|
||||
// traffic lights
|
||||
boot := map[string]any{"lang": s.Lang, "theme": s.Theme, "textSize": s.TextSize, "web": isWeb(w)}
|
||||
// and whether it is a gateway's page alone (gatewaymode.go), so the
|
||||
// pages left out never show
|
||||
if on, _ := gatewayMode(isWeb(w)); on {
|
||||
boot["gateway"] = true
|
||||
}
|
||||
// on Omarchy the page takes its theme's look before it paints
|
||||
if th, ok := omarchyTheme(); ok {
|
||||
boot["omarchy"] = th
|
||||
@@ -914,6 +929,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
||||
whatsNewRoutes(mux)
|
||||
cliBehindRoutes(mux)
|
||||
gatewayFixRoutes(mux)
|
||||
gatewayModeRoutes(mux)
|
||||
mux.HandleFunc("GET /api/settings",func(rw http.ResponseWriter, r *http.Request) {
|
||||
access.MigrateLegacyLANKeyBestEffort()
|
||||
writeJSON(rw, settingsState())
|
||||
@@ -965,6 +981,8 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
|
||||
in.UpdateSkip = cur.UpdateSkip
|
||||
// the GitHub mirror updates come through, set by magpie update mirror
|
||||
in.UpdateMirror = cur.UpdateMirror
|
||||
// gateway mode, set on its own (gateway-mode)
|
||||
in.GatewayMode = cur.GatewayMode
|
||||
if v := strings.TrimSpace(in.Vision); v != "" && v != "off" && v != cur.Vision {
|
||||
if _, _, ok := provider.Resolve(v); !ok {
|
||||
fail(rw, fmt.Errorf("no model %s to describe images", v))
|
||||
|
||||
@@ -10,6 +10,12 @@ if (mode === "panel") $("header.top").style.setProperty("--wails-draggable", "no
|
||||
// around it — it opens links itself, and what is the desktop's is left out
|
||||
const web = !!window.bootPrefs?.web;
|
||||
if (web) document.body.classList.add("web");
|
||||
// Gateway mode (gatewaymode.go): magpie web on a server that is only the
|
||||
// gateway for other computers' agents leaves out this computer's agents'
|
||||
// pages — Agents, Sessions, Library — and their settings; Settings ›
|
||||
// General turns it off, and they are back.
|
||||
let gatewayMode = web && !!window.bootPrefs?.gateway;
|
||||
const GATEWAY_HIDES = ["agents", "sessions", "library"];
|
||||
// iOS zooms the page into a field it focuses whose text is under 16px, and
|
||||
// leaves it zoomed; at most 1 stops that, and Safari still lets a pinch zoom
|
||||
if (web && (/iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1))) {
|
||||
@@ -15507,8 +15513,8 @@ function applyPrefs(s, rate) {
|
||||
// icon), offered only in the app on Omarchy
|
||||
let barIcon = null;
|
||||
function renderBarIcon() {
|
||||
$("#barIconRow").hidden = !barIcon?.available;
|
||||
if (!barIcon?.available) return;
|
||||
$("#barIconRow").hidden = !barIcon?.available || gatewayMode; // the desktop's, not a gateway's
|
||||
if ($("#barIconRow").hidden) return;
|
||||
$("#barIconSegs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], barIcon.on ? "on" : "off", (v) =>
|
||||
api("omarchy/widget", { on: v === "on" }).then((b) => { barIcon = b; renderBarIcon(); })
|
||||
.catch((e) => { status(t(e.message), "err"); renderBarIcon(); })));
|
||||
@@ -15526,6 +15532,8 @@ async function loadSettings() {
|
||||
// form open: load() keeps off this path while one is, so it is never
|
||||
// rebuilt under whoever is typing in it
|
||||
syncView = null;
|
||||
// gateway mode turned in another tab, or the agents here come and gone
|
||||
if (web && !!s.gatewayOn !== gatewayMode) setGatewayMode(!!s.gatewayOn);
|
||||
renderSettings();
|
||||
}
|
||||
|
||||
@@ -15730,6 +15738,7 @@ function renderSettings() {
|
||||
prefsBase = keep;
|
||||
$("#themeSegs").replaceChildren(segs(THEMES.map(([id, name]) => [id, t(name)]), s.theme, (theme) => savePrefs({ ...keep, theme })));
|
||||
$("#langSegs").replaceChildren(segs(LOCALES.map(([id, name]) => [id, t(name)]), s.lang, (lang) => savePrefs({ ...keep, lang })));
|
||||
renderGatewayMode(s);
|
||||
// a browser tab has its own zoom, and magpie leaves it to it
|
||||
$("#textSizeRow").hidden = web;
|
||||
$("#textSizeSub").textContent = t("Everything in magpie's windows, larger; {keys} too", { keys: textSizeKeys() });
|
||||
@@ -15868,10 +15877,33 @@ function renderSettings() {
|
||||
repo.title = "github.com/yetone/magpie";
|
||||
repo.onclick = () => api("open", { url: "https://github.com/yetone/magpie" }).catch(() => {});
|
||||
row(t("Community"), t("questions, ideas and feedback, on Discord or GitHub"), "", join, repo);
|
||||
// gateway mode leaves out what is written into this computer's agents'
|
||||
// files, and the alerts its desktop would show
|
||||
for (const r of [$("#plainNamesSegs").parentElement, $("#codexAgentsV1Segs").parentElement, $("#fullContextSegs").parentElement,
|
||||
$("#codexTitlesRow"), $("#usageAlertRow"), $("#balanceAlertRow"), $("#resetReminderRow")]) r.hidden = gatewayMode;
|
||||
// the parts' tabs, one gone whose rows are all hidden here
|
||||
setSetTab(setTab);
|
||||
}
|
||||
|
||||
// Gateway mode's switch, in magpie web alone (gatewaymode.go): Automatic
|
||||
// (on with --gateway or no agents here), On or Off. Turned, the tabs follow
|
||||
// at once.
|
||||
function renderGatewayMode(s) {
|
||||
$("#gatewayModeRow").hidden = !web;
|
||||
if (!web) return;
|
||||
$("#gatewayModeSegs").replaceChildren(segs([["auto", t("Automatic")], ["on", t("On")], ["off", t("Off")]], s.gatewayMode || "auto", (v) =>
|
||||
writingPrefs(api("settings/gateway-mode", { mode: v === "auto" ? "" : v })).then((ns) => {
|
||||
prefs = ns;
|
||||
setGatewayMode(!!ns.gatewayOn);
|
||||
renderSettings();
|
||||
}).catch((e) => { status(t(e.message), "err"); renderSettings(); })));
|
||||
const now = s.gatewayMode ? "" : {
|
||||
flag: t("Now on: magpie web was started with --gateway"),
|
||||
"no-agents": t("Now on: no agents on this computer"),
|
||||
}[s.gatewayWhy] || t("Now off: agents found on this computer");
|
||||
$("#gatewayModeSub").textContent = [t("Only what a gateway for other computers needs: no Agents, Sessions or Library, nor this computer's agents' settings"), now].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function renderSessionTerminal(s, keep) {
|
||||
const row = $("#sessionTerminalRow");
|
||||
row.hidden = !document.body.classList.contains("mac");
|
||||
@@ -17551,6 +17583,8 @@ for (const v of document.querySelectorAll(".view")) {
|
||||
}
|
||||
|
||||
function show(v) {
|
||||
// a page gateway mode leaves out (a link to it, an address kept) opens Providers
|
||||
if (gatewayMode && GATEWAY_HIDES.includes(v)) v = "providers";
|
||||
view = v;
|
||||
if (mode === "window") { for (const b of $("#nav").querySelectorAll("button")) b.classList.toggle("on", b.dataset.view === v); slide($("#nav"), "nav"); navInSight(); }
|
||||
$("#prefs").classList.toggle("on", v === "settings");
|
||||
@@ -17584,6 +17618,16 @@ function syncURL() {
|
||||
if (s !== location.search) history.replaceState(null, "", s);
|
||||
}
|
||||
if (mode === "window") for (const b of $("#nav").querySelectorAll("button")) b.onclick = () => { show(b.dataset.view); b.blur(); };
|
||||
// setGatewayMode shows or leaves out gateway mode's pages' tabs; the page
|
||||
// open, one of them, gives way to Providers
|
||||
function setGatewayMode(on) {
|
||||
gatewayMode = on;
|
||||
document.body.classList.toggle("gateway-mode", on);
|
||||
if (mode !== "window") return;
|
||||
for (const v of GATEWAY_HIDES) $(`#nav button[data-view="${v}"]`).hidden = on;
|
||||
if (on && GATEWAY_HIDES.includes(view)) show("providers");
|
||||
else slide($("#nav"), "nav");
|
||||
}
|
||||
// openSettings: the Settings page, in the window (the panel opens it there)
|
||||
function openSettings() {
|
||||
if (mode !== "window") { api("window/main?view=settings", {}); return; }
|
||||
@@ -18105,5 +18149,7 @@ if (mode === "window" && params.get("view") === "usage") {
|
||||
history.replaceState(null, "", u);
|
||||
}
|
||||
if (mode === "window" && ["providers", "gateway", "routing", "usage", "sessions", "library", "plugins", "settings"].includes(params.get("view"))) show(params.get("view"));
|
||||
else if (mode === "window") slide($("#nav"), "nav");
|
||||
else if (mode === "window" && !gatewayMode) slide($("#nav"), "nav");
|
||||
// in gateway mode the first page is Providers, Agents' tab gone
|
||||
if (gatewayMode) setGatewayMode(true);
|
||||
load();
|
||||
@@ -359,6 +359,11 @@ const I18N = {
|
||||
"move magpie to Applications first, then turn this on": "请先把 magpie 移到「应用程序」文件夹,再打开这个选项",
|
||||
"Keep magpie in the Dock too, always or while its window is open, for Cmd-Tab and a menu bar too full to show its icon": "程序坞中也显示 magpie(常驻或仅在窗口打开时),便于 Cmd-Tab 切换,菜单栏图标被挤掉时也能打开",
|
||||
"Lightweight mode": "轻量模式",
|
||||
"Gateway mode": "网关模式",
|
||||
"Only what a gateway for other computers needs: no Agents, Sessions or Library, nor this computer's agents' settings": "只显示给其他电脑当网关所需的内容:不显示 Agent、会话和资源库,也不显示本机 Agent 的设置",
|
||||
"Now on: magpie web was started with --gateway": "当前开启:magpie web 以 --gateway 启动",
|
||||
"Now on: no agents on this computer": "当前开启:本机没有 Agent",
|
||||
"Now off: agents found on this computer": "当前关闭:本机找到了 Agent",
|
||||
"A closed panel or window frees its page after a minute, about 100 MB each, and loads it anew when opened": "面板或窗口关闭约一分钟后释放其网页(每个约 100 MB),再次打开时重新加载",
|
||||
"With window": "跟随窗口",
|
||||
"What clicking magpie’s icon in the menu bar or tray opens": "点击菜单栏或托盘里的 magpie 图标时打开什么",
|
||||
@@ -3604,6 +3609,11 @@ const I18N = {
|
||||
"move magpie to Applications first, then turn this on": "先に magpie を「アプリケーション」に移動してから、これをオンにしてください",
|
||||
"Keep magpie in the Dock too, always or while its window is open, for Cmd-Tab and a menu bar too full to show its icon": "Cmd-Tab で切り替えたり、メニューバーがいっぱいでアイコンが表示されないときのために、magpie を Dock にも表示します(常に、またはウィンドウが開いている間)",
|
||||
"Lightweight mode": "軽量モード",
|
||||
"Gateway mode": "ゲートウェイモード",
|
||||
"Only what a gateway for other computers needs: no Agents, Sessions or Library, nor this computer's agents' settings": "他のコンピューターのゲートウェイに必要なものだけ:エージェント、セッション、ライブラリ、このコンピューターのエージェントの設定は表示しません",
|
||||
"Now on: magpie web was started with --gateway": "現在オン:magpie web が --gateway で起動されています",
|
||||
"Now on: no agents on this computer": "現在オン:このコンピューターにエージェントがありません",
|
||||
"Now off: agents found on this computer": "現在オフ:このコンピューターにエージェントがあります",
|
||||
"A closed panel or window frees its page after a minute, about 100 MB each, and loads it anew when opened": "閉じたパネルやウィンドウは 1 分後にページを解放し(各約 100 MB)、開いたときに読み込み直します",
|
||||
"With window": "ウィンドウと連動",
|
||||
"What clicking magpie’s icon in the menu bar or tray opens": "メニューバーまたはトレイの magpie アイコンをクリックしたときに開くもの",
|
||||
@@ -6830,6 +6840,11 @@ const I18N = {
|
||||
"move magpie to Applications first, then turn this on": "magpie zuerst in „Programme“ verschieben, dann einschalten",
|
||||
"Keep magpie in the Dock too, always or while its window is open, for Cmd-Tab and a menu bar too full to show its icon": "magpie auch im Dock halten, immer oder solange das Fenster offen ist, für Cmd-Tab und eine Menüleiste, die zu voll für das Symbol ist",
|
||||
"Lightweight mode": "Ressourcensparmodus",
|
||||
"Gateway mode": "Gateway-Modus",
|
||||
"Only what a gateway for other computers needs: no Agents, Sessions or Library, nor this computer's agents' settings": "Nur, was ein Gateway für andere Computer braucht: keine Agenten, Sitzungen oder Bibliothek, keine Einstellungen der Agenten auf diesem Computer",
|
||||
"Now on: magpie web was started with --gateway": "Jetzt an: magpie web wurde mit --gateway gestartet",
|
||||
"Now on: no agents on this computer": "Jetzt an: keine Agenten auf diesem Computer",
|
||||
"Now off: agents found on this computer": "Jetzt aus: Agenten auf diesem Computer gefunden",
|
||||
"A closed panel or window frees its page after a minute, about 100 MB each, and loads it anew when opened": "Ein geschlossenes Panel oder Fenster gibt seine Seite nach einer Minute frei (je etwa 100 MB) und lädt sie beim Öffnen neu",
|
||||
"With window": "Mit Fenster",
|
||||
"What clicking magpie’s icon in the menu bar or tray opens": "Was ein Klick auf das magpie-Symbol in Menüleiste oder Tray öffnet",
|
||||
|
||||
@@ -237,6 +237,10 @@
|
||||
<div class="who"><div class="name" data-t>Language</div><div class="sub" data-t>Applies at once, no restart</div></div>
|
||||
<div id="langSegs"></div>
|
||||
</div>
|
||||
<div class="row pref" id="gatewayModeRow" hidden>
|
||||
<div class="who"><div class="name" data-t>Gateway mode</div><div class="sub" id="gatewayModeSub"></div></div>
|
||||
<div id="gatewayModeSegs"></div>
|
||||
</div>
|
||||
<div class="row pref" id="textSizeRow">
|
||||
<div class="who"><div class="name" data-t>Text size</div><div class="sub" id="textSizeSub"></div></div>
|
||||
<div id="textSizeSegs"></div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/yetone/magpie/internal/agent"
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// Gateway mode (Player on Discord): `magpie web` on a server that is only
|
||||
// the gateway for other machines' agents shows what such a gateway needs —
|
||||
// Providers, Gateway, Routing, Usage, Plugins and Settings — and leaves out
|
||||
// what is this machine's agents' (Agents, Sessions, Library, the settings
|
||||
// written into agents' files) and its desktop's (the tray, the Dock, open
|
||||
// at login, notifications). It is the browser page's alone: the app's
|
||||
// windows show everything whatever it says.
|
||||
//
|
||||
// Settings' choice (settings.GatewayMode, "on" or "off") wins; with none,
|
||||
// `magpie web --gateway` turns it on, and so does a magpie web with no
|
||||
// agent on its machine (a container, a NAS). Settings › General turns it
|
||||
// off again, which brings the pages back.
|
||||
|
||||
// WebGateway is `magpie web --gateway`'s.
|
||||
var WebGateway atomic.Bool
|
||||
|
||||
// webPage is set once the page is served by `magpie web`: settingsState,
|
||||
// which has no Windows, tells the page its gateway mode by it.
|
||||
var webPage atomic.Bool
|
||||
|
||||
// gatewayMode says whether the page is in gateway mode, and why: "on" or
|
||||
// "off" (Settings' choice), "flag" (magpie web --gateway), "no-agents", or
|
||||
// "" (the app's windows, or magpie web on a machine with agents).
|
||||
func gatewayMode(web bool) (bool, string) {
|
||||
if !web {
|
||||
return false, ""
|
||||
}
|
||||
switch m := settings.Load().GatewayMode; m {
|
||||
case "on", "off":
|
||||
return m == "on", m
|
||||
}
|
||||
if WebGateway.Load() {
|
||||
return true, "flag"
|
||||
}
|
||||
if !agentsHere() {
|
||||
return true, "no-agents"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// agentsHere says whether an agent is found on this computer; a test's own.
|
||||
var agentsHere = func() bool { return len(agent.Detected()) > 0 }
|
||||
|
||||
// gatewayModeRoutes: POST /api/settings/gateway-mode {"mode": "on" | "off"
|
||||
// | ""} sets Settings' choice, "" leaving it to magpie again.
|
||||
func gatewayModeRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/settings/gateway-mode", func(rw http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
if in.Mode != "" && in.Mode != "on" && in.Mode != "off" {
|
||||
fail(rw, fmt.Errorf("gateway mode is on, off or \"\" (automatic), not %q", in.Mode))
|
||||
return
|
||||
}
|
||||
s := settings.Load()
|
||||
s.GatewayMode = in.Mode
|
||||
if err := settings.Save(s); err != nil {
|
||||
fail(rw, err)
|
||||
return
|
||||
}
|
||||
writeJSON(rw, settingsState())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/settings"
|
||||
)
|
||||
|
||||
// Gateway mode (Player on Discord): magpie web with no agents on its
|
||||
// machine is in it by itself, and so is one started with --gateway;
|
||||
// Settings' On or Off wins over both, and the Settings page's own save
|
||||
// leaves it as it is. The app's windows are never in it. The page is told
|
||||
// before it paints (boot.js), and the settings say why.
|
||||
func TestGatewayMode(t *testing.T) {
|
||||
h := t.TempDir()
|
||||
t.Setenv("HOME", h)
|
||||
t.Setenv("USERPROFILE", h)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
|
||||
here := false
|
||||
was := agentsHere
|
||||
agentsHere = func() bool { return here }
|
||||
t.Cleanup(func() { agentsHere = was; WebGateway.Store(false); webPage.Store(false) })
|
||||
|
||||
srv := Handler(webHost{func() {}}, nil)
|
||||
do := func(method, path, body string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
type state struct {
|
||||
GatewayMode string
|
||||
GatewayOn bool
|
||||
GatewayWhy string
|
||||
Web bool
|
||||
}
|
||||
check := func(when string, on bool, why string) {
|
||||
t.Helper()
|
||||
var s state
|
||||
if rec := do("GET", "/api/settings", ""); rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &s) != nil {
|
||||
t.Fatalf("%s: settings %d %s", when, rec.Code, rec.Body)
|
||||
}
|
||||
if !s.Web || s.GatewayOn != on || s.GatewayWhy != why {
|
||||
t.Fatalf("%s: settings say %+v, want on=%v why=%q", when, s, on, why)
|
||||
}
|
||||
boot := do("GET", "/boot.js", "").Body.String()
|
||||
if strings.Contains(boot, `"gateway":true`) != on {
|
||||
t.Fatalf("%s: boot.js %s", when, boot)
|
||||
}
|
||||
}
|
||||
set := func(mode string) {
|
||||
t.Helper()
|
||||
if rec := do("POST", "/api/settings/gateway-mode", `{"mode":"`+mode+`"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("set %q: %d %s", mode, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
check("no agents here", true, "no-agents")
|
||||
here = true
|
||||
check("an agent here", false, "")
|
||||
WebGateway.Store(true)
|
||||
check("--gateway", true, "flag")
|
||||
set("off")
|
||||
check("turned off with --gateway", false, "off")
|
||||
if settings.Load().GatewayMode != "off" {
|
||||
t.Fatalf("saved %q", settings.Load().GatewayMode)
|
||||
}
|
||||
// the Settings page's save, which sends what it was drawn with, leaves it
|
||||
if rec := do("POST", "/api/settings", `{"theme":"dark","lang":"en","gatewayMode":"on"}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("settings save: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
check("after the Settings page's save", false, "off")
|
||||
WebGateway.Store(false)
|
||||
set("on")
|
||||
check("turned on with agents here", true, "on")
|
||||
set("")
|
||||
check("automatic again", false, "")
|
||||
if rec := do("POST", "/api/settings/gateway-mode", `{"mode":"maybe"}`); rec.Code == http.StatusOK || settings.Load().GatewayMode != "" {
|
||||
t.Fatalf("a mode not offered was taken: %d", rec.Code)
|
||||
}
|
||||
|
||||
// the app's windows: never, whatever Settings says
|
||||
set("on")
|
||||
rec := httptest.NewRecorder()
|
||||
Handler(&zoomWindows{}, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/boot.js", nil))
|
||||
if strings.Contains(rec.Body.String(), `"gateway"`) {
|
||||
t.Fatalf("the app's window was told gateway mode: %s", rec.Body)
|
||||
}
|
||||
if on, _ := gatewayMode(false); on {
|
||||
t.Fatal("gatewayMode(false) is on")
|
||||
}
|
||||
|
||||
// a sync or a restored backup never brings it from another computer
|
||||
in := settings.Settings{GatewayMode: "off"}
|
||||
in.KeepOwn(settings.Settings{GatewayMode: "on"})
|
||||
if in.GatewayMode != "on" {
|
||||
t.Fatalf("KeepOwn: %q", in.GatewayMode)
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) {
|
||||
UpdateMirror: "https://mirror.example/",
|
||||
Window: []int{900, 700},
|
||||
FullContext: true,
|
||||
GatewayMode: "off",
|
||||
}
|
||||
if err := settings.Save(was); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// Gateway mode (Player on Discord): magpie web on a server that is only
|
||||
// the gateway for other computers' agents. boot.js says so and the page
|
||||
// opens on Providers with no Agents, Sessions or Library tab, an address
|
||||
// kept for one of them opening Providers; Settings leaves out what is
|
||||
// written into this computer's agents' files (provider in model names,
|
||||
// Codex subagents, long conversations, Codex thread titles) and the
|
||||
// desktop's alerts, and General has the switch — Automatic, On, Off —
|
||||
// saying why it is on. Off brings the tabs and the rows back at once, the
|
||||
// Agents page opening again; Automatic takes them away again. magpie web
|
||||
// not in gateway mode keeps every tab, and the switch is magpie web's
|
||||
// alone. No click scrolls the page, no native <select>, no left border.
|
||||
// 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");
|
||||
|
||||
function settingsPayload(over) {
|
||||
return {
|
||||
theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd", textSize: 100,
|
||||
dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [],
|
||||
codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false,
|
||||
trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "",
|
||||
version: "0.1.400", dir: "/config/home/.config/magpie", gateway: "http://127.0.0.1:3425",
|
||||
proxyNow: "none", proxySource: "none", login: false,
|
||||
visionModels: [], imageGenModels: [], workbuddyCheckins: [], lanURLs: [],
|
||||
fx: { rate: 7.2, at: new Date().toISOString(), stale: false },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
// agents says whether magpie found agents here: Automatic is on without
|
||||
function server(lang, posts, { web = true, gateway = true, agents = false } = {}) {
|
||||
const why = (mode) => (mode === "on" || mode === "off" ? mode : agents ? "" : "no-agents");
|
||||
const on = (mode) => web && (mode === "on" || (mode !== "off" && !agents));
|
||||
let cur = settingsPayload({ lang, web, gatewayMode: "", gatewayOn: web && gateway, gatewayWhy: web && gateway ? why("") : "" });
|
||||
return async (route) => {
|
||||
const req = route.request(), url = new URL(req.url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:${web}${web && gateway ? ",gateway:true" : ""}};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" }, fx: cur.fx });
|
||||
if (url.pathname === "/api/settings/gateway-mode") {
|
||||
const { mode } = req.postDataJSON();
|
||||
posts.push(["gateway-mode", mode]);
|
||||
cur = { ...cur, gatewayMode: mode, gatewayOn: on(mode), gatewayWhy: on(mode) || mode === "off" ? why(mode) : "" };
|
||||
return json(cur);
|
||||
}
|
||||
if (url.pathname === "/api/settings") {
|
||||
if (req.method() === "POST") { posts.push(["settings", req.postDataJSON()]); cur = { ...cur, ...req.postDataJSON() }; }
|
||||
return json(cur);
|
||||
}
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/usage/quotas") return json([]);
|
||||
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
|
||||
if (url.pathname === "/api/window/fit") return route.fulfill({ status: 204 });
|
||||
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)];
|
||||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
const L = {
|
||||
en: { mode: "Gateway mode", noAgents: "Now on: no agents on this computer", off: "Now off: agents found on this computer" },
|
||||
zh: { mode: "网关模式", noAgents: "当前开启:本机没有 Agent", off: "当前关闭:本机找到了 Agent" },
|
||||
};
|
||||
const HIDDEN = ["agents", "sessions", "library"];
|
||||
const KEPT = ["providers", "gateway", "routing", "usage", "plugins"];
|
||||
|
||||
// the tabs shown, the page shown
|
||||
const tabs = (page) => page.evaluate(() => [...document.querySelectorAll("#nav button")].filter((b) => !b.hidden && b.offsetParent).map((b) => b.dataset.view));
|
||||
const viewShown = (page) => page.evaluate(() => [...document.querySelectorAll("main.view")].filter((v) => !v.hidden).map((v) => v.id));
|
||||
// the rows gateway mode leaves out of Settings, each shown or not
|
||||
const agentRows = (page) => page.evaluate(() => ["#plainNamesSegs", "#codexAgentsV1Segs", "#fullContextSegs", "#codexTitlesPick", "#usageAlertSegs", "#balanceAlertSegs", "#resetReminderSegs"]
|
||||
.map((s) => !document.querySelector(s).closest(".row").hidden));
|
||||
|
||||
async function noSelectNoBorder(page, where) {
|
||||
const bad = await page.evaluate(() => {
|
||||
const out = [];
|
||||
for (const s of document.querySelectorAll("select")) if (s.offsetParent) out.push("select " + (s.id || s.className));
|
||||
for (const e of [document.querySelector("#gatewayModeRow"), ...document.querySelectorAll("#gatewayModeRow *")]) {
|
||||
const cs = getComputedStyle(e);
|
||||
if (parseFloat(cs.borderLeftWidth) > 0 && cs.borderLeftStyle !== "none" && cs.borderLeftColor !== cs.borderTopColor) out.push("left border " + (e.id || e.className));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
assert.deepEqual(bad, [], where);
|
||||
}
|
||||
|
||||
// clicks what at, and says the page didn't move
|
||||
async function clickStill(page, at, where) {
|
||||
const before = await page.evaluate(() => { const v = [...document.querySelectorAll("main.view")].find((x) => !x.hidden); return [v.id, v.scrollTop, scrollY]; });
|
||||
await page.locator(at).click();
|
||||
await page.waitForTimeout(250);
|
||||
const after = await page.evaluate((id) => [id, document.getElementById(id).scrollTop, scrollY], before[0]);
|
||||
assert.deepEqual(after, before, `${where}: the click scrolled`);
|
||||
}
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
test(`${engine}: gateway mode`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const where = `${engine} ${lang}`;
|
||||
const context = await browser.newContext({ viewport: { width: 1100, height: 560 }, reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [], posts = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", server(lang, posts));
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.waitForSelector("#nav button.on");
|
||||
assert.deepEqual(await tabs(page), KEPT, `${where}: the tabs`);
|
||||
assert.deepEqual(await viewShown(page), ["view-providers"], `${where}: opens on Providers`);
|
||||
assert.equal(await page.locator("#nav button.on").getAttribute("data-view"), "providers", where);
|
||||
|
||||
// an address kept for a page left out opens Providers
|
||||
for (const v of HIDDEN) {
|
||||
await page.goto(`http://magpie.test/?view=${v}`);
|
||||
await page.waitForSelector("#nav button.on");
|
||||
assert.deepEqual(await viewShown(page), ["view-providers"], `${where}: ?view=${v}`);
|
||||
}
|
||||
|
||||
// Settings: the switch, why it is on, and the agents' rows gone
|
||||
await clickStill(page, "#prefs", `${where}: Settings`);
|
||||
await page.waitForSelector("#gatewayModeSegs .opt");
|
||||
assert.ok(await page.locator("#gatewayModeRow").isVisible(), `${where}: the switch`);
|
||||
assert.equal((await page.locator("#gatewayModeRow .name").textContent()).trim(), L[lang].mode, where);
|
||||
assert.match(await page.locator("#gatewayModeSub").textContent(), new RegExp(L[lang].noAgents), where);
|
||||
assert.equal(await page.locator("#gatewayModeSegs .opt.on").evaluate((b) => [...b.parentElement.children].filter((x) => x.matches(".opt")).indexOf(b)), 0, `${where}: Automatic picked`);
|
||||
assert.deepEqual(await agentRows(page), [false, false, false, false, false, false, false], `${where}: agents' rows in gateway mode`);
|
||||
await noSelectNoBorder(page, where);
|
||||
|
||||
// Off: the tabs and the rows are back, Settings stays open
|
||||
await clickStill(page, "#gatewayModeSegs .opt:nth-child(4)", `${where}: Off`);
|
||||
await page.waitForFunction(() => !document.querySelector('#nav button[data-view="agents"]').hidden);
|
||||
assert.deepEqual(posts.filter((p) => p[0] === "gateway-mode"), [["gateway-mode", "off"]], where);
|
||||
assert.deepEqual(await tabs(page), ["agents", "providers", "gateway", "routing", "usage", "sessions", "library", "plugins"], `${where}: off`);
|
||||
assert.deepEqual(await viewShown(page), ["view-settings"], `${where}: Settings stays`);
|
||||
assert.deepEqual(await agentRows(page), [true, true, true, true, true, true, true], `${where}: rows back`);
|
||||
assert.doesNotMatch(await page.locator("#gatewayModeSub").textContent(), new RegExp(L[lang].noAgents), where);
|
||||
await clickStill(page, '#nav button[data-view="agents"]', `${where}: Agents`);
|
||||
assert.deepEqual(await viewShown(page), ["view-agents"], `${where}: the Agents page opens`);
|
||||
|
||||
// Automatic again: gone again
|
||||
await clickStill(page, "#prefs", `${where}: Settings again`);
|
||||
await clickStill(page, "#gatewayModeSegs .opt:nth-child(2)", `${where}: Automatic`);
|
||||
await page.waitForFunction(() => document.querySelector('#nav button[data-view="agents"]').hidden);
|
||||
assert.deepEqual(posts.filter((p) => p[0] === "gateway-mode").at(-1), ["gateway-mode", ""], where);
|
||||
assert.deepEqual(await tabs(page), KEPT, `${where}: automatic`);
|
||||
assert.deepEqual(await agentRows(page), [false, false, false, false, false, false, false], where);
|
||||
// the Settings page's own saves never send it
|
||||
assert.ok(posts.filter((p) => p[0] === "settings").every((p) => !("gatewayMode" in p[1])), where);
|
||||
assert.deepEqual(errors, [], where);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
// magpie web with agents here, and the app's window: every tab; the
|
||||
// switch is magpie web's alone
|
||||
for (const [web, name] of [[true, "web with agents"], [false, "window"]]) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const where = `${engine} ${lang} ${name}`;
|
||||
const context = await browser.newContext({ viewport: { width: 1100, height: 560 }, reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", server(lang, [], { web, gateway: false, agents: true }));
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.waitForSelector("#nav button.on");
|
||||
assert.deepEqual(await tabs(page), ["agents", "providers", "gateway", "routing", "usage", "sessions", "library", "plugins"], where);
|
||||
assert.deepEqual(await viewShown(page), ["view-agents"], where);
|
||||
await page.locator("#prefs").click();
|
||||
await page.waitForSelector("#langSegs .opt");
|
||||
assert.equal(await page.locator("#gatewayModeRow").isVisible(), web, `${where}: the switch`);
|
||||
if (web) assert.match(await page.locator("#gatewayModeSub").textContent(), new RegExp(L[lang].off), where);
|
||||
assert.deepEqual(errors, [], where);
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -198,6 +198,11 @@ type Settings struct {
|
||||
// again when it is opened (#580): less memory, a moment's wait. This
|
||||
// computer's own (KeepOwn).
|
||||
Lightweight bool `json:"lightweight,omitempty"`
|
||||
// GatewayMode is whether `magpie web` shows only what a gateway serving
|
||||
// other machines needs (Player on Discord): "on", "off", or "" to
|
||||
// decide by itself — on for `magpie web --gateway`, or with no agents
|
||||
// on this machine. This computer's own (KeepOwn).
|
||||
GatewayMode string `json:"gatewayMode,omitempty"`
|
||||
// QuotaLeft shows a subscription's windows by how much of each is left,
|
||||
// not used: the Usage page, the tray panel and the menu bar alike.
|
||||
QuotaLeft bool `json:"quotaLeft,omitempty"`
|
||||
@@ -572,12 +577,13 @@ func (s Settings) Compact() int {
|
||||
|
||||
// KeepOwn puts back cur's settings that are this computer's own, which a
|
||||
// sync or a restored backup never brings from another: the window's size,
|
||||
// the proxy, the gateway's port, the Dock, and what the menu bar or tray shows beside magpie's
|
||||
// the proxy, the gateway's port, the Dock, gateway mode, and what the menu bar or tray shows beside magpie's
|
||||
// icon (yoooo on Discord: usage turned off on a Mac came back from a
|
||||
// Windows box that shows it).
|
||||
func (s *Settings) KeepOwn(cur Settings) {
|
||||
s.Window, s.Proxy, s.Port, s.Dock, s.DockWindow, s.Lightweight = cur.Window, cur.Proxy, cur.Port, cur.Dock, cur.DockWindow, cur.Lightweight
|
||||
s.TrayUsages, s.TrayUsage, s.TrayUsageEvery, s.TrayNoLogos, s.TrayNoBird = cur.TrayUsages, cur.TrayUsage, cur.TrayUsageEvery, cur.TrayNoLogos, cur.TrayNoBird
|
||||
s.GatewayMode = cur.GatewayMode
|
||||
}
|
||||
|
||||
// RenamePerModel moves what the user said of a provider's models to the id
|
||||
|
||||
@@ -38,9 +38,10 @@ const usage = `magpie — one place to pick every agent's model
|
||||
magpie panel open the menu bar icon's quick panel, or close it
|
||||
magpie autostart [on|off] open magpie (in the menu bar) when you log in, or say whether it does
|
||||
magpie tui the same thing, in the terminal
|
||||
magpie web [--addr host:port] [--lan] [--no-open]
|
||||
magpie web [--addr host:port] [--lan] [--no-open] [--gateway]
|
||||
the app's window in a browser, with the gateway (no desktop needed: WSL, a server over SSH)
|
||||
a new key each run; MAGPIE_WEB_KEY (16+ letters, digits, - . _ ~) keeps one, signed in for 400 days
|
||||
--gateway: gateway mode, no Agents, Sessions or Library (on by itself with no agents here; Settings › General turns it off)
|
||||
magpie ls list detected agents and their settings
|
||||
magpie <agent> show one agent
|
||||
magpie <agent> <model> set an agent's model e.g. magpie claude deepseek/deepseek-chat
|
||||
|
||||
+7
-3
@@ -13,8 +13,10 @@ import (
|
||||
// webAddr is where `magpie web` listens unless told: beside the gateway.
|
||||
const webAddr = "127.0.0.1:3430"
|
||||
|
||||
// webCmd: magpie web [--addr host:port] [--lan] [--no-open] — the app's
|
||||
// window in a browser, for a computer that can't show the app.
|
||||
// webCmd: magpie web [--addr host:port] [--lan] [--no-open] [--gateway] —
|
||||
// the app's window in a browser, for a computer that can't show the app;
|
||||
// --gateway shows it in gateway mode (gui.WebGateway) unless Settings says
|
||||
// otherwise.
|
||||
func webCmd(args []string) error {
|
||||
addr, lan, open := webAddr, false, true
|
||||
for i := 0; i < len(args); i++ {
|
||||
@@ -28,8 +30,10 @@ func webCmd(args []string) error {
|
||||
lan = true
|
||||
case a == "--no-open":
|
||||
open = false
|
||||
case a == "--gateway":
|
||||
gui.WebGateway.Store(true)
|
||||
default:
|
||||
return fmt.Errorf("magpie web: unknown %q · magpie web [--addr host:port] [--lan] [--no-open], MAGPIE_WEB_KEY to keep one key", a)
|
||||
return fmt.Errorf("magpie web: unknown %q · magpie web [--addr host:port] [--lan] [--no-open] [--gateway], MAGPIE_WEB_KEY to keep one key", a)
|
||||
}
|
||||
}
|
||||
if lan && addr == webAddr {
|
||||
|
||||
Reference in new issue
Block a user