Files
isokit/studio/agent.js
T
grabowski 1a1b62d475
Deploy web Studio / deploy (push) Successful in 3m3s
studio: agent window, a chat with a model that edits the scene
✦ Agent in the Studio's top bar opens a floating chat window. The model
(any OpenAI-compatible endpoint with tool calling; default B4L's inference
server, https://inference.p0.contact/v1) gets the 15 MCP tools plus two page
tools (select_object, set_view) and the Studio's state (selection, level,
rotation) in its system prompt. Answers and reasoning stream in; tool calls
show as rows with arguments and results; object ids link to the object;
Stop ends a turn; Undo takes the agent's edits back. The window moves by
its title bar and resizes at its top-left corner.

- studio/agent.js: shared by both front ends (waits for studio.js's
  isokit:studio event); MCP over /mcp or isokit.mcp in the web Studio
- isokit studio: /api/agent/{config,models,chat} relay the calls on their
  own threads (ureq, chunked and flushed per read), so the key stays on the
  machine and the endpoint needs no CORS; settings from ISOKIT_LLM_URL /
  _KEY / _MODEL or agent.json in the user's config folder
- web Studio: calls the endpoint from the page with a key kept in the
  browser; build_web.py --agent-url names a same-site relay as default; the
  deploy builds with /llm/v1, which deploy/Caddyfile forwards to the
  inference server (its nginx refuses the CORS preflight)
- tools/mock_llm.py + tools/agent_checks.py: the agent window checks in both
  check_studio.py and check_web.py
2026-10-23 19:35:53 +07:00

610 lines
34 KiB
JavaScript

/* 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; a build can name a same-site relay as
* the default endpoint (<meta name="isokit-agent" content="/llm/v1">).
*/
(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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
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 siteDefault() { var m = document.querySelector('meta[name="isokit-agent"]'); return (m && m.getAttribute("content")) || DEFAULT_URL; }
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 === siteDefault()) 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; }
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 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("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.");
});
}
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);
m = m.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
var hint = r.status === 401 || r.status === 403 ? " Check the API key (⚙)." : r.status === 404 ? " Check the endpoint: most servers want the URL ending in /v1." : "";
return new Error("HTTP " + r.status + (m ? ": " + clip(m, 300) : "") + hint);
});
}
function loadModels() {
return llm("/models").then(function (r) {
if (!r.ok) return httpError(r).then(function (e) { throw e; });
return r.json();
}).then(function (x) {
A.models = (x.data || x.models || []).map(function (m) { return m.id || m.name; }).filter(Boolean);
A.modelsErr = null;
return A.models;
}, function (e) { A.models = []; A.modelsErr = e.message || String(e); throw e; });
}
/** One streamed completion; onDelta(msg) as it grows. */
function complete(messages, signal, onDelta) {
var body = { model: model(), messages: messages, tools: A.tools, stream: true };
if (!A.cfg.think) body.chat_template_kwargs = { enable_thinking: false };
var msg = { content: "", reasoning: "", tool_calls: [] }, finish = null;
function take(ch) {
var d = ch.delta || ch.message || {};
if (d.reasoning || d.reasoning_content) msg.reasoning += d.reasoning || d.reasoning_content;
if (d.content) msg.content += d.content;
(d.tool_calls || []).forEach(function (t, k) {
var i = t.index !== undefined ? t.index : k;
var c = msg.tool_calls[i] || (msg.tool_calls[i] = { id: "", type: "function", "function": { name: "", arguments: "" } });
if (t.id) c.id = t.id;
if (t["function"]) {
if (t["function"].name) c["function"].name += t["function"].name;
if (t["function"].arguments) c["function"].arguments += typeof t["function"].arguments === "string" ? t["function"].arguments : JSON.stringify(t["function"].arguments);
}
});
if (ch.finish_reason) finish = ch.finish_reason;
}
return llm("/chat/completions", body, signal).then(function (r) {
if (!r.ok) return httpError(r).then(function (e) { throw e; });
if ((r.headers.get("content-type") || "").indexOf("text/event-stream") < 0) { // a server that does not stream
return r.json().then(function (x) { if (x.error) throw new Error(x.error.message || JSON.stringify(x.error)); take(x.choices[0]); onDelta(msg); return { msg: msg, finish: finish }; });
}
var rd = r.body.getReader(), dec = new TextDecoder(), buf = "";
return (function pump() {
return rd.read().then(function (x) {
if (x.done) return { msg: msg, finish: finish };
buf += dec.decode(x.value, { stream: true });
var lines = buf.split("\n");
buf = lines.pop();
lines.forEach(function (line) {
line = line.trim();
if (line.indexOf("data:") !== 0) return;
var data = line.slice(5).trim();
if (!data || data === "[DONE]") return;
var j = JSON.parse(data);
if (j.error) throw new Error(j.error.message || JSON.stringify(j.error));
if (j.choices && j.choices[0]) take(j.choices[0]);
});
onDelta(msg);
return pump();
});
})();
});
}
/** Reasoning apart from the answer (servers without a reasoning parser send <think> in the text). */
function split(m) {
var c = m.content || "", r = m.reasoning || "";
var i = c.indexOf("<think>");
if (i >= 0) {
var j = c.indexOf("</think>", 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;
}
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 user's language, short and plain, without tables: say what you changed and give object ids in backticks (the user can click them).",
"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.")
].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, "<br>"));
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();
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) 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) + ' <button type="button" class="ag-link" data-act="settings">Settings</button> <button type="button" class="ag-link" data-act="retry">Try again</button>');
return d;
}
function renderEmpty() {
log().innerHTML = '<div class="ag-empty"><p>Ask for changes in plain words. The agent edits the scene with the same tools as you, and <b>Undo</b> takes any of its steps back.</p>' +
'<div class="ag-sugs">' + SUGGEST.map(function (s) { return '<button type="button" class="ag-sug">' + esc(s) + "</button>"; }).join("") + "</div></div>";
}
function md(src) {
var html = "", list = null, code = null;
function close() { if (list) { html += "</" + list + ">"; list = null; } }
function inline(s) {
return esc(s).replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>").replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, "$1<i>$2</i>");
}
src.replace(/\r/g, "").split("\n").forEach(function (line) {
if (/^\s*```/.test(line)) {
if (code === null) { close(); code = []; } else { html += "<pre>" + esc(code.join("\n")) + "</pre>"; 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 += "<li>" + inline(m[3]) + "</li>";
return;
}
close();
if (!line.trim()) return;
var h = /^#{1,6}\s+(.*)$/.exec(line);
html += h ? "<p><b>" + inline(h[1]) + "</b></p>" : "<p>" + inline(line) + "</p>";
});
close();
if (code !== null) html += "<pre>" + esc(code.join("\n")) + "</pre>";
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", '<details class="ag-think" hidden><summary>Thinking…</summary><div></div></details><div class="ag-text"></div>');
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", '<details><summary><b>' + esc(n) + "</b> <span>" + esc(summary(n, a)) + '</span><em>…</em></summary><pre class="ag-args"></pre><pre class="ag-res"></pre></details>');
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 = '<code class="ag-obj" data-obj="' + esc(id) + '" title="Select and zoom to it">' + esc(id) + "</code>", 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 = '<i class="ag-dot ' + (ok ? "ag-on" : A.modelsErr ? "ag-off" : "") + '"></i>' + 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 = '<option value="">' + esc(A.models.length ? "first offered (" + A.models[0] + ")" : "first offered") + "</option>" +
names.map(function (m) { return "<option" + (m === c.model ? " selected" : "") + ">" + esc(m) + "</option>"; }).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 by the Studio server" + (c.file ? " in <code>" + esc(c.file) + "</code>" : "") + "; 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 =
'<header class="ag-head" id="aghead" title="Drag to move">' +
'<b class="ag-logo">✦ Agent</b><button type="button" class="ag-who" id="agwho"></button><span class="st-grow"></span>' +
'<button type="button" id="agnew" title="Start a new chat (the scene stays as it is)">New chat</button>' +
'<button type="button" id="agset" title="Endpoint, key and model" aria-pressed="false">⚙</button>' +
'<button type="button" id="agclose" title="Close (the chat stays)">✕</button></header>' +
'<form class="ag-set" id="agsetf" hidden autocomplete="off">' +
'<label for="agurl">Endpoint</label><input id="agurl" spellcheck="false" placeholder="https://…/v1">' +
'<label for="agkey">API key</label><div class="ag-row"><input id="agkey" type="password" autocomplete="new-password"><button type="button" id="agforget" title="Forget the saved key">Forget</button></div>' +
'<label for="agmodel">Model</label><div class="ag-row"><select id="agmodel"></select><button type="button" id="agrefresh" title="Ask the endpoint for its models">↻</button></div>' +
'<span></span><label class="ag-check"><input type="checkbox" id="agthink"> Think before answering <small>(slower, plans better)</small></label>' +
'<p class="ag-fnote" id="agsetnote"></p>' +
'<div class="ag-row ag-end"><span class="ag-smsg" id="agsetmsg"></span><button type="submit">Save</button></div></form>' +
'<div class="ag-log" id="aglog" aria-live="polite"></div>' +
'<form class="ag-in" id="agin"><textarea id="agtext" rows="2" placeholder="Tell the agent what to change… (Enter sends, Shift+Enter for a new line)"></textarea>' +
'<button type="submit" id="agsend" title="Send (Enter)">Send</button></form>' +
'<span class="ag-grip" id="aggrip" title="Drag to resize"></span>';
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 });
})();