open-standards/wai/wai-app/web/create.js
Transaction Science 608cd843e9 wai-app: Create's crate, a library with no binary, whose export wai create writes
wai/wai-ui becomes wai/wai-app, the library `wai_app`, with no binary and no `cli`
feature: `wai`, built with its feature `app`, is its command line and its MCP
server. Its served files are named wai-app-* (the WebAssembly package wai_app),
the recordings wai-app-*.json.

- export.rs gives the files of a starter project for a program and the facts its
  project file holds (title, summary, `wai dev`'s mode for the app's room, a watch
  room's file, a show's audience); `wai create --from app.oui` writes them, the
  project file and the licence. The export no longer writes app.json, room.toml,
  relay.toml, package.json, check.mjs or serve.mjs: wai.json names the room, `wai
  dev` runs it and `wai app check` checks the project. Its README and AGENTS.md
  say so, and the README its licence.
- runtime.rs reads a Create runtime by its listing (create/wai-app-runtime.json),
  checking each file's SHA-256.
- bridge.rs keeps the bridge on 127.0.0.1 a coding agent streams a program into
  the page through, with a stream helper; the stdio server, its tools and the
  binary's test go (`wai mcp --toolset app` serves them).
- The page's Export hands over app.oui, the program in its canonical form, and the
  command that makes it a project; the ZIP writer and the page's export in
  WebAssembly go. The page names `wai mcp --toolset app` for the coding agent.
- joule-ui/docs/create.md §4 and §5: the export as `wai create --from app.oui`, and
  the agent's tools app_guide, app_check, app_stream and app_export.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:13:27 -04:00

188 lines
7.9 KiB
JavaScript

// Create: describe a WAI app, watch it build itself from WAI widgets as a model writes it,
// try its room, and take its program (app.oui) away: `wai create <dir> --from app.oui` makes
// it a starter project on the person's machine (catalogue `app.export`).
//
// The page calls no model of its own and holds no key on any server. A program comes from
// the visitor's own model (an endpoint and a key they type, called from this page alone),
// from their coding agent (`wai mcp --toolset app`, through its bridge on 127.0.0.1), or from a
// recorded stream; the page says which. Each chunk is parsed by wai-app's WebAssembly build
// (joule-ui's line language), and the snapshot, every widget of it accepted by the WAI
// registry, is rendered at once.
import { openSession, guide } from "./session.js";
import { Renderer } from "./renderer.js";
import { components, context } from "./components.js";
import { ownModel, bridge, replay } from "./model.js";
const root = document.getElementById("create");
const $ = (id) => document.getElementById(id);
const session = await openSession();
// The room service each kind of room is tried on (`data-service-live`, `-watch`, `-show`),
// else `data-service`; every one on this page's own origin.
const services = { live: root.dataset.serviceLive, watch: root.dataset.serviceWatch, show: root.dataset.serviceShow };
const ctx = context({ session, service: root.dataset.service, services, frame: root.dataset.frame });
const renderer = new Renderer($("app"), components, ctx);
const test = (window.createTest = { session, ctx, renderer, snapshots: [], last: null, source: null, done: 0, errors: [] });
const SAYS = {
none: "No model is called until you choose one. This page has no model of its own and holds no key.",
own: (u) => `Model: your own, at ${u}, called from this page with the key you typed. The key stays in this tab's memory and goes to that address alone; it is never stored.`,
agent: (u) => `Model: your coding agent, which streams the app to this page through the bridge on your machine (${u}).`,
replay: (n) => `Model: none. This replays a recorded stream (${n}).`,
};
const says = (t) => { $("model-says").textContent = t; };
function sourceNow() {
const r = document.querySelector('input[name="source"]:checked');
return r ? r.value : "replay";
}
function showSource() {
const s = sourceNow();
for (const id of ["own", "agent", "replay"]) $(id).hidden = id !== s;
says(SAYS.none);
}
for (const r of document.querySelectorAll('input[name="source"]')) r.addEventListener("change", showSource);
showSource();
// ---------------------------------------------------------------------------- the stream
let running = 0;
async function build(chunks, label) {
const run = ++running;
session.reset();
test.snapshots = [];
test.source = label;
test.errors = [];
$("export").disabled = true;
$("download").hidden = true;
$("export-status").textContent = "";
$("export-command").textContent = "";
root.dataset.state = "streaming";
try {
for await (const c of chunks) {
if (run !== running) return;
show(session.push(c));
}
if (run !== running) return;
show(session.finish());
root.dataset.state = "done";
test.done++;
} catch (e) {
root.dataset.state = "failed";
test.errors.push(String(e.message || e));
says(`The stream stopped: ${e.message || e}`);
}
}
function show(s) {
renderer.render(s);
test.last = s;
test.snapshots.push({ widgets: s.nodes.length, keys: s.nodes.map((n) => n.key), errors: s.errors.map((e) => `${e.line}:${e.code}`), chars: session.program().length, finished: s.finished });
drawProgram(s);
drawProblems(s);
const ok = s.finished && s.room && !s.errors.some((e) => !e.warning);
$("export").disabled = !ok;
}
function drawProgram(s) {
const lines = session.program().split("\n");
const bad = new Map();
for (const e of s.errors) if (e.line) bad.set(e.line, `${e.code}: ${e.message}`);
const pending = s.statements.filter((x) => !x.complete).map((x) => x.line);
const ol = $("program");
ol.replaceChildren(...lines.map((l, i) => {
const li = document.createElement("li");
li.textContent = l || " ";
li.value = i + 1;
if (bad.has(i + 1)) { li.className = "bad"; li.title = bad.get(i + 1); }
else if (pending.includes(i + 1)) li.className = "pending";
return li;
}));
ol.scrollTop = ol.scrollHeight;
}
function drawProblems(s) {
const ul = $("problems");
const shown = s.errors.length ? s.errors : [];
ul.replaceChildren(...shown.map((e) => {
const li = document.createElement("li");
li.dataset.code = e.code;
li.className = e.warning ? "warn" : "bad";
li.textContent = `${e.line ? `line ${e.line}` : "the app"}: ${e.message} (${e.code})`;
return li;
}));
if (!shown.length) {
const li = document.createElement("li");
li.className = "ok";
li.textContent = s.finished ? "None: every line was accepted." : s.tree ? "None so far." : "Waiting for the first line.";
ul.append(li);
}
}
// ---------------------------------------------------------------------------- the sources
let disconnect = null;
$("describe").addEventListener("submit", async (e) => {
e.preventDefault();
const prompt = $("prompt").value.trim();
const s = sourceNow();
if (s === "own") {
const endpoint = $("own-endpoint").value.trim();
const model = $("own-model").value.trim();
let u;
try { u = new URL(endpoint); } catch { return says("Give your model's address: the full URL of its chat/completions route."); }
says(SAYS.own(u.host));
// The key is read here, at the call, and handed to the call alone.
await build(ownModel({ endpoint, model, key: $("own-key").value, system: guide(), prompt }), "own");
} else if (s === "agent") {
connectAgent();
} else {
const name = $("recording").value;
says(SAYS.replay(name));
const rec = await (await fetch(`${root.dataset.recorded}wai-app-${name}.json`, { cache: "no-store" })).json();
$("prompt").value = rec.prompt;
await build(replay(rec, { speed: Number(root.dataset.speed || 1) }), "replay");
}
});
function connectAgent() {
if (disconnect) disconnect();
const url = $("agent-bridge").value.trim();
const code = $("agent-code").value.trim();
try {
says(SAYS.agent(new URL(url).host));
disconnect = bridge({
url, code,
onProgram: (iter) => build(iter, "agent"),
onState: (st) => { $("agent-state").textContent = st === "connected" ? "Connected: ask your agent to build the app." : st === "retrying" ? "Not connected: is `wai mcp --toolset app` running, and the code right?" : "Closed."; },
});
} catch (e) {
says(String(e.message || e));
}
}
// ---------------------------------------------------------------------------- the export
// The program in its canonical form, as a file: `wai create <dir> --from app.oui` makes the
// project of it, on the person's machine, with the one writer every WAI project has.
$("export").addEventListener("click", () => {
const status = $("export-status");
try {
const text = session.canonical();
const title = (test.last && test.last.tree && test.last.tree.props && test.last.tree.props.title && test.last.tree.props.title.value) || "wai-app";
const dir = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "wai-app";
const a = $("download");
if (a.href) URL.revokeObjectURL(a.href);
a.href = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
a.download = "app.oui";
a.textContent = `app.oui (${text.split("\n").filter(Boolean).length} lines)`;
a.hidden = false;
const command = `wai create ${dir} --from app.oui`;
$("export-command").textContent = command;
test.exported = { file: "app.oui", text, command };
status.textContent = "Then, where you saved it: run the command below, and `wai dev` in the project it makes. The project is Apache-2.0, and no key is in it.";
a.click();
} catch (e) {
status.textContent = `Not exported: ${e.message || e}`;
}
});