agmon 0.0.0-stage → 0.1.0

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,335 @@
1
+ import { Container, FillGradient, Graphics, Rectangle, Text } from "pixi.js";
2
+ import { STATE_COLORS, STATE_LABELS } from "./office-layout";
3
+
4
+ function clip(text, max) {
5
+ const str = String(text || "");
6
+ return str.length > max ? `${str.slice(0, max - 1)}…` : str;
7
+ }
8
+
9
+ const MONO = "ui-monospace, Menlo, monospace";
10
+ const SANS = "Inter, system-ui, sans-serif";
11
+
12
+ // Vertical two/three-stop gradient in the shape's own space — the cheap trick
13
+ // that turns flat 2D primitives into lit, rounded-looking "3D" volumes.
14
+ function vgrad(...stops) {
15
+ return new FillGradient({
16
+ type: "linear",
17
+ start: { x: 0, y: 0 },
18
+ end: { x: 0, y: 1 },
19
+ colorStops: stops.map((color, i) => ({ offset: i / (stops.length - 1), color })),
20
+ textureSpace: "local",
21
+ });
22
+ }
23
+
24
+ // A chibi robot (big round helmet, oval visor with scanline eyes, coloured ear
25
+ // pods, tiny body) drawn from primitives — no sprite sheet, so every colour and
26
+ // pose is controllable from live data. Origin = base of the torso (hidden
27
+ // behind the desk/console), so the helmet and shoulders peek over the edge.
28
+ //
29
+ // Poses (`mode`):
30
+ // streaming typing, scanline eyes pending one arm waving, waiting
31
+ // happy ^ ^ eyes, smile, bounce sleeping closed eyes, drifting z's
32
+ // error X eyes, shaking, smoke, arms up
33
+ export function createCharacter({ color, depth = 1, scale = 1, seed = 0, trimColor, mode = "streaming" }) {
34
+ const root = new Container();
35
+ const trims = [0xb5528f, 0x3fb6a8, 0xe08a3c, 0x7c63d8, 0x4a8ee0];
36
+ const trim = trimColor ?? trims[seed % trims.length];
37
+ const SHELL = 0xf3f5f9;
38
+ const SHADE = 0xc9d0dc;
39
+
40
+ // Torso: two stacked shells with a coloured band between them.
41
+ const body = new Graphics();
42
+ body.roundRect(-17, -30, 34, 22, 11).fill(vgrad(0xffffff, SHELL, SHADE));
43
+ body.roundRect(-17, -30, 34, 8, 8).fill({ color: 0xffffff, alpha: 0.6 });
44
+ body.roundRect(-14, -12, 28, 8, 4).fill({ color: trim });
45
+ body.roundRect(-16, -8, 32, 16, 8).fill(vgrad(SHELL, SHADE, 0x8c97a9));
46
+ body.roundRect(-16, 2, 32, 6, 4).fill({ color: SHADE, alpha: 0.6 });
47
+
48
+ const neck = new Graphics();
49
+ neck.roundRect(-10, -36, 20, 8, 4).fill({ color: trim });
50
+ const status = new Graphics();
51
+ status.roundRect(-6, -34, 12, 3, 1.5).fill({ color });
52
+
53
+ // Arms pivot at the shoulder so raising one does not swing it around the hip.
54
+ const armL = new Graphics();
55
+ const armR = new Graphics();
56
+ for (const [arm, x, dir] of [[armL, -26, -1], [armR, 18, 1]]) {
57
+ arm.roundRect(x, -28, 8, 24, 4).fill({ color: SHELL });
58
+ arm.roundRect(x, -28, 8, 8, 4).fill({ color: 0xffffff, alpha: 0.6 });
59
+ arm.circle(x + 4 + dir * 1.5, -2, 4.5).stroke({ width: 2.4, color: SHELL });
60
+ arm.pivot.set(x + 4, -26);
61
+ arm.position.set(x + 4, -26);
62
+ }
63
+
64
+ const head = new Graphics();
65
+ head.circle(-27, -56, 8).fill({ color: trim });
66
+ head.circle(27, -56, 8).fill({ color: trim });
67
+ head.circle(-29, -58, 3).fill({ color: 0xffffff, alpha: 0.35 });
68
+ head.circle(25, -58, 3).fill({ color: 0xffffff, alpha: 0.35 });
69
+ head.ellipse(0, -56, 26, 24).fill(vgrad(0xffffff, SHELL, 0x9aa6b8));
70
+ head.ellipse(4, -50, 24, 21).stroke({ width: 3, color: 0x6b7a90, alpha: 0.25 }); // bottom-right rim shade
71
+ head.ellipse(-8, -68, 12, 5.5).fill({ color: 0xffffff, alpha: 0.8 }); // specular
72
+ head.circle(-14, -64, 2).fill({ color: 0xffffff, alpha: 0.9 });
73
+ head.ellipse(0, -53, 22, 17).fill({ color: SHADE });
74
+ head.ellipse(0, -53, 20, 15.5).fill({ color: 0x0f141c });
75
+ head.ellipse(-5, -58, 11, 5).fill({ color: 0xffffff, alpha: 0.06 });
76
+
77
+ const face = new Graphics();
78
+ const fx = new Graphics(); // smoke / z's, drawn in front of everything
79
+
80
+ root.addChild(body, neck, status, armL, armR, head, face, fx);
81
+ root.scale.set(scale * depth);
82
+
83
+ let currentMode = mode;
84
+ let faceColor = color;
85
+
86
+ const stroke = (w, c, a = 1) => ({ width: w, color: c, alpha: a, cap: "round", join: "round" });
87
+
88
+ const eye = (cx, cy, w, h, a, kind) => {
89
+ if (kind === "happy") {
90
+ face.moveTo(cx - 4.6, cy + 2).arc(cx, cy + 2, 4.6, Math.PI, 0).stroke(stroke(2.6, faceColor, a));
91
+ } else if (kind === "sleep") {
92
+ face.moveTo(cx - 4.6, cy).arc(cx, cy, 4.6, 0, Math.PI).stroke(stroke(2.2, faceColor, 0.7));
93
+ } else if (kind === "x") {
94
+ face.moveTo(cx - 4, cy - 4).lineTo(cx + 4, cy + 4).stroke(stroke(2.6, faceColor, a));
95
+ face.moveTo(cx + 4, cy - 4).lineTo(cx - 4, cy + 4).stroke(stroke(2.6, faceColor, a));
96
+ } else {
97
+ face.ellipse(cx, cy, w, h).fill({ color: faceColor, alpha: 0.9 * a });
98
+ for (let y = cy - h + 1; y < cy + h; y += 2.4) {
99
+ face.rect(cx - w - 1, y, w * 2 + 2, 1).fill({ color: 0x0f141c, alpha: 0.55 });
100
+ }
101
+ }
102
+ };
103
+
104
+ return {
105
+ root,
106
+ statusDot: status,
107
+ setMode(next) {
108
+ currentMode = next;
109
+ },
110
+ /** `intensity` 0..1 scales how hard the agent is working. */
111
+ animate(t, intensity = 0) {
112
+ const bx = root.baseX ?? root.x;
113
+ const m = currentMode;
114
+ const phase = t * 0.012 + bx;
115
+
116
+ // Body motion per mode.
117
+ let bob = Math.sin(t * 0.004 + bx * 0.05) * (0.8 + intensity * 1.4);
118
+ let shake = 0;
119
+ let tilt = 0;
120
+ if (m === "happy") bob = -Math.abs(Math.sin(t * 0.005 + bx)) * 3.2;
121
+ if (m === "sleeping") {
122
+ bob = Math.sin(t * 0.0016 + bx) * 1.1 + 2.2;
123
+ tilt = 0.12 + Math.sin(t * 0.0016 + bx) * 0.02;
124
+ }
125
+ if (m === "error") shake = Math.sin(t * 0.09) * 1.8;
126
+ root.x = bx + shake;
127
+ root.y = (root.baseY || 0) + bob;
128
+ root.rotation = tilt;
129
+
130
+ // Arms.
131
+ armL.rotation = 0;
132
+ armR.rotation = 0;
133
+ if (m === "streaming") {
134
+ armL.rotation = Math.sin(phase) * 0.14 * (0.3 + intensity);
135
+ armR.rotation = -Math.sin(phase + 1.3) * 0.14 * (0.3 + intensity);
136
+ } else if (m === "pending") {
137
+ armL.rotation = 2.5 + Math.sin(t * 0.012) * 0.35; // waving for attention
138
+ armR.rotation = -Math.sin(phase + 1.3) * 0.06;
139
+ } else if (m === "happy") {
140
+ armL.rotation = 2.2 + Math.sin(t * 0.01) * 0.2;
141
+ armR.rotation = -2.2 - Math.sin(t * 0.01 + 1) * 0.2;
142
+ } else if (m === "error") {
143
+ armL.rotation = 2.7 + Math.sin(t * 0.08) * 0.1; // hands on head
144
+ armR.rotation = -2.7 - Math.sin(t * 0.08) * 0.1;
145
+ } else if (m === "sleeping") {
146
+ armL.rotation = 0.05;
147
+ armR.rotation = -0.05;
148
+ }
149
+
150
+ // Face.
151
+ const blinking = Math.sin(t * 0.0013 + seed * 2.1) > 0.985;
152
+ const flicker = 0.85 + Math.sin(t * 0.01 + seed) * 0.15;
153
+ face.clear();
154
+ const kind = m === "happy" ? "happy" : m === "sleeping" ? "sleep" : m === "error" ? "x" : "scan";
155
+ eye(-8, -57, 4.8, blinking ? 0.8 : 4.4, flicker, kind);
156
+ eye(8, -57, 4.8, blinking ? 0.8 : 4.4, flicker, kind);
157
+ if (m === "happy") {
158
+ face.moveTo(-6, -48).arc(0, -48, 6, 0, Math.PI).stroke(stroke(2.2, faceColor, 0.9));
159
+ } else if (m === "sleeping") {
160
+ face.circle(0, -45, 1.8).stroke(stroke(1.6, faceColor, 0.6));
161
+ } else if (m === "error") {
162
+ face.moveTo(-7, -45).lineTo(-3.5, -48).lineTo(0, -45).lineTo(3.5, -48).lineTo(7, -45)
163
+ .stroke(stroke(2, faceColor, flicker));
164
+ } else {
165
+ const mw = 5 + intensity * 3;
166
+ face.ellipse(0, -46, mw, 2.4 + intensity * 1.2).fill({ color: faceColor, alpha: 0.75 * flicker });
167
+ }
168
+
169
+ // Effects: drifting z's while asleep, smoke while failing.
170
+ fx.clear();
171
+ if (m === "sleeping") {
172
+ for (let k = 0; k < 3; k += 1) {
173
+ const p = ((t * 0.0004 + k / 3 + seed * 0.13) % 1);
174
+ const zx = 20 + p * 16;
175
+ const zy = -78 - p * 30;
176
+ const s = 3 + p * 4;
177
+ fx.moveTo(zx, zy).lineTo(zx + s, zy).lineTo(zx, zy + s).lineTo(zx + s, zy + s)
178
+ .stroke(stroke(1.6, 0xa5b4cc, 1 - p));
179
+ }
180
+ } else if (m === "error") {
181
+ for (let k = 0; k < 4; k += 1) {
182
+ const p = ((t * 0.0007 + k / 4 + seed * 0.1) % 1);
183
+ fx.circle(Math.sin(p * 9 + k) * 6 + (k - 1.5) * 4, -80 - p * 34, 3 + p * 6)
184
+ .fill({ color: 0x6b7280, alpha: 0.55 * (1 - p) });
185
+ }
186
+ }
187
+
188
+ status.alpha = 0.6 + Math.sin(t * 0.007 + seed) * 0.4 * (0.3 + intensity);
189
+ },
190
+ setState(nextColor) {
191
+ status.tint = nextColor;
192
+ },
193
+ };
194
+ }
195
+
196
+ // Desk + monitor + nameplate. Origin = top-left of the desk surface. All the
197
+ // numbers sit on the desk's front face so neighbouring rows never collide:
198
+ // row 1 account · row 2 model + tokens · row 3 latency, cost, cache share
199
+ // bottom bar = elapsed time (green → amber → red), left pile = queued requests
200
+ export function createDesk({
201
+ color,
202
+ label = "",
203
+ meta = "",
204
+ elapsedMs = 0,
205
+ cost = 0,
206
+ cachedPct = 0,
207
+ queued = 1,
208
+ depth = 1,
209
+ }) {
210
+ const root = new Container();
211
+ const W = 150;
212
+ const H = 96;
213
+
214
+ const desk = new Graphics();
215
+ // Soft layered ground shadow (offset down-right, as if lit from top-left).
216
+ desk.ellipse(W / 2 + 8, H + 6, W * 0.62, 11).fill({ color: 0x000000, alpha: 0.18 });
217
+ desk.ellipse(W / 2 + 6, H + 4, W * 0.56, 8).fill({ color: 0x000000, alpha: 0.28 });
218
+ // Front face (gradient, darker toward the floor) + right side face for depth.
219
+ desk.roundRect(0, 34, W, H - 34, 7).fill(vgrad(0x2b3443, 0x181d26));
220
+ desk.poly([W, 40, W + 9, 32, W + 9, H - 8, W, H - 2]).fill({ color: 0x10141b });
221
+ // Desk top: lighter slab seen from slightly above, with a bright front lip.
222
+ desk.poly([-5, 28, 8, 20, W - 2, 20, W + 9, 28]).fill({ color: 0x5d6b7d });
223
+ desk.roundRect(-5, 26, W + 14, 14, 6).fill(vgrad(0x65738a, 0x3a4554));
224
+ desk.roundRect(-5, 26, W + 14, 2.5, 2).fill({ color: 0xffffff, alpha: 0.3 });
225
+ desk.roundRect(0, 40, W, 2, 1).fill({ color: 0x000000, alpha: 0.35 });
226
+ root.addChild(desk);
227
+
228
+ // Elapsed-time bar: fill grows with latency, colour warns when it drags.
229
+ const ratio = Math.min(1, elapsedMs / 40000);
230
+ const barColor = elapsedMs < 12000 ? 0x34d399 : elapsedMs < 26000 ? 0xfbbf24 : 0xef4444;
231
+ const bar = new Graphics();
232
+ bar.roundRect(10, H - 9, W - 20, 4, 2).fill({ color: 0x0f141c });
233
+ bar.roundRect(10, H - 9, Math.max(4, (W - 20) * ratio), 4, 2).fill({ color: barColor });
234
+ root.addChild(bar);
235
+
236
+ const glow = new Graphics();
237
+ glow.roundRect(W / 2 - 62, -30, 76, 62, 12).fill({ color, alpha: 0.12 });
238
+ root.addChild(glow);
239
+
240
+ const screen = new Graphics();
241
+ screen.roundRect(W / 2 - 50, -16, 58, 38, 5).fill({ color: 0x000000, alpha: 0.35 }); // drop shadow
242
+ screen.roundRect(W / 2 - 52, -18, 58, 38, 5).fill(vgrad(0x2a313c, 0x0b0e13));
243
+ screen.roundRect(W / 2 - 48, -14, 50, 30, 3).fill({ color, alpha: 0.6 });
244
+ for (let i = 0; i < 4; i += 1) {
245
+ screen.roundRect(W / 2 - 44, -9 + i * 6, 18 + ((i * 11) % 22), 2.4, 1).fill({ color: 0xffffff, alpha: 0.5 });
246
+ }
247
+ screen.poly([W / 2 - 48, -14, W / 2 - 22, -14, W / 2 - 34, 16, W / 2 - 48, 16]).fill({ color: 0xffffff, alpha: 0.07 }); // glass glare
248
+ screen.roundRect(W / 2 - 29, 20, 8, 6, 2).fill({ color: 0x2d3541 });
249
+ root.addChild(screen);
250
+
251
+ const keys = new Graphics();
252
+ keys.roundRect(W / 2 + 14, 29, 34, 8, 3).fill({ color: 0x0b0e13 });
253
+ keys.roundRect(W / 2 + 14, 27, 34, 8, 3).fill(vgrad(0x3b4554, 0x232a35));
254
+ root.addChild(keys);
255
+
256
+ // Request pile: one sheet per queued request (max 5) + ×N when stacked.
257
+ const pile = new Graphics();
258
+ const sheets = Math.min(5, Math.max(1, queued));
259
+ for (let i = 0; i < sheets; i += 1) {
260
+ pile.roundRect(-2 + (i % 2), 22 - i * 3.4, 22, 5, 1.5)
261
+ .fill({ color: i % 2 ? 0xe6edf6 : 0xcfd8e6 })
262
+ .stroke({ width: 0.6, color: 0x0f141c, alpha: 0.5 });
263
+ }
264
+ root.addChild(pile);
265
+ if (queued > 1) {
266
+ const q = new Text({ text: `×${queued}`, style: { fontFamily: SANS, fontSize: 10, fill: 0xfbbf24, fontWeight: "800" } });
267
+ q.anchor.set(0.5, 1);
268
+ q.x = 9;
269
+ q.y = 22 - sheets * 3.4 - 1;
270
+ root.addChild(q);
271
+ }
272
+
273
+ const name = new Text({
274
+ text: clip(label, 20),
275
+ style: { fontFamily: SANS, fontSize: 12, fill: 0xe6edf6, fontWeight: "700" },
276
+ });
277
+ name.anchor.set(0.5, 0);
278
+ name.x = W / 2;
279
+ name.y = 43;
280
+
281
+ const sub = new Text({
282
+ text: clip(meta, 28),
283
+ style: { fontFamily: MONO, fontSize: 9, fill: 0x93a1b5 },
284
+ });
285
+ sub.anchor.set(0.5, 0);
286
+ sub.x = W / 2;
287
+ sub.y = 58;
288
+
289
+ const secs = elapsedMs >= 1000 ? `${(elapsedMs / 1000).toFixed(1)}s` : `${elapsedMs}ms`;
290
+ const stats = new Text({
291
+ text: `${secs} · $${cost.toFixed(2)} · ${cachedPct}% cached`,
292
+ style: { fontFamily: MONO, fontSize: 8.5, fill: barColor },
293
+ });
294
+ stats.anchor.set(0.5, 0);
295
+ stats.x = W / 2;
296
+ stats.y = 71;
297
+
298
+ root.addChild(name, sub, stats);
299
+
300
+ // Selection ring (toggled from outside) + click target.
301
+ const ring = new Graphics();
302
+ ring.roundRect(-12, -76, W + 24, H + 84, 14).stroke({ width: 2.5, color: 0xfde047, alpha: 0.9 });
303
+ ring.roundRect(-12, -76, W + 24, H + 84, 14).fill({ color: 0xfde047, alpha: 0.05 });
304
+ ring.visible = false;
305
+ root.addChildAt(ring, 0);
306
+
307
+ root.eventMode = "static";
308
+ root.cursor = "pointer";
309
+ root.hitArea = new Rectangle(-12, -76, W + 24, H + 84);
310
+ root.scale.set(depth);
311
+
312
+ return {
313
+ root,
314
+ screen,
315
+ glow,
316
+ setSelected(on) {
317
+ ring.visible = !!on;
318
+ },
319
+ /** Flicker on the monitor while work is in flight. */
320
+ animate(t, intensity = 0) {
321
+ screen.alpha = 0.7 + Math.sin(t * 0.008 + root.x) * 0.12 * (0.4 + intensity);
322
+ glow.alpha = 0.3 + intensity * 0.5 + Math.sin(t * 0.004 + root.x) * 0.08;
323
+ },
324
+ };
325
+ }
326
+
327
+ export function createBadge(text, { color = 0x22d3ee, size = 10 } = {}) {
328
+ const label = new Text({
329
+ text,
330
+ style: { fontFamily: SANS, fontSize: size, fill: color, fontWeight: "700" },
331
+ });
332
+ return label;
333
+ }
334
+
335
+ export { STATE_COLORS, STATE_LABELS };
@@ -0,0 +1,188 @@
1
+ // Maps the frozen trace contract onto positions in a virtual office.
2
+ //
3
+ // Everything here derives from build-factory-graph's grouping so the scene and
4
+ // the topology view agree on what an "account" is. No new data model.
5
+
6
+ export const STATE_COLORS = {
7
+ pending: 0xf59e0b,
8
+ streaming: 0x22d3ee,
9
+ done: 0x34d399,
10
+ error: 0xef4444,
11
+ };
12
+
13
+ // A finished account that has been quiet this long is shown asleep.
14
+ export const SLEEP_AFTER_MS = 32000;
15
+
16
+ export const STATE_LABELS = {
17
+ pending: "Queued",
18
+ streaming: "Working",
19
+ done: "Done",
20
+ error: "Failed",
21
+ };
22
+
23
+ // Office geometry, in world units (the camera scales these to the viewport).
24
+ export const OFFICE = {
25
+ floorY: 820,
26
+ agentCols: 6,
27
+ agentRows: 5,
28
+ deskW: 150,
29
+ deskH: 84,
30
+ colGap: 200,
31
+ rowGap: 170,
32
+ originX: 60,
33
+ rackX: 1500,
34
+ rackY: 480,
35
+ podX: 1860,
36
+ podGap: 104,
37
+ podCapacity: 4,
38
+ };
39
+
40
+ function humanSize(n) {
41
+ if (!n) return "0";
42
+ if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
43
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
44
+ return String(n);
45
+ }
46
+
47
+ /**
48
+ * Build the scene graph: one workstation per account, one rack, one pod per
49
+ * provider.
50
+ *
51
+ * @param {Array} traces Records in the frozen contract shape.
52
+ * @returns {{ workstations: Array, rack: object, pods: Array, stats: object }}
53
+ */
54
+ export function buildOffice(traces = []) {
55
+ const byAccount = new Map();
56
+
57
+ for (const trace of traces) {
58
+ if (!trace?.connectionId) continue;
59
+ let entry = byAccount.get(trace.connectionId);
60
+ if (!entry) {
61
+ entry = {
62
+ connectionId: trace.connectionId,
63
+ account: trace.account || trace.connectionId,
64
+ model: trace.model,
65
+ provider: trace.provider,
66
+ clientIcon: trace.clientIcon,
67
+ traces: [],
68
+ tokens: { input: 0, output: 0, cached: 0 },
69
+ activeCount: 0,
70
+ errorCount: 0,
71
+ tools: [],
72
+ errorReason: null,
73
+ fallbackFrom: null,
74
+ queued: 0,
75
+ elapsedMs: 0,
76
+ cost: 0,
77
+ pendingCount: 0,
78
+ };
79
+ byAccount.set(trace.connectionId, entry);
80
+ }
81
+ entry.traces.push(trace);
82
+ for (const call of trace.toolCalls || []) {
83
+ const toolName = call.tool || call.type;
84
+ if (toolName && !entry.tools.includes(toolName)) entry.tools.push(toolName);
85
+ }
86
+ entry.tokens.input += trace.tokens?.input || 0;
87
+ entry.tokens.output += trace.tokens?.output || 0;
88
+ entry.tokens.cached += trace.tokens?.cached || 0;
89
+ entry.queued += Math.max(1, trace.concurrent || 1);
90
+ entry.elapsedMs = Math.max(entry.elapsedMs, trace.elapsedMs || 0);
91
+ entry.cost += trace.cost || 0;
92
+ if (trace.fallback?.from && !entry.fallbackFrom) entry.fallbackFrom = trace.fallback.from;
93
+ if (trace.state === "error") {
94
+ entry.errorCount += 1;
95
+ entry.errorReason = entry.errorReason || trace.error || "error";
96
+ } else if (trace.state !== "done") {
97
+ entry.activeCount += 1;
98
+ if (trace.state === "pending") entry.pendingCount += 1;
99
+ }
100
+ }
101
+
102
+ const workstations = [...byAccount.values()].map((entry, i) => {
103
+ const col = i % OFFICE.agentCols;
104
+ const row = Math.floor(i / OFFICE.agentCols);
105
+ // Only even rows get a character: half the desks are empty, which reads as a
106
+ // real office rather than a fully packed grid.
107
+ // Every account has an agent at its desk; what they are doing is `mode`.
108
+ const occupied = true;
109
+ const state = entry.errorCount > 0 ? "error" : entry.activeCount > 0 ? "streaming" : "done";
110
+ const mode =
111
+ state === "error"
112
+ ? "error"
113
+ : state === "streaming"
114
+ ? entry.pendingCount === entry.activeCount
115
+ ? "pending"
116
+ : "streaming"
117
+ : entry.elapsedMs >= SLEEP_AFTER_MS
118
+ ? "sleeping"
119
+ : "happy";
120
+ const totalTokens = entry.tokens.input + entry.tokens.output + entry.tokens.cached;
121
+ const cachedPct = totalTokens ? Math.round((entry.tokens.cached / totalTokens) * 100) : 0;
122
+ return {
123
+ ...entry,
124
+ occupied,
125
+ state,
126
+ mode,
127
+ cachedPct,
128
+ cost: Math.round(entry.cost * 100) / 100,
129
+ color: STATE_COLORS[state],
130
+ totalTokens: entry.tokens.input + entry.tokens.output + entry.tokens.cached,
131
+ totalLabel: humanSize(entry.tokens.input + entry.tokens.output + entry.tokens.cached),
132
+ count: entry.traces.length,
133
+ x: OFFICE.originX + col * OFFICE.colGap,
134
+ y: OFFICE.floorY - row * OFFICE.rowGap,
135
+ // Desks further back are drawn first and slightly smaller.
136
+ depth: 1,
137
+ busy: entry.activeCount,
138
+ };
139
+ });
140
+
141
+ const providers = [...new Set(workstations.map((w) => w.provider).filter(Boolean))];
142
+
143
+ const pods = providers.map((provider, i) => {
144
+ const clients = workstations.filter((w) => w.provider === provider);
145
+ const busy = clients.reduce((s, w) => s + w.busy, 0);
146
+ const tokens = clients.reduce((s, w) => s + w.totalTokens, 0);
147
+ const anyError = clients.some((w) => w.state === "error");
148
+ return {
149
+ provider,
150
+ x: OFFICE.podX,
151
+ y: OFFICE.rackY - ((providers.length - 1) * OFFICE.podGap) / 2 + i * OFFICE.podGap,
152
+ busy,
153
+ load: Math.min(1, busy / OFFICE.podCapacity),
154
+ overloaded: busy >= OFFICE.podCapacity,
155
+ totalTokens: tokens,
156
+ totalLabel: humanSize(tokens),
157
+ color: anyError ? STATE_COLORS.error : busy > 0 ? STATE_COLORS.streaming : STATE_COLORS.done,
158
+ };
159
+ });
160
+
161
+ // Fallback lanes: failed provider -> provider that actually served it.
162
+ const podNames = new Set(providers);
163
+ const laneMap = new Map();
164
+ for (const w of workstations) {
165
+ if (!w.fallbackFrom || !podNames.has(w.fallbackFrom) || w.fallbackFrom === w.provider) continue;
166
+ const key = `${w.fallbackFrom}>${w.provider}`;
167
+ const lane = laneMap.get(key) || { from: w.fallbackFrom, to: w.provider, count: 0 };
168
+ lane.count += 1;
169
+ laneMap.set(key, lane);
170
+ }
171
+
172
+ return {
173
+ workstations,
174
+ pods,
175
+ fallbacks: [...laneMap.values()],
176
+ rack: {
177
+ x: OFFICE.rackX,
178
+ y: OFFICE.rackY,
179
+ active: workstations.reduce((s, w) => s + w.busy, 0),
180
+ },
181
+ stats: {
182
+ agents: workstations.length,
183
+ busy: workstations.filter((w) => w.busy > 0).length,
184
+ tokens: workstations.reduce((s, w) => s + w.totalTokens, 0),
185
+ tokensLabel: humanSize(workstations.reduce((s, w) => s + w.totalTokens, 0)),
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,85 @@
1
+ import { Container, Graphics } from "pixi.js";
2
+
3
+ // Token packets travelling between desks, the rack, and provider pods.
4
+ //
5
+ // Three kinds, so the picture says which way tokens flow:
6
+ // input round, desk -> rack -> provider (arcs over the room)
7
+ // output diamond, provider -> rack -> desk (arcs underneath)
8
+ // cached square, desk -> rack only: served from cache, never reaches a provider
9
+ //
10
+ // One Graphics object is shared by every particle and rebuilt each frame: at a
11
+ // few hundred dots that is far cheaper than a Sprite per packet.
12
+
13
+ function lerpPoint(from, to, t, arc) {
14
+ const lift = Math.sin(t * Math.PI) * arc;
15
+ return {
16
+ x: from.x + (to.x - from.x) * t,
17
+ y: from.y + (to.y - from.y) * t - lift,
18
+ };
19
+ }
20
+
21
+ const MAX_PACKETS = 700;
22
+
23
+ export function createTokenStreams() {
24
+ const container = new Container();
25
+ const layer = new Graphics();
26
+ const glowLayer = new Graphics();
27
+ container.addChild(glowLayer, layer);
28
+
29
+ const packets = [];
30
+
31
+ return {
32
+ container,
33
+
34
+ /**
35
+ * Replace the set of active routes; called when the graph changes.
36
+ * route: { from, to, color, weight, shape?: "circle"|"diamond"|"square", arc?: number }
37
+ */
38
+ setRoutes(routes) {
39
+ packets.length = 0;
40
+ for (const route of routes) {
41
+ const spawn = Math.max(1, Math.min(6, Math.round(route.weight * 6)));
42
+ for (let i = 0; i < spawn; i += 1) {
43
+ packets.push({
44
+ from: route.from,
45
+ to: route.to,
46
+ t: (i / spawn + Math.random() * 0.1) % 1,
47
+ speed: 0.00035 + Math.random() * 0.0006 + route.weight * 0.0004,
48
+ color: route.color,
49
+ size: 1.6 + route.weight * 2.2,
50
+ shape: route.shape || "circle",
51
+ arc: route.arc ?? 26,
52
+ });
53
+ }
54
+ }
55
+ while (packets.length > MAX_PACKETS) packets.pop();
56
+ },
57
+
58
+ animate(deltaMS) {
59
+ layer.clear();
60
+ glowLayer.clear();
61
+
62
+ for (const p of packets) {
63
+ p.t += p.speed * deltaMS;
64
+ if (p.t > 1) {
65
+ p.t -= 1;
66
+ p.speed = 0.00035 + Math.random() * 0.0006;
67
+ }
68
+ const pt = lerpPoint(p.from, p.to, p.t, p.arc);
69
+ // Fade in/out at the ends so packets do not pop.
70
+ const alpha = Math.min(1, p.t * 6) * Math.min(1, (1 - p.t) * 6);
71
+ const s = p.size;
72
+
73
+ if (p.shape === "diamond") {
74
+ layer.poly([pt.x, pt.y - s * 1.5, pt.x + s * 1.2, pt.y, pt.x, pt.y + s * 1.5, pt.x - s * 1.2, pt.y]).fill({ color: p.color, alpha });
75
+ } else if (p.shape === "square") {
76
+ const q = s * 0.7;
77
+ layer.rect(pt.x - q, pt.y - q, q * 2, q * 2).fill({ color: p.color, alpha: alpha * 0.9 });
78
+ } else {
79
+ layer.circle(pt.x, pt.y, s).fill({ color: p.color, alpha });
80
+ }
81
+ glowLayer.circle(pt.x, pt.y, s * 3).fill({ color: p.color, alpha: alpha * 0.14 });
82
+ }
83
+ },
84
+ };
85
+ }