studio: agent window, a chat with a model that edits the scene
Deploy web Studio / deploy (push) Successful in 3m3s
Deploy web Studio / deploy (push) Successful in 3m3s
✦ 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
This commit is contained in:
1 parent
c647f39059
commit
1a1b62d475
21 files changed
+1690
-25
No files matched your search
+609
@@ -0,0 +1,609 @@
|
||||
/* 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, "&").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 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 });
|
||||
})();
|
||||
Reference in new issue
Block a user