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
607 lines
33 KiB
JavaScript
607 lines
33 KiB
JavaScript
/* isokit web Studio: the Studio GUI with the engine running in the page (WASM).
|
|
*
|
|
* Projects live in this browser (IndexedDB), or in a folder on disk the user
|
|
* opened (File System Access API, Chrome / Edge): edits are written straight
|
|
* to its files and changes made there by agents or editors show up live.
|
|
* This module answers the Studio's /api requests (window.IsoKitBackend), adds
|
|
* the project and code dialogs, and then starts the unchanged studio.js.
|
|
*/
|
|
import init, { Studio, new_project_files, engine_version } from "./isokit_wasm.js";
|
|
|
|
var Q = import.meta.url.split("?")[1] || "";
|
|
function asset(rel) { return new URL(rel + (Q ? "?" + Q : ""), import.meta.url); }
|
|
var $ = function (id) { return document.getElementById(id); };
|
|
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
function slug(s) { return String(s || "scene").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "scene"; }
|
|
function newId() { return "p-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
|
|
|
var toastT = 0;
|
|
function toast(msg, bad) {
|
|
if (window.__studio && window.__studio.toast) { window.__studio.toast(msg, bad); return; }
|
|
var t = $("toast");
|
|
t.textContent = msg; t.hidden = false; t.classList.toggle("st-bad", !!bad);
|
|
clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, bad ? 6000 : 2600);
|
|
}
|
|
|
|
// ------------------------------------------------------------ project files
|
|
var SKIP_DIRS = { dist: 1, target: 1, node_modules: 1 };
|
|
function skipDir(name) { return name.charAt(0) === "." || SKIP_DIRS[name]; }
|
|
function projectFile(name) { return /\.(rhai|toml|json)$/.test(name); }
|
|
function keepFile(path) { return projectFile(path) || /(^|\/)README\.md$/.test(path); }
|
|
/** A file the project keeps (not an export under dist/, not in a dot folder). */
|
|
function inProject(path) { return keepFile(path) && !path.split("/").slice(0, -1).some(skipDir); }
|
|
|
|
/** Files of an import (path -> text), re-rooted at the folder that holds isokit.toml. */
|
|
function reroot(all) {
|
|
var roots = Object.keys(all).filter(function (p) { return /(^|\/)isokit\.toml$/.test(p); })
|
|
.sort(function (a, b) { return a.split("/").length - b.split("/").length; });
|
|
if (!roots.length) throw new Error("no isokit.toml found: an isokit project has one at its top");
|
|
var pre = roots[0].slice(0, roots[0].length - "isokit.toml".length), out = {};
|
|
Object.keys(all).forEach(function (p) {
|
|
if (p.indexOf(pre) !== 0) return;
|
|
var rel = p.slice(pre.length), parts = rel.split("/");
|
|
if (parts.slice(0, -1).some(skipDir) || !keepFile(rel)) return;
|
|
out[rel] = all[p];
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// ------------------------------------------------------------ zip (store to write, store + deflate to read)
|
|
var CRC = (function () { var t = new Uint32Array(256); for (var n = 0; n < 256; n++) { var c = n; for (var k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
|
|
function crc32(b) { var c = 0xFFFFFFFF; for (var i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 255] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; }
|
|
function zip(files, root) {
|
|
var enc = new TextEncoder(), parts = [], cd = [], off = 0, n = 0, d = new Date();
|
|
var time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
|
|
var date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
|
|
Object.keys(files).sort().forEach(function (p) {
|
|
var name = enc.encode(root + p), data = enc.encode(files[p]), crc = crc32(data);
|
|
var h = new DataView(new ArrayBuffer(30));
|
|
h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(6, 0x0800, true);
|
|
h.setUint16(10, time, true); h.setUint16(12, date, true); h.setUint32(14, crc, true);
|
|
h.setUint32(18, data.length, true); h.setUint32(22, data.length, true); h.setUint16(26, name.length, true);
|
|
parts.push(h.buffer, name, data);
|
|
var c = new DataView(new ArrayBuffer(46));
|
|
c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(8, 0x0800, true);
|
|
c.setUint16(12, time, true); c.setUint16(14, date, true); c.setUint32(16, crc, true);
|
|
c.setUint32(20, data.length, true); c.setUint32(24, data.length, true); c.setUint16(28, name.length, true);
|
|
c.setUint32(42, off, true);
|
|
cd.push(c.buffer, name);
|
|
off += 30 + name.length + data.length; n++;
|
|
});
|
|
var size = cd.reduce(function (s, x) { return s + x.byteLength; }, 0);
|
|
var e = new DataView(new ArrayBuffer(22));
|
|
e.setUint32(0, 0x06054b50, true); e.setUint16(8, n, true); e.setUint16(10, n, true); e.setUint32(12, size, true); e.setUint32(16, off, true);
|
|
return new Blob(parts.concat(cd, [e.buffer]), { type: "application/zip" });
|
|
}
|
|
async function unzip(buf) {
|
|
var v = new DataView(buf), u8 = new Uint8Array(buf), dec = new TextDecoder(), out = {};
|
|
var i = buf.byteLength - 22;
|
|
while (i >= 0 && v.getUint32(i, true) !== 0x06054b50) i--;
|
|
if (i < 0) throw new Error("not a zip file");
|
|
var n = v.getUint16(i + 10, true), p = v.getUint32(i + 16, true);
|
|
for (var k = 0; k < n; k++) {
|
|
if (v.getUint32(p, true) !== 0x02014b50) throw new Error("damaged zip file");
|
|
var method = v.getUint16(p + 10, true), csize = v.getUint32(p + 20, true), nlen = v.getUint16(p + 28, true);
|
|
var skip = nlen + v.getUint16(p + 30, true) + v.getUint16(p + 32, true), loc = v.getUint32(p + 42, true);
|
|
var name = dec.decode(u8.subarray(p + 46, p + 46 + nlen)).replace(/\\/g, "/");
|
|
p += 46 + skip;
|
|
if (/\/$/.test(name) || !keepFile(name) || csize > 8 << 20) continue;
|
|
var start = loc + 30 + v.getUint16(loc + 26, true) + v.getUint16(loc + 28, true), data = u8.subarray(start, start + csize);
|
|
if (method === 8) data = new Uint8Array(await new Response(new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer());
|
|
else if (method !== 0) continue;
|
|
out[name] = dec.decode(data);
|
|
}
|
|
return out;
|
|
}
|
|
function download(name, blob) {
|
|
var a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob); a.download = name;
|
|
document.body.appendChild(a); a.click(); a.remove();
|
|
setTimeout(function () { URL.revokeObjectURL(a.href); }, 30000);
|
|
}
|
|
|
|
// ------------------------------------------------------------ folders (File System Access) and drops
|
|
var canOpenFolder = typeof window.showDirectoryPicker === "function";
|
|
async function readFolder(dir, pre, out, stats) {
|
|
out = out || {}; stats = stats || {}; pre = pre || "";
|
|
for await (var entry of dir.values()) {
|
|
if (entry.kind === "directory") { if (!skipDir(entry.name)) await readFolder(entry, pre + entry.name + "/", out, stats); }
|
|
else if (keepFile(pre + entry.name)) {
|
|
var f = await entry.getFile();
|
|
stats[pre + entry.name] = f.size + ":" + f.lastModified;
|
|
if (!(pre + entry.name in out)) out[pre + entry.name] = await f.text();
|
|
}
|
|
}
|
|
return { files: out, stats: stats };
|
|
}
|
|
async function folderStats(dir, pre, stats, handles) {
|
|
for await (var entry of dir.values()) {
|
|
if (entry.kind === "directory") { if (!skipDir(entry.name)) await folderStats(entry, pre + entry.name + "/", stats, handles); }
|
|
else if (keepFile(pre + entry.name)) { var f = await entry.getFile(); stats[pre + entry.name] = f.size + ":" + f.lastModified; handles[pre + entry.name] = f; }
|
|
}
|
|
}
|
|
async function fileHandle(dir, path, create) {
|
|
var parts = path.split("/"), d = dir;
|
|
for (var i = 0; i < parts.length - 1; i++) d = await d.getDirectoryHandle(parts[i], { create: create });
|
|
return d.getFileHandle(parts[parts.length - 1], { create: create });
|
|
}
|
|
async function permitted(handle, ask) {
|
|
var o = { mode: "readwrite" };
|
|
if ((await handle.queryPermission(o)) === "granted") return true;
|
|
return ask ? (await handle.requestPermission(o)) === "granted" : false;
|
|
}
|
|
function readEntry(entry, path, out) {
|
|
if (entry.isFile) {
|
|
if (!keepFile(path)) return Promise.resolve();
|
|
return new Promise(function (res, rej) { entry.file(function (f) { f.text().then(function (t) { out[path] = t; res(); }, rej); }, rej); });
|
|
}
|
|
if (!entry.isDirectory || skipDir(entry.name)) return Promise.resolve();
|
|
var reader = entry.createReader(), jobs = [];
|
|
return new Promise(function (res, rej) {
|
|
(function more() {
|
|
reader.readEntries(function (list) {
|
|
if (!list.length) { Promise.all(jobs).then(res, rej); return; }
|
|
list.forEach(function (e) { jobs.push(readEntry(e, path + "/" + e.name, out)); });
|
|
more();
|
|
}, rej);
|
|
})();
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------ storage (IndexedDB)
|
|
var DB = null;
|
|
function db() {
|
|
if (!DB) DB = new Promise(function (res, rej) {
|
|
var r = indexedDB.open("isokit-studio", 1);
|
|
r.onupgradeneeded = function () { r.result.createObjectStore("projects", { keyPath: "id" }); };
|
|
r.onsuccess = function () { res(r.result); };
|
|
r.onerror = function () { rej(r.error); };
|
|
});
|
|
return DB;
|
|
}
|
|
function tx(mode, fn) {
|
|
return db().then(function (d) {
|
|
return new Promise(function (res, rej) {
|
|
var t = d.transaction("projects", mode), r = fn(t.objectStore("projects")), out;
|
|
if (r) r.onsuccess = function () { out = r.result; };
|
|
t.oncomplete = function () { res(out); };
|
|
t.onerror = t.onabort = function () { rej(t.error); };
|
|
});
|
|
});
|
|
}
|
|
var store = {
|
|
all: function () { return tx("readonly", function (s) { return s.getAll(); }); },
|
|
get: function (id) { return tx("readonly", function (s) { return s.get(id); }); },
|
|
put: function (rec) { return tx("readwrite", function (s) { return s.put(rec); }); },
|
|
del: function (id) { return tx("readwrite", function (s) { return s.delete(id); }); }
|
|
};
|
|
function titleOf(files) { var m = /^\s*title\s*=\s*"([^"]*)"/m.exec(files["isokit.toml"] || ""); return (m && m[1]) || "Untitled"; }
|
|
async function createProject(files, name) {
|
|
var rec = { id: newId(), name: name || titleOf(files), kind: "local", files: files, updated: Date.now() };
|
|
await store.put(rec);
|
|
return rec;
|
|
}
|
|
function openProject(id) { history.replaceState(null, "", "#p=" + encodeURIComponent(id)); location.reload(); }
|
|
|
|
// ------------------------------------------------------------ the open project
|
|
var P = { rec: null, files: {}, stats: {}, why: null, queue: Promise.resolve() };
|
|
/** Run storage jobs one after the other; a failed job does not block the next. */
|
|
function enqueue(job) {
|
|
var run = P.queue.then(job);
|
|
P.queue = run.catch(function (e) { console.error("isokit: storage", e); });
|
|
return run;
|
|
}
|
|
var studio = null, chan = typeof BroadcastChannel === "function" ? new BroadcastChannel("isokit-studio") : null;
|
|
function isFolder() { return P.rec && P.rec.kind === "folder"; }
|
|
|
|
/** Persist files written by the engine or the code editor (in order; null deletes). */
|
|
function persist(changes) {
|
|
var paths = Object.keys(changes);
|
|
if (!paths.length) return Promise.resolve();
|
|
return enqueue(async function () {
|
|
if (isFolder()) {
|
|
for (var p of paths) {
|
|
if (changes[p] === null) {
|
|
var parts = p.split("/"), d = P.rec.handle;
|
|
for (var i = 0; i < parts.length - 1; i++) d = await d.getDirectoryHandle(parts[i]);
|
|
await d.removeEntry(parts[parts.length - 1]).catch(function () { /* already gone */ });
|
|
delete P.files[p]; delete P.stats[p];
|
|
} else {
|
|
var h = await fileHandle(P.rec.handle, p, true), w = await h.createWritable();
|
|
await w.write(changes[p]); await w.close();
|
|
var f = await h.getFile();
|
|
if (inProject(p)) { P.files[p] = changes[p]; P.stats[p] = f.size + ":" + f.lastModified; }
|
|
}
|
|
}
|
|
} else {
|
|
var kept = {};
|
|
paths.forEach(function (p) {
|
|
if (!inProject(p)) return; // exports (dist/index.html) stay out of the browser store
|
|
if (changes[p] === null) delete P.files[p]; else P.files[p] = changes[p];
|
|
kept[p] = changes[p];
|
|
});
|
|
if (!Object.keys(kept).length) return;
|
|
P.rec.files = P.files; P.rec.updated = Date.now();
|
|
await store.put(P.rec);
|
|
if (chan) chan.postMessage({ id: P.rec.id, files: kept });
|
|
}
|
|
});
|
|
}
|
|
function flush() { return persist(JSON.parse(studio.take_written())); }
|
|
|
|
/** An edit from outside the Studio (code editor, another tab, the folder). */
|
|
function outside(changes, why) {
|
|
Object.keys(changes).forEach(function (p) {
|
|
if (changes[p] === null) delete P.files[p]; else P.files[p] = changes[p];
|
|
if (studio) studio.set_file(p, changes[p]);
|
|
});
|
|
P.why = why;
|
|
}
|
|
if (chan) chan.onmessage = function (e) {
|
|
if (P.rec && !isFolder() && e.data && e.data.id === P.rec.id) outside(e.data.files, "Changed in another tab");
|
|
};
|
|
|
|
// a folder project follows changes made on disk (agents, editors, git)
|
|
function watchFolder() {
|
|
setInterval(function () {
|
|
if (document.hidden) return;
|
|
enqueue(async function () {
|
|
var stats = {}, got = {}, changes = {}, n = 0;
|
|
try { await folderStats(P.rec.handle, "", stats, got); } catch (e) { return; }
|
|
for (var p in stats) {
|
|
if (stats[p] === P.stats[p]) continue;
|
|
var t = await got[p].text();
|
|
P.stats[p] = stats[p];
|
|
if (t !== P.files[p]) { changes[p] = t; n++; }
|
|
}
|
|
Object.keys(P.files).forEach(function (p) { if (!(p in stats) && projectFile(p)) { changes[p] = null; delete P.stats[p]; n++; } });
|
|
if (n) outside(changes, "Files changed in the folder (agent or editor)");
|
|
});
|
|
}, 1500);
|
|
}
|
|
|
|
// ------------------------------------------------------------ the backend studio.js talks to
|
|
var themeCss = null, lastVersion = null;
|
|
function J(s) { return JSON.parse(s); }
|
|
function backend() {
|
|
return {
|
|
web: true,
|
|
saveTitle: isFolder() ? "Every change is written to the folder at once" : "Every change is saved in this browser at once",
|
|
api: function (path, body) {
|
|
try { return route(path, body); } catch (e) { crashed(e); throw e; }
|
|
},
|
|
source: function (meta) {
|
|
return {
|
|
meta: meta, dev: true,
|
|
render: function (level, rot, cfg) {
|
|
try { return J(studio.render(level, rot, cfg ? JSON.stringify(cfg) : "", true)); } catch (e) { crashed(e); throw e; }
|
|
}
|
|
};
|
|
},
|
|
preview: function () {
|
|
var r = J(studio.page());
|
|
if (r.error) { toast(r.error.message || String(r.error), true); return; }
|
|
window.open(URL.createObjectURL(new Blob([r.html], { type: "text/html" })), "_blank", "noopener");
|
|
},
|
|
agentBox: function () {
|
|
var dir = isFolder() ? "<folder of " + P.rec.name + ">" : "<project folder>";
|
|
return '<div class="st-box"><h2>AI agents (MCP)</h2>' +
|
|
'<p class="st-help">The 15 isokit MCP tools run inside this page: ✦ Agent (top bar) chats with a model that uses them, browsers with WebMCP offer them to their assistant, and scripts call <b>isokit.mcp(message)</b>.</p>' +
|
|
(isFolder() ? "Desktop agents work on the same folder; their edits show up here live:" : "For desktop agents, open a folder (Projects → Open folder) or export the project, then:") +
|
|
"<code>isokit mcp " + esc(dir) + "</code></div>";
|
|
}
|
|
};
|
|
}
|
|
function route(path, body) {
|
|
var get = body === undefined;
|
|
if (get && path === "/api/meta") {
|
|
var m = J(studio.meta()), css = studio.theme_css();
|
|
if (css !== themeCss) { themeCss = css; $("iso-theme").textContent = css; }
|
|
if (!isFolder() && m.title && m.title !== P.rec.name) { P.rec.name = m.title; store.put(P.rec); }
|
|
$("wb-where").textContent = isFolder() ? "📁 " + P.rec.name : "in this browser";
|
|
return m;
|
|
}
|
|
if (get && path === "/api/doc") return J(studio.state());
|
|
if (get && path === "/api/version") {
|
|
var v = studio.version(), r = { version: v };
|
|
if (v !== lastVersion) { lastVersion = v; if (P.why) { r.why = P.why; P.why = null; } }
|
|
return r;
|
|
}
|
|
if (get && path === "/api/check") return J(studio.check());
|
|
var res;
|
|
if (path === "/api/op") res = J(studio.op(JSON.stringify(body)));
|
|
else if (path === "/api/undo") res = J(studio.undo());
|
|
else if (path === "/api/redo") res = J(studio.redo());
|
|
else if (path === "/api/build") return buildPage();
|
|
else return { error: "unknown request " + path };
|
|
lastVersion = studio.version();
|
|
return flush().then(function () { return res; });
|
|
}
|
|
function buildPage() {
|
|
if (isFolder()) { // like `isokit build`: the page goes where isokit.toml says (dist/index.html)
|
|
var out = J(studio.mcp(JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/call", params: { name: "build_html", arguments: {} } })));
|
|
var res = out.result || {};
|
|
if (res.isError) return { error: res.content[0].text };
|
|
return flush().then(function () { return { message: "Page written to the folder: " + J(res.content[0].text).path.replace(/\\/g, "/") }; });
|
|
}
|
|
var r = J(studio.page());
|
|
if (r.error) return { error: r.error.message || String(r.error) };
|
|
var name = slug(P.rec.name) + ".html";
|
|
download(name, new Blob([r.html], { type: "text/html" }));
|
|
return { message: "Page downloaded: " + name };
|
|
}
|
|
|
|
// ------------------------------------------------------------ MCP inside the page (and WebMCP)
|
|
var rpcId = 0;
|
|
function mcpCall(msg) {
|
|
var out = studio.mcp(typeof msg === "string" ? msg : JSON.stringify(msg));
|
|
P.why = "Edited by an agent (MCP)";
|
|
return flush().then(function () { return out ? J(out) : null; });
|
|
}
|
|
function callTool(name, args) {
|
|
return mcpCall({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: name, arguments: args || {} } }).then(function (r) { return r.result; });
|
|
}
|
|
function registerWebMcp() {
|
|
var mc = navigator.modelContext;
|
|
if (!mc) return 0;
|
|
var tools = J(studio.mcp(JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/list" }))).result.tools.map(function (t) {
|
|
return { name: t.name, description: "isokit: " + t.description, inputSchema: t.inputSchema, execute: function (args) { return callTool(t.name, args); } };
|
|
});
|
|
try {
|
|
if (typeof mc.provideContext === "function") mc.provideContext({ tools: tools });
|
|
else if (typeof mc.registerTool === "function") tools.forEach(function (t) { mc.registerTool(t); });
|
|
else return 0;
|
|
} catch (e) { console.warn("isokit: WebMCP registration failed", e); return 0; }
|
|
return tools.length;
|
|
}
|
|
|
|
// ------------------------------------------------------------ UI: header, projects, code
|
|
function injectUI() {
|
|
var title = $("title");
|
|
title.insertAdjacentHTML("afterend",
|
|
'<span class="wb-where" id="wb-where"></span>' +
|
|
'<button type="button" id="wb-projects" title="Open, create, import and export projects">Projects ▾</button>' +
|
|
'<button type="button" id="wb-code" title="Edit the scripts, prefabs, theme and settings">{ } Code</button>');
|
|
document.body.insertAdjacentHTML("beforeend",
|
|
'<dialog id="wb-projdlg" class="st-dialog wb-projdlg">' +
|
|
'<h3>Projects</h3>' +
|
|
'<div class="wb-cur" id="wb-cur"></div>' +
|
|
'<ul class="wb-plist" id="wb-plist"></ul>' +
|
|
'<div class="wb-new"><input id="wb-ntitle" placeholder="Title of a new project" autocomplete="off"><button type="button" id="wb-ncreate">New project</button><button type="button" id="wb-ndemo" title="A fresh copy of the demo scene">Demo copy</button></div>' +
|
|
'<div class="wb-drop" id="wb-drop">Import: drop a project folder or .zip here, or <button type="button" id="wb-izip" title="Import a project .zip into this browser">.zip…</button><button type="button" id="wb-idir" title="Copy a project folder into this browser">folder…</button>' +
|
|
(canOpenFolder ? '<span class="st-grow"></span><button type="button" id="wb-ofolder" title="Edit the files in the folder on disk directly; changes made there show up live">Open folder live…</button>' : "") + "</div>" +
|
|
'<p class="st-help">Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (<b>isokit mcp</b>), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "</p>" +
|
|
'<input type="file" id="wb-fzip" accept=".zip,application/zip" hidden><input type="file" id="wb-fdir" webkitdirectory multiple hidden>' +
|
|
'<form method="dialog"><button>Close</button></form></dialog>' +
|
|
'<dialog id="wb-codedlg" class="st-dialog wb-codedlg">' +
|
|
'<div class="wb-chead"><b>Code</b><span class="wb-cpath" id="wb-cpath"></span><span class="st-grow"></span>' +
|
|
'<button type="button" id="wb-cnew">New file</button><button type="button" id="wb-cdel">Delete file</button>' +
|
|
'<button type="button" id="wb-capply" title="Ctrl+S">Apply</button><button type="button" id="wb-cclose">Close</button></div>' +
|
|
'<div class="wb-cbody"><ul class="wb-cfiles" id="wb-cfiles"></ul><textarea id="wb-ctext" spellcheck="false" autocomplete="off" wrap="off"></textarea></div>' +
|
|
'<div class="wb-cmsg" id="wb-cmsg"></div></dialog>' +
|
|
'<div class="wb-gate" id="wb-gate" hidden></div>');
|
|
$("wb-projects").addEventListener("click", showProjects);
|
|
$("wb-code").addEventListener("click", function () { showCode(); });
|
|
wireProjects(); wireCode();
|
|
}
|
|
|
|
async function showProjects() {
|
|
var list = (await store.all()).sort(function (a, b) { return b.updated - a.updated; });
|
|
var cur = P.rec;
|
|
$("wb-cur").innerHTML = cur ? "<b>" + esc(cur.name) + "</b> <small>" + (cur.kind === "folder" ? "folder on disk" : "in this browser") + "</small>" +
|
|
'<span class="st-grow"></span><button type="button" data-act="export" title="All project files as a .zip (for isokit on the desktop, git, a colleague)">Export .zip</button>' : "No project open";
|
|
$("wb-plist").innerHTML = list.map(function (r) {
|
|
var when = new Date(r.updated).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
|
|
return '<li data-id="' + esc(r.id) + '"' + (cur && r.id === cur.id ? ' aria-current="true"' : "") + "><i>" + (r.kind === "folder" ? "📁" : "▣") + "</i><span>" + esc(r.name) + "</span><small>" + esc(when) + "</small>" +
|
|
'<button type="button" data-act="open"' + (cur && r.id === cur.id ? " disabled" : "") + '>Open</button><button type="button" data-act="del" title="' + (r.kind === "folder" ? "Forget the folder (its files stay)" : "Delete from this browser") + '">' + (r.kind === "folder" ? "Forget" : "Delete") + "</button></li>";
|
|
}).join("") || '<li class="wb-empty">No projects yet.</li>';
|
|
if (!$("wb-projdlg").open) $("wb-projdlg").showModal();
|
|
}
|
|
function wireProjects() {
|
|
$("wb-projdlg").addEventListener("click", async function (e) {
|
|
var b = e.target.closest("button[data-act]"); if (!b) return;
|
|
var a = b.getAttribute("data-act"), li = b.closest("li"), id = li && li.getAttribute("data-id");
|
|
if (a === "export") exportZip();
|
|
else if (a === "open") openProject(id);
|
|
else if (a === "del") {
|
|
var r = await store.get(id);
|
|
if (!r || !confirm((r.kind === "folder" ? "Forget the folder \u201c" : "Delete \u201c") + r.name + "\u201d" + (r.kind === "folder" ? "? Its files stay on disk." : " from this browser? Export it first to keep a copy."))) return;
|
|
await store.del(id);
|
|
if (P.rec && id === P.rec.id) { localStorage.removeItem("isokit-web-last"); location.hash = ""; location.reload(); return; }
|
|
showProjects();
|
|
}
|
|
});
|
|
$("wb-ncreate").addEventListener("click", async function () {
|
|
var t = $("wb-ntitle").value.trim() || "New site";
|
|
openProject((await createProject(J(new_project_files(t)), t)).id);
|
|
});
|
|
$("wb-ntitle").addEventListener("keydown", function (e) { if (e.key === "Enter") $("wb-ncreate").click(); });
|
|
$("wb-ndemo").addEventListener("click", async function () { openProject((await createProject(await demoFiles())).id); });
|
|
$("wb-izip").addEventListener("click", function () { $("wb-fzip").click(); });
|
|
$("wb-idir").addEventListener("click", function () { $("wb-fdir").click(); });
|
|
$("wb-fzip").addEventListener("change", function () { var f = this.files[0]; if (f) importZip(f); this.value = ""; });
|
|
$("wb-fdir").addEventListener("change", async function () {
|
|
var all = {}, list = Array.prototype.slice.call(this.files);
|
|
for (var f of list) { var p = f.webkitRelativePath || f.name; if (inProject(p)) all[p] = await f.text(); }
|
|
this.value = "";
|
|
importFiles(all);
|
|
});
|
|
if (canOpenFolder) $("wb-ofolder").addEventListener("click", openFolder);
|
|
var drop = $("wb-drop");
|
|
["dragenter", "dragover"].forEach(function (k) { drop.addEventListener(k, function (e) { e.preventDefault(); drop.classList.add("wb-over"); }); });
|
|
drop.addEventListener("dragleave", function () { drop.classList.remove("wb-over"); });
|
|
drop.addEventListener("drop", async function (e) {
|
|
e.preventDefault(); drop.classList.remove("wb-over");
|
|
var items = Array.prototype.slice.call(e.dataTransfer.items || []), files = Array.prototype.slice.call(e.dataTransfer.files || []);
|
|
var zipFile = files.filter(function (f) { return /\.zip$/i.test(f.name); })[0];
|
|
if (zipFile) { importZip(zipFile); return; }
|
|
var all = {}, jobs = [];
|
|
items.forEach(function (it) { var en = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (en) jobs.push(readEntry(en, en.name, all)); });
|
|
try { await Promise.all(jobs); importFiles(all); } catch (err) { toast("Could not read the drop: " + err, true); }
|
|
});
|
|
}
|
|
async function importFiles(all) {
|
|
try {
|
|
var files = reroot(all);
|
|
new Studio("check", JSON.stringify(files)).free();
|
|
openProject((await createProject(files)).id);
|
|
} catch (e) { toast("Not imported: " + (e.message || e), true); }
|
|
}
|
|
async function importZip(f) {
|
|
try { importFiles(await unzip(await f.arrayBuffer())); } catch (e) { toast("Not imported: " + (e.message || e), true); }
|
|
}
|
|
async function openFolder() {
|
|
var dir;
|
|
try { dir = await window.showDirectoryPicker({ id: "isokit", mode: "readwrite" }); } catch (e) { return; }
|
|
var got = await readFolder(dir);
|
|
if (!got.files["isokit.toml"]) {
|
|
if (!confirm("\u201c" + dir.name + "\u201d has no isokit.toml. Start a new project in it?")) return;
|
|
var fresh = J(new_project_files(dir.name));
|
|
for (var p in fresh) { var h = await fileHandle(dir, p, true), w = await h.createWritable(); await w.write(fresh[p]); await w.close(); }
|
|
}
|
|
var rec = { id: newId(), name: dir.name, kind: "folder", handle: dir, updated: Date.now() };
|
|
await store.put(rec);
|
|
openProject(rec.id);
|
|
}
|
|
function exportZip() {
|
|
var name = slug(P.rec.name);
|
|
download(name + ".zip", zip(P.files, name + "/"));
|
|
toast("Exported " + name + ".zip");
|
|
}
|
|
async function demoFiles() { return (await fetch(asset("demo.json"))).json(); }
|
|
|
|
// the code editor: the project's files, applied as one outside edit
|
|
var C = { path: null, dirty: false };
|
|
function codeFiles() { return Object.keys(P.files).sort(function (a, b) { return (a.indexOf("/") >= 0) - (b.indexOf("/") >= 0) || a.localeCompare(b); }); }
|
|
function showCode(path) {
|
|
var files = codeFiles();
|
|
path = path || C.path || (files.indexOf("scene.rhai") >= 0 ? "scene.rhai" : "isokit.toml");
|
|
if (!(path in P.files)) path = files[0];
|
|
C.path = path; C.dirty = false;
|
|
$("wb-cfiles").innerHTML = files.map(function (p) { return '<li data-path="' + esc(p) + '" aria-selected="' + (p === path) + '">' + esc(p) + "</li>"; }).join("");
|
|
$("wb-cpath").textContent = path || "";
|
|
$("wb-ctext").value = path ? P.files[path] : "";
|
|
$("wb-capply").disabled = true;
|
|
codeMsg();
|
|
if (!$("wb-codedlg").open) $("wb-codedlg").showModal();
|
|
$("wb-ctext").focus();
|
|
}
|
|
/** Errors of the loaded project (red), else `note`. */
|
|
function codeMsg(note) {
|
|
var m = $("wb-cmsg"), errs = [];
|
|
if (studio) {
|
|
var meta = J(studio.meta());
|
|
if (meta.load_error) errs.push(meta.load_error);
|
|
var e = meta.script_error;
|
|
if (e) errs.push((e.file || "script") + (e.line ? ":" + e.line : "") + (e.col ? ":" + e.col : "") + ": " + e.message);
|
|
}
|
|
m.textContent = errs.length ? errs.join("\n") : note || "Scripts run when you apply (Ctrl+S). Changes can be undone in the Studio.";
|
|
m.classList.toggle("wb-bad", errs.length > 0);
|
|
}
|
|
function leaveCode() { return !C.dirty || confirm("Discard the changes to " + C.path + "?"); }
|
|
async function applyCode() {
|
|
if (!C.path) return;
|
|
var ch = {}; ch[C.path] = $("wb-ctext").value;
|
|
outside(ch, "Code applied");
|
|
await persist(ch);
|
|
C.dirty = false; $("wb-capply").disabled = true;
|
|
if (!studio) { tryStart(); return; }
|
|
codeMsg("Applied: " + C.path);
|
|
}
|
|
function wireCode() {
|
|
var ta = $("wb-ctext");
|
|
ta.addEventListener("input", function () { C.dirty = true; $("wb-capply").disabled = false; });
|
|
ta.addEventListener("keydown", function (e) {
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S" || e.key === "Enter")) { e.preventDefault(); applyCode(); return; }
|
|
if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); ta.setRangeText(" ", ta.selectionStart, ta.selectionEnd, "end"); C.dirty = true; $("wb-capply").disabled = false; }
|
|
e.stopPropagation(); // the Studio's keys (Delete, arrows, R) must not fire while typing
|
|
});
|
|
$("wb-cfiles").addEventListener("click", function (e) { var li = e.target.closest("li"); if (li && leaveCode()) showCode(li.getAttribute("data-path")); });
|
|
$("wb-capply").addEventListener("click", applyCode);
|
|
$("wb-cclose").addEventListener("click", function () { if (leaveCode()) $("wb-codedlg").close(); });
|
|
$("wb-codedlg").addEventListener("cancel", function (e) { if (!leaveCode()) e.preventDefault(); });
|
|
$("wb-cnew").addEventListener("click", async function () {
|
|
if (!leaveCode()) return;
|
|
var p = (prompt("New file (relative path), e.g. lib/kiosk.rhai", "lib/new.rhai") || "").trim().replace(/\\/g, "/");
|
|
if (!p) return;
|
|
if (!projectFile(p) || /(^|\/)\.\.(\/|$)/.test(p) || p.charAt(0) === "/") { toast("Use a relative path ending in .rhai, .toml or .json", true); return; }
|
|
if (p in P.files) { showCode(p); return; }
|
|
var body = /\.rhai$/.test(p) ? "// " + p + ": list it in isokit.toml under prelude to load its prefabs\n" : "";
|
|
var ch = {}; ch[p] = body; outside(ch, "File added: " + p); await persist(ch);
|
|
showCode(p);
|
|
});
|
|
$("wb-cdel").addEventListener("click", async function () {
|
|
if (!C.path || C.path === "isokit.toml") { toast("isokit.toml is the project itself and stays", true); return; }
|
|
if (!confirm("Delete " + C.path + "?")) return;
|
|
var ch = {}; ch[C.path] = null; outside(ch, "File deleted: " + C.path); await persist(ch);
|
|
C.path = null; showCode();
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------ start
|
|
function gate(html) { var g = $("wb-gate"); g.innerHTML = '<div class="wb-gatebox">' + html + "</div>"; g.hidden = false; return g; }
|
|
function tryStart() {
|
|
try { studio = new Studio(isFolder() ? P.rec.name : "browser: " + P.rec.name, JSON.stringify(P.files)); }
|
|
catch (e) {
|
|
var g = gate("<h3>" + esc(P.rec.name) + " does not load</h3><pre>" + esc(String(e)) + "</pre>" +
|
|
'<p class="st-help">Fix the file in the code editor, or open another project.</p>' +
|
|
'<button type="button" id="wb-gcode">Edit the files</button> <button type="button" id="wb-gproj">Projects</button>');
|
|
g.querySelector("#wb-gcode").onclick = function () { showCode("isokit.toml"); };
|
|
g.querySelector("#wb-gproj").onclick = showProjects;
|
|
return;
|
|
}
|
|
$("wb-gate").hidden = true;
|
|
window.IsoKitBackend = backend();
|
|
window.isokit = {
|
|
engine: engine_version(), project: P.rec.name, studio: studio,
|
|
files: function () { return Object.assign({}, P.files); },
|
|
mcp: mcpCall, call: callTool, webmcp: registerWebMcp()
|
|
};
|
|
var s = document.createElement("script");
|
|
s.src = asset("../studio/studio.js").href;
|
|
document.body.appendChild(s);
|
|
}
|
|
async function boot() {
|
|
injectUI();
|
|
await init({ module_or_path: asset("isokit_wasm_bg.wasm") });
|
|
var m = /[#&]p=([^&]+)/.exec(location.hash), id = m ? decodeURIComponent(m[1]) : localStorage.getItem("isokit-web-last");
|
|
var rec = id ? await store.get(id) : null;
|
|
if (!rec) {
|
|
var all = await store.all();
|
|
rec = all.sort(function (a, b) { return b.updated - a.updated; })[0] || await createProject(await demoFiles());
|
|
}
|
|
P.rec = rec;
|
|
history.replaceState(null, "", "#p=" + encodeURIComponent(rec.id));
|
|
localStorage.setItem("isokit-web-last", rec.id);
|
|
window.addEventListener("hashchange", function () { location.reload(); });
|
|
if (isFolder()) {
|
|
if (!(await permitted(rec.handle, false))) {
|
|
var g = gate("<h3>Open the folder \u201c" + esc(rec.name) + "\u201d</h3><p>The browser asks once per visit before the Studio may edit files on disk.</p>" +
|
|
'<button type="button" id="wb-gallow">Allow access</button> <button type="button" id="wb-gproj">Other project</button>');
|
|
g.querySelector("#wb-gproj").onclick = showProjects;
|
|
await new Promise(function (res) {
|
|
g.querySelector("#wb-gallow").onclick = async function () { if (await permitted(rec.handle, true)) res(); };
|
|
});
|
|
}
|
|
var got = await readFolder(rec.handle);
|
|
P.files = got.files; P.stats = got.stats;
|
|
watchFolder();
|
|
} else {
|
|
P.files = rec.files || {};
|
|
}
|
|
tryStart();
|
|
}
|
|
// an engine panic leaves the WASM instance unusable: say so instead of failing silently
|
|
function crashed(e) {
|
|
if (!(e instanceof WebAssembly.RuntimeError) && !/unsafe aliasing|unreachable/.test(String(e && e.message))) return;
|
|
var g = gate("<h3>The engine stopped</h3><pre>" + esc(e.message || e) + "</pre><p class=\"st-help\">Your last saved state is kept. Reload to continue; the browser console has the details.</p>" +
|
|
'<button type="button" id="wb-greload">Reload</button>');
|
|
g.querySelector("#wb-greload").onclick = function () { location.reload(); };
|
|
}
|
|
window.addEventListener("error", function (e) { crashed(e.error); });
|
|
window.addEventListener("unhandledrejection", function (e) { crashed(e.reason); });
|
|
boot().catch(function (e) {
|
|
console.error(e);
|
|
gate("<h3>The web Studio could not start</h3><pre>" + esc(e && e.stack || e) + "</pre><p class=\"st-help\">It needs a current browser (WebAssembly, ES modules, IndexedDB).</p>");
|
|
});
|