/* 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(""); if (i >= 0) { var j = c.indexOf("", i); r += (r ? "\n" : "") + c.slice(i + 7, j < 0 ? c.length : j); c = c.slice(0, i) + (j < 0 ? "" : c.slice(j + 8)); } return { reasoning: r.trim(), content: c.replace(/^\s+/, "") }; } // ------------------------------------------------------------ tools function mcp(msg) { if (web()) { // reads go straight to the engine; edits through the page, which saves them return Promise.resolve(msg.method === "tools/call" ? window.isokit.mcp(msg) : JSON.parse(window.isokit.studio.mcp(JSON.stringify(msg)) || "null")); } return fetch("/mcp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(msg) }) .then(function (r) { return r.status === 202 ? null : r.json(); }); } var PAGE = { select_object: { description: "Point at an object on the user's screen: select it in the Studio and zoom the view to it. Without id: clear the selection.", parameters: { type: "object", properties: { id: { type: "string" }, zoom: { type: "boolean", description: "zoom to it (default true)" } }, required: [] }, run: function (a) { var s = st(); if (!a.id) { s.select(null); return "selection cleared"; } if (!s.obj(a.id)) throw new Error('no object "' + a.id + '"'); s.select(a.id); if (a.zoom !== false) s.view.focus(a.id, 3); return "selected " + a.id + " on the user's screen"; } }, set_view: { description: "Turn the user's view (rotation 0-3, quarter turns) or show another level (level ids from project_info; storeys are drawn cut open).", parameters: { type: "object", properties: { rotation: { type: "integer", minimum: 0, maximum: 3 }, level: { type: "string" } }, required: [] }, run: function (a) { var s = st(), lv = s.S.meta.levels || []; if (a.level) { if (!lv.some(function (l) { return l.id === a.level; })) throw new Error('no level "' + a.level + '" (levels: ' + lv.map(function (l) { return l.id; }).join(", ") + ")"); s.view.setLevel(a.level); } if (typeof a.rotation === "number") s.view.setRotation(((Math.round(a.rotation) % 4) + 4) % 4); return "the user now sees level " + s.view.getLevel() + ", rotation " + s.view.getRotation(); } } }; function loadTools() { if (A.tools) return Promise.resolve(A.tools); var init = { jsonrpc: "2.0", id: ++A.rpc, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "isokit-agent-window", version: "1" } } }; return Promise.all([mcp(init), mcp({ jsonrpc: "2.0", id: ++A.rpc, method: "tools/list" })]).then(function (rs) { A.instructions = (rs[0] && rs[0].result && rs[0].result.instructions) || ""; var list = ((rs[1] && rs[1].result && rs[1].result.tools) || []).map(function (t) { return { type: "function", "function": { name: t.name, description: t.description, parameters: t.inputSchema } }; }); Object.keys(PAGE).forEach(function (k) { list.push({ type: "function", "function": { name: k, description: PAGE[k].description, parameters: PAGE[k].parameters } }); }); A.tools = list; return list; }); } function parseArgs(s) { if (s && typeof s === "object") return s; if (!s || !String(s).trim()) return {}; return JSON.parse(s); } /** -> { error, text } */ function runTool(name, args) { var a; try { a = parseArgs(args); } catch (e) { return Promise.resolve({ error: true, text: "The arguments are not valid JSON (" + e.message + "): send the call again with a JSON object." }); } if (PAGE[name]) { try { return Promise.resolve({ error: false, text: PAGE[name].run(a) }); } catch (e) { return Promise.resolve({ error: true, text: e.message || String(e) }); } } return mcp({ jsonrpc: "2.0", id: ++A.rpc, method: "tools/call", params: { name: name, arguments: a } }).then(function (r) { if (!r) return { error: true, text: "no answer from the scene" }; if (r.error) return { error: true, text: r.error.message || JSON.stringify(r.error) }; var res = r.result || {}; return { error: !!res.isError, text: (res.content || []).map(function (c) { return c.text || ""; }).join("\n") }; }, function (e) { return { error: true, text: String(e && e.message || e) }; }); } /** The Studio redraws as soon as an edit lands (its poll would take up to a second). */ function follow() { var s = st(); return Promise.resolve(s.api("/api/version")).then(function (r) { if (r.version !== s.S.version) return s.reloadAll(); }).catch(function () { /* the poll catches up */ }); } // ------------------------------------------------------------ the conversation function compact(o) { var c = { id: o.id, type: o.type, x: o.x, y: o.y }; ["z", "rot", "name", "tag", "props", "levels", "locked", "hidden"].forEach(function (k) { if (o[k] !== undefined && o[k] !== null && o[k] !== "") c[k] = o[k]; }); return c; } /** World directions of the user's screen right and up, for "left of", "above", shapes meant to be read. */ function screenAxes(rot) { function dir(sx, sy) { var a = IsoKit.unproject(rot, sx, sy, 0), o = IsoKit.unproject(rot, 0, 0, 0), x = a[0] - o[0], y = a[1] - o[1], l = Math.hypot(x, y) || 1; return "(" + +(x / l).toFixed(3) + ", " + +(y / l).toFixed(3) + ")"; } return "On the user's screen, right is the world direction " + dir(1, 0) + " and up is " + dir(0, -1) + " (unit vectors in x, y). The view is isometric: a metre along screen-up looks half as long as a metre along screen-right, so a shape that should read correctly on screen (a letter, a logo) needs twice the depth in world metres along screen-up."; } function system() { var s = st(), S = s.S, sel = S.sel && s.obj(S.sel), v = s.view; return { role: "system", content: [ "You are the agent inside the isokit Studio, the page where the user edits an isometric scene. You change the scene with the tools; every edit appears on the user's map at once, and the user can take any edit back with Undo (Ctrl+Z).", A.instructions, "How to work: look before you build (list_objects shows what is where with world boxes; list_prefabs with a type gives its props). Place things inside the site, on free ground, and use the boxes to avoid overlaps. Put related edits into one apply_ops call so they undo as one step. After bigger changes, check with render_view or check_project. Use select_object to show the user what you mean. Ask back when a request is unclear instead of guessing at big changes.", "Answer in the language of the user's latest message, short and plain, without tables: say what you changed and give object ids in backticks (the user can click them). To show the result, write the answer first and call select_object in the same step: that ends your turn.", "The Studio right now: project \"" + S.meta.title + "\", " + (S.doc.objects || []).length + " objects; view level \"" + v.getLevel() + "\", rotation " + v.getRotation() + "; snap step " + (s.snapStep() || "off") + ". " + (sel ? "Selected (\"this\", \"it\"): " + JSON.stringify(compact(sel)) : "Nothing is selected.") + " " + screenAxes(v.getRotation()) ].filter(Boolean).join("\n\n") }; } function send(text) { text = String(text || "").trim(); if (!text || A.busy) return; A.msgs.push({ role: "user", content: text }); addMsg("ag-user", esc(text).replace(/\n/g, "
")); 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.

' + '
' + SUGGEST.map(function (s) { return '"; }).join("") + "
"; } function md(src) { var html = "", list = null, code = null; function close() { if (list) { html += ""; list = null; } } function inline(s) { return esc(s).replace(/`([^`]+)`/g, "$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(m[3]) + "
  • "; return; } close(); if (!line.trim()) return; var h = /^#{1,6}\s+(.*)$/.exec(line); html += h ? "

    " + 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", '
    '); var think = d.querySelector(".ag-think"), tt = think.querySelector("div"), tx = d.querySelector(".ag-text"); var t0 = Date.now(), t1 = 0, last = null, raf = 0; function paint() { raf = 0; if (!last) return; var stick = nearEnd(), m = split(last); if (m.reasoning) { think.hidden = false; tt.textContent = m.reasoning; } tx.innerHTML = md(m.content); if (stick) log().scrollTop = log().scrollHeight; } return { update: function (m) { last = m; if (!t1 && (split(m).content || m.tool_calls.length)) t1 = Date.now(); if (!raf) raf = requestAnimationFrame(paint); }, done: function (m) { // m null: broken off, keep what came if (raf) cancelAnimationFrame(raf); if (m) last = m; paint(); d.classList.remove("ag-live"); var p = last ? split(last) : { reasoning: "", content: "" }; if (p.reasoning) think.querySelector("summary").textContent = "Thought for " + Math.max(1, Math.round(((t1 || Date.now()) - t0) / 1000)) + " s"; if (!p.content && !p.reasoning) d.remove(); else linkIds(tx); } }; } function num(v) { return typeof v === "number" ? +v.toFixed(2) : v; } function summary(n, a) { var ops = a.ops || []; switch (n) { case "add_object": return (a.type || "?") + (a.x !== undefined ? " at " + num(a.x) + ", " + num(a.y) : ""); case "update_object": return (a.id || "?") + ": " + Object.keys(a).filter(function (k) { return k !== "id"; }).map(function (k) { return k === "props" ? Object.keys(a.props || {}).join(", ") : k; }).join(", "); case "remove_object": case "get_object": case "duplicate_object": case "select_object": return a.id || ""; case "apply_ops": return ops.length + " edit" + (ops.length === 1 ? "" : "s") + ": " + ops.slice(0, 4).map(function (o) { return (o.op || "?") + " " + (o.type || o.id || ""); }).join(", ") + (ops.length > 4 ? ", …" : ""); case "list_prefabs": return a.type || a.category || ""; case "list_objects": return a.type || ""; case "set_params": return Object.keys(a.values || {}).join(", "); case "render_view": case "set_view": return [a.level, a.rotation !== undefined ? "rotation " + a.rotation : "", a.format].filter(Boolean).join(", "); default: return ""; } } function touched(n, a, res) { if (res.error) return null; try { if (n === "add_object" || n === "duplicate_object") return JSON.parse(res.text).id || null; if (n === "apply_ops") { var ids = (JSON.parse(res.text).ids || []).filter(Boolean); return ids[ids.length - 1] || null; } } catch (e) { return null; } if (n === "update_object" || n === "select_object" || n === "get_object") return a.id || null; return null; } function toolView(c) { var n = c["function"].name, a = {}; try { a = parseArgs(c["function"].arguments); } catch (e) { /* shown raw */ } var d = addMsg("ag-tool", '
    ' + esc(n) + " " + esc(summary(n, a)) + '…
    '); d.querySelector(".ag-args").textContent = typeof c["function"].arguments === "string" && !Object.keys(a).length ? c["function"].arguments : JSON.stringify(a, null, 1); return { done: function (r) { var em = d.querySelector("em"), id = touched(n, a, r); d.classList.add(r.error ? "ag-bad" : "ag-ok"); em.textContent = r.error ? "✗" : "✓"; em.title = r.error ? clip(r.text, 300) : "done"; d.querySelector(".ag-res").textContent = clip(r.text, 6000); if (id) { // the Studio may not have reloaded the new object yet; a click checks var link = '' + 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 "" + esc(m) + ""; }).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 = '
    ' + '' + '' + '' + '
    ' + '' + '
    ' + '
    ' + '
    ' + ''; document.querySelector(".st-centre").appendChild(win); renderEmpty(); btn.addEventListener("click", function () { open(win.hidden); }); $("agclose").addEventListener("click", function () { open(false); }); $("agnew").addEventListener("click", newChat); $("agset").addEventListener("click", function () { if ($("agsetf").hidden) fillSettings(); else hideSettings(); }); $("agwho").addEventListener("click", function () { if ($("agsetf").hidden) fillSettings(); else hideSettings(); }); $("agsetf").addEventListener("submit", saveSettings); $("agrefresh").addEventListener("click", function () { setMsg("Asking…"); connect().then(fillSettings); }); $("agforget").addEventListener("click", function () { saveCfg({ key: "" }).then(connect).then(fillSettings); }); $("agin").addEventListener("submit", function (e) { e.preventDefault(); if (A.busy) { stop(); return; } var t = $("agtext"); if (!t.value.trim()) return; send(t.value); t.value = ""; }); $("agtext").addEventListener("keydown", function (e) { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (!A.busy) $("agin").requestSubmit(); } if (e.key === "Escape") { if (A.busy) stop(); else e.target.blur(); } }); log().addEventListener("click", function (e) { var s = e.target.closest(".ag-sug"); if (s) { send(s.textContent); return; } var o = e.target.closest(".ag-obj"); if (o) { e.preventDefault(); var id = o.getAttribute("data-obj"); if (st().obj(id)) { st().select(id); st().view.focus(id, 3); } else st().toast("\u201c" + id + "\u201d is no longer in the scene"); return; } var b = e.target.closest("button[data-act]"); if (b && b.getAttribute("data-act") === "settings") fillSettings(); if (b && b.getAttribute("data-act") === "retry" && !A.busy) { // drop the failed turn's leftovers and ask again while (A.msgs.length && A.msgs[A.msgs.length - 1].role !== "user") A.msgs.pop(); var last = A.msgs.pop(); b.closest(".ag-err").remove(); if (last) send(last.content); } }); dragger($("aghead"), function (g, dx, dy) { return { w: g.w, h: g.h, r: g.r - dx, b: g.b - dy }; }); dragger($("aggrip"), function (g, dx, dy) { return { w: g.w - dx, h: g.h - dy, r: g.r, b: g.b }; }); window.addEventListener("resize", place); if (A.ui.open) open(true); window.__agent = { A: A, send: send, stop: stop, open: open, newChat: newChat }; } function start() { if (!$("agwin")) buildUI(); } if (window.__studio && window.__studio.ready) start(); else window.addEventListener("isokit:studio", start, { once: true }); })();