/* isokit Studio: the agent window. A chat with a language model on an
* OpenAI-compatible endpoint (by default B4L's inference server) that edits
* the open scene with the isokit MCP tools, plus two page tools (point at an
* object, turn the view). Its edits show on the map at once; Undo reverts them.
*
* 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. 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";
var $ = function (id) { return document.getElementById(id); };
var DEFAULT_URL = "https://inference.p0.contact/v1";
var MAX_STEPS = 32, MAX_RESULT = 24000;
var LS_CFG = "isokit-agent", LS_UI = "isokit-agent-ui";
var SUGGEST = ["What is in this scene?", "Plant a row of trees along the road", "Add a carport with two cars next to the main building", "Make the selected building one storey taller"];
var A = { cfg: null, models: [], modelsErr: null, msgs: [], busy: false, ctl: null, tools: null, instructions: "", rpc: 0, ui: lsGet(LS_UI) || {} };
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); }
function clip(s, n) { s = String(s); return s.length > n ? s.slice(0, n) + "\n… (" + (s.length - n) + " more characters cut)" : s; }
function st() { return window.__studio; }
function web() { return !!(window.IsoKitBackend && window.IsoKitBackend.web); }
function lsGet(k) { try { return JSON.parse(localStorage.getItem(k) || "null"); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* private mode */ } }
function json(r) { return r.json(); }
function saveUi() { lsSet(LS_UI, A.ui); }
// ------------------------------------------------------------ settings
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) || {};
A.cfg = { url: c.url || siteDefault(), url_from: c.url ? "browser" : "default", model: c.model || "", think: c.think !== false, key: c.key || "", has_key: !!c.key, key_from: c.key ? "browser" : "" };
return Promise.resolve(A.cfg);
}
/** v: { url, model, think, key } (key undefined: unchanged, "": forget) */
function saveCfg(v) {
if (!web()) {
return fetch("/api/agent/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(v) }).then(json)
.then(function (c) { if (c.error) throw new Error(c.error); A.cfg = c; return c; });
}
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 !== undefined && isDefault(c.url)) delete c.url; // follow the site's default when it moves
lsSet(LS_CFG, c);
return loadCfg();
}
/** 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
function llm(path, body, signal) {
var o = { signal: signal, cache: "no-store", headers: {} };
if (body) { o.method = "POST"; o.body = JSON.stringify(body); o.headers["Content-Type"] = "application/json"; }
if (!web()) return fetch(path === "/models" ? "/api/agent/models" : "/api/agent/chat", o);
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(unreachable(e));
});
}
function httpError(r) {
return r.text().then(function (t) {
var m = t;
try { var x = JSON.parse(t); m = (x.error && (x.error.message || x.error)) || x.message || x.detail || t; } catch (e) { /* plain text */ }
if (typeof m !== "string") m = JSON.stringify(m);
if (/^\s* in the text). */
function split(m) {
var c = m.content || "", r = m.reasoning || "";
var i = c.indexOf("
"));
busy(true);
var ctl = new AbortController(), step = 0, pending = null;
A.ctl = ctl;
function turn() {
if (step++ >= MAX_STEPS) { note("Stopped after " + MAX_STEPS + " steps. Say \u201ccontinue\u201d to go on."); return null; }
var view = botView();
return complete([system()].concat(A.msgs), ctl.signal, view.update).then(function (res) {
view.done(res.msg);
var m = split(res.msg), calls = res.msg.tool_calls.filter(function (c) { return c && c["function"].name; });
var out = { role: "assistant", content: m.content || "" };
if (calls.length) {
out.tool_calls = calls.map(function (c, i) { return { id: c.id || "call_" + Date.now().toString(36) + "_" + i, type: "function", "function": { name: c["function"].name, arguments: c["function"].arguments || "{}" } }; });
}
A.msgs.push(out);
if (!calls.length) {
if (res.finish === "length") note("The answer hit the length limit and was cut off.");
if (!m.content) note("The model gave no answer.");
return null;
}
pending = out.tool_calls.slice();
// pointing at things (select_object, set_view) after a written answer ends the turn: asked
// again with nothing left to say, models ramble (Qwen even switches language)
var gesture = !!m.content.trim() && out.tool_calls.every(function (c) { return PAGE[c["function"].name]; });
return out.tool_calls.reduce(function (p, c) {
return p.then(function () {
if (ctl.signal.aborted) return;
var chip = toolView(c);
return runTool(c["function"].name, c["function"].arguments).then(function (r) {
chip.done(r);
A.msgs.push({ role: "tool", tool_call_id: c.id, content: clip(r.text, MAX_RESULT) });
pending.shift();
});
});
}, Promise.resolve()).then(follow).then(function () { if (!ctl.signal.aborted && !gesture) return turn(); });
}, function (e) {
view.done(null);
throw e;
});
}
ready().then(turn).catch(function (e) {
if (ctl.signal.aborted || (e && e.name === "AbortError")) return;
error(e && e.message || String(e));
}).then(function () {
// every tool call needs an answer, or the endpoint refuses the next request
(pending || []).forEach(function (c) { A.msgs.push({ role: "tool", tool_call_id: c.id, content: "not run: the turn was stopped" }); });
if (ctl.signal.aborted) { note("Stopped."); follow(); }
busy(false);
A.ctl = null;
});
}
function stop() { if (A.ctl) A.ctl.abort(); }
/** Settings and tools loaded (once; a failed start is tried again on the next message). */
function ready() {
if (!A.ready) {
A.ready = loadCfg().then(function () { who(); return Promise.all([loadTools(), connect()]); });
A.ready.catch(function () { A.ready = null; });
}
return A.ready;
}
function newChat() { stop(); A.msgs = []; renderEmpty(); $("agtext").focus(); }
// ------------------------------------------------------------ chat view
function log() { return $("aglog"); }
function nearEnd() { var l = log(); return l.scrollHeight - l.scrollTop - l.clientHeight < 60; }
function addMsg(cls, html) {
var e = log().querySelector(".ag-empty");
if (e) e.remove();
var d = document.createElement("div");
d.className = cls;
d.innerHTML = html;
log().appendChild(d);
log().scrollTop = log().scrollHeight;
return d;
}
function note(t) { addMsg("ag-note", esc(t)); }
function error(t) {
var d = addMsg("ag-err", esc(t) + ' ');
return d;
}
function renderEmpty() {
log().innerHTML = '
Ask for changes in plain words. The agent edits the scene with the same tools as you, and Undo takes any of its steps back.
' + '$1").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1$2");
}
src.replace(/\r/g, "").split("\n").forEach(function (line) {
if (/^\s*```/.test(line)) {
if (code === null) { close(); code = []; } else { html += "" + esc(code.join("\n")) + ""; code = null; }
return;
}
if (code !== null) { code.push(line); return; }
var m = /^\s*(?:([-*•])|(\d+)[.)])\s+(.*)$/.exec(line);
if (m) {
var kind = m[1] ? "ul" : "ol";
if (list !== kind) { close(); html += "<" + kind + ">"; list = kind; }
html += "" + inline(h[1]) + "
" : "" + inline(line) + "
"; }); close(); if (code !== null) html += "" + esc(code.join("\n")) + "";
return html;
}
/** `ids` of objects in the scene become links that select them. */
function linkIds(el) {
var s = st();
Array.prototype.forEach.call(el.querySelectorAll("code"), function (c) {
var id = c.textContent.trim();
if (s.obj(id)) { c.classList.add("ag-obj"); c.setAttribute("data-obj", id); c.title = "Select and zoom to it"; }
});
}
function botView() {
var d = addMsg("ag-msg ag-bot ag-live", '' + esc(id) + "", sp = d.querySelector("summary span");
// "id: x, y" -> the id itself becomes the link; else it follows the summary
if (sp.textContent.indexOf(id) === 0) { sp.textContent = sp.textContent.slice(id.length); sp.insertAdjacentHTML("beforebegin", link); }
else em.insertAdjacentHTML("beforebegin", link);
}
}
};
}
function busy(on) {
A.busy = on;
var b = $("agsend");
b.textContent = on ? "Stop" : "Send";
b.classList.toggle("ag-stop", on);
b.title = on ? "Stop the agent" : "Send (Enter)";
$("agwin").classList.toggle("ag-busy", on);
}
// ------------------------------------------------------------ settings form
function who() {
var b = $("agwho");
if (!A.cfg) { b.innerHTML = "…"; return; }
var ok = !A.modelsErr && A.models.length;
b.innerHTML = '' + esc((model() || "no model") + " · " + host());
b.title = A.modelsErr ? "Not connected: " + A.modelsErr : "Model and endpoint (⚙ to change)";
}
function fillSettings() {
var c = A.cfg, f = $("agsetf");
$("agurl").value = c.url;
$("agurl").disabled = c.url_from === "env";
$("agurl").title = c.url_from === "env" ? "Set by ISOKIT_LLM_URL" : "OpenAI-compatible base URL, usually ending in /v1";
$("agkey").value = "";
$("agkey").disabled = c.key_from === "env";
$("agkey").placeholder = c.key_from === "env" ? "set by ISOKIT_LLM_KEY" : c.has_key ? "saved (type to replace)" : "none (most hosted endpoints need one)";
$("agforget").hidden = !c.has_key || c.key_from === "env";
var sel = $("agmodel"), names = A.models.slice();
if (c.model && names.indexOf(c.model) < 0) names.unshift(c.model);
sel.innerHTML = '" +
names.map(function (m) { return ""; }).join("");
$("agthink").checked = c.think !== false;
$("agsetnote").innerHTML = web()
? "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;
$("agset").setAttribute("aria-pressed", "true");
}
function setMsg(t, bad) { var m = $("agsetmsg"); m.textContent = t; m.classList.toggle("ag-badtext", !!bad); m.title = t; }
function hideSettings() { $("agsetf").hidden = true; $("agset").setAttribute("aria-pressed", "false"); }
function saveSettings(e) {
e.preventDefault();
var v = { model: $("agmodel").value, think: $("agthink").checked };
if (!$("agurl").disabled) v.url = $("agurl").value.trim();
if ($("agkey").value.trim()) v.key = $("agkey").value.trim();
setMsg("Saving…");
saveCfg(v).then(connect).then(function () { fillSettings(); if (!A.modelsErr) setTimeout(hideSettings, 700); }, function (err) { setMsg(String(err && err.message || err), true); });
}
function connect() {
return loadModels().catch(function () { /* shown in the form and the dot */ }).then(who);
}
// ------------------------------------------------------------ window
function geom() { var g = A.ui.geom || {}; return { r: g.r === undefined ? 12 : g.r, b: g.b === undefined ? 12 : g.b, w: g.w || 390, h: g.h || 480 }; }
function place() {
var win = $("agwin");
if (win.hidden) return;
var c = win.parentNode, foot = c.querySelector(".st-status"), fh = foot ? foot.offsetHeight : 0;
var cw = c.clientWidth, ch = c.clientHeight - fh, g = geom();
var w = Math.max(280, Math.min(g.w, cw - 8)), h = Math.max(220, Math.min(g.h, ch - 8));
var r = Math.max(4, Math.min(g.r, cw - w - 4)), b = Math.max(4, Math.min(g.b, ch - h - 4));
win.style.width = w + "px"; win.style.height = h + "px";
win.style.right = r + "px"; win.style.bottom = (b + fh) + "px";
}
function dragger(el, fn) {
el.addEventListener("pointerdown", function (e) {
if (e.button !== 0 || e.target.closest("button, input, select, a, code")) return;
e.preventDefault();
var g0 = geom(), x0 = e.clientX, y0 = e.clientY;
el.setPointerCapture(e.pointerId);
function mv(ev) { A.ui.geom = fn(g0, ev.clientX - x0, ev.clientY - y0); place(); }
function up() {
el.removeEventListener("pointermove", mv); el.removeEventListener("pointerup", up); el.removeEventListener("pointercancel", up);
var win = $("agwin"), foot = win.parentNode.querySelector(".st-status");
A.ui.geom = { w: win.offsetWidth, h: win.offsetHeight, r: parseFloat(win.style.right), b: parseFloat(win.style.bottom) - (foot ? foot.offsetHeight : 0) };
saveUi();
}
el.addEventListener("pointermove", mv); el.addEventListener("pointerup", up); el.addEventListener("pointercancel", up);
});
}
function open(on) {
var win = $("agwin");
win.hidden = !on;
$("agent").setAttribute("aria-pressed", String(on));
A.ui.open = on; saveUi();
if (!on) return;
place();
$("agtext").focus();
if (!A.started) {
A.started = true;
ready().then(function () { if (A.modelsErr && !A.msgs.length) fillSettings(); })
.catch(function (e) { error("The agent could not start: " + (e && e.message || e)); });
}
}
function buildUI() {
var btn = document.createElement("button");
btn.type = "button"; btn.id = "agent"; btn.className = "ag-open";
btn.title = "Chat with an AI agent that edits this scene";
btn.setAttribute("aria-pressed", "false");
btn.textContent = "✦ Agent";
$("preview").parentNode.insertBefore(btn, $("preview"));
var win = document.createElement("section");
win.className = "ag-win"; win.id = "agwin"; win.hidden = true;
win.setAttribute("aria-label", "Agent");
win.innerHTML =
'