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]>
188 lines
7.9 KiB
JavaScript
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}`;
|
|
}
|
|
});
|