web: magpie web knows its version and updates itself as the app does — the badge's restart puts the new version in and runs it in the old one's place with the same arguments and key, so the open page reloads onto it (#111); routing: a gateway served by magpie serve, which has no window, is told apart from another magpie's window, and the routing view says to stop it so this magpie serves the gateway and shows routing live (#110)
This commit is contained in:
12 files changed
+117
-21
No files matched your search
@@ -71,16 +71,29 @@ func URL() string {
|
||||
return "http://" + a
|
||||
}
|
||||
|
||||
// Window says this process shows the routing of the gateway it serves:
|
||||
// the app's window (a tray's too, when opened) or magpie web's page, and
|
||||
// not magpie serve, which has none. Set by the gui.
|
||||
var Window bool
|
||||
|
||||
// Running reports whether a gateway answers at the address.
|
||||
func Running() bool {
|
||||
running, _ := Serving()
|
||||
return running
|
||||
}
|
||||
|
||||
// Serving asks the address what answers there: whether a magpie gateway
|
||||
// does, and whether the magpie serving it has a window its routing can be
|
||||
// watched in (#110).
|
||||
func Serving() (running, window bool) {
|
||||
c := &http.Client{Timeout: 700 * time.Millisecond}
|
||||
res, err := c.Get(URL() + "/")
|
||||
if err != nil {
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
defer res.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(res.Body, 4096))
|
||||
return bytes.Contains(b, []byte(`"magpie"`))
|
||||
return bytes.Contains(b, []byte(`"magpie"`)), bytes.Contains(b, []byte(`"window":true`))
|
||||
}
|
||||
|
||||
// Call is one request the gateway handled, for the status views.
|
||||
@@ -235,7 +248,7 @@ func (s *Server) Handler() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Server) info(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"name": "magpie", "version": Version, "models": len(provider.Catalog()),
|
||||
writeJSON(w, 200, map[string]any{"name": "magpie", "version": Version, "models": len(provider.Catalog()), "window": Window,
|
||||
"apis": []string{"/v1/chat/completions", "/v1/responses", "/v1/messages", "/v1beta/models/{model}:generateContent", "/v1/magpie/quotas"}})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -245,7 +245,7 @@ func Run(version string, showMain bool, link string) error {
|
||||
menu.Add("Version " + version).SetEnabled(false)
|
||||
restart := menu.Add("Restart to Update").SetHidden(true)
|
||||
restart.OnClick(func(*application.Context) {
|
||||
if restartToUpdate() {
|
||||
if restartToUpdate(false) {
|
||||
h.app.Quit()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -741,6 +741,7 @@ async function renderUpdateBadge() {
|
||||
// an answer means it didn't: the password prompt dismissed, the swap
|
||||
// failed, or a newer version is out and downloading first
|
||||
const a = await api("update/install", {}).catch(() => ({}));
|
||||
if (!a) return backAsNew(u.current);
|
||||
if (a) {
|
||||
if (["checking", "downloading"].includes(a.state)) b.dataset.pulling = "1";
|
||||
else b.classList.remove("busy");
|
||||
@@ -772,10 +773,22 @@ async function renderUpdateBadge() {
|
||||
label.textContent = t("Downloading…");
|
||||
return api("update/install", {}).then(renderUpdateBadge, renderUpdateBadge);
|
||||
}
|
||||
if (web && u.url) return window.open(u.url, "_blank", "noopener");
|
||||
api("update/install", {}).catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
// backAsNew waits, in magpie web, for the version the page restarted into
|
||||
// to answer in its place, and reloads the page from it (#111).
|
||||
async function backAsNew(was) {
|
||||
if (!web) return;
|
||||
for (let i = 0; i < 180; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const u = await fetch("/api/update").then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
||||
if (u && u.current !== was) return location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// updateStuck says why this magpie can't replace itself where it is.
|
||||
function updateStuck(u) {
|
||||
return u.stuck === "translocated"
|
||||
@@ -4668,12 +4681,17 @@ async function renderUpdate(r, u) {
|
||||
case "ready":
|
||||
sub.textContent = t("{v} is downloaded", { v: u.latest }) + (u.error ? " · " + u.error : "");
|
||||
// back with an answer only when it didn't restart
|
||||
btn(t("Restart to update"), async () => renderUpdate(r, await api("update/install", {}).catch(() => null) || undefined));
|
||||
btn(t("Restart to update"), async () => {
|
||||
const a = await api("update/install", {}).catch(() => ({ state: "error" }));
|
||||
if (a) return renderUpdate(r, a.current ? a : undefined);
|
||||
sub.textContent = t("Restarting…");
|
||||
backAsNew(u.current);
|
||||
});
|
||||
break;
|
||||
case "available":
|
||||
sub.textContent = t("{v} is out", { v: u.latest });
|
||||
if (u.stuck) sub.textContent += " · " + updateStuck(u);
|
||||
btn(t("Download"), () => api("update/install", {}));
|
||||
btn(t("Download"), () => (web && u.url ? window.open(u.url, "_blank", "noopener") : api("update/install", {})));
|
||||
break;
|
||||
case "downloading":
|
||||
sub.textContent = t("Downloading {v}…", { v: u.latest });
|
||||
|
||||
@@ -801,6 +801,7 @@ const I18N = {
|
||||
"Every request an agent sends to magpie shows up here, routed for real.": "Agent 发给 magpie 的每个请求都会出现在这里——真实的路由。",
|
||||
"Another magpie serves the gateway; its routing plays live in that magpie's window.": "网关由另一个 magpie 提供,它的路由在那个 magpie 的窗口里实时播放。",
|
||||
"The gateway isn't running, so nothing is routed.": "网关没有运行,因此没有任何路由。",
|
||||
"The gateway is served by a magpie without a window (magpie serve), so its routing can't be watched. Stop it and let this magpie serve the gateway to see routing live.": "网关由一个没有窗口的 magpie(magpie serve)提供,它的路由没法在这里看到。停掉它、让这个 magpie 来提供网关,就能实时看到路由。",
|
||||
"Smart: of the accounts with quota to spare, the one whose allowance renews soonest goes first — what it has left would be lost at the reset. One at 90% or more waits until the others can't answer; one resting after a failure goes last.": "智能:在还有余量的账号里,额度最先重置的排第一——它剩下的额度到重置时就作废了。用到 90% 以上的账号等其他账号都答不了时才用;失败后正在休息的排最后。",
|
||||
"Smart: keys that suit the request go first — one made for the model's own API — then in their order. One resting after a failure goes last.": "智能:适合这个请求的 Key 排前面——专为该模型原生 API 的 Key 优先——其余按顺序。失败后正在休息的排最后。",
|
||||
"In order: the first answers everything until it can't; then the next.": "按顺序:第一个处理所有请求,直到它不行了,再换下一个。",
|
||||
|
||||
@@ -1200,7 +1200,11 @@
|
||||
statB[1].textContent = d.totals.rerouted;
|
||||
statB[2].textContent = d.totals.errors;
|
||||
if (!mine) {
|
||||
offline(t(providers?.gateway?.running ? "Another magpie serves the gateway; its routing plays live in that magpie's window." : "The gateway isn't running, so nothing is routed."));
|
||||
const gw = providers?.gateway;
|
||||
offline(t(!gw?.running ? "The gateway isn't running, so nothing is routed."
|
||||
: gw.window ? "Another magpie serves the gateway; its routing plays live in that magpie's window."
|
||||
// magpie serve: its routing isn't shown anywhere
|
||||
: "The gateway is served by a magpie without a window (magpie serve), so its routing can't be watched. Stop it and let this magpie serve the gateway to see routing live."));
|
||||
loaded = false;
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
continue;
|
||||
|
||||
@@ -22,6 +22,7 @@ var served atomic.Pointer[gateway.Server]
|
||||
// running from before an update, a magpie serve in a terminal), and the
|
||||
// model lists kept warm.
|
||||
func startBackend() (gw *gateway.Server) {
|
||||
gateway.Window = true // the routing this process serves is shown on its page
|
||||
gw = serveGateway()
|
||||
go watchGateway()
|
||||
// Model lists are fetched, never compiled in: whatever the agents can see
|
||||
|
||||
@@ -102,7 +102,8 @@ type presetJSON struct {
|
||||
type gatewayJSON struct {
|
||||
URL string `json:"url"`
|
||||
Running bool `json:"running"`
|
||||
Mine bool `json:"mine"` // this process serves it
|
||||
Mine bool `json:"mine"` // this process serves it
|
||||
Window bool `json:"window"` // the magpie serving it shows its routing
|
||||
Models int `json:"models"`
|
||||
Calls []gateway.Call `json:"calls"`
|
||||
Groups []gwGroupJSON `json:"groups"` // the catalog's routing groups, listed before the models
|
||||
@@ -295,10 +296,10 @@ func providersState() providersJSON {
|
||||
s.Gateway.Groups = append(s.Gateway.Groups, g)
|
||||
}
|
||||
if gw := served.Load(); gw != nil {
|
||||
s.Gateway.Running, s.Gateway.Mine = true, true
|
||||
s.Gateway.Running, s.Gateway.Mine, s.Gateway.Window = true, true, true
|
||||
s.Gateway.Calls = gw.Recent()
|
||||
} else {
|
||||
s.Gateway.Running = gateway.Running()
|
||||
s.Gateway.Running, s.Gateway.Window = gateway.Serving()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ func updateRoutes(mux *http.ServeMux, w Windows) {
|
||||
}
|
||||
switch j := updates.json(); {
|
||||
case j.State == "ready":
|
||||
if restartToUpdate() {
|
||||
if restartToUpdate(isWeb(w)) {
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
go w.Quit()
|
||||
return
|
||||
@@ -274,12 +274,17 @@ func updateRoutes(mux *http.ServeMux, w Windows) {
|
||||
}
|
||||
|
||||
// restartToUpdate installs the staged version and arranges for it to open
|
||||
// once this process is gone; the caller then quits.
|
||||
func restartToUpdate() bool {
|
||||
// once this process is gone; the caller then quits. magpie web runs on as
|
||||
// the new version in its own place (Web.Wait) instead of opening the app.
|
||||
func restartToUpdate(web bool) bool {
|
||||
bundle, exe := updates.bundle, updates.exe
|
||||
if !updates.install(true) {
|
||||
return false
|
||||
}
|
||||
if web {
|
||||
webReexec.Store(true)
|
||||
return true
|
||||
}
|
||||
var err error
|
||||
if bundle != "" {
|
||||
err = update.Relaunch(bundle)
|
||||
|
||||
+36
-5
@@ -12,7 +12,10 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/yetone/magpie/internal/update"
|
||||
)
|
||||
|
||||
// `magpie web` serves the window's page to a browser, for a computer that
|
||||
@@ -38,15 +41,26 @@ func (webHost) OpenFolder(path string) error {
|
||||
// browser in.
|
||||
type Web struct {
|
||||
Addr, Link string
|
||||
key string
|
||||
srv *http.Server
|
||||
quit chan struct{}
|
||||
}
|
||||
|
||||
// StartWeb serves the page on addr (host:port). Every request needs the
|
||||
// webReexec is set when the page's restart to update has put the new
|
||||
// version in: Wait then runs it in this one's place.
|
||||
var webReexec atomic.Bool
|
||||
|
||||
// webRunKey hands a run's own key to the version it restarts into, so the
|
||||
// browser's cookie still opens the page (#111).
|
||||
const webRunKey = "MAGPIE_WEB_RUNKEY"
|
||||
|
||||
// StartWeb serves the page on addr (host:port), as the given version of
|
||||
// magpie, which keeps itself up to date as the app does. Every request needs the
|
||||
// key the link carries, taken once into a cookie other sites' pages can't
|
||||
// send with a POST (every change is one), so neither a page elsewhere nor
|
||||
// anyone else on the network reaches the settings and keys behind it.
|
||||
func StartWeb(addr string) (*Web, error) {
|
||||
func StartWeb(addr, version string) (*Web, error) {
|
||||
Version = version
|
||||
key, fixed, err := webKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -59,7 +73,7 @@ func StartWeb(addr string) (*Web, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w := &Web{Addr: ln.Addr().String(), quit: make(chan struct{})}
|
||||
w := &Web{Addr: ln.Addr().String(), key: key, quit: make(chan struct{})}
|
||||
host, port, _ := net.SplitHostPort(w.Addr)
|
||||
if ip := net.ParseIP(host); ip == nil || ip.IsUnspecified() {
|
||||
host = "127.0.0.1"
|
||||
@@ -72,14 +86,25 @@ func StartWeb(addr string) (*Web, error) {
|
||||
ReadHeaderTimeout: 30 * time.Second,
|
||||
}
|
||||
go w.srv.Serve(ln)
|
||||
updates.start()
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Wait returns once the page's Quit is used.
|
||||
func (w *Web) Wait() {
|
||||
// Wait returns once the page's Quit is used. After a restart to update it
|
||||
// runs the new version in this one's place, with the same arguments and
|
||||
// key, so the page open in the browser finds it where it was.
|
||||
func (w *Web) Wait() error {
|
||||
<-w.quit
|
||||
time.Sleep(200 * time.Millisecond) // the answer to the Quit gets out
|
||||
w.srv.Close()
|
||||
if !webReexec.Load() {
|
||||
return nil
|
||||
}
|
||||
exe, err := update.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return update.Reexec(exe, os.Args[1:], append(os.Environ(), webRunKey+"="+w.key))
|
||||
}
|
||||
|
||||
// webKeyMin is the shortest MAGPIE_WEB_KEY taken: it is all that stands
|
||||
@@ -95,6 +120,12 @@ const webCookieAge = 400 * 24 * time.Hour
|
||||
// MAGPIE_WEB_KEY names one — for a page kept running as a service, which
|
||||
// browsers then stay signed in to across restarts. fixed says which.
|
||||
func webKey() (key string, fixed bool, err error) {
|
||||
if k := os.Getenv(webRunKey); k != "" {
|
||||
os.Unsetenv(webRunKey) // the run's alone, not its children's
|
||||
if len(k) == 32 && strings.Trim(k, "0123456789abcdef") == "" && os.Getenv("MAGPIE_WEB_KEY") == "" {
|
||||
return k, false, nil
|
||||
}
|
||||
}
|
||||
if k := os.Getenv("MAGPIE_WEB_KEY"); k != "" {
|
||||
if len(k) < webKeyMin {
|
||||
return "", false, fmt.Errorf("MAGPIE_WEB_KEY is %d characters; it needs at least %d", len(k), webKeyMin)
|
||||
|
||||
@@ -26,3 +26,10 @@ func detach(cmd *exec.Cmd) {
|
||||
}
|
||||
cmd.SysProcAttr.Setsid = true
|
||||
}
|
||||
|
||||
// Reexec runs exe in this process's place with args and env: the same
|
||||
// process, in the same terminal or under the same service manager, now
|
||||
// running the new version (magpie web's restart to update).
|
||||
func Reexec(exe string, args, env []string) error {
|
||||
return syscall.Exec(exe, append([]string{exe}, args...), env)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"github.com/yetone/magpie/internal/proc"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,3 +35,16 @@ func detach(cmd *exec.Cmd) {
|
||||
}
|
||||
cmd.SysProcAttr.CreationFlags |= detachedProc | newProcGroup
|
||||
}
|
||||
|
||||
// Reexec starts exe with args and env in this console and returns, for
|
||||
// the caller to exit: Windows can't run another program in a process's
|
||||
// place, so the new version goes on printing where this one did.
|
||||
func Reexec(exe string, args, env []string) error {
|
||||
cmd := proc.Command(exe, args...)
|
||||
cmd.Env = env
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.Process.Release()
|
||||
}
|
||||
+2
-3
@@ -36,7 +36,7 @@ func webCmd(args []string) error {
|
||||
_, port, _ := net.SplitHostPort(webAddr)
|
||||
addr = "0.0.0.0:" + port
|
||||
}
|
||||
w, err := gui.StartWeb(addr)
|
||||
w, err := gui.StartWeb(addr, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -58,6 +58,5 @@ func webCmd(args []string) error {
|
||||
if open {
|
||||
openInBrowser(w.Link)
|
||||
}
|
||||
w.Wait()
|
||||
return nil
|
||||
return w.Wait()
|
||||
}
|
||||
Reference in new issue
Block a user