diffusionpi 0.2.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.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/app/demo.pi-factory.toml +36 -0
- package/app/extensions/diffusion-canvas.ts +964 -0
- package/app/extensions/smooth-scroll.ts +159 -0
- package/app/pi-factory.toml +31 -0
- package/app/prompts/demo-followup.md +1 -0
- package/app/prompts/demo-initial.md +1 -0
- package/bin/diffusionpi +40 -0
- package/docs/diffusion-canvas-repro.md +104 -0
- package/package.json +33 -0
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// Diffusion LLM servers (e.g. DiffusionGemma on vLLM) denoise a whole canvas
|
|
4
|
+
// internally and only stream committed tokens when a canvas converges, so
|
|
5
|
+
// clients receive bursts separated by silent denoising intervals.
|
|
6
|
+
//
|
|
7
|
+
// Live mode (truthful): when the server exposes the /v1/diffusion/events SSE
|
|
8
|
+
// side channel (vLLM --diffusion-stream-canvas), this widget renders the real
|
|
9
|
+
// intermediate canvas per denoising step: accepted tokens mixed with the
|
|
10
|
+
// sampler's actual renoise tokens, converging to the committed text.
|
|
11
|
+
//
|
|
12
|
+
// Simulated mode (fallback, labeled): without the side channel, intermediate
|
|
13
|
+
// states never leave the engine, so the widget shows glyph noise during the
|
|
14
|
+
// real denoising silence and animates each commit burst. Burst boundaries,
|
|
15
|
+
// commit timing, and step counts are real; the glyphs are illustrative.
|
|
16
|
+
//
|
|
17
|
+
// Server URLs resolve at turn start: the PI_DIFFUSION_CANVAS_EVENTS_URL and
|
|
18
|
+
// PI_DIFFUSION_CANVAS_METRICS_URL environment variables win, otherwise both
|
|
19
|
+
// derive from the active model's baseUrl (an OpenAI-compatible ".../v1").
|
|
20
|
+
let metricsUrl: string | null = null;
|
|
21
|
+
let eventsUrl: string | null = null;
|
|
22
|
+
|
|
23
|
+
function resolveServerUrls(ctx: ExtensionContext): void {
|
|
24
|
+
const baseUrl = ctx.model?.baseUrl;
|
|
25
|
+
metricsUrl =
|
|
26
|
+
process.env["PI_DIFFUSION_CANVAS_METRICS_URL"] ?? serverUrlFromBase(baseUrl, "/metrics");
|
|
27
|
+
eventsUrl =
|
|
28
|
+
process.env["PI_DIFFUSION_CANVAS_EVENTS_URL"] ??
|
|
29
|
+
serverUrlFromBase(baseUrl, "/v1/diffusion/events");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function serverUrlFromBase(baseUrl: string | undefined, path: string): string | null {
|
|
33
|
+
if (baseUrl === undefined || baseUrl.length === 0) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const origin = baseUrl.replace(/\/v1\/?$/u, "").replace(/\/$/u, "");
|
|
37
|
+
return origin.length === 0 ? null : origin + path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const widgetId = "pi-diffusion-canvas";
|
|
41
|
+
const tickMs = 90;
|
|
42
|
+
const maxRows = 4;
|
|
43
|
+
// Live mode shows the entire canvas being denoised (a ~256-token block can
|
|
44
|
+
// take ~10 rows), so its cap is much larger than the simulated window.
|
|
45
|
+
const liveMaxRows = 16;
|
|
46
|
+
const minResolveMs = 300;
|
|
47
|
+
const maxResolveMs = 1500;
|
|
48
|
+
const defaultResolveMs = 1200;
|
|
49
|
+
const metricsTimeoutMs = 5000;
|
|
50
|
+
const maxBufferedCanvases = 8;
|
|
51
|
+
// After a commit, canvas events of the committed block can still be in
|
|
52
|
+
// flight on the SSE connection (it races the completion stream on a separate
|
|
53
|
+
// socket); rendering one would duplicate the settled text. Events carrying a
|
|
54
|
+
// block ordinal are filtered exactly; without one, drop events for a short
|
|
55
|
+
// grace window after each commit.
|
|
56
|
+
const commitGraceMs = 250;
|
|
57
|
+
// The server never streams the converged canvas (commit steps emit no
|
|
58
|
+
// event), so the widget renders it itself: the committed text stays bright
|
|
59
|
+
// for a moment before muting into the settled document.
|
|
60
|
+
const flashMs = 450;
|
|
61
|
+
// Rows of settled context kept above the seam (where settled text meets the
|
|
62
|
+
// canvas) in the live window.
|
|
63
|
+
const liveSettledContextRows = 2;
|
|
64
|
+
const noiseGlyphs = "abcdefghijklmnopqrstuvwxyz0123456789#%&@$+=~?";
|
|
65
|
+
|
|
66
|
+
type ActiveCell = {
|
|
67
|
+
readonly char: string;
|
|
68
|
+
readonly resolveAt: number;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type TurnState = {
|
|
72
|
+
readonly startedAt: number;
|
|
73
|
+
firstBurstAt: number | undefined;
|
|
74
|
+
lastBurstAt: number | undefined;
|
|
75
|
+
burstCount: number;
|
|
76
|
+
totalChars: number;
|
|
77
|
+
settledText: string;
|
|
78
|
+
active: ActiveCell[];
|
|
79
|
+
intervals: number[];
|
|
80
|
+
liveMode: boolean;
|
|
81
|
+
rowsHighWater: number;
|
|
82
|
+
responseId: string | undefined;
|
|
83
|
+
latestCanvasByRequest: Map<string, CanvasEvent>;
|
|
84
|
+
liveText: string | undefined;
|
|
85
|
+
liveStep: number;
|
|
86
|
+
liveBlock: number | undefined;
|
|
87
|
+
// Staleness floors, set at each commit: canvas events at or below them
|
|
88
|
+
// belong to an already-committed block. Steps and block ordinals are
|
|
89
|
+
// per-request, so both reset when the completion's request id changes.
|
|
90
|
+
stepFloor: number;
|
|
91
|
+
blockFloor: number | undefined;
|
|
92
|
+
suppressCanvasUntil: number;
|
|
93
|
+
// The most recent commit renders bright until flashUntil (the converged
|
|
94
|
+
// canvas frame the server never streams).
|
|
95
|
+
flashUntil: number;
|
|
96
|
+
flashChars: number;
|
|
97
|
+
done: boolean;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type DiffusionCounters = {
|
|
101
|
+
readonly steps: number;
|
|
102
|
+
readonly positions: number;
|
|
103
|
+
readonly committed: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type RenderCellKind = "settled" | "resolved" | "noise" | "ahead";
|
|
107
|
+
|
|
108
|
+
type RenderCell = {
|
|
109
|
+
readonly char: string;
|
|
110
|
+
readonly kind: RenderCellKind;
|
|
111
|
+
readonly width: number;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type WidgetTheme = {
|
|
115
|
+
fg(color: "accent" | "muted" | "dim" | "text", text: string): string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type CanvasEvent = {
|
|
119
|
+
readonly requestId: string;
|
|
120
|
+
readonly step: number;
|
|
121
|
+
readonly text: string;
|
|
122
|
+
// Commit ordinal of the block this snapshot belongs to. Newer servers
|
|
123
|
+
// include it; when present it identifies stale snapshots exactly.
|
|
124
|
+
readonly block: number | undefined;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export default function diffusionpiDiffusionCanvas(pi: ExtensionAPI): void {
|
|
128
|
+
let current: TurnState | undefined;
|
|
129
|
+
let ticker: ReturnType<typeof setInterval> | undefined;
|
|
130
|
+
let requestRender = (): void => undefined;
|
|
131
|
+
let widgetInstalled = false;
|
|
132
|
+
let countersAtTurnStart: Promise<DiffusionCounters | undefined> | undefined;
|
|
133
|
+
let stepsPerCanvas: number | undefined;
|
|
134
|
+
let eventsAbort: AbortController | undefined;
|
|
135
|
+
let subscribedRequestId: string | undefined;
|
|
136
|
+
|
|
137
|
+
function installWidget(ctx: ExtensionContext): void {
|
|
138
|
+
if (widgetInstalled) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
widgetInstalled = true;
|
|
142
|
+
ctx.ui.setWidget(widgetId, (tui, theme) => {
|
|
143
|
+
requestRender = () => {
|
|
144
|
+
tui.requestRender();
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
render: (width: number) => renderWidget(width, theme),
|
|
148
|
+
invalidate: () => undefined
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function startTicker(): void {
|
|
154
|
+
ticker ??= setInterval(() => {
|
|
155
|
+
requestRender();
|
|
156
|
+
if (current !== undefined && current.done && animationDone(current)) {
|
|
157
|
+
stopTicker();
|
|
158
|
+
}
|
|
159
|
+
}, tickMs);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function stopTicker(): void {
|
|
163
|
+
if (ticker !== undefined) {
|
|
164
|
+
clearInterval(ticker);
|
|
165
|
+
ticker = undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Subscribe scoped to one request id: the server then never sends other
|
|
170
|
+
// clients' canvas states to this process. The subscription is (re)opened
|
|
171
|
+
// whenever the current completion's id becomes known, so nothing is
|
|
172
|
+
// subscribed while no id is held.
|
|
173
|
+
function openEventStream(requestId: string): void {
|
|
174
|
+
if (eventsUrl === null) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (subscribedRequestId === requestId && eventsAbort !== undefined) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
closeEventStream();
|
|
181
|
+
subscribedRequestId = requestId;
|
|
182
|
+
const abort = new AbortController();
|
|
183
|
+
eventsAbort = abort;
|
|
184
|
+
const separator = eventsUrl.includes("?") ? "&" : "?";
|
|
185
|
+
const url = `${eventsUrl}${separator}request_id=${encodeURIComponent(requestId)}`;
|
|
186
|
+
void consumeEventStream(url, abort.signal, (event) => {
|
|
187
|
+
const state = current;
|
|
188
|
+
if (state === undefined || state.done) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// A server that predates the request_id query parameter ignores it and
|
|
192
|
+
// broadcasts every request, so filtering here stays as defense. Buffer
|
|
193
|
+
// per request id (the correlated id can change mid-turn or arrive late
|
|
194
|
+
// on servers that ignore the X-Request-Id header), but bound the buffer
|
|
195
|
+
// so a busy shared server cannot grow it without limit: evict the
|
|
196
|
+
// oldest foreign entry, never the correlated one.
|
|
197
|
+
state.latestCanvasByRequest.delete(event.requestId);
|
|
198
|
+
state.latestCanvasByRequest.set(event.requestId, event);
|
|
199
|
+
if (state.latestCanvasByRequest.size > maxBufferedCanvases) {
|
|
200
|
+
for (const requestId of state.latestCanvasByRequest.keys()) {
|
|
201
|
+
if (requestId !== state.responseId) {
|
|
202
|
+
state.latestCanvasByRequest.delete(requestId);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
refreshLiveCanvas(state);
|
|
208
|
+
requestRender();
|
|
209
|
+
}).catch(() => {
|
|
210
|
+
// Side channel unavailable (unpatched server or flag off): the widget
|
|
211
|
+
// stays in the labeled simulated mode.
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// The SSE subscription is scoped server-side to this turn's request id;
|
|
216
|
+
// this filter is defense in depth for servers that predate the request_id
|
|
217
|
+
// query parameter and still broadcast every request. Display only the
|
|
218
|
+
// canvas belonging to this turn's current completion; an uncorrelated
|
|
219
|
+
// canvas is never rendered, since on a shared server it could be another
|
|
220
|
+
// client's text. The id is known before the first denoising step because
|
|
221
|
+
// this extension assigns it via the X-Request-Id header (see the
|
|
222
|
+
// before_provider_headers handler); Pi's assistant stream additionally
|
|
223
|
+
// reports the server-authoritative id as partial.responseId with the first
|
|
224
|
+
// committed chunk, which corrects the prediction on servers that ignore
|
|
225
|
+
// the header.
|
|
226
|
+
function refreshLiveCanvas(state: TurnState): void {
|
|
227
|
+
if (state.responseId === undefined) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const event = state.latestCanvasByRequest.get(state.responseId);
|
|
231
|
+
if (event === undefined) {
|
|
232
|
+
// No canvas yet for the current completion (e.g. a new completion
|
|
233
|
+
// started after a tool call); drop any stale one.
|
|
234
|
+
state.liveText = undefined;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (isStaleCanvas(state, event)) {
|
|
238
|
+
// A snapshot of an already-committed block that raced the commit
|
|
239
|
+
// chunk; rendering it would duplicate the settled text.
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (!state.liveMode) {
|
|
243
|
+
// If the event stream came up after commits already animated in
|
|
244
|
+
// simulated mode, those committed chars still sit in the resolve
|
|
245
|
+
// animation; settle them so the live renderer (which ignores the
|
|
246
|
+
// animation cells) keeps the committed prefix visible.
|
|
247
|
+
state.settledText += state.active.map((cell) => cell.char).join("");
|
|
248
|
+
state.active = [];
|
|
249
|
+
state.liveMode = true;
|
|
250
|
+
}
|
|
251
|
+
state.liveText = event.text;
|
|
252
|
+
state.liveStep = event.step;
|
|
253
|
+
state.liveBlock = event.block;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function closeEventStream(): void {
|
|
257
|
+
eventsAbort?.abort();
|
|
258
|
+
eventsAbort = undefined;
|
|
259
|
+
subscribedRequestId = undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The canvas rows come first and the stats line sits below them, so the
|
|
263
|
+
// document flows straight from Pi's streamed message into the resolving
|
|
264
|
+
// canvas with nothing in between.
|
|
265
|
+
function renderWidget(width: number, theme: WidgetTheme): string[] {
|
|
266
|
+
const state = current;
|
|
267
|
+
if (state === undefined) {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
const cols = Math.max(16, width - 2);
|
|
271
|
+
const statsLine = " " + theme.fg("dim", truncate(headerText(state), cols));
|
|
272
|
+
// Once the turn is over and the last canvas has resolved, collapse to the
|
|
273
|
+
// stats line: the full text is already visible in the message above.
|
|
274
|
+
if (state.done && animationDone(state)) {
|
|
275
|
+
return [statsLine];
|
|
276
|
+
}
|
|
277
|
+
const rows = canvasRows(state, cols, theme);
|
|
278
|
+
// Keep the widget height stable while active: pad to the tallest layout
|
|
279
|
+
// seen this turn (it only ratchets up when a bigger canvas arrives). The
|
|
280
|
+
// TUI renders differentially, so a widget that grew and shrank with the
|
|
281
|
+
// canvas text every frame would leave a trail of stale frames in the
|
|
282
|
+
// terminal scrollback; rewriting a fixed set of rows in place leaves
|
|
283
|
+
// none.
|
|
284
|
+
state.rowsHighWater = Math.max(state.rowsHighWater, rows.length);
|
|
285
|
+
while (rows.length < state.rowsHighWater) {
|
|
286
|
+
rows.push("");
|
|
287
|
+
}
|
|
288
|
+
return [...rows.map((row) => " " + row), statsLine];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function headerText(state: TurnState): string {
|
|
292
|
+
const mode = state.liveMode ? "live" : "simulated";
|
|
293
|
+
// The server's step counter is monotonic per request; subtracting the
|
|
294
|
+
// floor recorded at the last commit yields the step within the canvas
|
|
295
|
+
// currently being denoised.
|
|
296
|
+
const canvasStep = Math.max(state.liveStep - state.stepFloor, 0);
|
|
297
|
+
if (state.firstBurstAt === undefined) {
|
|
298
|
+
if (state.done) {
|
|
299
|
+
return "diffusion canvas | no output";
|
|
300
|
+
}
|
|
301
|
+
const waited = ((Date.now() - state.startedAt) / 1000).toFixed(1);
|
|
302
|
+
if (state.liveMode) {
|
|
303
|
+
return `diffusion canvas | live | denoising canvas 1, step ${String(canvasStep)}... ${waited}s`;
|
|
304
|
+
}
|
|
305
|
+
return `diffusion canvas | simulated | denoising canvas 1 server-side... ${waited}s | text appears when the canvas commits`;
|
|
306
|
+
}
|
|
307
|
+
const tokens = Math.ceil(state.totalChars / 4);
|
|
308
|
+
const parts = [
|
|
309
|
+
"diffusion canvas",
|
|
310
|
+
mode,
|
|
311
|
+
`${String(state.burstCount)} commits`,
|
|
312
|
+
`~${String(Math.max(Math.round(tokens / state.burstCount), 1))} tok/commit`
|
|
313
|
+
];
|
|
314
|
+
const interval = medianOf(state.intervals);
|
|
315
|
+
if (interval !== undefined) {
|
|
316
|
+
parts.push(`${(interval / 1000).toFixed(1)}s/commit`);
|
|
317
|
+
}
|
|
318
|
+
const elapsedSeconds = Math.max(
|
|
319
|
+
((state.lastBurstAt ?? state.startedAt) - state.startedAt) / 1000,
|
|
320
|
+
0.001
|
|
321
|
+
);
|
|
322
|
+
parts.push(`~${(tokens / elapsedSeconds).toFixed(1)} tok/s`);
|
|
323
|
+
if (stepsPerCanvas !== undefined) {
|
|
324
|
+
// Prometheus counters are server-wide, so this is an aggregate over
|
|
325
|
+
// everything the server ran during the turn, not per-request.
|
|
326
|
+
parts.push(`${stepsPerCanvas.toFixed(1)} steps/canvas (server)`);
|
|
327
|
+
}
|
|
328
|
+
if (state.done) {
|
|
329
|
+
parts.push("done");
|
|
330
|
+
} else if (state.liveMode) {
|
|
331
|
+
parts.push(
|
|
332
|
+
`denoising canvas ${String(state.burstCount + 1)}, step ${String(canvasStep)}...`
|
|
333
|
+
);
|
|
334
|
+
} else {
|
|
335
|
+
parts.push(`denoising canvas ${String(state.burstCount + 1)}...`);
|
|
336
|
+
}
|
|
337
|
+
return parts.join(" | ");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
341
|
+
// Component-factory widgets only render in the terminal UI; other modes
|
|
342
|
+
// (RPC, print) must not start tickers, metrics polling, or the SSE feed.
|
|
343
|
+
if (ctx.mode !== "tui") {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
resolveServerUrls(ctx);
|
|
347
|
+
const state = newTurnState();
|
|
348
|
+
current = state;
|
|
349
|
+
stepsPerCanvas = undefined;
|
|
350
|
+
countersAtTurnStart = fetchCounters();
|
|
351
|
+
installWidget(ctx);
|
|
352
|
+
// The widget header already shows live turn status (canvas, step,
|
|
353
|
+
// elapsed), so Pi's built-in working spinner row is redundant noise
|
|
354
|
+
// directly above it.
|
|
355
|
+
ctx.ui.setWorkingVisible(false);
|
|
356
|
+
startTicker();
|
|
357
|
+
// The SSE subscription opens once the completion's request id is known
|
|
358
|
+
// (header hook or first stream chunk); it is always scoped to that id.
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Assign each completion's request id up front: vLLM derives its request
|
|
362
|
+
// id from the X-Request-Id header (chatcmpl-<header>), so setting the
|
|
363
|
+
// header lets this turn correlate side-channel canvas events from the very
|
|
364
|
+
// first denoising step, before the server streams any completion chunk.
|
|
365
|
+
pi.on("before_provider_headers", (event) => {
|
|
366
|
+
const state = current;
|
|
367
|
+
if (state === undefined || state.done || eventsUrl === null) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const requestTag = crypto.randomUUID().replace(/-/gu, "");
|
|
371
|
+
event.headers["X-Request-Id"] = requestTag;
|
|
372
|
+
state.responseId = `chatcmpl-${requestTag}`;
|
|
373
|
+
resetLiveCorrelation(state);
|
|
374
|
+
openEventStream(state.responseId);
|
|
375
|
+
refreshLiveCanvas(state);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
pi.on("message_update", (event, ctx) => {
|
|
379
|
+
const state = current;
|
|
380
|
+
if (ctx.mode !== "tui" || state === undefined || state.done) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// A turn can contain several completions (one per tool-call round), each
|
|
384
|
+
// with its own server request id. The id was predicted when the
|
|
385
|
+
// X-Request-Id header was set; the stream's partial.responseId is the
|
|
386
|
+
// server-authoritative value and corrects the prediction on servers that
|
|
387
|
+
// ignore the header.
|
|
388
|
+
const responseId = responseIdFromEvent(event.assistantMessageEvent);
|
|
389
|
+
if (responseId !== undefined && responseId !== state.responseId) {
|
|
390
|
+
state.responseId = responseId;
|
|
391
|
+
resetLiveCorrelation(state);
|
|
392
|
+
openEventStream(responseId);
|
|
393
|
+
refreshLiveCanvas(state);
|
|
394
|
+
}
|
|
395
|
+
const added = deltaFromEvent(event.assistantMessageEvent);
|
|
396
|
+
if (added === undefined || added.text.length === 0) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
recordBurst(state, added.text, added.display);
|
|
400
|
+
requestRender();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
404
|
+
const state = current;
|
|
405
|
+
if (ctx.mode !== "tui" || state === undefined) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
finishTurn(state);
|
|
409
|
+
closeEventStream();
|
|
410
|
+
// Wait for the turn-start sample: on short turns it can still be in
|
|
411
|
+
// flight when the turn ends.
|
|
412
|
+
const beforePromise = countersAtTurnStart;
|
|
413
|
+
void Promise.all([beforePromise, fetchCounters()]).then(([before, after]) => {
|
|
414
|
+
if (current !== state) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (before !== undefined && after !== undefined) {
|
|
418
|
+
stepsPerCanvas = stepsPerCanvasFromDelta(before, after) ?? stepsPerCanvas;
|
|
419
|
+
}
|
|
420
|
+
requestRender();
|
|
421
|
+
});
|
|
422
|
+
requestRender();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
426
|
+
stopTicker();
|
|
427
|
+
closeEventStream();
|
|
428
|
+
current = undefined;
|
|
429
|
+
if (widgetInstalled && ctx.hasUI) {
|
|
430
|
+
ctx.ui.setWidget(widgetId, undefined);
|
|
431
|
+
ctx.ui.setWorkingVisible(true);
|
|
432
|
+
}
|
|
433
|
+
widgetInstalled = false;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function newTurnState(): TurnState {
|
|
438
|
+
return {
|
|
439
|
+
startedAt: Date.now(),
|
|
440
|
+
firstBurstAt: undefined,
|
|
441
|
+
lastBurstAt: undefined,
|
|
442
|
+
burstCount: 0,
|
|
443
|
+
totalChars: 0,
|
|
444
|
+
settledText: "",
|
|
445
|
+
active: [],
|
|
446
|
+
intervals: [],
|
|
447
|
+
liveMode: false,
|
|
448
|
+
rowsHighWater: maxRows,
|
|
449
|
+
responseId: undefined,
|
|
450
|
+
latestCanvasByRequest: new Map(),
|
|
451
|
+
liveText: undefined,
|
|
452
|
+
liveStep: 0,
|
|
453
|
+
liveBlock: undefined,
|
|
454
|
+
stepFloor: 0,
|
|
455
|
+
blockFloor: undefined,
|
|
456
|
+
suppressCanvasUntil: 0,
|
|
457
|
+
flashUntil: 0,
|
|
458
|
+
flashChars: 0,
|
|
459
|
+
done: false
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// A commit ends its block, but the block's canvas events can still be in
|
|
464
|
+
// flight (the SSE feed and the completion stream are separate connections);
|
|
465
|
+
// rendering one after the commit shows the committed text twice. The block
|
|
466
|
+
// ordinal identifies such snapshots exactly. Servers without it fall back to
|
|
467
|
+
// the per-request step counter (a replayed step is always stale) plus a
|
|
468
|
+
// short post-commit grace window absorbing cross-connection reordering.
|
|
469
|
+
function isStaleCanvas(state: TurnState, event: CanvasEvent): boolean {
|
|
470
|
+
if (event.block !== undefined) {
|
|
471
|
+
return state.blockFloor !== undefined && event.block <= state.blockFloor;
|
|
472
|
+
}
|
|
473
|
+
if (event.step <= state.stepFloor) {
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
return Date.now() < state.suppressCanvasUntil;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// A new completion is a new server request whose step counter and block
|
|
480
|
+
// ordinals restart from zero; the previous completion's staleness floors
|
|
481
|
+
// must not swallow its first canvas events.
|
|
482
|
+
function resetLiveCorrelation(state: TurnState): void {
|
|
483
|
+
state.liveStep = 0;
|
|
484
|
+
state.liveBlock = undefined;
|
|
485
|
+
state.stepFloor = 0;
|
|
486
|
+
state.blockFloor = undefined;
|
|
487
|
+
state.suppressCanvasUntil = 0;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function recordBurst(state: TurnState, added: string, display: boolean): void {
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
state.firstBurstAt ??= now;
|
|
493
|
+
if (state.lastBurstAt !== undefined) {
|
|
494
|
+
state.intervals.push(now - state.lastBurstAt);
|
|
495
|
+
}
|
|
496
|
+
state.lastBurstAt = now;
|
|
497
|
+
state.burstCount += 1;
|
|
498
|
+
state.totalChars += added.length;
|
|
499
|
+
// This commit ends the block being denoised: raise the staleness floors so
|
|
500
|
+
// the block's in-flight canvas events cannot re-render after the settled
|
|
501
|
+
// text (see isStaleCanvas).
|
|
502
|
+
state.stepFloor = state.liveStep;
|
|
503
|
+
state.blockFloor = state.liveBlock;
|
|
504
|
+
state.suppressCanvasUntil = now + commitGraceMs;
|
|
505
|
+
if (state.liveMode) {
|
|
506
|
+
// Truthful mode: the emergence was already shown live via the real
|
|
507
|
+
// canvas states. The committed text is the converged canvas the server
|
|
508
|
+
// never streams (commit steps emit no event), so render it bright for a
|
|
509
|
+
// moment before it mutes into the settled document. The next denoising
|
|
510
|
+
// step brings the next block.
|
|
511
|
+
if (display) {
|
|
512
|
+
const settled = sanitize(added);
|
|
513
|
+
state.settledText += settled;
|
|
514
|
+
state.flashChars = [...settled].length;
|
|
515
|
+
state.flashUntil = now + flashMs;
|
|
516
|
+
}
|
|
517
|
+
state.liveText = undefined;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
state.settledText += state.active.map((cell) => cell.char).join("");
|
|
521
|
+
if (!display) {
|
|
522
|
+
// Tool-call commits pace the stats but their JSON is not settled into
|
|
523
|
+
// the display text; Pi renders the tool call itself.
|
|
524
|
+
state.active = [];
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const resolveMs = resolveDuration(state);
|
|
528
|
+
const chars = [...sanitize(added)];
|
|
529
|
+
state.active = chars.map((char) => ({
|
|
530
|
+
char,
|
|
531
|
+
resolveAt: now + Math.random() * resolveMs
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Keep the in-flight resolve animation on turn end; the last canvas would
|
|
536
|
+
// otherwise snap to text instantly because bursty servers deliver the final
|
|
537
|
+
// commit and the turn end back to back.
|
|
538
|
+
function finishTurn(state: TurnState): void {
|
|
539
|
+
state.done = true;
|
|
540
|
+
state.liveText = undefined;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function animationDone(state: TurnState): boolean {
|
|
544
|
+
const now = Date.now();
|
|
545
|
+
return now >= state.flashUntil && state.active.every((cell) => now >= cell.resolveAt);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function resolveDuration(state: TurnState): number {
|
|
549
|
+
const interval = medianOf(state.intervals);
|
|
550
|
+
if (interval === undefined) {
|
|
551
|
+
return defaultResolveMs;
|
|
552
|
+
}
|
|
553
|
+
return Math.min(Math.max(interval * 0.6, minResolveMs), maxResolveMs);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function medianOf(values: readonly number[]): number | undefined {
|
|
557
|
+
if (values.length === 0) {
|
|
558
|
+
return undefined;
|
|
559
|
+
}
|
|
560
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
561
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// The canvas mixes scripts freely mid-denoise (multilingual renoise tokens).
|
|
565
|
+
// Terminals disagree with any width model for combining marks, Indic
|
|
566
|
+
// conjuncts, emoji sequences, and bidi-reordered RTL runs; a single
|
|
567
|
+
// mispredicted cell hard-wraps a row and desyncs the TUI's differential
|
|
568
|
+
// renderer, leaving stale frames in the transcript. Render only glyphs with
|
|
569
|
+
// unambiguous monospace width and substitute the rest with a neutral dot:
|
|
570
|
+
// they read as noise either way, and the real text lands in the message.
|
|
571
|
+
const substituteGlyph = "\u00b7";
|
|
572
|
+
|
|
573
|
+
function displayableChar(char: string): string {
|
|
574
|
+
const cp = char.codePointAt(0) ?? 0;
|
|
575
|
+
if (
|
|
576
|
+
(cp >= 0x20 && cp <= 0x7e) ||
|
|
577
|
+
(cp >= 0xa1 && cp <= 0x17f) ||
|
|
578
|
+
(cp >= 0x370 && cp <= 0x3ff && cp !== 0x374 && cp !== 0x375) ||
|
|
579
|
+
(cp >= 0x400 && cp <= 0x4ff) ||
|
|
580
|
+
(cp >= 0x2010 && cp <= 0x2027) ||
|
|
581
|
+
cp === 0x2591 ||
|
|
582
|
+
isWideChar(cp)
|
|
583
|
+
) {
|
|
584
|
+
return char;
|
|
585
|
+
}
|
|
586
|
+
return substituteGlyph;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function sanitize(text: string): string {
|
|
590
|
+
const flattened = text.replace(/\s+/gu, " ").replace(/\p{C}/gu, "");
|
|
591
|
+
let result = "";
|
|
592
|
+
for (const char of flattened) {
|
|
593
|
+
result += displayableChar(char);
|
|
594
|
+
}
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Unambiguously two-cell scripts in every monospace terminal: CJK
|
|
599
|
+
// punctuation and kana, CJK unified ideographs, Hangul syllables, CJK
|
|
600
|
+
// compatibility ideographs, and fullwidth forms.
|
|
601
|
+
function isWideChar(cp: number): boolean {
|
|
602
|
+
return (
|
|
603
|
+
(cp >= 0x3000 && cp <= 0x30ff) ||
|
|
604
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
605
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
606
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
607
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
608
|
+
(cp >= 0xff01 && cp <= 0xff60)
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function charWidth(char: string): number {
|
|
613
|
+
return isWideChar(char.codePointAt(0) ?? 0) ? 2 : 1;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function textWidth(text: string): number {
|
|
617
|
+
let width = 0;
|
|
618
|
+
for (const char of text) {
|
|
619
|
+
width += charWidth(char);
|
|
620
|
+
}
|
|
621
|
+
return width;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function cellsFromText(text: string, kind: RenderCellKind): RenderCell[] {
|
|
625
|
+
return [...text].map((char) => ({ char, kind, width: charWidth(char) }));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function tailByWidth(text: string, maxWidth: number): string {
|
|
629
|
+
const chars = [...text];
|
|
630
|
+
let width = 0;
|
|
631
|
+
let start = chars.length;
|
|
632
|
+
for (let index = chars.length - 1; index >= 0; index -= 1) {
|
|
633
|
+
const cw = charWidth(chars[index] ?? "");
|
|
634
|
+
if (width + cw > maxWidth) {
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
width += cw;
|
|
638
|
+
start = index;
|
|
639
|
+
}
|
|
640
|
+
return chars.slice(start).join("");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function canvasRows(state: TurnState, cols: number, theme: WidgetTheme): string[] {
|
|
644
|
+
const rowCap = state.liveMode ? liveMaxRows : maxRows;
|
|
645
|
+
const cells = renderCells(state, cols * rowCap);
|
|
646
|
+
const rows: RenderCell[][] = [];
|
|
647
|
+
let row: RenderCell[] = [];
|
|
648
|
+
let rowWidth = 0;
|
|
649
|
+
let seamRow = 0;
|
|
650
|
+
for (const cell of cells) {
|
|
651
|
+
if (rowWidth + cell.width > cols) {
|
|
652
|
+
rows.push(row);
|
|
653
|
+
row = [];
|
|
654
|
+
rowWidth = 0;
|
|
655
|
+
}
|
|
656
|
+
row.push(cell);
|
|
657
|
+
rowWidth += cell.width;
|
|
658
|
+
if (cell.kind === "settled" || cell.kind === "resolved") {
|
|
659
|
+
seamRow = rows.length;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (row.length > 0) {
|
|
663
|
+
rows.push(row);
|
|
664
|
+
}
|
|
665
|
+
// Live mode packs the whole settled+canvas document from its start, so row
|
|
666
|
+
// boundaries of already-committed text never move. The window anchors to
|
|
667
|
+
// the seam (the row where the settled text ends): a fixed amount of
|
|
668
|
+
// settled context stays above it and the canvas fills the rest. The
|
|
669
|
+
// canvas's decoded width fluctuates on every denoising step, so an
|
|
670
|
+
// end-anchored window would shift all visible rows each step; anchored to
|
|
671
|
+
// the seam, the committed text holds still and the view scrolls only when
|
|
672
|
+
// a commit moves the seam (like a terminal). Simulated mode builds a
|
|
673
|
+
// budget-sized window, so its rows are the head.
|
|
674
|
+
const windowStart = state.liveMode ? Math.max(0, seamRow - liveSettledContextRows) : 0;
|
|
675
|
+
const visible = rows.slice(windowStart, windowStart + rowCap);
|
|
676
|
+
return visible.map((cellsRow) => styleRow(cellsRow, theme));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function renderCells(state: TurnState, budget: number): RenderCell[] {
|
|
680
|
+
if (state.liveMode) {
|
|
681
|
+
return renderLiveCells(state);
|
|
682
|
+
}
|
|
683
|
+
return renderSimulatedCells(state, budget);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Live mode: one continuous document, the committed text concatenated with
|
|
687
|
+
// the canvas snapshot currently being denoised. It is never clipped here;
|
|
688
|
+
// canvasRows packs it from the start (stable row boundaries for committed
|
|
689
|
+
// text) and anchors its window to the seam, so on a commit the resolved text
|
|
690
|
+
// stays in place and the next canvas continues mid-row without a gap. The
|
|
691
|
+
// freshest commit renders bright until its flash expires.
|
|
692
|
+
function renderLiveCells(state: TurnState): RenderCell[] {
|
|
693
|
+
const canvas = state.liveText === undefined ? "" : sanitize(state.liveText);
|
|
694
|
+
const settled = [...state.settledText];
|
|
695
|
+
const flashCount =
|
|
696
|
+
Date.now() < state.flashUntil ? Math.min(state.flashChars, settled.length) : 0;
|
|
697
|
+
const stable = settled.slice(0, settled.length - flashCount).join("");
|
|
698
|
+
const flashing = settled.slice(settled.length - flashCount).join("");
|
|
699
|
+
return [
|
|
700
|
+
...cellsFromText(stable, "settled"),
|
|
701
|
+
...cellsFromText(flashing, "resolved"),
|
|
702
|
+
...cellsFromText(canvas, "noise")
|
|
703
|
+
];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Simulated mode: while the turn runs, glyph noise fills all window space not
|
|
707
|
+
// yet holding text (the canvas still being denoised server-side), and commit
|
|
708
|
+
// bursts resolve from noise into the real text. Budgets are display-width
|
|
709
|
+
// cells; noise glyphs are all width 1.
|
|
710
|
+
function renderSimulatedCells(state: TurnState, budget: number): RenderCell[] {
|
|
711
|
+
const now = Date.now();
|
|
712
|
+
const settledWidth = textWidth(state.settledText);
|
|
713
|
+
const activeWidth = state.active.reduce((width, cell) => width + charWidth(cell.char), 0);
|
|
714
|
+
const aheadCount = state.done
|
|
715
|
+
? 0
|
|
716
|
+
: Math.min(Math.max(budget - settledWidth - activeWidth, Math.ceil(budget / maxRows)), budget);
|
|
717
|
+
const settledBudget = Math.max(budget - activeWidth - aheadCount, 0);
|
|
718
|
+
const cells: RenderCell[] = [
|
|
719
|
+
...cellsFromText(tailByWidth(state.settledText, settledBudget), "settled")
|
|
720
|
+
];
|
|
721
|
+
for (const cell of state.active) {
|
|
722
|
+
cells.push(
|
|
723
|
+
now >= cell.resolveAt
|
|
724
|
+
? { char: cell.char, kind: "resolved", width: charWidth(cell.char) }
|
|
725
|
+
: { char: noiseChar(), kind: "noise", width: 1 }
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
for (let index = 0; index < aheadCount; index += 1) {
|
|
729
|
+
cells.push({ char: noiseChar(), kind: "ahead", width: 1 });
|
|
730
|
+
}
|
|
731
|
+
let total = cells.reduce((width, cell) => width + cell.width, 0);
|
|
732
|
+
while (total > budget && cells.length > 0) {
|
|
733
|
+
total -= cells[0]?.width ?? 0;
|
|
734
|
+
cells.shift();
|
|
735
|
+
}
|
|
736
|
+
return cells;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function styleRow(cells: readonly RenderCell[], theme: WidgetTheme): string {
|
|
740
|
+
let row = "";
|
|
741
|
+
let runKind: RenderCellKind | undefined;
|
|
742
|
+
let runText = "";
|
|
743
|
+
const flush = (): void => {
|
|
744
|
+
if (runKind !== undefined && runText.length > 0) {
|
|
745
|
+
row += styleRun(runKind, runText, theme);
|
|
746
|
+
}
|
|
747
|
+
runText = "";
|
|
748
|
+
};
|
|
749
|
+
for (const cell of cells) {
|
|
750
|
+
if (cell.kind !== runKind) {
|
|
751
|
+
flush();
|
|
752
|
+
runKind = cell.kind;
|
|
753
|
+
}
|
|
754
|
+
runText += cell.char;
|
|
755
|
+
}
|
|
756
|
+
flush();
|
|
757
|
+
return row;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function styleRun(kind: RenderCellKind, text: string, theme: WidgetTheme): string {
|
|
761
|
+
switch (kind) {
|
|
762
|
+
case "settled":
|
|
763
|
+
return theme.fg("muted", text);
|
|
764
|
+
case "resolved":
|
|
765
|
+
return theme.fg("text", text);
|
|
766
|
+
case "noise":
|
|
767
|
+
return theme.fg("accent", text);
|
|
768
|
+
case "ahead":
|
|
769
|
+
return theme.fg("dim", text);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function noiseChar(): string {
|
|
774
|
+
return noiseGlyphs[Math.floor(Math.random() * noiseGlyphs.length)] ?? "?";
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function truncate(text: string, maxLength: number): string {
|
|
778
|
+
return text.length <= maxLength ? text : `${text.slice(0, Math.max(maxLength - 1, 0))}…`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function consumeEventStream(
|
|
782
|
+
url: string,
|
|
783
|
+
signal: AbortSignal,
|
|
784
|
+
onEvent: (event: CanvasEvent) => void
|
|
785
|
+
): Promise<void> {
|
|
786
|
+
const response = await fetch(url, { signal });
|
|
787
|
+
if (!response.ok || response.body === null) {
|
|
788
|
+
throw new Error(`diffusion events unavailable: ${String(response.status)}`);
|
|
789
|
+
}
|
|
790
|
+
const reader = (response.body as ReadableStream<Uint8Array>).getReader();
|
|
791
|
+
// fetch rejects reads on abort, but cancel explicitly so the stream is
|
|
792
|
+
// released even when a runtime (or test double) ignores the signal.
|
|
793
|
+
signal.addEventListener("abort", () => void reader.cancel().catch(() => undefined), {
|
|
794
|
+
once: true
|
|
795
|
+
});
|
|
796
|
+
const decoder = new TextDecoder();
|
|
797
|
+
let buffer = "";
|
|
798
|
+
for (;;) {
|
|
799
|
+
const { done, value } = await reader.read();
|
|
800
|
+
if (done || signal.aborted) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
// SSE allows LF, CRLF, and CR line endings; normalize to LF so frame
|
|
804
|
+
// splitting works for all conforming servers.
|
|
805
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/gu, "\n");
|
|
806
|
+
for (;;) {
|
|
807
|
+
const boundary = buffer.indexOf("\n\n");
|
|
808
|
+
if (boundary === -1) {
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
const frame = buffer.slice(0, boundary);
|
|
812
|
+
buffer = buffer.slice(boundary + 2);
|
|
813
|
+
const event = parseEventFrame(frame);
|
|
814
|
+
if (event !== undefined) {
|
|
815
|
+
onEvent(event);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function parseEventFrame(frame: string): CanvasEvent | undefined {
|
|
822
|
+
const dataLines = frame
|
|
823
|
+
.split("\n")
|
|
824
|
+
.filter((line) => line.startsWith("data:"))
|
|
825
|
+
.map((line) => line.slice(5).trim());
|
|
826
|
+
if (dataLines.length === 0) {
|
|
827
|
+
return undefined;
|
|
828
|
+
}
|
|
829
|
+
try {
|
|
830
|
+
const parsed: unknown = JSON.parse(dataLines.join("\n"));
|
|
831
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
832
|
+
return undefined;
|
|
833
|
+
}
|
|
834
|
+
const object = parsed as Record<string, unknown>;
|
|
835
|
+
const requestId = object["request_id"];
|
|
836
|
+
const step = object["step"];
|
|
837
|
+
const text = object["text"];
|
|
838
|
+
const block = object["block"];
|
|
839
|
+
if (typeof requestId !== "string" || typeof step !== "number" || typeof text !== "string") {
|
|
840
|
+
return undefined;
|
|
841
|
+
}
|
|
842
|
+
return { requestId, step, text, block: typeof block === "number" ? block : undefined };
|
|
843
|
+
} catch {
|
|
844
|
+
return undefined;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function fetchCounters(): Promise<DiffusionCounters | undefined> {
|
|
849
|
+
if (metricsUrl === null) {
|
|
850
|
+
return undefined;
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
853
|
+
// Bound the request so a stalled /metrics endpoint cannot accumulate
|
|
854
|
+
// pending sockets across turns; a missed sample only hides the
|
|
855
|
+
// steps/canvas stat.
|
|
856
|
+
const response = await fetch(metricsUrl, { signal: AbortSignal.timeout(metricsTimeoutMs) });
|
|
857
|
+
if (!response.ok) {
|
|
858
|
+
return undefined;
|
|
859
|
+
}
|
|
860
|
+
return parseDiffusionCounters(await response.text());
|
|
861
|
+
} catch {
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function parseDiffusionCounters(body: string): DiffusionCounters | undefined {
|
|
867
|
+
let steps: number | undefined;
|
|
868
|
+
let positions: number | undefined;
|
|
869
|
+
let committed: number | undefined;
|
|
870
|
+
let found = false;
|
|
871
|
+
for (const line of body.split("\n")) {
|
|
872
|
+
if (line.startsWith("#")) {
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const match = /^vllm:diffusion_(\w+?)(?:_total)?(?:\{[^}]*\})? (\S+)$/u.exec(line.trim());
|
|
876
|
+
if (match === null) {
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
const value = Number(match[2]);
|
|
880
|
+
if (!Number.isFinite(value)) {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (match[1] === "num_denoising_steps") {
|
|
884
|
+
steps = (steps ?? 0) + value;
|
|
885
|
+
found = true;
|
|
886
|
+
} else if (match[1] === "num_canvas_positions") {
|
|
887
|
+
positions = (positions ?? 0) + value;
|
|
888
|
+
found = true;
|
|
889
|
+
} else if (match[1] === "num_committed_tokens") {
|
|
890
|
+
committed = (committed ?? 0) + value;
|
|
891
|
+
found = true;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (!found || steps === undefined || positions === undefined || committed === undefined) {
|
|
895
|
+
return undefined;
|
|
896
|
+
}
|
|
897
|
+
return { steps, positions, committed };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function stepsPerCanvasFromDelta(
|
|
901
|
+
before: DiffusionCounters,
|
|
902
|
+
after: DiffusionCounters
|
|
903
|
+
): number | undefined {
|
|
904
|
+
const steps = after.steps - before.steps;
|
|
905
|
+
const positions = after.positions - before.positions;
|
|
906
|
+
const committed = after.committed - before.committed;
|
|
907
|
+
if (steps <= 0 || positions <= 0 || committed <= 0) {
|
|
908
|
+
return undefined;
|
|
909
|
+
}
|
|
910
|
+
const canvasLength = positions / steps;
|
|
911
|
+
const canvases = committed / canvasLength;
|
|
912
|
+
const denoiseSteps = steps - canvases;
|
|
913
|
+
if (canvases <= 0 || denoiseSteps <= 0) {
|
|
914
|
+
return undefined;
|
|
915
|
+
}
|
|
916
|
+
return denoiseSteps / canvases;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
type CommitDelta = {
|
|
920
|
+
readonly text: string;
|
|
921
|
+
// Whether the delta belongs in the settled display text. Tool-call JSON
|
|
922
|
+
// paces the commit stats but is rendered by Pi's own tool widgets.
|
|
923
|
+
readonly display: boolean;
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// Pi's assistant stream events carry the in-progress message as "partial"
|
|
927
|
+
// (or the final one as "message"), whose responseId is the server-side
|
|
928
|
+
// request id (chatcmpl-...). That id is what the diffusion events side
|
|
929
|
+
// channel reports, so it correlates live canvas events with this turn.
|
|
930
|
+
function responseIdFromEvent(value: unknown): string | undefined {
|
|
931
|
+
if (value === null || typeof value !== "object") {
|
|
932
|
+
return undefined;
|
|
933
|
+
}
|
|
934
|
+
const object = value as Record<string, unknown>;
|
|
935
|
+
const partial = object["partial"] ?? object["message"];
|
|
936
|
+
if (partial === null || typeof partial !== "object") {
|
|
937
|
+
return undefined;
|
|
938
|
+
}
|
|
939
|
+
const responseId = (partial as Record<string, unknown>)["responseId"];
|
|
940
|
+
return typeof responseId === "string" ? responseId : undefined;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Every streamed delta is a canvas commit on a diffusion server, including
|
|
944
|
+
// thinking (DiffusionGemma routes its whole output through the reasoning
|
|
945
|
+
// field until an explicit end marker), so all delta kinds count toward the
|
|
946
|
+
// stats. Non-delta events (start/end/done markers) carry no new tokens.
|
|
947
|
+
function deltaFromEvent(value: unknown): CommitDelta | undefined {
|
|
948
|
+
if (value === null || typeof value !== "object") {
|
|
949
|
+
return undefined;
|
|
950
|
+
}
|
|
951
|
+
const object = value as Record<string, unknown>;
|
|
952
|
+
const type = object["type"];
|
|
953
|
+
const delta = object["delta"];
|
|
954
|
+
if (typeof type !== "string" || typeof delta !== "string") {
|
|
955
|
+
return undefined;
|
|
956
|
+
}
|
|
957
|
+
if (type === "text_delta" || type === "thinking_delta") {
|
|
958
|
+
return { text: delta, display: true };
|
|
959
|
+
}
|
|
960
|
+
if (type === "toolcall_delta") {
|
|
961
|
+
return { text: delta, display: false };
|
|
962
|
+
}
|
|
963
|
+
return undefined;
|
|
964
|
+
}
|