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,159 @@
|
|
|
1
|
+
// diffusionpi hack: smooth viewport scroll.
|
|
2
|
+
//
|
|
3
|
+
// Pi's TUI renders the whole chat in one frame and its differential renderer
|
|
4
|
+
// appends every new line at once, so a diffusion commit (~a whole canvas,
|
|
5
|
+
// often 15+ lines) shoves the viewport up in a single jump. There is no
|
|
6
|
+
// public extension API over the chat container or the viewport, so this
|
|
7
|
+
// extension deliberately reaches into the live TUI instance (grabbed through
|
|
8
|
+
// a throwaway widget factory) and wraps the chat container's render() to
|
|
9
|
+
// reveal appended lines at a bounded rate instead of all at once. Real
|
|
10
|
+
// content, paced only at the render layer.
|
|
11
|
+
//
|
|
12
|
+
// This is version-sensitive by design: it checks that the component tree
|
|
13
|
+
// looks like Pi 0.8x (nine TUI children, chat at index 2) and no-ops
|
|
14
|
+
// otherwise, so a Pi upgrade degrades to the stock jumpy behavior rather
|
|
15
|
+
// than breaking.
|
|
16
|
+
//
|
|
17
|
+
// Tuning:
|
|
18
|
+
// DIFFUSIONPI_SMOOTH_SCROLL=0 disable the hack
|
|
19
|
+
// DIFFUSIONPI_SCROLL_SPEED=40 reveal rate in lines per second
|
|
20
|
+
// DIFFUSIONPI_SCROLL_DEBUG=<path> append per-frame pacing decisions to a file
|
|
21
|
+
import { appendFileSync } from "node:fs";
|
|
22
|
+
|
|
23
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
|
|
25
|
+
type RenderComponent = {
|
|
26
|
+
render(width: number): string[];
|
|
27
|
+
invalidate?(): void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type TuiLike = {
|
|
31
|
+
children: RenderComponent[];
|
|
32
|
+
requestRender(force?: boolean): void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type WidgetUi = {
|
|
36
|
+
setWidget(
|
|
37
|
+
key: string,
|
|
38
|
+
content: ((tui: TuiLike, theme: unknown) => RenderComponent) | undefined
|
|
39
|
+
): void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const enabled = process.env["DIFFUSIONPI_SMOOTH_SCROLL"] !== "0";
|
|
43
|
+
const linesPerSecond = positiveNumber(process.env["DIFFUSIONPI_SCROLL_SPEED"], 40);
|
|
44
|
+
const debugPath = process.env["DIFFUSIONPI_SCROLL_DEBUG"];
|
|
45
|
+
|
|
46
|
+
function debugLog(message: string): void {
|
|
47
|
+
if (debugPath === undefined || debugPath === "") {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
appendFileSync(debugPath, `${String(Date.now())} ${message}\n`);
|
|
52
|
+
} catch {
|
|
53
|
+
// Debug logging must never break rendering.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// A growth this large is a session restore or theme redraw, not streaming:
|
|
57
|
+
// show it instantly instead of animating through it.
|
|
58
|
+
const snapThresholdLines = 300;
|
|
59
|
+
// Upper bound on the time credited between renders, so the first paced frame
|
|
60
|
+
// after an idle period does not instantly spend seconds of accumulated budget.
|
|
61
|
+
const maxTickSeconds = 0.1;
|
|
62
|
+
const catchUpDelayMs = 33;
|
|
63
|
+
const expectedTuiChildren = 9;
|
|
64
|
+
const chatChildIndex = 2;
|
|
65
|
+
|
|
66
|
+
function positiveNumber(raw: string | undefined, fallback: number): number {
|
|
67
|
+
const value = Number(raw);
|
|
68
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default function diffusionpiSmoothScroll(pi: ExtensionAPI): void {
|
|
72
|
+
if (!enabled) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
let patched = false;
|
|
76
|
+
pi.on("session_start", (_event, ctx) => {
|
|
77
|
+
if (ctx.mode !== "tui" || patched) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
patched = tryPatch(ctx);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The widget factory is invoked synchronously with the live TUI instance;
|
|
85
|
+
// registering and immediately removing a probe widget is the only way an
|
|
86
|
+
// extension can get its hands on the TUI outside a dialog.
|
|
87
|
+
function tryPatch(ctx: ExtensionContext): boolean {
|
|
88
|
+
let tui: TuiLike | undefined;
|
|
89
|
+
const ui = ctx.ui as unknown as WidgetUi;
|
|
90
|
+
if (typeof ui.setWidget !== "function") {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
ui.setWidget("diffusionpi-smooth-scroll-probe", (liveTui) => {
|
|
94
|
+
tui = liveTui;
|
|
95
|
+
return { render: () => [], invalidate: () => undefined };
|
|
96
|
+
});
|
|
97
|
+
ui.setWidget("diffusionpi-smooth-scroll-probe", undefined);
|
|
98
|
+
if (tui === undefined || !Array.isArray(tui.children)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
if (tui.children.length !== expectedTuiChildren) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const chat = tui.children[chatChildIndex];
|
|
105
|
+
if (chat === undefined || typeof chat.render !== "function") {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
installPacer(tui, chat);
|
|
109
|
+
debugLog("pacer installed");
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function installPacer(tui: TuiLike, chat: RenderComponent): void {
|
|
114
|
+
const original = chat.render.bind(chat);
|
|
115
|
+
let revealed = Number.POSITIVE_INFINITY; // first render snaps to full
|
|
116
|
+
let lastWidth = -1;
|
|
117
|
+
let lastTickAt = 0;
|
|
118
|
+
let carry = 0;
|
|
119
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
120
|
+
|
|
121
|
+
const scheduleCatchUp = (): void => {
|
|
122
|
+
if (timer !== undefined) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
timer = setTimeout(() => {
|
|
126
|
+
timer = undefined;
|
|
127
|
+
tui.requestRender();
|
|
128
|
+
}, catchUpDelayMs);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
chat.render = (width: number): string[] => {
|
|
132
|
+
const lines = original(width);
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
if (width !== lastWidth) {
|
|
135
|
+
// Rewrap after a resize changes every line count; animating the reflow
|
|
136
|
+
// would look like scrolling through the whole session.
|
|
137
|
+
lastWidth = width;
|
|
138
|
+
revealed = lines.length;
|
|
139
|
+
return lines;
|
|
140
|
+
}
|
|
141
|
+
if (lines.length <= revealed || lines.length - revealed > snapThresholdLines) {
|
|
142
|
+
revealed = lines.length;
|
|
143
|
+
lastTickAt = now;
|
|
144
|
+
carry = 0;
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
const dt = Math.min(maxTickSeconds, Math.max(0, (now - lastTickAt) / 1000));
|
|
148
|
+
lastTickAt = now;
|
|
149
|
+
carry += linesPerSecond * dt;
|
|
150
|
+
const step = Math.floor(carry);
|
|
151
|
+
carry -= step;
|
|
152
|
+
revealed = Math.min(lines.length, revealed + step);
|
|
153
|
+
debugLog(`pacing revealed=${String(revealed)} total=${String(lines.length)} step=${String(step)}`);
|
|
154
|
+
if (revealed < lines.length) {
|
|
155
|
+
scheduleCatchUp();
|
|
156
|
+
}
|
|
157
|
+
return lines.slice(0, revealed);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
id = "diffusionpi"
|
|
2
|
+
name = "DiffusionPi"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
schema_version = 1
|
|
5
|
+
description = "Pi + DiffusionGemma with a live diffusion canvas"
|
|
6
|
+
state_dir = "~/.local/state/diffusionpi"
|
|
7
|
+
# Stock Pi via pi-factory's default pi_command (npx @earendil-works/pi-coding-agent).
|
|
8
|
+
thinking = "medium"
|
|
9
|
+
|
|
10
|
+
[provider]
|
|
11
|
+
id = "vllm"
|
|
12
|
+
base_url = "http://127.0.0.1:8000/v1"
|
|
13
|
+
api = "openai-completions"
|
|
14
|
+
|
|
15
|
+
[model]
|
|
16
|
+
id = "nvidia/diffusiongemma-26B-A4B-it-NVFP4"
|
|
17
|
+
name = "DiffusionGemma 26B-A4B NVFP4"
|
|
18
|
+
context_window = 32768
|
|
19
|
+
max_tokens = 8192
|
|
20
|
+
reasoning = true
|
|
21
|
+
|
|
22
|
+
[[extensions]]
|
|
23
|
+
path = "extensions/diffusion-canvas.ts"
|
|
24
|
+
|
|
25
|
+
# Shared self-driving demo extension; inert unless PI_DEMO_MODE=1 (the demo
|
|
26
|
+
# manifest sets it), so it is safe to keep loaded in interactive sessions.
|
|
27
|
+
[[extensions]]
|
|
28
|
+
path = "../node_modules/pi-demo-mode/extensions/demo-mode.ts"
|
|
29
|
+
|
|
30
|
+
[[extensions]]
|
|
31
|
+
path = "extensions/smooth-scroll.ts"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Continue. Try to write as long as possible.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
You are narrating a never-ending sci-fi adventure. Continue in short paragraphs. Whenever the user sends a message, treat it as a live director note and incorporate it immediately. Never end the story.
|
package/bin/diffusionpi
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Launches the diffusionpi Pi app bundle via pi-factory (stock Pi, no fork).
|
|
3
|
+
# Subcommands: run (default), demo, plan, validate.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
|
7
|
+
app_dir="$root/app"
|
|
8
|
+
|
|
9
|
+
pi_factory() {
|
|
10
|
+
if command -v pi-factory >/dev/null 2>&1; then
|
|
11
|
+
pi-factory "$@"
|
|
12
|
+
else
|
|
13
|
+
npx -y @dutifuldev/pi-factory@latest "$@"
|
|
14
|
+
fi
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# Shared Pi extension packages (pi-demo-mode) are npm dependencies of the
|
|
18
|
+
# bundle; fetch them on first run so the TOML extension paths resolve.
|
|
19
|
+
ensure_deps() {
|
|
20
|
+
if [ ! -d "$root/node_modules/pi-demo-mode" ]; then
|
|
21
|
+
npm install --prefix "$root" --no-fund --no-audit >&2
|
|
22
|
+
fi
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ensure_deps
|
|
26
|
+
|
|
27
|
+
case "${1:-run}" in
|
|
28
|
+
run) pi_factory run --app-dir "$app_dir" ;;
|
|
29
|
+
demo)
|
|
30
|
+
PI_DEMO_INITIAL_PROMPT_FILE="$app_dir/prompts/demo-initial.md" \
|
|
31
|
+
PI_DEMO_FOLLOWUP_PROMPT_FILE="$app_dir/prompts/demo-followup.md" \
|
|
32
|
+
pi_factory run --app-file "$app_dir/demo.pi-factory.toml"
|
|
33
|
+
;;
|
|
34
|
+
plan) pi_factory plan --app-dir "$app_dir" ;;
|
|
35
|
+
validate) pi_factory validate "$app_dir" ;;
|
|
36
|
+
*)
|
|
37
|
+
echo "usage: diffusionpi [run|demo|plan|validate]" >&2
|
|
38
|
+
exit 2
|
|
39
|
+
;;
|
|
40
|
+
esac
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Reproducing the live diffusion canvas
|
|
2
|
+
|
|
3
|
+
This documents every piece needed to reproduce the truthful diffusion canvas
|
|
4
|
+
visualization: DiffusionGemma served by a patched vLLM that streams its
|
|
5
|
+
intermediate denoising states, rendered live in the Pi TUI.
|
|
6
|
+
|
|
7
|
+
## The pieces
|
|
8
|
+
|
|
9
|
+
| Piece | Where it lives | What it does |
|
|
10
|
+
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
11
|
+
| vLLM canvas streaming | [`osolmaz/vllm`](https://github.com/osolmaz/vllm), branch `diffusion-canvas-events`, release tag `canvas-v0.23.1rc4` | Adds the opt-in `--diffusion-stream-canvas` server flag and the `GET /v1/diffusion/events` SSE side channel that emits the detokenized canvas on every denoising step. See the fork's [`DIFFUSION_CANVAS.md`](https://github.com/osolmaz/vllm/blob/diffusion-canvas-events/DIFFUSION_CANVAS.md). Tracked as [PR #1 on the fork](https://github.com/osolmaz/vllm/pull/1). |
|
|
12
|
+
| Pi widget | [`app/extensions/diffusion-canvas.ts`](../app/extensions/diffusion-canvas.ts) in this repo | Self-contained Pi extension that renders the canvas above the editor. Not published separately; this repo is its home. |
|
|
13
|
+
| diffusionpi app | [`app/`](../app/) bundle + [`bin/diffusionpi`](../bin/diffusionpi) launcher | pi-factory bundle that launches stock Pi with the widget against the local vLLM server. |
|
|
14
|
+
|
|
15
|
+
The vLLM changes are deliberately not upstreamed; they are maintained as
|
|
16
|
+
tagged fork releases (`canvas-v<upstream version>rc<N>`, PEP 440-parseable so
|
|
17
|
+
vLLM's version detection accepts them).
|
|
18
|
+
|
|
19
|
+
## 1. Install the patched vLLM
|
|
20
|
+
|
|
21
|
+
All fork changes are pure Python, so the install overlays them on the
|
|
22
|
+
official precompiled kernels — no compilation:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
VLLM_USE_PRECOMPILED=1 \
|
|
26
|
+
VLLM_PRECOMPILED_WHEEL_COMMIT=4e5ca89cfe98121642d76b40e32a006f4d0fbf3b \
|
|
27
|
+
pip install git+https://github.com/osolmaz/vllm@canvas-v0.23.1rc4
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`VLLM_PRECOMPILED_WHEEL_COMMIT` pins the official wheel that provides the
|
|
31
|
+
binary artifacts. It must match the fork release's upstream base commit,
|
|
32
|
+
which is recorded in the fork's `DIFFUSION_CANVAS.md`.
|
|
33
|
+
|
|
34
|
+
## 2. Serve DiffusionGemma with canvas streaming
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
vllm serve nvidia/diffusiongemma-26B-A4B-it-NVFP4 \
|
|
38
|
+
--host 127.0.0.1 --port 8000 \
|
|
39
|
+
--max-model-len 32768 --max-num-seqs 16 --max-num-batched-tokens 8192 \
|
|
40
|
+
--kv-cache-dtype fp8 \
|
|
41
|
+
--enable-auto-tool-choice --tool-call-parser gemma4 \
|
|
42
|
+
--diffusion-stream-canvas
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`--diffusion-stream-canvas` requires the single-process FastAPI frontend (no
|
|
46
|
+
`--grpc`, headless mode, or external/hybrid/multi-port data-parallel load
|
|
47
|
+
balancing); the server rejects incompatible combinations at startup.
|
|
48
|
+
|
|
49
|
+
To sanity-check the side channel independently of any client:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
curl -N http://127.0.0.1:8000/v1/diffusion/events
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
then run any chat completion against the server; per-step JSON events
|
|
56
|
+
(`request_id`, `step`, `block`, `text`) should stream out.
|
|
57
|
+
|
|
58
|
+
## 3. Run the visualizer
|
|
59
|
+
|
|
60
|
+
Through the diffusionpi launcher (from the repo root):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bin/diffusionpi # interactive session
|
|
64
|
+
bin/diffusionpi demo # self-driving story demo
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or in plain Pi, load the self-contained extension file directly (copy it into
|
|
68
|
+
your Pi extensions directory or pass it with `--extension`):
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pi --extension ./diffusionpi/app/extensions/diffusion-canvas.ts
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The widget needs no configuration: it derives the events and metrics URLs
|
|
75
|
+
from the active model's `baseUrl`, with `PI_DIFFUSION_CANVAS_EVENTS_URL` and
|
|
76
|
+
`PI_DIFFUSION_CANVAS_METRICS_URL` as overrides. Against a server without the
|
|
77
|
+
side channel it falls back to a clearly labeled simulation paced by the real
|
|
78
|
+
commit bursts.
|
|
79
|
+
|
|
80
|
+
## How the truthful path works
|
|
81
|
+
|
|
82
|
+
- Each denoising step, vLLM's diffusion sampler produces an `argmax_canvas`
|
|
83
|
+
(its current best guess for every canvas position). The fork carries these
|
|
84
|
+
through the draft-token path into `EngineCoreOutput`, detokenizes them in
|
|
85
|
+
the API server, and broadcasts them on `/v1/diffusion/events`.
|
|
86
|
+
- The Pi extension assigns each completion a request id up front via the
|
|
87
|
+
`X-Request-Id` header (vLLM derives the `chatcmpl-*` id from it), then
|
|
88
|
+
subscribes to the side channel scoped to that id
|
|
89
|
+
(`?request_id=...`). Uncorrelated canvases are never rendered, so a shared
|
|
90
|
+
server cannot leak another client's text into the TUI.
|
|
91
|
+
- The widget renders committed text and the resolving canvas as one
|
|
92
|
+
continuous document windowed at the settled/canvas seam, keeps a stable
|
|
93
|
+
height, discards canvas snapshots of already-committed blocks (the `block`
|
|
94
|
+
ordinal), and substitutes glyphs of ambiguous terminal width, so the TUI's
|
|
95
|
+
differential renderer never leaves stale frames in scrollback.
|
|
96
|
+
|
|
97
|
+
## Upgrading the fork to a newer vLLM
|
|
98
|
+
|
|
99
|
+
1. Rebase `diffusion-canvas-events` onto the new upstream base commit.
|
|
100
|
+
2. Run the fork's test suite: `pytest tests/v1/engine/test_diffusion_events.py`.
|
|
101
|
+
3. Update the base commit in the fork's `DIFFUSION_CANVAS.md` and in the
|
|
102
|
+
install one-liners here and in the package README.
|
|
103
|
+
4. Tag the next `canvas-v<upstream version>rc<N>` and push the tag.
|
|
104
|
+
5. Re-verify the install one-liner in a scratch venv and the live TUI flow.
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "diffusionpi",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Watch a diffusion LLM denoise its answer live in the Pi TUI",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"diffusion",
|
|
8
|
+
"vllm",
|
|
9
|
+
"tui"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/osolmaz/diffusionpi.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/osolmaz/diffusionpi/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/osolmaz/diffusionpi#readme",
|
|
20
|
+
"bin": {
|
|
21
|
+
"diffusionpi": "bin/diffusionpi"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin/**",
|
|
25
|
+
"app/**",
|
|
26
|
+
"docs/**",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"pi-demo-mode": "github:osolmaz/pi-demo-mode#v0.1.0"
|
|
32
|
+
}
|
|
33
|
+
}
|