Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 236 additions & 0 deletions src/client/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
export const AGENT_JS = `/* ---------- agent stream ---------- */
async function send() {
const text = input.value.trim();
const ready = pendingImages.filter((p) => p.id && !p.uploading);
if ((!text && !ready.length) || busy || uploading > 0 || booting) return;
const images = ready.map((p) => ({ id: p.id, mimeType: p.mimeType }));
// Entries sent this turn: kept aside so a request that never reaches the
// agent can be retried without re-uploading (their R2 objects persist).
// Once the SSE stream opens, the DO owns the message (refs are persisted
// before Pi even submits), so restoring would duplicate it — only
// pre-acceptance failures restore.
const sentEntries = ready;
let accepted = false;
input.value = "";
input.style.height = "auto";
pendingImages = [];
renderPending();
busy = true; sendBtn.disabled = true;
// Shared playgrounds attribute the turn; the server stores it on the message.
const me = (ghStatus && ghStatus.user && ghStatus.user.login) || null;
addUser(text, images, me);
const thinking = addThinking();
// One agent turn runs at a time per playground: if nothing streams back
// within a few seconds we are queued behind the other member's turn, not
// stuck. Best-effort who/position from the sharing status.
let firstEvent = false;
const queueTimer = setTimeout(async () => {
if (firstEvent || !thinking.isConnected) return;
let who = "the other member's";
try {
const st = await apiFetch("/api/share/status", { cache: "no-store" }).then((r) => r.json());
Comment thread
tpaulshippy marked this conversation as resolved.
Resolved
if (st && st.turn && st.turn.active) {
who = st.turn.actor ? (st.turn.actor + "'s") : who;
if (typeof st.turn.queued === "number" && st.turn.queued > 0) {
thinking.textContent = "queued behind " + who + " turn (" + st.turn.queued + " waiting)…";
return;
}
}
} catch { /* best-effort label only */ }
thinking.textContent = "queued behind " + who + " turn…";
}, 3000);

let settled = false;
// Open narration segments by Pi content index. Deltas append to their
// segment; a whole block replaces the segment with the same index when
// it extends it (grown block), commits what is open and starts a fresh
// bubble when the index is new (consecutive distinct paragraphs) or
// when the same index carries non-extending text (retry rewrite,
// redaction). Distinct text never overwrites visible text: the live
// transcript keeps everything the restored snapshot will show.
const segTexts = new Map();
const segNodes = new Map();
const segOrder = [];
const previewText = () => segOrder.map((i) => segTexts.get(i) || "").join("");
const showSeg = (i) => {
if (thinking.isConnected) thinking.textContent = previewText().slice(0, 400);
else {
let node = segNodes.get(i);
if (!node) { node = el("div", "msg agent", ""); log.appendChild(node); segNodes.set(i, node); }
node.innerHTML = markdownToHtml(segTexts.get(i));
scroll();
}
};
const commitSegs = () => {
if (thinking.isConnected) thinking.remove();
const order = [...segOrder].sort((a, b) => a - b);
let any = false;
for (const i of order) {
const t = segTexts.get(i);
if (!t) continue;
any = true;
if (!segNodes.get(i)) {
const bubble = el("div", "msg agent", "");
bubble.innerHTML = markdownToHtml(t);
log.appendChild(bubble);
} else {
segNodes.get(i).innerHTML = markdownToHtml(t);
}
}
if (any) log.appendChild(el("div", "model", "✳ " + getGateway() + "/" + modelSel.value));
segOrder.length = 0; segTexts.clear(); segNodes.clear();
scroll();
};
const flush = () => commitSegs();

const apiKey = readApiKey();
if (apiKey) localStorage.setItem("ab_key", apiKey);
Comment thread
tpaulshippy marked this conversation as resolved.
Resolved
try {
const res = await apiFetch("/api/chat", {
method: "POST",
headers: { "content-type": "application/json", ...(apiKey ? { "x-provider-key": apiKey } : {}) },
body: JSON.stringify({
message: text || "(see attached images)",
gateway: getGateway(),
model: modelSel.value,
...(images.length ? { images } : {}),
}),
});
if (res.status === 401) {
const body = await res.json().catch(() => ({}));
// The gate flip already happened inside apiFetch; the key-missing 401
// keeps the chat visible with the key prompt.
throw new Error(body.error || "unauthorized: set the API key below");
}
if (!res.ok || !res.body) throw new Error("chat request failed: " + res.status);
// The stream is open, so the DO owns the turn (its refs were persisted
// before Pi submission): from here on, failures must not restore the
// sent entries, or a retry would duplicate the recorded message — and
// removing a restored thumb could delete bytes history still needs.
accepted = true;
Comment thread
tpaulshippy marked this conversation as resolved.
Resolved

const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
const drain = (final) => {
const frames = buf.split("\\n\\n");
buf = final ? "" : (frames.pop() || "");
for (const frame of frames) {
const line = frame.split("\\n").find((l) => l.startsWith("data: "));
if (!line) continue;
const ev = JSON.parse(line.slice(6));
if (!firstEvent) {
firstEvent = true;
clearTimeout(queueTimer);
}
if (ev.type === "text") {
const i = ev.index || 0;
if (!segTexts.has(i)) segOrder.push(i);
segTexts.set(i, (segTexts.get(i) || "") + ev.text);
showSeg(i);
} else if (ev.type === "text_block") {
if (ev.text) {
const i = ev.index || 0;
const cur = segTexts.get(i);
if (cur === undefined) {
commitSegs();
segOrder.push(i);
segTexts.set(i, ev.text);
} else if (ev.text !== cur && ev.text.indexOf(cur) !== 0) {
// Same index but not a grown block (retry rewrite, redaction):
// keep the open text as its own bubble instead of overwriting
// it, so a revision never deletes visible narration.
commitSegs();
segOrder.push(i);
segTexts.set(i, ev.text);
} else {
segTexts.set(i, ev.text);
}
showSeg(i);
}
} else if (ev.type === "tool") {
// Flush any pending narration first: the model often sends text
// ("building your redesign now") ahead of its tool calls in the
// same round, and dropping it makes the transcript look stuck.
commitSegs();
addTool(ev.name, ev.detail);
} else if (ev.type === "tool_result") {
// The tool line already showed the action. Only surface a result
// when it failed, otherwise the transcript is just file dumps.
if (!ev.ok) addTool(ev.name, ev.detail, false);
} else if (ev.type === "build") {
built = ev.result;
if (ev.result.files) files = ev.result.files;
setMetrics(built);
renderPane();
} else if (ev.type === "done") {
settled = true;
files = ev.files;
setMetrics(built);
renderPane();
} else if (ev.type === "error") {
settled = true;
flush();
addError(ev.message);
}
}
};
// A failure inside the frame handlers must not silently drop narration
// that already streamed: flush the open segments before the error
// reaches the turn-level handler. The one exception is a partial tail
// frame when the stream closes (complete-but-unterminated final frames
// are recovered above; a truly partial one cannot parse and is skipped,
// with the persisted transcript as its backstop).
const drainGuarded = (final) => {
try {
drain(final);
} catch (e) {
if (final && e instanceof SyntaxError) return;
try { commitSegs(); } catch { /* never mask the original failure */ }
throw e;
}
};
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
drainGuarded(false);
}
drainGuarded(true);
flush();
// A killed proxy/Worker request closes the stream with no done/error,
// which previously looked stuck after the last edit. Progress is saved
// every round, so a retry continues cleanly.
if (!settled) addError("interrupted before finishing — progress is saved, retry your message");
} catch (e) {
if (thinking.isConnected) thinking.remove();
addError(e.message ?? String(e));
} finally {
clearTimeout(queueTimer);
if (!accepted && sentEntries.length) {
// The request never reached the agent, so nothing was recorded:
// give the attachments back for a retry (plus anything attached
// meanwhile), capped so the next send is not rejected outright.
const restored = [...sentEntries, ...pendingImages];
if (restored.length > 4) {
addError("kept the first 4 attachments; remove some before retrying");
}
pendingImages = restored.slice(0, 4);
renderPending();
}
busy = false; sendBtn.disabled = false;
input.focus();
}
}

sendBtn.onclick = send;
input.addEventListener("keydown", (e) => {
// Enter inserts a newline for multiline prompts; ⌘/Ctrl+Enter sends.
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); }
});
input.addEventListener("input", () => {
input.style.height = "auto";
input.style.height = Math.min(140, input.scrollHeight) + "px";
});

`;
142 changes: 142 additions & 0 deletions src/client/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
export const ATTACH_JS = `/* ---------- image attachments (paste / attach, stored in R2) ---------- */
const attachBtn = document.getElementById("attach");
const filePick = document.getElementById("filepick");
const pendingBox = document.getElementById("pending");
const box = document.getElementById("box");
let pendingImages = [];
let uploading = 0;
function renderPending() {
if (!pendingBox) return;
pendingBox.innerHTML = "";
pendingBox.hidden = !pendingImages.length;
for (const p of pendingImages) {
const t = el("div", "thumb" + (p.uploading ? " uploading" : ""));
if (p.preview) {
const img = document.createElement("img");
img.src = p.preview;
img.alt = "attached image";
t.appendChild(img);
} else {
t.appendChild(el("div", "thumbwait", "…"));
}
const x = el("button", null, "×");
x.title = "Remove attachment";
x.setAttribute("aria-label", "Remove attachment");
x.onclick = () => {
pendingImages = pendingImages.filter((q) => q !== p);
// The bytes were already persisted on attach: delete the R2 object so
// removed attachments do not linger as orphans. Best-effort — a
// failure keeps the (harmless, unreferenced) bytes, never the UI.
if (p.id) {
apiFetch("/api/images/" + encodeURIComponent(p.id), { method: "DELETE" }).catch(() => {});
}
renderPending();
syncSend();
};
t.appendChild(x);
pendingBox.appendChild(t);
}
}
function syncSend() {
sendBtn.disabled = busy || uploading > 0 || booting;
}
/** Read a File as a data URL (for upload + instant thumbnail). */
function readAsDataUrl(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result || ""));
r.onerror = () => reject(new Error("could not read " + (file.name || "image")));
r.readAsDataURL(file);
});
}
/** Upload data URLs: at most 4 total, images only, 5 MiB each (server enforces). */
async function attachFiles(files) {
const list = [...(files || [])].filter((f) => f && /^image\\//.test(f.type || ""));
if (!list.length) return;
const valid = [];
for (const file of list) {
if (file.size > 5 * 1024 * 1024) {
addError("image too large (max 5 MiB): " + (file.name || "pasted image"));
continue;
}
valid.push(file);
}
if (!valid.length) return;
// Reserve slots synchronously: concurrent paste/drop/pick events must not
// all pass the cap check before any of them records its entries.
const slots = 4 - pendingImages.length;
if (slots <= 0) {
addError("at most 4 images per message");
return;
}
const take = valid.slice(0, slots);
if (valid.length > slots) addError("at most 4 images per message (extras ignored)");
uploading += take.length;
syncSend();
const entries = take.map((file) => {
const entry = { preview: "", uploading: true, id: null, mimeType: file.type || "image/png", file };
pendingImages.push(entry);
return entry;
});
renderPending();
await Promise.all(
entries.map(async (entry) => {
try {
const preview = await readAsDataUrl(entry.file);
entry.preview = preview;
renderPending();
const r = await apiFetch("/api/images", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ data: preview, mimeType: entry.mimeType }),
}).then((r) => r.json());
if (r.error) throw new Error(r.error);
entry.id = r.id;
Comment thread
tpaulshippy marked this conversation as resolved.
Resolved
entry.mimeType = r.mimeType || entry.mimeType;
entry.uploading = false;
} catch (e) {
pendingImages = pendingImages.filter((q) => q !== entry);
addError("upload failed: " + (e.message ?? e));
} finally {
uploading--;
}
}),
);
renderPending();
syncSend();
}
if (attachBtn && filePick) {
attachBtn.onclick = () => filePick.click();
filePick.onchange = () => {
attachFiles(filePick.files).catch((e) => addError(e.message ?? String(e)));
filePick.value = "";
};
}
if (box) {
box.addEventListener("dragover", (e) => {
if ([...(e.dataTransfer?.types || [])].includes("Files")) e.preventDefault();
});
box.addEventListener("drop", (e) => {
// Claim the drop before filtering: a non-image file must not trigger the
// browser default (which navigates away from the app).
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
e.preventDefault();
const files = [...(e.dataTransfer?.files || [])].filter((f) => /^image\\//.test(f.type || ""));
if (files.length) {
attachFiles(files).catch((err) => addError(err.message ?? String(err)));
}
});
}
input.addEventListener("paste", (e) => {
const files = [...(e.clipboardData?.files || [])].filter((f) => /^image\\//.test(f.type || ""));
const items = [...(e.clipboardData?.items || [])].filter((i) => i.type.startsWith("image/"));
if (files.length || items.length) {
const picked = files.length ? files : items.map((i) => i.getAsFile()).filter(Boolean);
if (picked.length) {
e.preventDefault();
attachFiles(picked).catch((err) => addError(err.message ?? String(err)));
}
}
});

`;
Loading
Loading