Deploy web Studio / deploy (push) Successful in 3m16s
A 401/403 with an HTML error page (nginx) showed as "HTTP 401: 401 Authorization Required 401 Authorization Required nginx". Now: "The endpoint wants an API key: enter it in ⚙" before one is set, "The endpoint refused the API key (...)" after.
638 lines
36 KiB
JavaScript
638 lines
36 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. Most inference servers refuse calls
|
|
* from web pages on other sites (CORS), so a build can name a same-site relay
|
|
* (<meta name="isokit-agent" content="/llm/v1" data-upstream="https://…/v1">):
|
|
* 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, ">").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*</.test(m)) m = ""; // an HTML error page says no more than the status
|
|
m = m.replace(/\s+/g, " ").trim();
|
|
if (r.status === 401 || r.status === 403) {
|
|
return new Error(A.cfg.has_key ? "The endpoint refused the API key (HTTP " + r.status + (m ? ": " + clip(m, 200) : "") + "). Check it in ⚙."
|
|
: "The endpoint wants an API key: enter it in ⚙ (HTTP " + r.status + ").");
|
|
}
|
|
var hint = 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." + (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 <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 });
|
|
})();
|