spatialagents 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,933 @@
1
+ /**
2
+ * chat.js -- the shared AI chat page (spatialagents.chat), lifted from
3
+ * Plexora's chat panel (client/src/js/views/chatPanel.js) as a full page.
4
+ *
5
+ * It speaks the `ChatApi` wire (spatialagents/chat/http.py):
6
+ *
7
+ * - POST <base>/conversations starts one; a refusal carrying a `license`
8
+ * block goes to the host's licence hook instead of a line;
9
+ * - POST <base>/conversations/<id>/messages sends the person's turn;
10
+ * - GET <base>/conversations/<id>/events streams events (server-sent
11
+ * events, resuming after the last id on a reconnect), and falls back to a
12
+ * held poll (?after=&wait=20) when the stream errors -- a proxy that
13
+ * buffers it, say;
14
+ * - POST .../control (Stop), .../approve (Approve / Deny), .../undo (Undo,
15
+ * when the host offers it);
16
+ * - and, when the host runs plans, <base>/plans and <base>/plans/<id>/...:
17
+ * compose a plan for a goal, answer its decisions, confirm and run it,
18
+ * summarise it.
19
+ *
20
+ * The five hooks a host sets (window.SPATIALAGENTS_CHAT, or the page's
21
+ * settings block, or the script tag's data- attributes):
22
+ *
23
+ * base where the API is mounted ("api", "ai/v1")
24
+ * title the page's title, or a function returning a conversation title
25
+ * onLicenseRefused called with the `license` block of a 403 (default: a line saying so)
26
+ * undo whether to offer Undo on a reversible write
27
+ * globalName the window property this registers as ("SpatialAgentsChat")
28
+ *
29
+ * What the person sees: their words with thumbnails of what they attached;
30
+ * the answer as it streams, then rendered as Markdown -- headings, lists,
31
+ * tables, code, emphasis, links to http(s) only -- built with
32
+ * createElement/textContent, so nothing a model or a tool says is ever parsed
33
+ * as markup; one chip per tool call (cached, declined, error, Undo) with the
34
+ * figures a tool returned shown inline; approval cards; sub-agents' lines; a
35
+ * pause for credit; the credit meter. The plan panel shows a plan's steps
36
+ * (function, rationale badged by who wrote it, status), its claim ceiling,
37
+ * unit of replication and n per arm, its decisions as cards the person
38
+ * answers, the actions open now, and the report.
39
+ *
40
+ * Classic script, no build step.
41
+ */
42
+ (function () {
43
+ "use strict";
44
+
45
+ const POLL_WAIT_S = 20;
46
+ const MAX_IMAGES = 4;
47
+ const INPUT_MAX_PX = 200;
48
+ const STICK_PX = 48;
49
+ const AUTHORS = { agent: "model", model: "model", template: "template", user: "you", rule: "rule" };
50
+ const KNOWN = ["disclosure", "user_message", "turn_started", "text_delta", "text", "tool_call", "tool_result",
51
+ "approval_requested", "approval_decided", "usage", "done", "error", "paused", "stopped",
52
+ "compacted", "agent_started", "agent_finished", "control", "undone", "turn_finished", "idle"];
53
+ const PLAN_EVENTS = ["composed", "problems", "decision_needed", "answered", "phase", "step", "run_finished",
54
+ "report", "usage", "paused", "failed", "stopped", "stop_requested", "error"];
55
+
56
+ // -- settings -----------------------------------------------------------------------
57
+
58
+ function settings() {
59
+ let fromPage = {};
60
+ try {
61
+ const block = document.getElementById("spatialagents-settings");
62
+ if (block) fromPage = JSON.parse(block.textContent || "{}") || {};
63
+ } catch (_) { fromPage = {}; }
64
+ const tag = document.currentScript || document.querySelector("script[data-global]");
65
+ const fromTag = tag ? { globalName: tag.dataset.global, base: tag.dataset.base } : {};
66
+ const fromHost = (typeof window.SPATIALAGENTS_CHAT === "object" && window.SPATIALAGENTS_CHAT) || {};
67
+ const out = { base: "api", title: "AI assistant", assistant: "the assistant", undo: false, plans: false,
68
+ globalName: "SpatialAgentsChat", dataset: "", storage: "spatialagents.chat",
69
+ mount: "spatialagents-chat", onLicenseRefused: null };
70
+ for (const source of [fromPage, fromTag, fromHost]) {
71
+ for (const key of Object.keys(source)) {
72
+ if (source[key] !== undefined && source[key] !== null && source[key] !== "") out[key] = source[key];
73
+ }
74
+ }
75
+ out.base = String(out.base).replace(/^\/+|\/+$/g, "");
76
+ return out;
77
+ }
78
+
79
+ const config = settings();
80
+
81
+ const state = {
82
+ root: null, log: null, input: null, send: null, stop: null, meter: null, thumbs: null, planBox: null,
83
+ conversation: null, cursor: 0, source: null, polling: false, streamingNode: null, attached: [],
84
+ busy: false, credits: 0, echoes: 0, approvals: {},
85
+ plan: null, planCursor: 0, planSource: null, planPolling: false, planStatus: null, report: null,
86
+ };
87
+
88
+ function url(path) {
89
+ return (config.base ? config.base + "/" : "") + path;
90
+ }
91
+
92
+ // -- the DOM, safely ------------------------------------------------------------------
93
+
94
+ function el(tag, className, text) {
95
+ const node = document.createElement(tag);
96
+ if (className) node.className = className;
97
+ if (text !== undefined && text !== null) node.textContent = String(text);
98
+ return node;
99
+ }
100
+
101
+ function button(label, className, onClick) {
102
+ const node = el("button", className, label);
103
+ node.type = "button";
104
+ node.addEventListener("click", onClick);
105
+ return node;
106
+ }
107
+
108
+ async function post(path, body) {
109
+ try {
110
+ const response = await fetch(url(path), {
111
+ method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin",
112
+ body: JSON.stringify(body || {}),
113
+ });
114
+ let payload = {};
115
+ try { payload = await response.json(); } catch (_) { payload = {}; }
116
+ return { status: response.status, body: payload };
117
+ } catch (error) {
118
+ return { status: 0, body: { ok: false, error: { message: "The page could not reach the server." } } };
119
+ }
120
+ }
121
+
122
+ async function get(path) {
123
+ try {
124
+ const response = await fetch(url(path), { credentials: "same-origin" });
125
+ let payload = {};
126
+ try { payload = await response.json(); } catch (_) { payload = {}; }
127
+ return { status: response.status, body: payload };
128
+ } catch (_) {
129
+ return { status: 0, body: { ok: false } };
130
+ }
131
+ }
132
+
133
+ function errorText(body, fallback) {
134
+ const error = body && body.error;
135
+ if (error && typeof error === "object") return error.message || error.code || fallback;
136
+ return error || fallback;
137
+ }
138
+
139
+ // -- Markdown, as DOM ---------------------------------------------------------------------
140
+
141
+ const INLINE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*\s][^*]*\*)|(_[^_\s][^_]*_)|(\[[^\]]+\]\([^)\s]+\))/;
142
+
143
+ /** `text` with `code`, **bold**, *italic* and [links](https://...) as nodes. */
144
+ function inline(parent, text) {
145
+ let rest = String(text || "");
146
+ while (rest) {
147
+ const found = INLINE.exec(rest);
148
+ if (!found) {
149
+ parent.appendChild(document.createTextNode(rest));
150
+ break;
151
+ }
152
+ if (found.index) parent.appendChild(document.createTextNode(rest.slice(0, found.index)));
153
+ const token = found[0];
154
+ if (found[1]) {
155
+ parent.appendChild(el("code", "", token.slice(1, -1)));
156
+ } else if (found[2] || found[3]) {
157
+ inline(parent.appendChild(el("strong")), token.slice(2, -2));
158
+ } else if (found[4] || found[5]) {
159
+ inline(parent.appendChild(el("em")), token.slice(1, -1));
160
+ } else {
161
+ const label = token.slice(1, token.indexOf("]("));
162
+ const href = token.slice(token.indexOf("](") + 2, -1);
163
+ if (/^https?:\/\//i.test(href)) {
164
+ const link = el("a", "", label);
165
+ link.href = href;
166
+ link.target = "_blank";
167
+ link.rel = "noopener noreferrer";
168
+ parent.appendChild(link);
169
+ } else {
170
+ parent.appendChild(document.createTextNode(label));
171
+ }
172
+ }
173
+ rest = rest.slice(found.index + token.length);
174
+ }
175
+ return parent;
176
+ }
177
+
178
+ function cells(row) {
179
+ return row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
180
+ }
181
+
182
+ /** Markdown into `parent`: headings, lists, tables, fenced code, quotes, paragraphs. */
183
+ function markdown(parent, text) {
184
+ parent.textContent = "";
185
+ const lines = String(text || "").replace(/\r\n?/g, "\n").split("\n");
186
+ let i = 0;
187
+ while (i < lines.length) {
188
+ const line = lines[i];
189
+ if (/^\s*```/.test(line)) {
190
+ const body = [];
191
+ i += 1;
192
+ while (i < lines.length && !/^\s*```/.test(lines[i])) body.push(lines[i++]);
193
+ i += 1;
194
+ parent.appendChild(el("pre", "sa-code")).appendChild(el("code", "", body.join("\n")));
195
+ continue;
196
+ }
197
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
198
+ if (heading) {
199
+ const level = Math.min(6, heading[1].length + 2);
200
+ inline(parent.appendChild(el("h" + level, "sa-md-h")), heading[2]);
201
+ i += 1;
202
+ continue;
203
+ }
204
+ if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1])) {
205
+ const table = parent.appendChild(el("table", "sa-md-table"));
206
+ const head = table.appendChild(el("thead")).appendChild(el("tr"));
207
+ for (const cell of cells(line)) inline(head.appendChild(el("th")), cell);
208
+ const body = table.appendChild(el("tbody"));
209
+ i += 2;
210
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
211
+ const row = body.appendChild(el("tr"));
212
+ for (const cell of cells(lines[i])) inline(row.appendChild(el("td")), cell);
213
+ i += 1;
214
+ }
215
+ continue;
216
+ }
217
+ const bullet = /^\s*([-*+]|\d+[.)])\s+(.*)$/.exec(line);
218
+ if (bullet) {
219
+ const ordered = /\d/.test(bullet[1]);
220
+ const list = parent.appendChild(el(ordered ? "ol" : "ul", "sa-md-list"));
221
+ while (i < lines.length) {
222
+ const item = /^\s*([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]);
223
+ if (!item || /\d/.test(item[1]) !== ordered) break;
224
+ inline(list.appendChild(el("li")), item[2]);
225
+ i += 1;
226
+ }
227
+ continue;
228
+ }
229
+ if (/^\s*>/.test(line)) {
230
+ const quote = [];
231
+ while (i < lines.length && /^\s*>/.test(lines[i])) quote.push(lines[i++].replace(/^\s*>\s?/, ""));
232
+ inline(parent.appendChild(el("blockquote", "sa-md-quote")), quote.join(" "));
233
+ continue;
234
+ }
235
+ if (!line.trim()) {
236
+ i += 1;
237
+ continue;
238
+ }
239
+ const paragraph = [];
240
+ while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|\s*```|\s*[-*+]\s|\s*\d+[.)]\s|\s*>|\s*\|)/
241
+ .test(lines[i])) {
242
+ paragraph.push(lines[i++].trim());
243
+ }
244
+ if (!paragraph.length) paragraph.push(lines[i++].trim());
245
+ inline(parent.appendChild(el("p", "sa-md-p")), paragraph.join(" "));
246
+ }
247
+ return parent;
248
+ }
249
+
250
+ // -- the transcript ---------------------------------------------------------------------
251
+
252
+ function scroll(force) {
253
+ const log = state.log;
254
+ if (!log) return;
255
+ const away = (log.scrollHeight || 0) - (log.scrollTop || 0) - (log.clientHeight || 0);
256
+ if (force || away < STICK_PX) log.scrollTop = log.scrollHeight;
257
+ }
258
+
259
+ function line(className, text) {
260
+ const node = el("div", "sa-line " + className, text);
261
+ state.log.appendChild(node);
262
+ scroll();
263
+ return node;
264
+ }
265
+
266
+ function figure(parent, image, caption) {
267
+ const box = parent.appendChild(el("figure", "sa-figure"));
268
+ const img = box.appendChild(el("img", "sa-figure-img"));
269
+ img.src = "data:image/" + (image.format || "png") + ";base64," + image.data;
270
+ img.alt = caption || "a figure a tool returned";
271
+ if (caption) box.appendChild(el("figcaption", "", caption));
272
+ return box;
273
+ }
274
+
275
+ function credits(micro) {
276
+ return (Number(micro || 0) / 10000).toFixed(1);
277
+ }
278
+
279
+ function setBusy(busy) {
280
+ state.busy = busy;
281
+ if (state.send) state.send.disabled = busy;
282
+ if (state.stop) state.stop.hidden = !busy;
283
+ if (state.root) state.root.classList.toggle("is-busy", busy);
284
+ }
285
+
286
+ function grow() {
287
+ const input = state.input;
288
+ input.style.height = "auto";
289
+ input.style.height = Math.min(input.scrollHeight || 0, INPUT_MAX_PX) + "px";
290
+ }
291
+
292
+ function mount() {
293
+ if (state.root) return;
294
+ const host = document.getElementById(config.mount) || document.body;
295
+ const root = el("div", "sa-chat");
296
+ const header = root.appendChild(el("header", "sa-header"));
297
+ header.appendChild(el("h1", "sa-title", typeof config.title === "string" ? config.title : "AI assistant"));
298
+ state.meter = header.appendChild(el("span", "sa-meter", "0.0 credits"));
299
+ state.meter.title = "AI credits this page has used";
300
+
301
+ const body = root.appendChild(el("div", "sa-body"));
302
+ const chat = body.appendChild(el("section", "sa-conversation"));
303
+ chat.setAttribute("aria-label", "Conversation");
304
+ state.log = chat.appendChild(el("div", "sa-log"));
305
+ state.log.setAttribute("role", "log");
306
+ state.log.setAttribute("aria-live", "polite");
307
+
308
+ const dock = chat.appendChild(el("div", "sa-dock"));
309
+ state.thumbs = dock.appendChild(el("div", "sa-thumbs"));
310
+ const form = dock.appendChild(el("div", "sa-form"));
311
+ const picker = el("input", "sa-file");
312
+ picker.type = "file";
313
+ picker.accept = "image/png,image/jpeg,image/webp";
314
+ picker.multiple = true;
315
+ picker.hidden = true;
316
+ picker.addEventListener("change", () => attach(picker.files || []));
317
+ form.appendChild(button("Attach", "sa-attach", () => picker.click()));
318
+ state.input = form.appendChild(el("textarea", "sa-input"));
319
+ state.input.rows = 1;
320
+ state.input.placeholder = "Ask " + config.assistant + "...";
321
+ state.input.setAttribute("aria-label", "Message");
322
+ state.input.addEventListener("input", grow);
323
+ state.input.addEventListener("keydown", (event) => {
324
+ if (event.key === "Enter" && !event.shiftKey) {
325
+ event.preventDefault();
326
+ submit();
327
+ }
328
+ });
329
+ state.input.addEventListener("paste", (event) => {
330
+ const items = (event.clipboardData && event.clipboardData.files) || [];
331
+ if (items.length) attach(items);
332
+ });
333
+ state.stop = form.appendChild(button("Stop", "sa-stop", () => control("stop")));
334
+ state.stop.hidden = true;
335
+ state.send = form.appendChild(button("Send", "sa-send", () => submit()));
336
+ form.appendChild(picker);
337
+
338
+ if (config.plans) {
339
+ state.planBox = body.appendChild(el("aside", "sa-plan"));
340
+ state.planBox.setAttribute("aria-label", "Plan");
341
+ }
342
+ host.appendChild(root);
343
+ state.root = root;
344
+ if (config.plans) drawPlan();
345
+ }
346
+
347
+ // -- the conversation -----------------------------------------------------------------------
348
+
349
+ function storage(key, value) {
350
+ try {
351
+ if (!window.sessionStorage) return null;
352
+ if (value === undefined) return window.sessionStorage.getItem(config.storage + "." + key);
353
+ if (value === null) window.sessionStorage.removeItem(config.storage + "." + key);
354
+ else window.sessionStorage.setItem(config.storage + "." + key, value);
355
+ } catch (_) { /* private window: nothing survives a reload */ }
356
+ return null;
357
+ }
358
+
359
+ function titleOf() {
360
+ if (typeof config.title === "function") return String(config.title() || "");
361
+ return String(config.title || "");
362
+ }
363
+
364
+ function licenseRefused(license) {
365
+ if (typeof config.onLicenseRefused === "function") return config.onLicenseRefused(license);
366
+ line("sa-error", (license && license.message) || "This needs a licence that includes AI.");
367
+ return null;
368
+ }
369
+
370
+ async function open() {
371
+ mount();
372
+ if (state.conversation) return true;
373
+ const saved = storage("conversation");
374
+ if (saved) {
375
+ const answer = await get(`conversations/${encodeURIComponent(saved)}`);
376
+ if (answer.status === 200 && answer.body.ok) {
377
+ state.conversation = saved;
378
+ state.cursor = 0;
379
+ listen();
380
+ return true;
381
+ }
382
+ storage("conversation", null);
383
+ }
384
+ const answer = await post("conversations", { title: titleOf(), viewer: false });
385
+ if (answer.status === 403 && answer.body.license) {
386
+ licenseRefused(answer.body.license);
387
+ return false;
388
+ }
389
+ if (!answer.body.ok) {
390
+ line("sa-error", errorText(answer.body, "The assistant is not available."));
391
+ return false;
392
+ }
393
+ state.conversation = answer.body.conversation_id;
394
+ storage("conversation", state.conversation);
395
+ state.cursor = 0;
396
+ listen();
397
+ return true;
398
+ }
399
+
400
+ function attach(files) {
401
+ for (const file of Array.from(files)) {
402
+ if (state.attached.length >= MAX_IMAGES || !/^image\//.test(file.type || "")) continue;
403
+ const reader = new FileReader();
404
+ reader.onload = () => {
405
+ state.attached.push({ data: String(reader.result), format: (file.type.split("/")[1] || "png") });
406
+ drawThumbs();
407
+ };
408
+ reader.readAsDataURL(file);
409
+ }
410
+ }
411
+
412
+ function drawThumbs() {
413
+ state.thumbs.textContent = "";
414
+ state.attached.forEach((image, index) => {
415
+ const img = el("img", "sa-thumb");
416
+ img.src = image.data;
417
+ img.alt = "attached image " + (index + 1);
418
+ img.title = "Click to remove";
419
+ img.addEventListener("click", () => {
420
+ state.attached.splice(index, 1);
421
+ drawThumbs();
422
+ });
423
+ state.thumbs.appendChild(img);
424
+ });
425
+ }
426
+
427
+ async function submit() {
428
+ if (state.busy) return;
429
+ if (!state.conversation && !(await open())) return;
430
+ const text = (state.input.value || "").trim();
431
+ const images = state.attached.slice();
432
+ if (!text && !images.length) return;
433
+ state.input.value = "";
434
+ grow();
435
+ state.attached = [];
436
+ drawThumbs();
437
+ state.echoes += 1;
438
+ const mine = line("sa-user", text);
439
+ for (const image of images) {
440
+ const img = el("img", "sa-thumb");
441
+ img.src = image.data;
442
+ img.alt = "your image";
443
+ mine.appendChild(img);
444
+ }
445
+ scroll(true);
446
+ setBusy(true);
447
+ const answer = await post(`conversations/${state.conversation}/messages`, { text, images });
448
+ if (!answer.body.ok) {
449
+ state.echoes = Math.max(0, state.echoes - 1);
450
+ setBusy(false);
451
+ line("sa-error", errorText(answer.body, "The message was not sent."));
452
+ }
453
+ }
454
+
455
+ function control(action) {
456
+ if (!state.conversation) return Promise.resolve();
457
+ return post(`conversations/${state.conversation}/control`, { action });
458
+ }
459
+
460
+ // -- events, streamed or polled ----------------------------------------------------------------
461
+
462
+ /** Stream `path`'s events into `deliver`; fall back to a held poll. */
463
+ function subscribe(path, names, cursorOf, deliver, onFallback) {
464
+ if (typeof EventSource === "function") {
465
+ const source = new EventSource(url(`${path}?after=${cursorOf()}`));
466
+ const receive = (message) => {
467
+ let event;
468
+ try { event = JSON.parse(message.data); } catch (_) { return; }
469
+ deliver(event);
470
+ };
471
+ source.onmessage = receive;
472
+ for (const name of names) source.addEventListener(name, receive);
473
+ source.onerror = () => {
474
+ source.close();
475
+ onFallback();
476
+ };
477
+ return source;
478
+ }
479
+ onFallback();
480
+ return null;
481
+ }
482
+
483
+ async function pollLoop(path, active, cursorOf, deliver) {
484
+ while (active()) {
485
+ const answer = await get(`${path}?after=${cursorOf()}&wait=${POLL_WAIT_S}`);
486
+ for (const event of (answer.body && answer.body.events) || []) deliver(event);
487
+ if (answer.status !== 200) await new Promise((resolve) => setTimeout(resolve, 2000));
488
+ }
489
+ }
490
+
491
+ function listen() {
492
+ const path = `conversations/${state.conversation}/events`;
493
+ state.source = subscribe(path, KNOWN, () => state.cursor, handle, () => {
494
+ state.source = null;
495
+ if (state.polling) return;
496
+ state.polling = true;
497
+ pollLoop(path, () => state.polling && state.conversation, () => state.cursor, handle);
498
+ });
499
+ }
500
+
501
+ function handle(event) {
502
+ if (!event || typeof event.seq !== "number" || event.seq <= state.cursor) return;
503
+ state.cursor = event.seq;
504
+ const sub = event.agent ? "[" + event.agent + "] " : "";
505
+ switch (event.event) {
506
+ case "user_message":
507
+ if (state.echoes > 0) {
508
+ state.echoes -= 1;
509
+ } else {
510
+ const mine = line("sa-user", event.text || "");
511
+ if (event.images) mine.appendChild(el("div", "sa-note",
512
+ event.images + (event.images === 1 ? " image" : " images")));
513
+ }
514
+ break;
515
+ case "approval_decided":
516
+ decided(event);
517
+ break;
518
+ case "turn_started":
519
+ setBusy(true);
520
+ break;
521
+ case "text_delta":
522
+ if (event.agent) break;
523
+ if (!state.streamingNode) state.streamingNode = line("sa-assistant", "");
524
+ state.streamingNode.textContent += event.text;
525
+ scroll();
526
+ break;
527
+ case "text":
528
+ if (event.agent) {
529
+ line("sa-sub", sub + event.text);
530
+ } else {
531
+ const node = state.streamingNode || line("sa-assistant", "");
532
+ markdown(node, event.text);
533
+ state.streamingNode = null;
534
+ scroll();
535
+ }
536
+ break;
537
+ case "tool_result":
538
+ toolChip(event, sub);
539
+ break;
540
+ case "approval_requested":
541
+ approvalCard(event, sub);
542
+ break;
543
+ case "usage":
544
+ if (typeof event.total_charged_micro === "number") meter(event.total_charged_micro, "chat");
545
+ break;
546
+ case "paused":
547
+ line("sa-notice", sub + "Paused: " + (event.message || event.reason) +
548
+ (event.resume ? " (" + event.resume + ")" : ""));
549
+ break;
550
+ case "error":
551
+ line("sa-error", sub + (event.message || event.code));
552
+ break;
553
+ case "stopped":
554
+ line("sa-notice", "Stopped.");
555
+ break;
556
+ case "agent_started":
557
+ line("sa-sub", sub + "started: " + (event.brief || event.role || ""));
558
+ break;
559
+ case "agent_finished":
560
+ line("sa-sub", sub + (event.state === "done" ? "done: " : event.state + ": ") + (event.summary || ""));
561
+ break;
562
+ case "undone":
563
+ line("sa-notice", "Undone: " + event.operation_id);
564
+ break;
565
+ case "turn_finished":
566
+ state.streamingNode = null;
567
+ setBusy(false);
568
+ break;
569
+ default:
570
+ break;
571
+ }
572
+ }
573
+
574
+ const spent = { chat: 0, plan: 0 };
575
+
576
+ function meter(micro, which) {
577
+ spent[which] = micro;
578
+ state.meter.textContent = credits(spent.chat + spent.plan) + " credits";
579
+ }
580
+
581
+ function toolChip(event, sub) {
582
+ const chip = el("div", "sa-tool" + (event.ok ? "" : " is-error"));
583
+ const head = chip.appendChild(el("div", "sa-tool-head"));
584
+ head.appendChild(el("span", "sa-tool-name", sub + event.tool));
585
+ const status = event.ok ? (event.source === "cache" ? "ok, cached" : "ok") :
586
+ (event.source === "declined" ? "declined" : "error");
587
+ head.appendChild(el("span", "sa-tool-status", status));
588
+ if (event.summary && !event.ok) chip.title = event.summary;
589
+ if (config.undo && event.undo && event.operation_id) {
590
+ const undo = button("Undo", "sa-undo", async () => {
591
+ undo.disabled = true;
592
+ const answer = await post(`conversations/${state.conversation}/undo`,
593
+ { operation_id: event.operation_id });
594
+ undo.textContent = answer.body.ok ? "Undone" : "Undo failed";
595
+ });
596
+ undo.title = "Undo " + event.operation_id;
597
+ head.appendChild(undo);
598
+ }
599
+ for (const image of event.images || []) figure(chip, image, event.tool);
600
+ state.log.appendChild(chip);
601
+ scroll();
602
+ }
603
+
604
+ function approvalCard(event, sub) {
605
+ const card = el("div", "sa-approval");
606
+ const what = event.permission === "destructive" ? "cannot be undone" : "writes into your source file";
607
+ card.appendChild(el("div", "sa-approval-title", sub + event.tool + " " + what));
608
+ if (event.purpose) card.appendChild(el("div", "sa-approval-purpose", event.purpose));
609
+ card.appendChild(el("pre", "sa-code", JSON.stringify(event.arguments || {}, null, 1)));
610
+ const row = card.appendChild(el("div", "sa-actions"));
611
+ const decide = (decision) => async () => {
612
+ approve.disabled = true;
613
+ deny.disabled = true;
614
+ const answer = await post(`conversations/${state.conversation}/approve`,
615
+ { approval_id: event.approval_id, decision });
616
+ row.textContent = answer.body.ok ? (decision === "approve" ? "Approved" : "Declined") :
617
+ errorText(answer.body, "Not recorded");
618
+ };
619
+ const approve = row.appendChild(button("Approve", "sa-primary", decide("approve")));
620
+ const deny = row.appendChild(button("Deny", "sa-secondary", decide("deny")));
621
+ state.approvals[event.approval_id] = { row, approve, deny };
622
+ state.log.appendChild(card);
623
+ scroll();
624
+ }
625
+
626
+ function decided(event) {
627
+ const card = state.approvals[event.approval_id];
628
+ if (!card) return;
629
+ card.approve.disabled = true;
630
+ card.deny.disabled = true;
631
+ const words = { approved: "Approved", denied: "Declined", stopped: "Stopped", expired: "Expired" };
632
+ card.row.textContent = words[event.status] || String(event.status || "Decided");
633
+ }
634
+
635
+ // -- plans --------------------------------------------------------------------------------------
636
+
637
+ const ACTIONS = { confirm: "Confirm and run", continue: "Continue", summarise: "Summarise",
638
+ retry: "Try again", compose: "Start over" };
639
+ const PHASE_WORDS = { composing: "Composing a plan", awaiting_decision: "Waiting for you",
640
+ validated: "Ready to run", running: "Running", finished: "Finished",
641
+ summarising: "Writing the report", reported: "Reported", nothing: "Nothing to run",
642
+ failed: "Could not continue", stopped: "Stopped" };
643
+
644
+ async function startPlan(goal, dataset) {
645
+ const answer = await post("plans", { goal, dataset });
646
+ if (answer.status === 403 && answer.body.license) return licenseRefused(answer.body.license);
647
+ if (!answer.body.ok) {
648
+ line("sa-error", errorText(answer.body, "The plan could not be started."));
649
+ return null;
650
+ }
651
+ followPlan(answer.body.run_id);
652
+ return answer.body.run_id;
653
+ }
654
+
655
+ function followPlan(runId) {
656
+ if (state.planSource) state.planSource.close();
657
+ state.plan = runId;
658
+ state.planCursor = 0;
659
+ state.planPolling = false;
660
+ state.report = null;
661
+ storage("plan", runId);
662
+ const path = `plans/${runId}/events`;
663
+ state.planSource = subscribe(path, PLAN_EVENTS, () => state.planCursor, planEvent, () => {
664
+ state.planSource = null;
665
+ if (state.planPolling) return;
666
+ state.planPolling = true;
667
+ pollLoop(path, () => state.planPolling && state.plan === runId, () => state.planCursor, planEvent);
668
+ });
669
+ refreshPlan();
670
+ }
671
+
672
+ let refreshing = null;
673
+
674
+ async function refreshPlan() {
675
+ if (!state.plan) return;
676
+ if (refreshing) return refreshing;
677
+ refreshing = (async () => {
678
+ const answer = await get(`plans/${state.plan}`);
679
+ if (answer.status === 200 && answer.body.ok) {
680
+ state.planStatus = answer.body;
681
+ if (typeof answer.body.charged_micro === "number") meter(answer.body.charged_micro, "plan");
682
+ drawPlan();
683
+ }
684
+ refreshing = null;
685
+ })();
686
+ return refreshing;
687
+ }
688
+
689
+ function planEvent(event) {
690
+ if (!event || typeof event.seq !== "number" || event.seq <= state.planCursor) return;
691
+ state.planCursor = event.seq;
692
+ if (event.event === "report") state.report = event.markdown || "";
693
+ if (event.event === "usage" && typeof event.total_charged_micro === "number") {
694
+ meter(event.total_charged_micro, "plan");
695
+ return;
696
+ }
697
+ if (event.event === "error") line("sa-error", "Plan: " + (event.message || event.code));
698
+ refreshPlan();
699
+ }
700
+
701
+ async function planAction(action) {
702
+ const answer = await post(`plans/${state.plan}/${action}`, {});
703
+ if (!answer.body.ok) line("sa-error", errorText(answer.body, "That did not work."));
704
+ refreshPlan();
705
+ }
706
+
707
+ async function answerDecision(decision, value, row) {
708
+ const answer = await post(`plans/${state.plan}/answer`, { decision_id: decision.id, answer: value });
709
+ if (!answer.body.ok) {
710
+ if (row) row.appendChild(el("div", "sa-error", errorText(answer.body, "That answer was not taken.")));
711
+ return;
712
+ }
713
+ state.planStatus = answer.body;
714
+ drawPlan();
715
+ }
716
+
717
+ function drawPlan() {
718
+ const box = state.planBox;
719
+ if (!box) return;
720
+ box.textContent = "";
721
+ box.appendChild(el("h2", "sa-plan-title", "Plan"));
722
+ const status = state.planStatus;
723
+ if (!state.plan || !status) {
724
+ box.appendChild(planForm());
725
+ return;
726
+ }
727
+ const head = box.appendChild(el("div", "sa-plan-head"));
728
+ head.appendChild(el("div", "sa-plan-goal", status.goal || ""));
729
+ head.appendChild(el("span", "sa-phase sa-phase-" + status.phase, PHASE_WORDS[status.phase] || status.phase));
730
+ if (status.summary) box.appendChild(el("p", "sa-plan-summary", status.summary));
731
+ if (status.paused) {
732
+ box.appendChild(el("div", "sa-notice", "Paused: " + (status.paused.message || status.paused.code)));
733
+ }
734
+ if (status.reason) box.appendChild(el("div", "sa-error", status.reason));
735
+ const plan = status.plan;
736
+ if (plan) {
737
+ const facts = box.appendChild(el("dl", "sa-facts"));
738
+ const fact = (label, value) => {
739
+ if (value === null || value === undefined || value === "") return;
740
+ facts.appendChild(el("dt", "", label));
741
+ facts.appendChild(el("dd", "", typeof value === "object" ? perArm(value) : String(value)));
742
+ };
743
+ fact("Claim ceiling", plan.claim_ceiling);
744
+ fact("Unit of replication", plan.unit_of_replication);
745
+ fact("n per arm", plan.n_per_arm);
746
+ fact("Version", plan.version);
747
+ const steps = box.appendChild(el("ol", "sa-steps"));
748
+ for (const step of plan.steps || []) steps.appendChild(stepItem(step));
749
+ for (const decision of plan.decisions || []) {
750
+ if (decision.state === "open") box.appendChild(decisionCard(decision));
751
+ }
752
+ }
753
+ const actions = box.appendChild(el("div", "sa-actions"));
754
+ for (const action of status.actions || []) {
755
+ if (action === "answer") continue;
756
+ if (action === "compose") {
757
+ actions.appendChild(button(ACTIONS.compose, "sa-secondary", () => {
758
+ state.plan = null;
759
+ state.planStatus = null;
760
+ storage("plan", null);
761
+ drawPlan();
762
+ }));
763
+ continue;
764
+ }
765
+ actions.appendChild(button(ACTIONS[action] || action, action === "confirm" ? "sa-primary" : "sa-secondary",
766
+ () => planAction(action)));
767
+ }
768
+ if (status.phase === "running") actions.appendChild(button("Stop after this step", "sa-secondary",
769
+ () => planAction("stop")));
770
+ if (state.report !== null && state.report !== undefined) {
771
+ const report = box.appendChild(el("section", "sa-report"));
772
+ report.appendChild(el("h3", "", "Report"));
773
+ markdown(report.appendChild(el("div", "sa-report-body")), state.report);
774
+ if (status.report && status.report.path) report.appendChild(el("div", "sa-note", status.report.path));
775
+ }
776
+ if (typeof status.charged_micro === "number") {
777
+ box.appendChild(el("div", "sa-note", credits(status.charged_micro) + " credits for this plan"));
778
+ }
779
+ }
780
+
781
+ function perArm(value) {
782
+ return Object.keys(value).map((key) => key + ": " + value[key]).join(", ");
783
+ }
784
+
785
+ function planForm() {
786
+ const form = el("div", "sa-plan-form");
787
+ const goal = form.appendChild(el("textarea", "sa-input"));
788
+ goal.rows = 3;
789
+ goal.placeholder = "What do you want to learn?";
790
+ goal.setAttribute("aria-label", "Goal");
791
+ const dataset = form.appendChild(el("input", "sa-text"));
792
+ dataset.placeholder = "Dataset";
793
+ dataset.value = config.dataset || "";
794
+ dataset.setAttribute("aria-label", "Dataset");
795
+ form.appendChild(button("Make a plan", "sa-primary", () => {
796
+ const text = (goal.value || "").trim();
797
+ if (text) startPlan(text, (dataset.value || "").trim());
798
+ }));
799
+ return form;
800
+ }
801
+
802
+ function stepItem(step) {
803
+ const item = el("li", "sa-step sa-step-" + (step.status || "pending"));
804
+ const head = item.appendChild(el("div", "sa-step-head"));
805
+ head.appendChild(el("code", "sa-step-function", step.function));
806
+ head.appendChild(el("span", "sa-step-status", step.status || "pending"));
807
+ if (step.rationale) {
808
+ const why = item.appendChild(el("div", "sa-step-why"));
809
+ const by = AUTHORS[step.rationale_by] || step.rationale_by;
810
+ if (by) why.appendChild(el("span", "sa-badge sa-badge-" + by, by));
811
+ why.appendChild(document.createTextNode(" " + step.rationale));
812
+ }
813
+ if (step.claim) item.appendChild(el("div", "sa-note", "supports: " + step.claim));
814
+ if (step.error) item.appendChild(el("div", "sa-error", (step.error && step.error.message) || String(step.error)));
815
+ return item;
816
+ }
817
+
818
+ // -- decision cards ------------------------------------------------------------------------------
819
+
820
+ function decisionCard(decision) {
821
+ const card = el("div", "sa-decision sa-decision-" + decision.kind);
822
+ card.appendChild(el("div", "sa-decision-question", decision.question || decision.id));
823
+ if (decision.why) card.appendChild(el("div", "sa-decision-why", decision.why));
824
+ if (decision.consequence) card.appendChild(el("div", "sa-note", decision.consequence));
825
+ if ((decision.evidence || []).length) {
826
+ const list = card.appendChild(el("ul", "sa-evidence"));
827
+ for (const item of decision.evidence) list.appendChild(el("li", "", evidence(item)));
828
+ }
829
+ const row = card.appendChild(el("div", "sa-decision-answer"));
830
+ const options = (decision.options || []).filter((option) => option && option.id !== undefined);
831
+ if (decision.kind === "build_workflow") {
832
+ rowsEditor(decision, row);
833
+ } else if (decision.kind === "provide_file" || (decision.kind === "free_text" && !options.length)) {
834
+ const input = row.appendChild(el(decision.kind === "free_text" ? "textarea" : "input", "sa-text"));
835
+ input.placeholder = decision.kind === "provide_file" ? "A file path" : "Your answer";
836
+ if (decision.default) input.value = String(decision.default);
837
+ row.appendChild(button("Answer", "sa-primary", () => {
838
+ const value = (input.value || "").trim();
839
+ if (value) answerDecision(decision, value, row);
840
+ }));
841
+ } else {
842
+ for (const option of options) {
843
+ const choice = row.appendChild(button(option.label || String(option.id),
844
+ option.recommended ? "sa-primary" : "sa-secondary",
845
+ () => answerDecision(decision, option.id, row)));
846
+ if (option.consequence) choice.title = option.consequence;
847
+ if (option.recommended) choice.appendChild(el("span", "sa-badge", "recommended"));
848
+ }
849
+ if (decision.kind === "free_text" || decision.kind === "choose_option") freeText(decision, row, options);
850
+ }
851
+ return card;
852
+ }
853
+
854
+ function evidence(item) {
855
+ if (item === null || typeof item !== "object") return String(item);
856
+ return Object.keys(item).map((key) => key + ": " + (typeof item[key] === "object" ?
857
+ JSON.stringify(item[key]) : item[key])).join("; ");
858
+ }
859
+
860
+ /** In words, matched to an option by one small model call, which the person then confirms. */
861
+ function freeText(decision, row, options) {
862
+ if (!options.length) return;
863
+ const wrap = row.appendChild(el("div", "sa-free"));
864
+ const input = wrap.appendChild(el("input", "sa-text"));
865
+ input.placeholder = "Or say it in your words";
866
+ const result = el("div", "sa-note");
867
+ wrap.appendChild(button("Match", "sa-secondary", async () => {
868
+ const text = (input.value || "").trim();
869
+ if (!text) return;
870
+ const answer = await post(`plans/${state.plan}/resolve`, { decision_id: decision.id, text });
871
+ result.textContent = "";
872
+ if (!answer.body.ok || !answer.body.option) {
873
+ result.textContent = (answer.body && answer.body.why) || "That matched none of the options.";
874
+ return;
875
+ }
876
+ const option = options.find((item) => String(item.id) === String(answer.body.option));
877
+ result.appendChild(document.createTextNode("Did you mean “" +
878
+ ((option && option.label) || answer.body.option) + "”? "));
879
+ result.appendChild(button("Yes", "sa-primary", () => answerDecision(decision, answer.body.option, row)));
880
+ }));
881
+ wrap.appendChild(result);
882
+ }
883
+
884
+ /** A phenotype workflow, row by row: parent, population, markers. */
885
+ function rowsEditor(decision, row) {
886
+ const table = row.appendChild(el("table", "sa-md-table sa-rows"));
887
+ const head = table.appendChild(el("thead")).appendChild(el("tr"));
888
+ for (const name of ["Parent", "Population", "Markers (comma separated)", ""]) head.appendChild(el("th", "", name));
889
+ const body = table.appendChild(el("tbody"));
890
+ const addRow = (values) => {
891
+ const tr = body.appendChild(el("tr"));
892
+ for (const key of ["parent", "population", "markers"]) {
893
+ const input = tr.appendChild(el("td")).appendChild(el("input", "sa-text"));
894
+ input.dataset.key = key;
895
+ const value = values && values[key];
896
+ input.value = Array.isArray(value) ? value.join(", ") : (value || "");
897
+ }
898
+ tr.appendChild(el("td")).appendChild(button("Remove", "sa-secondary", () => tr.remove()));
899
+ };
900
+ for (const values of Array.isArray(decision.default) ? decision.default : [{}]) addRow(values);
901
+ const actions = row.appendChild(el("div", "sa-actions"));
902
+ actions.appendChild(button("Add a row", "sa-secondary", () => addRow({})));
903
+ actions.appendChild(button("Use these rows", "sa-primary", () => {
904
+ const rows = [];
905
+ for (const tr of Array.from(body.querySelectorAll("tr"))) {
906
+ const values = {};
907
+ for (const input of Array.from(tr.querySelectorAll("input"))) values[input.dataset.key] = input.value.trim();
908
+ if (!values.population) continue;
909
+ rows.push({ parent: values.parent || null, population: values.population,
910
+ markers: (values.markers || "").split(",").map((m) => m.trim()).filter(Boolean) });
911
+ }
912
+ if (rows.length) answerDecision(decision, rows, row);
913
+ }));
914
+ }
915
+
916
+ // -- start ----------------------------------------------------------------------------------------
917
+
918
+ function boot() {
919
+ mount();
920
+ open();
921
+ if (config.plans) {
922
+ const saved = storage("plan");
923
+ if (saved) followPlan(saved);
924
+ }
925
+ }
926
+
927
+ const api = { open, handle, planEvent, startPlan, followPlan, markdown, mount, config, _state: state };
928
+ window[config.globalName || "SpatialAgentsChat"] = api;
929
+ if (config.autostart !== false) {
930
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
931
+ else boot();
932
+ }
933
+ })();