diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml index cb30848..f11397e 100644 --- a/.gitea/workflows/deploy-web.yml +++ b/.gitea/workflows/deploy-web.yml @@ -80,7 +80,7 @@ jobs: run: | # the agent window's default endpoint: the site's relay to B4L's # inference server (deploy/Caddyfile, handle_path /llm/*) - python3 tools/build_web.py --agent-url /llm/v1 + python3 tools/build_web.py --agent-url /llm/v1 --agent-upstream https://inference.p0.contact/v1 echo "$GITHUB_SHA" > dist/web/version.txt - name: Set up SSH diff --git a/studio/agent.js b/studio/agent.js index 851c5d8..971f8cb 100644 --- a/studio/agent.js +++ b/studio/agent.js @@ -6,8 +6,11 @@ * Under `isokit studio` the model is reached through the Studio server * (/api/agent/*, crates/isokit-cli/src/agent.rs): the key stays on the machine * and the endpoint needs no CORS. The web Studio calls the endpoint from the - * page with a key kept in this browser; a build can name a same-site relay as - * the default endpoint (). + * page with a key kept in this browser. Most inference servers refuse calls + * from web pages on other sites (CORS), so a build can name a same-site relay + * (): + * it is the default endpoint, and the upstream URL, when entered, goes + * through it too. */ (function () { "use strict"; @@ -29,7 +32,15 @@ function saveUi() { lsSet(LS_UI, A.ui); } // ------------------------------------------------------------ settings - function siteDefault() { var m = document.querySelector('meta[name="isokit-agent"]'); return (m && m.getAttribute("content")) || DEFAULT_URL; } + function norm(u) { return String(u || "").trim().replace(/\/+$/, ""); } + /** This site's relay to an endpoint elsewhere (build_web.py --agent-url), or null. */ + function relay() { + var m = document.querySelector('meta[name="isokit-agent"]'); + return m && m.getAttribute("content") ? { url: norm(m.getAttribute("content")), upstream: norm(m.getAttribute("data-upstream") || DEFAULT_URL) } : null; + } + function siteDefault() { var r = relay(); return r ? r.url : DEFAULT_URL; } + /** Same as the site's default (the relay, or the endpoint it relays)? */ + function isDefault(u) { var r = relay(); u = norm(u); return u === norm(siteDefault()) || (!!r && u === r.upstream); } function loadCfg() { if (!web()) return fetch("/api/agent/config", { cache: "no-store" }).then(json).then(function (c) { A.cfg = c; return c; }); var c = lsGet(LS_CFG) || {}; @@ -44,12 +55,24 @@ } var c = lsGet(LS_CFG) || {}; ["url", "model", "key", "think"].forEach(function (k) { if (v[k] !== undefined) c[k] = typeof v[k] === "string" ? v[k].trim() : v[k]; }); - if (c.url === siteDefault()) delete c.url; // follow the site's default when it moves + if (c.url !== undefined && isDefault(c.url)) delete c.url; // follow the site's default when it moves lsSet(LS_CFG, c); return loadCfg(); } - function baseUrl() { return new URL(A.cfg.url.replace(/\/+$/, ""), location.href).href; } + /** The URL the page calls: the relay stands in for the endpoint it relays. */ + function baseUrl() { + var r = relay(), u = norm(A.cfg.url); + if (r && u === r.upstream) u = r.url; + return new URL(u, location.href).href; + } function host() { try { var u = new URL(baseUrl()); return u.host === location.host ? "this site's relay" : u.host; } catch (e) { return A.cfg.url; } } + function unreachable(e) { + var u = baseUrl(), r = relay(), why = " (" + (e && e.message || e) + ")."; + if (new URL(u).origin === location.origin) return "The browser could not reach this site's relay " + u + why + " The relay or the inference server behind it is down."; + return "The browser could not reach " + u + why + " Web pages may only call a server on another site when it allows them (CORS, including the preflight request, which carries no key); most inference servers do not. " + + (r ? "Set the endpoint to " + r.url + ": this site relays that to " + r.upstream + "." + : "Use the agent in `isokit studio` (it passes the calls on itself) or on https://isokit.buildfor.life (it relays to B4L's inference server)."); + } function model() { return A.cfg.model || A.models[0] || ""; } // ------------------------------------------------------------ the model's endpoint @@ -60,7 +83,7 @@ if (A.cfg.key) o.headers.Authorization = "Bearer " + A.cfg.key; return fetch(baseUrl() + path, o).catch(function (e) { if (e && e.name === "AbortError") throw e; - throw new Error("The browser could not reach " + baseUrl() + " (" + (e && e.message || e) + "). An endpoint on another site has to allow this page (CORS, including the preflight request, which carries no key), or use a relay on this site."); + throw new Error(unreachable(e)); }); } function httpError(r) { @@ -465,7 +488,7 @@ names.map(function (m) { return "" + esc(m) + ""; }).join(""); $("agthink").checked = c.think !== false; $("agsetnote").innerHTML = web() - ? "Kept in this browser only." + (siteDefault() !== DEFAULT_URL ? " Default endpoint: " + esc(siteDefault()) + " (this site's relay to B4L's inference server)." : "") + ? "Kept in this browser only." + (relay() ? " " + esc(relay().url) + " is this site's relay to " + esc(relay().upstream) + " (entering that URL uses the relay too)." : "") : "Kept by the Studio server" + (c.file ? " in " + esc(c.file) + "" : "") + "; the key never reaches this page."; setMsg(A.modelsErr ? A.modelsErr : A.models.length ? "Connected: " + A.models.length + " model" + (A.models.length === 1 ? "" : "s") : "", !!A.modelsErr); f.hidden = false; diff --git a/tools/build_web.py b/tools/build_web.py index 0257848..a17ac7b 100644 --- a/tools/build_web.py +++ b/tools/build_web.py @@ -113,7 +113,9 @@ def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--out", default=str(ROOT / "dist" / "web")) ap.add_argument("--skip-cargo", action="store_true", help="reuse the last WASM build (front-end changes only)") - ap.add_argument("--agent-url", help="default endpoint of the agent window, e.g. a relay on the same site (/llm/v1)") + ap.add_argument("--agent-url", help="default endpoint of the agent window: a relay on the same site (/llm/v1)") + ap.add_argument("--agent-upstream", default="https://inference.p0.contact/v1", + help="the endpoint that --agent-url relays to; entered in the window, it goes through the relay") a = ap.parse_args() out = Path(a.out).resolve() web = out / "assets" / "web" @@ -157,7 +159,8 @@ def main(): html = replace_once(html, 'isokit Studio', 'isokit Studio\n' + (f'\n' if build else "") - + (f'\n' if a.agent_url else "")) + + (f'\n' + if a.agent_url else "")) html = replace_once(html, 'href="/assets/studio/favicon.svg"', f'href="assets/studio/favicon.svg?{v}"') html = replace_once(html, 'href="/assets/viewer/isokit-view.css"', f'href="assets/viewer/isokit-view.css?{v}"') html = replace_once(html, '', '') diff --git a/tools/check_web.py b/tools/check_web.py index 5fb66c2..9beff62 100644 --- a/tools/check_web.py +++ b/tools/check_web.py @@ -196,6 +196,34 @@ try: stored = pg.evaluate("() => JSON.parse(localStorage.getItem('isokit-agent'))") check(stored.get("key") == "test-key" and not stored.get("model"), f"settings stay in this browser {stored}") + # a site relay (here: the mock) stands in for its upstream, which pages may not call directly + upstream = "https://inference.p0.contact/v1" + pg.evaluate("""([u, up]) => { const m = document.createElement('meta'); m.name = 'isokit-agent'; m.content = u; + m.setAttribute('data-upstream', up); document.head.appendChild(m); }""", [agent_checks.MOCK + "/v1", upstream]) + pg.locator("#agent").click() + pg.locator("#agset").click() + pg.locator("#agurl").fill(upstream + "/") + n_chat = len(agent_checks.chats()) + pg.locator('#agsetf button[type="submit"]').click() + pg.wait_for_function("() => document.querySelector('#agwho .ag-on')", timeout=5000) + check(pg.evaluate("() => JSON.parse(localStorage.getItem('isokit-agent')).url") is None, + "entering the relayed endpoint keeps following the site's default") + pg.locator("#agtext").fill("hello via the relay") + pg.locator("#agtext").press("Enter") + pg.wait_for_function("() => !document.querySelector('.ag-busy')", timeout=10000) + check(len(agent_checks.chats()) == n_chat + 1, "the relayed endpoint is called through the relay") + # no relay for another site: the message says what to do + pg.evaluate("() => document.querySelector('meta[name=isokit-agent]').remove()") + pg.locator("#agset").click() if pg.locator("#agsetf").is_hidden() else None + pg.locator("#agurl").fill("http://127.0.0.1:7939/v1") + pg.locator('#agsetf button[type="submit"]').click() + pg.wait_for_function("() => /could not reach/.test(document.getElementById('agsetmsg').textContent)", timeout=15000) + msg = pg.locator("#agsetmsg").inner_text() + check("CORS" in msg and "isokit studio" in msg, "an unreachable endpoint on another site explains CORS and the way out: " + msg[:90]) + shot(pg, "web-agent-unreachable.jpg") + pg.evaluate("() => localStorage.setItem('isokit-agent', JSON.stringify({url: arguments[0], key: 'test-key'}))".replace("arguments[0]", "'" + agent_checks.MOCK + "/v1'")) + pg.locator("#agclose").click() + # ---------------------------------------------------------------- two tabs on one project pg2 = ctx.new_page() watch(pg2) @@ -320,7 +348,7 @@ try: shot(pg, "web-narrow.jpg") # the browser logs the 401 of the agent window's wrong-key check - errs = [e for e in errs if "status of 401" not in e] + errs = [e for e in errs if "status of 401" not in e and "ERR_CONNECTION_REFUSED" not in e] check(not errs, f"no page errors {errs[:5]}") b.close() finally: