@a-t-h-i/bot-lobby 0.6.8 → 0.6.9

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,746 @@
1
+ /**
2
+ * An Excalidraw scene as an agent works with it: the elements a room holds,
3
+ * merged the way Excalidraw's own clients merge them, a plain-words reading of
4
+ * them for a model, and the builders that turn a model's short description of
5
+ * what to draw (shapes, labels, arrows between shapes) into complete
6
+ * elements a browser accepts. Pure: no network, no clock but `Date.now`, and
7
+ * randomness only for ids and seeds, so every rule here is tested directly.
8
+ */
9
+ import { randomBytes } from "node:crypto";
10
+
11
+ export interface SceneElement {
12
+ id: string;
13
+ type: string;
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ angle: number;
19
+ strokeColor: string;
20
+ backgroundColor: string;
21
+ fillStyle: string;
22
+ strokeWidth: number;
23
+ strokeStyle: string;
24
+ roughness: number;
25
+ opacity: number;
26
+ groupIds: string[];
27
+ frameId: string | null;
28
+ /** Excalidraw's fractional order key; null until a browser gives one. */
29
+ index: string | null;
30
+ roundness: { type: number; value?: number } | null;
31
+ seed: number;
32
+ version: number;
33
+ versionNonce: number;
34
+ isDeleted: boolean;
35
+ boundElements: Array<{ id: string; type: string }> | null;
36
+ updated: number;
37
+ link: string | null;
38
+ locked: boolean;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ /** Elements by id, as one room's scene keeps them (deleted ones too: a deletion is an update). */
43
+ export type Scene = Map<string, SceneElement>;
44
+
45
+ /* ---------------------------------------------------------------- merge */
46
+
47
+ /**
48
+ * Whether a remote element replaces the local one: Excalidraw keeps the higher
49
+ * version, and of two equal versions the one with the lower nonce, so every
50
+ * client settles on the same element whatever order the messages came in.
51
+ */
52
+ export function remoteWins(local: SceneElement | undefined, remote: SceneElement): boolean {
53
+ if (!local) return true;
54
+ if (remote.version !== local.version) return remote.version > local.version;
55
+ return remote.versionNonce < local.versionNonce;
56
+ }
57
+
58
+ /** Ids as Excalidraw and its neighbours make them; anything with other characters in it is not shown to a model or a terminal. */
59
+ const SAFE_ID = /^[A-Za-z0-9_.:-]{1,80}$/;
60
+
61
+ /** Whether a received value can be an element at all (a room is shared with strangers' software). */
62
+ export function isElement(value: unknown): value is SceneElement {
63
+ if (!value || typeof value !== "object") return false;
64
+ const element = value as Record<string, unknown>;
65
+ return typeof element.id === "string" && SAFE_ID.test(element.id) && typeof element.type === "string" && /^[a-z]{1,20}$/.test(element.type) && typeof element.version === "number" && typeof element.versionNonce === "number" && Number.isFinite(element.x) && Number.isFinite(element.y);
66
+ }
67
+
68
+ /** Text from a room made safe to print: one line, no control characters (a terminal would obey them). */
69
+ export function plainText(text: string): string {
70
+ return text.replace(/\s+/g, " ").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "").trim();
71
+ }
72
+
73
+ /** Merge received elements into a scene; the ids that changed. */
74
+ export function mergeElements(scene: Scene, received: readonly unknown[]): string[] {
75
+ const changed: string[] = [];
76
+ for (const value of received) {
77
+ if (!isElement(value)) continue;
78
+ if (!remoteWins(scene.get(value.id), value)) continue;
79
+ scene.set(value.id, value);
80
+ changed.push(value.id);
81
+ }
82
+ return changed;
83
+ }
84
+
85
+ /** The elements on show, in the order the scene holds them. */
86
+ export function visible(scene: Scene): SceneElement[] {
87
+ return [...scene.values()].filter((element) => !element.isDeleted);
88
+ }
89
+
90
+ /* ------------------------------------------------------------ measuring */
91
+
92
+ const FONT_SIZE = 20;
93
+ const LINE_HEIGHT = 1.25;
94
+ /** Excalifont's average glyph is a little over half an em wide; erring wide keeps labels inside their shapes. */
95
+ const CHAR_WIDTH = 0.6;
96
+ const LABEL_PADDING = 10;
97
+ const FONT_FAMILY = 5;
98
+
99
+ /** Wrap `text` to lines no wider than `maxWidth` pixels at `fontSize`, breaking words that are longer than a line. */
100
+ export function wrapLabel(text: string, fontSize: number, maxWidth: number): string[] {
101
+ const perLine = Math.max(4, Math.floor(maxWidth / (fontSize * CHAR_WIDTH)));
102
+ const lines: string[] = [];
103
+ for (const paragraph of text.replace(/\t/g, " ").split("\n")) {
104
+ let line = "";
105
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
106
+ let rest = word;
107
+ while (rest.length > perLine) {
108
+ if (line) lines.push(line);
109
+ lines.push(rest.slice(0, perLine));
110
+ rest = rest.slice(perLine);
111
+ line = "";
112
+ }
113
+ if (!line) line = rest;
114
+ else if (line.length + 1 + rest.length <= perLine) line += ` ${rest}`;
115
+ else {
116
+ lines.push(line);
117
+ line = rest;
118
+ }
119
+ }
120
+ lines.push(line);
121
+ }
122
+ return lines;
123
+ }
124
+
125
+ export function measure(lines: readonly string[], fontSize: number): { width: number; height: number } {
126
+ const longest = lines.reduce((most, line) => Math.max(most, line.length), 0);
127
+ return { width: Math.ceil(longest * fontSize * CHAR_WIDTH), height: Math.ceil(lines.length * fontSize * LINE_HEIGHT) };
128
+ }
129
+
130
+ /* ------------------------------------------------------------- building */
131
+
132
+ function randomInteger(): number {
133
+ return randomBytes(4).readUInt32BE() & 0x7fffffff;
134
+ }
135
+
136
+ const ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-";
137
+
138
+ /** An id like Excalidraw's own (21 url-safe characters). */
139
+ export function newId(): string {
140
+ const random = randomBytes(21);
141
+ let id = "";
142
+ for (const byte of random) id += ID_ALPHABET[byte & 63];
143
+ return id;
144
+ }
145
+
146
+ /** The colour names a model can use, as Excalidraw's palette draws them. */
147
+ const STROKES: Record<string, string> = {
148
+ black: "#1e1e1e", gray: "#868e96", red: "#e03131", pink: "#c2255c", grape: "#9c36b5", violet: "#6741d9",
149
+ blue: "#1971c2", cyan: "#0c8599", teal: "#099268", green: "#2f9e44", lime: "#66a80f", yellow: "#f08c00", orange: "#e8590c", white: "#ffffff",
150
+ };
151
+ const FILLS: Record<string, string> = {
152
+ gray: "#dee2e6", red: "#ffc9c9", pink: "#fcc2d7", grape: "#eebefa", violet: "#d0bfff", blue: "#a5d8ff", cyan: "#99e9f2",
153
+ teal: "#96f2d7", green: "#b2f2bb", lime: "#d8f5a2", yellow: "#ffec99", orange: "#ffd8a8", white: "#ffffff", black: "#1e1e1e", transparent: "transparent",
154
+ };
155
+ const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
156
+
157
+ export function strokeColor(name: string | undefined): string | undefined {
158
+ if (!name) return undefined;
159
+ const key = name.trim().toLowerCase();
160
+ return HEX.test(key) ? key : STROKES[key];
161
+ }
162
+
163
+ export function fillColor(name: string | undefined): string | undefined {
164
+ if (!name) return undefined;
165
+ const key = name.trim().toLowerCase();
166
+ return HEX.test(key) ? key : FILLS[key];
167
+ }
168
+
169
+ function base(type: string, fields: Partial<SceneElement> & Pick<SceneElement, "x" | "y">): SceneElement {
170
+ return {
171
+ id: newId(),
172
+ type,
173
+ width: 0,
174
+ height: 0,
175
+ angle: 0,
176
+ strokeColor: "#1e1e1e",
177
+ backgroundColor: "transparent",
178
+ fillStyle: "solid",
179
+ strokeWidth: 2,
180
+ strokeStyle: "solid",
181
+ roughness: 1,
182
+ opacity: 100,
183
+ groupIds: [],
184
+ frameId: null,
185
+ index: null,
186
+ roundness: null,
187
+ seed: randomInteger(),
188
+ version: 1,
189
+ versionNonce: randomInteger(),
190
+ isDeleted: false,
191
+ boundElements: null,
192
+ updated: Date.now(),
193
+ link: null,
194
+ locked: false,
195
+ // What agents draw says so, so that agents can take back their own work and never delete the user's.
196
+ customData: { botLobby: true },
197
+ ...fields,
198
+ };
199
+ }
200
+
201
+ /** The element again, one version on, so every client takes it over the one it has. */
202
+ export function bumped(element: SceneElement, changes: Partial<SceneElement> = {}): SceneElement {
203
+ return { ...element, ...changes, version: element.version + 1, versionNonce: randomInteger(), updated: Date.now() };
204
+ }
205
+
206
+ /** A text element: free-standing, or the label of a shape or arrow (`containerId`). */
207
+ function textElement(text: string, options: { x: number; y: number; fontSize: number; color: string; containerId?: string; width?: number }): SceneElement {
208
+ const lines = options.containerId && options.width ? wrapLabel(text, options.fontSize, options.width) : text.split("\n");
209
+ const { width, height } = measure(lines, options.fontSize);
210
+ return base("text", {
211
+ x: options.x,
212
+ y: options.y,
213
+ width,
214
+ height,
215
+ strokeColor: options.color,
216
+ text: lines.join("\n"),
217
+ originalText: text,
218
+ fontSize: options.fontSize,
219
+ fontFamily: FONT_FAMILY,
220
+ textAlign: options.containerId ? "center" : "left",
221
+ verticalAlign: options.containerId ? "middle" : "top",
222
+ containerId: options.containerId ?? null,
223
+ autoResize: true,
224
+ lineHeight: LINE_HEIGHT,
225
+ roundness: null,
226
+ });
227
+ }
228
+
229
+ const SHAPES = ["rectangle", "ellipse", "diamond"] as const;
230
+ type ShapeType = (typeof SHAPES)[number];
231
+
232
+ export function isShapeType(type: string): type is ShapeType {
233
+ return (SHAPES as readonly string[]).includes(type);
234
+ }
235
+
236
+ /** Room for a label inside a shape, so a shape drawn without a size fits what it says. */
237
+ function fitShape(type: ShapeType, text: string | undefined, fontSize: number): { width: number; height: number } {
238
+ const minimum = type === "diamond" ? { width: 180, height: 100 } : type === "ellipse" ? { width: 160, height: 80 } : { width: 160, height: 70 };
239
+ if (!text) return minimum;
240
+ // A diamond and an ellipse have less usable room than their box, so their labels get a wider box.
241
+ const slack = type === "rectangle" ? 1 : 1.5;
242
+ const lines = wrapLabel(text, fontSize, 260);
243
+ const size = measure(lines, fontSize);
244
+ return { width: Math.max(minimum.width, Math.ceil((size.width + 2 * LABEL_PADDING + 20) * slack)), height: Math.max(minimum.height, Math.ceil((size.height + 2 * LABEL_PADDING + 20) * slack)) };
245
+ }
246
+
247
+ /* ---------------------------------------------------------------- draw */
248
+
249
+ /** What a model may ask to have drawn. Only `type` is needed of a new shape. */
250
+ export interface DrawShape {
251
+ /** A name later shapes and arrows use; an existing element's id updates that element instead. */
252
+ id?: string;
253
+ type?: string;
254
+ text?: string;
255
+ x?: number;
256
+ y?: number;
257
+ width?: number;
258
+ height?: number;
259
+ /** Arrows: the shapes they join, by id (made in this same request, or already on the board). */
260
+ from?: string;
261
+ to?: string;
262
+ /** Lines and arrows between places, not shapes: absolute scene coordinates, at least two. */
263
+ points?: ReadonlyArray<readonly [number, number]>;
264
+ color?: string;
265
+ fill?: string;
266
+ fontSize?: number;
267
+ dashed?: boolean;
268
+ }
269
+
270
+ export interface DrawRequest {
271
+ shapes?: readonly DrawShape[];
272
+ /** Ids of elements to delete (their labels go with them, arrows joined to them are left loose). */
273
+ remove?: readonly string[];
274
+ }
275
+
276
+ export interface DrawOutcome {
277
+ /** Every element to send: new, changed and deleted, each at its new version. */
278
+ changed: SceneElement[];
279
+ /** The ids made or updated, by the name the request gave them (or their new id). */
280
+ drawn: Array<{ name: string; id: string; type: string; action: "created" | "updated" }>;
281
+ removed: string[];
282
+ /** What could not be done, in words the model can act on. */
283
+ problems: string[];
284
+ }
285
+
286
+ /** The size and position of the free space the next unplaced shape takes: rows below what is there. */
287
+ function placement(scene: ReadonlyMap<string, SceneElement>): (width: number, height: number) => { x: number; y: number } {
288
+ const shown = [...scene.values()].filter((element) => !element.isDeleted);
289
+ const bounds = sceneBounds(shown);
290
+ const left = bounds ? bounds.x1 : 0;
291
+ let x = left;
292
+ let y = bounds ? bounds.y2 + 100 : 0;
293
+ let rowHeight = 0;
294
+ let inRow = 0;
295
+ return (width, height) => {
296
+ if (inRow >= 4) {
297
+ x = left;
298
+ y += rowHeight + 60;
299
+ rowHeight = 0;
300
+ inRow = 0;
301
+ }
302
+ const at = { x, y };
303
+ x += width + 60;
304
+ rowHeight = Math.max(rowHeight, height);
305
+ inRow += 1;
306
+ return at;
307
+ };
308
+ }
309
+
310
+ export interface Bounds {
311
+ x1: number;
312
+ y1: number;
313
+ x2: number;
314
+ y2: number;
315
+ }
316
+
317
+ /** The box around some elements (linear ones by their own size, which is their extent). */
318
+ export function sceneBounds(elements: readonly SceneElement[]): Bounds | undefined {
319
+ if (elements.length === 0) return undefined;
320
+ let x1 = Infinity;
321
+ let y1 = Infinity;
322
+ let x2 = -Infinity;
323
+ let y2 = -Infinity;
324
+ for (const element of elements) {
325
+ x1 = Math.min(x1, element.x, element.x + element.width);
326
+ y1 = Math.min(y1, element.y, element.y + element.height);
327
+ x2 = Math.max(x2, element.x, element.x + element.width);
328
+ y2 = Math.max(y2, element.y, element.y + element.height);
329
+ }
330
+ return { x1: Math.round(x1), y1: Math.round(y1), x2: Math.round(x2), y2: Math.round(y2) };
331
+ }
332
+
333
+ /** Where a line from a shape's centre toward (tx, ty) leaves the shape, plus a small gap. */
334
+ function edgePoint(shape: SceneElement, tx: number, ty: number, gap: number): { x: number; y: number } {
335
+ const cx = shape.x + shape.width / 2;
336
+ const cy = shape.y + shape.height / 2;
337
+ let dx = tx - cx;
338
+ let dy = ty - cy;
339
+ if (dx === 0 && dy === 0) dy = 1;
340
+ const halfW = Math.max(1, shape.width / 2);
341
+ const halfH = Math.max(1, shape.height / 2);
342
+ const ax = Math.abs(dx) / halfW;
343
+ const ay = Math.abs(dy) / halfH;
344
+ // How many times the offset fits before the outline, for each outline.
345
+ const reach = shape.type === "ellipse" ? 1 / Math.hypot(ax, ay) : shape.type === "diamond" ? 1 / (ax + ay) : 1 / Math.max(ax, ay);
346
+ const length = Math.hypot(dx, dy);
347
+ const out = reach * length + gap;
348
+ return { x: cx + (dx / length) * out, y: cy + (dy / length) * out };
349
+ }
350
+
351
+ const GAP = 6;
352
+
353
+ type Point = { x: number; y: number };
354
+
355
+ function centre(shape: SceneElement): Point {
356
+ return { x: shape.x + shape.width / 2, y: shape.y + shape.height / 2 };
357
+ }
358
+
359
+ /** An arrow from `start` to `end`, as Excalidraw stores it: placed at its start, its points relative to it. */
360
+ function straight(start: Point, end: Point): Pick<SceneElement, "x" | "y" | "width" | "height" | "points"> {
361
+ return { x: start.x, y: start.y, width: Math.abs(end.x - start.x), height: Math.abs(end.y - start.y), points: [[0, 0], [end.x - start.x, end.y - start.y]] };
362
+ }
363
+
364
+ /** How an arrow's end is attached to a shape, in both generations of Excalidraw's binding, so a browser of either reads it. */
365
+ function bindingAt(shape: SceneElement, at: Point) {
366
+ return {
367
+ elementId: shape.id,
368
+ focus: 0,
369
+ gap: GAP,
370
+ fixedPoint: [Math.min(1, Math.max(0, (at.x - shape.x) / Math.max(1, shape.width))), Math.min(1, Math.max(0, (at.y - shape.y) / Math.max(1, shape.height)))],
371
+ mode: "orbit",
372
+ };
373
+ }
374
+
375
+ /**
376
+ * An arrow's geometry with each end on its shape's outline, aimed at the other
377
+ * end: the other shape's middle, or where a loose end already is.
378
+ */
379
+ function route(from: SceneElement | undefined, to: SceneElement | undefined, loose: { start: Point; end: Point }): Pick<SceneElement, "x" | "y" | "width" | "height" | "points" | "startBinding" | "endBinding"> {
380
+ const aimStart = to ? centre(to) : loose.end;
381
+ const aimEnd = from ? centre(from) : loose.start;
382
+ const start = from ? edgePoint(from, aimStart.x, aimStart.y, GAP) : loose.start;
383
+ const end = to ? edgePoint(to, aimEnd.x, aimEnd.y, GAP) : loose.end;
384
+ return { ...straight(start, end), startBinding: from ? bindingAt(from, start) : null, endBinding: to ? bindingAt(to, end) : null };
385
+ }
386
+
387
+ /** One new arrow joining two shapes. */
388
+ function joinShapes(from: SceneElement, to: SceneElement): ReturnType<typeof route> {
389
+ return route(from, to, { start: centre(from), end: centre(to) });
390
+ }
391
+
392
+ function label(container: SceneElement, text: string, fontSize: number, color: string): SceneElement {
393
+ const width = Math.max(20, container.width - 2 * LABEL_PADDING);
394
+ const made = textElement(text, { x: 0, y: 0, fontSize, color, containerId: container.id, width });
395
+ return { ...made, x: container.x + (container.width - made.width) / 2, y: container.y + (container.height - made.height) / 2 };
396
+ }
397
+
398
+ /** An arrow's label sits on the middle of its path. */
399
+ function arrowMiddle(arrow: SceneElement): { x: number; y: number } {
400
+ const points = (arrow.points as ReadonlyArray<readonly [number, number]> | undefined) ?? [[0, 0], [arrow.width, arrow.height]];
401
+ const last = points.at(-1) ?? [0, 0];
402
+ return { x: arrow.x + last[0] / 2, y: arrow.y + last[1] / 2 };
403
+ }
404
+
405
+ function labelOn(container: SceneElement, text: string, fontSize: number, color: string): SceneElement {
406
+ if (container.type !== "arrow") return label(container, text, fontSize, color);
407
+ const middle = arrowMiddle(container);
408
+ const made = textElement(text, { x: 0, y: 0, fontSize, color, containerId: container.id, width: 180 });
409
+ return { ...made, x: middle.x - made.width / 2, y: middle.y - made.height / 2 };
410
+ }
411
+
412
+ /** What one request may hold: a model's mistakes and a runaway loop must not become a huge broadcast. */
413
+ export const MAX_SHAPES = 100;
414
+ export const MAX_REMOVALS = 50;
415
+ const MAX_POINTS = 200;
416
+ const MAX_TEXT = 2000;
417
+ /** Excalidraw warns about elements far outside this; a model has no reason to use them. */
418
+ const COORDINATE_LIMIT = 100_000;
419
+
420
+ function within(value: unknown, low: number, high: number): number | undefined {
421
+ return typeof value === "number" && Number.isFinite(value) ? Math.min(high, Math.max(low, value)) : undefined;
422
+ }
423
+
424
+ /** A shape with its numbers, text and points brought within bounds; undefined (with the reason) when its id cannot be used. */
425
+ function tidyShape(shape: DrawShape, problems: string[]): DrawShape | undefined {
426
+ if (shape.id !== undefined && (typeof shape.id !== "string" || !SAFE_ID.test(shape.id))) {
427
+ problems.push(`an id must be 1-80 letters, digits or - _ . : (got ${JSON.stringify(String(shape.id).slice(0, 30))})`);
428
+ return undefined;
429
+ }
430
+ const tidy: DrawShape = { ...shape };
431
+ const set = <K extends "x" | "y" | "width" | "height" | "fontSize">(key: K, low: number, high: number) => {
432
+ if (shape[key] === undefined) return;
433
+ const value = within(shape[key], low, high);
434
+ if (value === undefined) delete tidy[key];
435
+ else tidy[key] = value;
436
+ };
437
+ set("x", -COORDINATE_LIMIT, COORDINATE_LIMIT);
438
+ set("y", -COORDINATE_LIMIT, COORDINATE_LIMIT);
439
+ set("width", 10, 5000);
440
+ set("height", 10, 5000);
441
+ set("fontSize", 8, 120);
442
+ if (typeof shape.text === "string" && shape.text.length > MAX_TEXT) tidy.text = shape.text.slice(0, MAX_TEXT);
443
+ if (Array.isArray(shape.points)) {
444
+ tidy.points = shape.points.slice(0, MAX_POINTS).map((point) => (Array.isArray(point) && point.length === 2 ? ([within(point[0], -COORDINATE_LIMIT, COORDINATE_LIMIT) ?? NaN, within(point[1], -COORDINATE_LIMIT, COORDINATE_LIMIT) ?? NaN] as const) : point));
445
+ }
446
+ return tidy;
447
+ }
448
+
449
+ /**
450
+ * Turn a request into the elements to send. Nothing here touches the scene it
451
+ * is given: the outcome lists every element that changed, at its next version,
452
+ * for the caller to broadcast and merge. New shapes come first so arrows in the
453
+ * same request can join them; an id already on the board updates that element.
454
+ */
455
+ export function planDraw(scene: ReadonlyMap<string, SceneElement>, asked: DrawRequest): DrawOutcome {
456
+ const outcome: DrawOutcome = { changed: [], drawn: [], removed: [], problems: [] };
457
+ const request: DrawRequest = {
458
+ shapes: (asked.shapes ?? []).slice(0, MAX_SHAPES).flatMap((shape) => tidyShape(shape, outcome.problems) ?? []),
459
+ remove: (asked.remove ?? []).filter((id): id is string => typeof id === "string").slice(0, MAX_REMOVALS),
460
+ };
461
+ if ((asked.shapes?.length ?? 0) > MAX_SHAPES) outcome.problems.push(`only the first ${MAX_SHAPES} shapes were drawn; send the rest in another call`);
462
+ if ((asked.remove?.length ?? 0) > MAX_REMOVALS) outcome.problems.push(`only the first ${MAX_REMOVALS} removals were made; a board is not cleared in one call`);
463
+ const working = new Map(scene);
464
+ const touched = new Map<string, SceneElement>();
465
+ const put = (element: SceneElement) => {
466
+ working.set(element.id, element);
467
+ touched.set(element.id, element);
468
+ };
469
+ /** Update an element already in the working scene (bumping it once per request, however many times it is touched). */
470
+ const revise = (element: SceneElement, changes: Partial<SceneElement>): SceneElement => {
471
+ const current = working.get(element.id) ?? element;
472
+ const next = touched.has(current.id) ? { ...current, ...changes } : bumped(current, changes);
473
+ put(next);
474
+ return next;
475
+ };
476
+ const place = placement(scene);
477
+ /** List an arrow on the shape it joins, or a browser would not move it with the shape. */
478
+ const attach = (shapeId: string, arrowId: string) => {
479
+ const shape = working.get(shapeId);
480
+ if (!shape || shape.isDeleted || (shape.boundElements ?? []).some((entry) => entry.id === arrowId)) return;
481
+ revise(shape, { boundElements: [...(shape.boundElements ?? []), { id: arrowId, type: "arrow" }] });
482
+ };
483
+ /** The request's names for the elements it makes, so arrows can join them. */
484
+ const names = new Map<string, string>();
485
+ const resolve = (name: string | undefined): SceneElement | undefined => {
486
+ if (!name) return undefined;
487
+ const id = names.get(name) ?? name;
488
+ const found = working.get(id);
489
+ return found && !found.isDeleted ? found : undefined;
490
+ };
491
+
492
+ // Removals first: a label goes with its shape, and an arrow that joined a removed shape comes loose.
493
+ for (const id of request.remove ?? []) {
494
+ const target = working.get(id);
495
+ if (!target || target.isDeleted) {
496
+ outcome.problems.push(`nothing to remove with id ${plainText(id).slice(0, 40)}`);
497
+ continue;
498
+ }
499
+ // A deletion is not one the user can undo, so agents take back only what agents drew.
500
+ if (!(target.customData as { botLobby?: boolean } | undefined)?.botLobby) {
501
+ outcome.problems.push(`${plainText(id).slice(0, 40)} was not drawn by an agent, so it is not yours to remove; ask the user to delete it (moving, recolouring and relabelling it are fine)`);
502
+ continue;
503
+ }
504
+ revise(target, { isDeleted: true });
505
+ outcome.removed.push(id);
506
+ for (const bound of target.boundElements ?? []) {
507
+ const other = working.get(bound.id);
508
+ if (!other || other.isDeleted) continue;
509
+ if (bound.type === "text") {
510
+ revise(other, { isDeleted: true });
511
+ outcome.removed.push(other.id);
512
+ } else if (bound.type === "arrow") {
513
+ revise(other, {
514
+ ...(other.startBinding && (other.startBinding as { elementId?: string }).elementId === id ? { startBinding: null } : {}),
515
+ ...(other.endBinding && (other.endBinding as { elementId?: string }).elementId === id ? { endBinding: null } : {}),
516
+ });
517
+ }
518
+ }
519
+ }
520
+
521
+ const shapes = request.shapes ?? [];
522
+ const isLinear = (shape: DrawShape) => shape.type === "arrow" || shape.type === "line";
523
+ // Shapes and text first, then the arrows and lines that join them.
524
+ for (const shape of [...shapes.filter((entry) => !isLinear(entry)), ...shapes.filter(isLinear)]) {
525
+ const name = shape.id ?? shape.type ?? "shape";
526
+ const existing = shape.id ? working.get(shape.id) : undefined;
527
+ if (existing && !existing.isDeleted) updateElement(existing, shape, name);
528
+ else if (shape.id && existing?.isDeleted) outcome.problems.push(`${shape.id} was deleted; give the new shape another id`);
529
+ else createElement(shape, name);
530
+ }
531
+
532
+ function updateElement(existing: SceneElement, shape: DrawShape, name: string): void {
533
+ const fontSize = shape.fontSize ?? (typeof existing.fontSize === "number" ? existing.fontSize : FONT_SIZE);
534
+ const color = strokeColor(shape.color);
535
+ const fill = fillColor(shape.fill);
536
+ if (shape.color && !color) outcome.problems.push(`unknown colour "${shape.color}" for ${name}; use a name (red, blue, green…) or a #hex`);
537
+ if (shape.fill && !fill) outcome.problems.push(`unknown fill "${shape.fill}" for ${name}; use a name (red, blue, green…), transparent or a #hex`);
538
+ let next = existing;
539
+ const moved = (shape.x !== undefined && shape.x !== existing.x) || (shape.y !== undefined && shape.y !== existing.y);
540
+ const resized = (shape.width !== undefined && shape.width !== existing.width) || (shape.height !== undefined && shape.height !== existing.height);
541
+ const changes: Partial<SceneElement> = {
542
+ ...(color ? { strokeColor: color } : {}),
543
+ ...(fill ? { backgroundColor: fill } : {}),
544
+ ...(shape.dashed !== undefined ? { strokeStyle: shape.dashed ? "dashed" : "solid" } : {}),
545
+ };
546
+ if (existing.type === "text" && !existing.containerId) {
547
+ if (shape.text !== undefined) {
548
+ const lines = shape.text.split("\n");
549
+ Object.assign(changes, { text: shape.text, originalText: shape.text, ...measure(lines, fontSize) });
550
+ }
551
+ if (shape.x !== undefined) changes.x = shape.x;
552
+ if (shape.y !== undefined) changes.y = shape.y;
553
+ } else if (existing.type !== "text" && !isLinear({ type: existing.type })) {
554
+ if (shape.x !== undefined) changes.x = shape.x;
555
+ if (shape.y !== undefined) changes.y = shape.y;
556
+ if (shape.width !== undefined) changes.width = Math.max(10, shape.width);
557
+ if (shape.height !== undefined) changes.height = Math.max(10, shape.height);
558
+ } else if (existing.type !== "text" && (shape.x !== undefined || shape.y !== undefined || shape.width !== undefined || shape.height !== undefined)) {
559
+ outcome.problems.push(`${name} is an arrow or line; delete it and draw it again to move it (arrows joined to shapes follow the shapes)`);
560
+ }
561
+ const touchedNow = Object.keys(changes).length > 0 || shape.text !== undefined;
562
+ if (!touchedNow) return void outcome.problems.push(`nothing to change on ${name}`);
563
+ next = revise(existing, changes);
564
+ // The label follows its container: its text, its colour, and the middle of the shape.
565
+ const bound = (next.boundElements ?? []).find((entry) => entry.type === "text");
566
+ const labelElement = bound ? working.get(bound.id) : undefined;
567
+ if (labelElement && !labelElement.isDeleted) {
568
+ const text = shape.text ?? (typeof labelElement.originalText === "string" ? labelElement.originalText : "");
569
+ const size = shape.fontSize ?? (typeof labelElement.fontSize === "number" ? labelElement.fontSize : FONT_SIZE);
570
+ const remade = labelOn(next, text, size, color ?? (labelElement.strokeColor as string));
571
+ revise(labelElement, { text: remade.text, originalText: remade.originalText, fontSize: size, x: remade.x, y: remade.y, width: remade.width, height: remade.height, strokeColor: remade.strokeColor });
572
+ } else if (shape.text !== undefined && next.type !== "text") {
573
+ const made = labelOn(next, shape.text, fontSize, color ?? next.strokeColor);
574
+ put(made);
575
+ next = revise(next, { boundElements: [...(next.boundElements ?? []), { id: made.id, type: "text" }] });
576
+ }
577
+ if (moved || resized) reroute(next);
578
+ outcome.drawn.push({ name, id: existing.id, type: existing.type, action: "updated" });
579
+ }
580
+
581
+ /** Arrows joined to a shape that moved or changed size follow it; an end that is joined to nothing stays where it is. */
582
+ function reroute(shape: SceneElement): void {
583
+ for (const bound of shape.boundElements ?? []) {
584
+ if (bound.type !== "arrow") continue;
585
+ const arrow = working.get(bound.id);
586
+ const points = arrow?.points as ReadonlyArray<readonly [number, number]> | undefined;
587
+ // Only straight two-point arrows are re-aimed; a path someone bent in a browser is left as drawn.
588
+ if (!arrow || arrow.isDeleted || arrow.type !== "arrow" || points?.length !== 2) continue;
589
+ const startId = (arrow.startBinding as { elementId?: string } | null)?.elementId;
590
+ const endId = (arrow.endBinding as { elementId?: string } | null)?.elementId;
591
+ const from = startId ? working.get(startId) : undefined;
592
+ const to = endId ? working.get(endId) : undefined;
593
+ const loose = { start: { x: arrow.x, y: arrow.y }, end: { x: arrow.x + points[1]![0], y: arrow.y + points[1]![1] } };
594
+ const next = revise(arrow, route(from && !from.isDeleted ? from : undefined, to && !to.isDeleted ? to : undefined, loose));
595
+ const labelId = (next.boundElements ?? []).find((entry) => entry.type === "text")?.id;
596
+ const text = labelId ? working.get(labelId) : undefined;
597
+ if (text && !text.isDeleted) {
598
+ const middle = arrowMiddle(next);
599
+ revise(text, { x: middle.x - text.width / 2, y: middle.y - text.height / 2 });
600
+ }
601
+ }
602
+ }
603
+
604
+ function createElement(shape: DrawShape, name: string): void {
605
+ const type = shape.type;
606
+ if (!type) return void outcome.problems.push(`${name}: give it a type (rectangle, ellipse, diamond, text, arrow or line)`);
607
+ const color = strokeColor(shape.color) ?? "#1e1e1e";
608
+ const fill = fillColor(shape.fill) ?? "transparent";
609
+ if (shape.color && !strokeColor(shape.color)) outcome.problems.push(`unknown colour "${shape.color}" for ${name}; drawn in black`);
610
+ if (shape.fill && !fillColor(shape.fill)) outcome.problems.push(`unknown fill "${shape.fill}" for ${name}; drawn unfilled`);
611
+ const fontSize = shape.fontSize ?? FONT_SIZE;
612
+ const custom = shape.id && !working.has(shape.id) ? { id: shape.id } : {};
613
+ const strokeStyle = shape.dashed ? "dashed" : "solid";
614
+ let made: SceneElement | undefined;
615
+ const extras: SceneElement[] = [];
616
+
617
+ if (isShapeType(type)) {
618
+ const size = fitShape(type, shape.text, fontSize);
619
+ const width = Math.max(10, shape.width ?? size.width);
620
+ const height = Math.max(10, shape.height ?? size.height);
621
+ const at = shape.x !== undefined && shape.y !== undefined ? { x: shape.x, y: shape.y } : place(width, height);
622
+ made = base(type, { ...custom, x: shape.x ?? at.x, y: shape.y ?? at.y, width, height, strokeColor: color, backgroundColor: fill, strokeStyle, roundness: type === "ellipse" ? null : { type: type === "rectangle" ? 3 : 2 } });
623
+ if (shape.text) {
624
+ const text = label(made, shape.text, fontSize, color);
625
+ made = { ...made, boundElements: [{ id: text.id, type: "text" }] };
626
+ extras.push(text);
627
+ }
628
+ } else if (type === "text") {
629
+ if (!shape.text) return void outcome.problems.push(`${name}: a text element needs its text`);
630
+ const lines = shape.text.split("\n");
631
+ const size = measure(lines, fontSize);
632
+ const at = shape.x !== undefined && shape.y !== undefined ? { x: shape.x, y: shape.y } : place(size.width, size.height);
633
+ made = { ...textElement(shape.text, { x: shape.x ?? at.x, y: shape.y ?? at.y, fontSize, color }), ...custom };
634
+ } else if (type === "arrow" || type === "line") {
635
+ made = createLinear(type, shape, name, color, strokeStyle, custom);
636
+ if (made && shape.text) {
637
+ const text = labelOn(made, shape.text, fontSize, color);
638
+ made = { ...made, boundElements: [...(made.boundElements ?? []), { id: text.id, type: "text" }] };
639
+ extras.push(text);
640
+ }
641
+ } else {
642
+ return void outcome.problems.push(`${name}: cannot draw a "${type}"; use rectangle, ellipse, diamond, text, arrow or line`);
643
+ }
644
+ if (!made) return;
645
+ names.set(name, made.id);
646
+ if (shape.id) names.set(shape.id, made.id);
647
+ put(made);
648
+ for (const extra of extras) put(extra);
649
+ if (made.type === "arrow") {
650
+ for (const key of ["startBinding", "endBinding"] as const) {
651
+ const target = made[key] as { elementId?: string } | null;
652
+ if (target?.elementId) attach(target.elementId, made.id);
653
+ }
654
+ }
655
+ outcome.drawn.push({ name, id: made.id, type: made.type, action: "created" });
656
+ }
657
+
658
+ function createLinear(type: "arrow" | "line", shape: DrawShape, name: string, color: string, strokeStyle: string, custom: { id?: string }): SceneElement | undefined {
659
+ const common = { ...custom, strokeColor: color, strokeStyle, roundness: { type: 2 }, lastCommittedPoint: null, startBinding: null, endBinding: null, startArrowhead: null, endArrowhead: type === "arrow" ? "arrow" : null, ...(type === "arrow" ? { elbowed: false } : { polygon: false }) };
660
+ if ((shape.from !== undefined || shape.to !== undefined) && type === "line") return void outcome.problems.push(`${name}: a line does not join shapes; use an arrow, or give points`);
661
+ if (shape.from !== undefined || shape.to !== undefined) {
662
+ const from = resolve(shape.from);
663
+ const to = resolve(shape.to);
664
+ if (!shape.from || !shape.to) return void outcome.problems.push(`${name}: an arrow between shapes needs both from and to`);
665
+ if (!from || !to) return void outcome.problems.push(`${name}: no shape with id ${!from ? shape.from : shape.to} (draw it in the same request, or use an id from excalidraw_read)`);
666
+ if (from.id === to.id) return void outcome.problems.push(`${name}: from and to are the same shape`);
667
+ return base(type, { ...common, ...joinShapes(from, to) });
668
+ }
669
+ const points = shape.points;
670
+ if (!points || points.length < 2 || points.some((point) => point.length !== 2 || !Number.isFinite(point[0]) || !Number.isFinite(point[1]))) {
671
+ return void outcome.problems.push(`${name}: an ${type} needs from and to (shape ids), or points: [[x, y], [x, y], …] with at least two`);
672
+ }
673
+ const [first] = points as [readonly [number, number]];
674
+ const relative = points.map((point) => [point[0] - first[0], point[1] - first[1]]);
675
+ const xs = relative.map((point) => point[0]!);
676
+ const ys = relative.map((point) => point[1]!);
677
+ return base(type, { ...common, x: first[0], y: first[1], width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys), points: relative });
678
+ }
679
+
680
+ outcome.changed = [...touched.values()];
681
+ return outcome;
682
+ }
683
+
684
+ /* ------------------------------------------------------------- reading */
685
+
686
+ function quoted(text: string): string {
687
+ const flat = plainText(text);
688
+ return `"${flat.length > 80 ? `${flat.slice(0, 79)}…` : flat}"`;
689
+ }
690
+
691
+ function where(element: SceneElement): string {
692
+ return `at ${Math.round(element.x)},${Math.round(element.y)}${element.width || element.height ? ` ${Math.round(element.width)}×${Math.round(element.height)}` : ""}`;
693
+ }
694
+
695
+ /** The label a shape or arrow carries, from its bound text element. */
696
+ function labelOf(element: SceneElement, scene: ReadonlyMap<string, SceneElement>): string {
697
+ const bound = (element.boundElements ?? []).find((entry) => entry.type === "text");
698
+ const text = bound ? scene.get(bound.id) : undefined;
699
+ return text && !text.isDeleted && typeof text.originalText === "string" ? text.originalText : typeof text?.text === "string" && !text.isDeleted ? text.text : "";
700
+ }
701
+
702
+ /** How many elements one reading lists before it says how many more there are. */
703
+ const READ_LIMIT = 120;
704
+ const READ_CHARS = 16_000;
705
+
706
+ /**
707
+ * A scene in words: shapes with their labels, arrows as `from → to`, free
708
+ * text, and a count of what is only drawing (freehand, images). Ids are there
709
+ * so the reader can move, recolour or delete what it names.
710
+ */
711
+ export function describeScene(scene: ReadonlyMap<string, SceneElement>, name: string): string {
712
+ const shown = [...scene.values()].filter((element) => !element.isDeleted);
713
+ const bounds = sceneBounds(shown);
714
+ // A label is part of its shape, not one more thing on the board.
715
+ const things = shown.filter((element) => !(element.type === "text" && element.containerId)).length;
716
+ const head = `Excalidraw session "${name}": ${shown.length === 0 ? "the board is empty" : `${things} element${things === 1 ? "" : "s"}, spanning x ${bounds!.x1}…${bounds!.x2}, y ${bounds!.y1}…${bounds!.y2}`}.`;
717
+ const lines: string[] = [];
718
+ const other = new Map<string, number>();
719
+ for (const element of shown) {
720
+ if (element.type === "text" && element.containerId) continue;
721
+ if (element.type === "text") lines.push(`text ${quoted(String(element.originalText ?? element.text ?? ""))} id=${element.id} ${where(element)}`);
722
+ else if (isShapeType(element.type)) {
723
+ const text = labelOf(element, scene);
724
+ lines.push(`${element.type} ${text ? `${quoted(text)} ` : ""}id=${element.id} ${where(element)}${element.backgroundColor && element.backgroundColor !== "transparent" ? ` fill ${element.backgroundColor}` : ""}`);
725
+ } else if (element.type === "arrow" || element.type === "line") {
726
+ const ends = ["startBinding", "endBinding"].map((key) => {
727
+ const binding = element[key] as { elementId?: string } | null;
728
+ const target = binding?.elementId ? scene.get(binding.elementId) : undefined;
729
+ if (!target || target.isDeleted) return undefined;
730
+ return labelOf(target, scene) || `${target.type} ${target.id}`;
731
+ });
732
+ const text = labelOf(element, scene);
733
+ const route = ends[0] || ends[1] ? `${ends[0] ? quoted(ends[0]) : "(loose end)"} ${element.type === "arrow" ? "→" : "—"} ${ends[1] ? quoted(ends[1]) : "(loose end)"}` : where(element);
734
+ lines.push(`${element.type} ${route}${text ? ` labelled ${quoted(text)}` : ""} id=${element.id}`);
735
+ } else if (element.type === "frame" || element.type === "magicframe") lines.push(`frame${element.name ? ` ${quoted(String(element.name))}` : ""} id=${element.id} ${where(element)}`);
736
+ else other.set(element.type, (other.get(element.type) ?? 0) + 1);
737
+ }
738
+ // The most a reading holds: a line count, and a size (long labels can make a few lines a lot of text).
739
+ let size = 0;
740
+ const kept = lines.slice(0, READ_LIMIT).filter((line) => (size += line.length + 3) <= READ_CHARS);
741
+ const listed = kept.map((line) => `- ${line}`);
742
+ const more = lines.length > kept.length ? [`… and ${lines.length - kept.length} more elements not listed`] : [];
743
+ const drawn = [...other].map(([type, count]) => `${count} ${type}${count === 1 ? "" : type.endsWith("s") ? "" : "s"}`);
744
+ const rest = drawn.length > 0 ? [`Also on the board (drawings, not described): ${drawn.join(", ")}.`] : [];
745
+ return [head, ...listed, ...more, ...rest].join("\n");
746
+ }