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]>
113 lines
4.7 KiB
JavaScript
113 lines
4.7 KiB
JavaScript
// Where a program comes from, each an async iterator of text chunks:
|
|
//
|
|
// ownModel the visitor's own model: an endpoint that streams the common
|
|
// `chat/completions` server-sent-events format (a model server on their own
|
|
// machine, or a provider they hold a key for), called from this page with
|
|
// their key. The key is held by the caller in memory and sent to that
|
|
// endpoint alone; nothing here stores it.
|
|
// bridge the visitor's coding agent, through the bridge `wai mcp --toolset app` runs on 127.0.0.1:
|
|
// each program the agent streams, read with the pairing code.
|
|
// replay a recorded stream: chunks and the pauses between them.
|
|
//
|
|
// No source calls a model of this site's own: there is none.
|
|
|
|
// Lines of a server-sent-events body, as they arrive.
|
|
async function* sseLines(body) {
|
|
const dec = new TextDecoder();
|
|
let buf = "";
|
|
const reader = body.getReader();
|
|
for (;;) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buf += dec.decode(value, { stream: true });
|
|
let nl;
|
|
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
const line = buf.slice(0, nl).replace(/\r$/, "");
|
|
buf = buf.slice(nl + 1);
|
|
yield line;
|
|
}
|
|
}
|
|
if (buf) yield buf;
|
|
}
|
|
|
|
// The text of each `data:` event of a `chat/completions` stream: `choices[0].delta.content`.
|
|
export async function* completionChunks(lines) {
|
|
for await (const line of lines) {
|
|
if (!line.startsWith("data:")) continue;
|
|
const data = line.slice(5).trim();
|
|
if (!data || data === "[DONE]") continue;
|
|
let j;
|
|
try { j = JSON.parse(data); } catch { continue; }
|
|
if (j.error) throw new Error(`the model answered: ${j.error.message || JSON.stringify(j.error)}`);
|
|
const c = j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content;
|
|
if (typeof c === "string" && c) yield c;
|
|
}
|
|
}
|
|
|
|
// The visitor's own model. `endpoint` is the full URL of its `chat/completions` route.
|
|
export async function* ownModel({ endpoint, model, key, system, prompt, signal }) {
|
|
const u = new URL(endpoint);
|
|
if (u.protocol !== "https:" && !(u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname))) {
|
|
throw new Error("the endpoint must be https, or http on this machine (127.0.0.1)");
|
|
}
|
|
const headers = { "content-type": "application/json" };
|
|
if (key) headers.authorization = `Bearer ${key}`;
|
|
const r = await fetch(u.toString(), {
|
|
method: "POST",
|
|
headers,
|
|
signal,
|
|
credentials: "omit",
|
|
referrerPolicy: "no-referrer",
|
|
cache: "no-store",
|
|
body: JSON.stringify({ model, stream: true, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
|
|
});
|
|
if (!r.ok || !r.body) throw new Error(`the model's endpoint answered ${r.status}`);
|
|
yield* completionChunks(sseLines(r.body));
|
|
}
|
|
|
|
// The coding agent's bridge: `onProgram(iterator)` is called for each program the agent
|
|
// streams. Returns a function that disconnects.
|
|
export function bridge({ url, code, onProgram, onState }) {
|
|
const u = new URL(url);
|
|
if (!["127.0.0.1", "localhost", "[::1]"].includes(u.hostname)) throw new Error("the bridge is on this machine: http://127.0.0.1:<port>");
|
|
const es = new EventSource(`${u.origin}/create/stream?code=${encodeURIComponent(code)}`);
|
|
let queue = null;
|
|
es.addEventListener("hello", () => onState && onState("connected"));
|
|
es.addEventListener("start", () => {
|
|
if (queue) queue.end();
|
|
queue = channel();
|
|
onProgram(queue.iter);
|
|
});
|
|
es.addEventListener("chunk", (e) => { if (queue) queue.put(JSON.parse(e.data).text); });
|
|
es.addEventListener("end", () => { if (queue) { queue.end(); queue = null; } });
|
|
es.onerror = () => onState && onState(es.readyState === EventSource.CLOSED ? "closed" : "retrying");
|
|
return () => { es.close(); if (queue) queue.end(); };
|
|
}
|
|
|
|
function channel() {
|
|
const items = [];
|
|
let wake = null;
|
|
let done = false;
|
|
return {
|
|
put(x) { items.push(x); if (wake) { wake(); wake = null; } },
|
|
end() { done = true; if (wake) { wake(); wake = null; } },
|
|
iter: (async function* () {
|
|
for (;;) {
|
|
if (items.length) { yield items.shift(); continue; }
|
|
if (done) return;
|
|
await new Promise((r) => { wake = r; });
|
|
}
|
|
})(),
|
|
};
|
|
}
|
|
|
|
// A recorded stream: `{ chunks: [...], pause_ms }` (or `pauses: [...]`), replayed with its
|
|
// pauses, `speed` times faster.
|
|
export async function* replay(rec, { speed = 1, signal } = {}) {
|
|
for (let i = 0; i < rec.chunks.length; i++) {
|
|
if (signal && signal.aborted) return;
|
|
const ms = rec.pauses ? rec.pauses[i] || 0 : rec.pause_ms || 0;
|
|
if (ms > 0) await new Promise((r) => setTimeout(r, ms / speed));
|
|
yield rec.chunks[i];
|
|
}
|
|
}
|