dsh-generative-ui 0.0.0 → 0.0.2
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 +90 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +18568 -0
- package/lib/client.js.map +62 -0
- package/lib/index.js +1597 -0
- package/lib/types/client/canvas/CanvasLauncher.d.ts +6 -0
- package/lib/types/client/canvas/CanvasPanel.d.ts +88 -0
- package/lib/types/client/canvas/collect.d.ts +45 -0
- package/lib/types/client/canvas/index.d.ts +43 -0
- package/lib/types/client/canvas/mount.d.ts +30 -0
- package/lib/types/client/canvas/panel-css.d.ts +1 -0
- package/lib/types/client/canvas/read.d.ts +12 -0
- package/lib/types/client/canvas/subpages.d.ts +20 -0
- package/lib/types/client/canvas/useDismissable.d.ts +15 -0
- package/lib/types/client/index.d.ts +20 -0
- package/lib/types/client/runtime/GenUISurface.d.ts +159 -0
- package/lib/types/client/runtime/bindings.d.ts +143 -0
- package/lib/types/client/runtime/compiler.d.ts +35 -0
- package/lib/types/client/runtime/inline-fence.d.ts +23 -0
- package/lib/types/client/runtime/observe.d.ts +30 -0
- package/lib/types/client/runtime/register.d.ts +2 -0
- package/lib/types/client/runtime/registry.d.ts +7 -0
- package/lib/types/client/runtime/report-error.d.ts +17 -0
- package/lib/types/client/runtime/segments.d.ts +18 -0
- package/lib/types/client/runtime/state.d.ts +18 -0
- package/lib/types/client/runtime/uno-config.d.ts +16 -0
- package/lib/types/client/runtime/uno.d.ts +50 -0
- package/lib/types/client/session.d.ts +26 -0
- package/lib/types/contract-assets.d.ts +41 -0
- package/lib/types/contract.d.ts +56 -0
- package/lib/types/index.d.ts +255 -0
- package/lib/types/prompt.d.ts +13 -0
- package/lib/types/skill.d.ts +27 -0
- package/package.json +135 -9
- package/src/client/canvas/CanvasLauncher.tsx +52 -0
- package/src/client/canvas/CanvasPanel.tsx +238 -0
- package/src/client/canvas/collect.ts +188 -0
- package/src/client/canvas/index.ts +255 -0
- package/src/client/canvas/mount.ts +91 -0
- package/src/client/canvas/panel-css.ts +2 -0
- package/src/client/canvas/panel.css +242 -0
- package/src/client/canvas/read.ts +55 -0
- package/src/client/canvas/subpages.ts +109 -0
- package/src/client/canvas/useDismissable.ts +37 -0
- package/src/client/index.ts +217 -0
- package/src/client/runtime/GenUISurface.tsx +359 -0
- package/src/client/runtime/bindings.ts +292 -0
- package/src/client/runtime/compiler.ts +80 -0
- package/src/client/runtime/inline-fence.ts +222 -0
- package/src/client/runtime/observe.ts +65 -0
- package/src/client/runtime/register.ts +57 -0
- package/src/client/runtime/registry.ts +65 -0
- package/src/client/runtime/report-error.ts +79 -0
- package/src/client/runtime/segments.ts +116 -0
- package/src/client/runtime/state.ts +47 -0
- package/src/client/runtime/uno-config.ts +71 -0
- package/src/client/runtime/uno.ts +124 -0
- package/src/client/session.ts +46 -0
- package/src/contract-assets.ts +46 -0
- package/src/contract.ts +111 -0
- package/src/index.ts +583 -0
- package/src/prompt.ts +377 -0
- package/src/skill.ts +931 -0
- package/types/README.md +34 -0
- package/types/ai.d.ts +14 -0
- package/types/chat.d.ts +14 -0
- package/types/check.ts +39 -0
- package/types/exec.d.ts +17 -0
- package/types/fs.d.ts +17 -0
- package/types/importmap.json +10 -0
- package/types/standalone/ai.js +7 -0
- package/types/standalone/chat.js +6 -0
- package/types/standalone/exec.js +7 -0
- package/types/standalone/fs.js +18 -0
- package/types/standalone/importmap.json +10 -0
- package/types/standalone/state.js +24 -0
- package/types/standalone/web.js +7 -0
- package/types/state.d.ts +25 -0
- package/types/web.d.ts +31 -0
- package/index.js +0 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import initTsx, { transform as transformTsx } from "@esm.sh/tsx";
|
|
2
|
+
import { normalizeGeneratedTsx } from "partial-tsx";
|
|
3
|
+
import { rewriteImportMetaResolveSpecifiers, type RendererImportMap } from "partial-react/import-map";
|
|
4
|
+
import { WASM_PATH } from "../../contract-assets.ts";
|
|
5
|
+
|
|
6
|
+
// Structurally equivalent to `partial-react/compiler`'s three types. Deliberately not
|
|
7
|
+
// imported from there: that module has a Vite-only `...tsx_bg.wasm?url` at the top level
|
|
8
|
+
// plus a node:fs read path and a `Bun` global, none of which survive a plugin bundle.
|
|
9
|
+
export type CompileOptions = { importMap?: RendererImportMap; partial?: boolean; previousCode?: string; filename?: string };
|
|
10
|
+
export type CompileResult = { code: string; source: string; changed: boolean };
|
|
11
|
+
export type TsxCompiler = { compile: (code: string, options?: CompileOptions) => Promise<CompileResult> };
|
|
12
|
+
|
|
13
|
+
let initPromise: Promise<unknown> | null = null;
|
|
14
|
+
|
|
15
|
+
/** The wasm is served by this plugin's own webServer route; /plugins only serves client.js. */
|
|
16
|
+
const initCompiler = () =>
|
|
17
|
+
(initPromise ??= initTsx(WASM_PATH).catch((error: unknown) => {
|
|
18
|
+
initPromise = null;
|
|
19
|
+
throw error;
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Starts loading the 2.6 MB wasm file so it is warm before the first real frame (a cold init
|
|
24
|
+
* costs 400-500 ms). The *file* is 2.6 MB; an instantiated compiler costs roughly 16 MB of
|
|
25
|
+
* heap, which is why `disposeCompiler` exists — the two numbers have been confused before.
|
|
26
|
+
*
|
|
27
|
+
* Never throws: `apply()` calls this and nothing else awaits it, so a
|
|
28
|
+
* synchronous failure inside `initTsx` — an unfetchable wasm path, say — would otherwise
|
|
29
|
+
* take the whole plugin's registration down with it and leave the shell loading forever.
|
|
30
|
+
* A cold compile on the first card is a far better outcome than no plugin at all.
|
|
31
|
+
*/
|
|
32
|
+
export const warmCompiler = (): Promise<unknown> => {
|
|
33
|
+
try {
|
|
34
|
+
return initCompiler().catch(() => undefined);
|
|
35
|
+
} catch {
|
|
36
|
+
initPromise = null;
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Drops the wasm instance so GC can take it. `@esm.sh/tsx` exports no dispose — only
|
|
43
|
+
* `init`/`initSync`/`transform` — so releasing the reference is the whole of what we can do.
|
|
44
|
+
* Measured 2026-08-23: an instance costs ~16MB, and each HMR round made a fresh one while the
|
|
45
|
+
* previous stayed reachable through this module-level promise. Dev-only, but a dozen reloads
|
|
46
|
+
* is 200MB.
|
|
47
|
+
*/
|
|
48
|
+
export function disposeCompiler(): void {
|
|
49
|
+
initPromise = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createBrowserTsxCompiler(): TsxCompiler {
|
|
53
|
+
return {
|
|
54
|
+
async compile(code, options = {}) {
|
|
55
|
+
await initCompiler();
|
|
56
|
+
const importMap = options.importMap;
|
|
57
|
+
const build = (source: string): CompileResult => {
|
|
58
|
+
const resolved = importMap?.imports ? rewriteImportMetaResolveSpecifiers(source, importMap.imports) : source;
|
|
59
|
+
const result = transformTsx({ filename: options.filename ?? "_.tsx", code: resolved, target: "es2022", importMap, jsxImportSource: "react" });
|
|
60
|
+
const compiled = new TextDecoder().decode(result.code);
|
|
61
|
+
return { code: compiled, source, changed: compiled !== options.previousCode };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Normalization runs for `final` too. The model does not reliably close its trailing
|
|
65
|
+
// `)` and `}` before writing the fence, so compiling the raw source renders every
|
|
66
|
+
// streaming frame fine and then throws `Expected ',', got '<eof>'` at the very moment
|
|
67
|
+
// the block completes — measured on exactly that shape.
|
|
68
|
+
if (options.partial === true) return build(normalizeGeneratedTsx(code, { mode: "streaming" }));
|
|
69
|
+
try {
|
|
70
|
+
return build(normalizeGeneratedTsx(code, { mode: "final" }));
|
|
71
|
+
} catch {
|
|
72
|
+
// **The final compile must never be more fragile than a streaming frame.** The only
|
|
73
|
+
// difference between the modes is that `streaming` first cuts back the still-being-typed
|
|
74
|
+
// tail, and some damage (an unterminated string, typically) is only recoverable by
|
|
75
|
+
// cutting. Losing the last half-sentence beats going blank on the last frame.
|
|
76
|
+
return build(normalizeGeneratedTsx(code, { mode: "streaming" }));
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders ```` ```ui4a/tsx ```` blocks in assistant prose live, in place, between the
|
|
3
|
+
* surrounding paragraphs — the ui4a-playground inline contract.
|
|
4
|
+
*
|
|
5
|
+
* Two sources, each doing what only it can:
|
|
6
|
+
*
|
|
7
|
+
* - **The session snapshot owns the code.** The host's markdown renderer withholds a
|
|
8
|
+
* fence's info string until the closing fence arrives, so a DOM-only implementation
|
|
9
|
+
* cannot tell a half-written ui4a block from any other code block, and can only claim
|
|
10
|
+
* it after the model has stopped typing. The raw assistant text has the opening fence
|
|
11
|
+
* from its very first token, so that is where the code and its language come from.
|
|
12
|
+
* - **The DOM owns the position.** There is no slot for a markdown code block, so the
|
|
13
|
+
* rendered block is what tells us where in the prose to mount. `md-code-block` is a
|
|
14
|
+
* hard-coded class on the host's CodeBlock wrapper.
|
|
15
|
+
*
|
|
16
|
+
* Blocks are matched to segments by content (a rendered block's text is a prefix of, or
|
|
17
|
+
* equal to, its segment's code), not by order, so unrelated code blocks in the same reply
|
|
18
|
+
* are left alone.
|
|
19
|
+
*
|
|
20
|
+
* The claimed block is hidden rather than removed: it belongs to the host's React tree,
|
|
21
|
+
* and detaching a node React still owns invites a NotFoundError on its next commit.
|
|
22
|
+
*/
|
|
23
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
24
|
+
import { createElement } from "react";
|
|
25
|
+
import { CodeBlock } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
26
|
+
import type { ReactElement } from "react";
|
|
27
|
+
import type { Ui4aSegment } from "./segments.ts";
|
|
28
|
+
import { observeTranscript } from "./observe.ts";
|
|
29
|
+
|
|
30
|
+
const CLAIMED = "data-ui4a-claimed";
|
|
31
|
+
const MOUNT = "data-ui4a-mount";
|
|
32
|
+
const PREVIEW = "data-ui4a-preview";
|
|
33
|
+
|
|
34
|
+
/** Unmounting a root during React's own commit throws; defer it like every other teardown here. */
|
|
35
|
+
const dropPreview = (claim: { preview: { host: HTMLElement; root: Root } | null }) => {
|
|
36
|
+
const preview = claim.preview;
|
|
37
|
+
if (preview === null) return;
|
|
38
|
+
claim.preview = null;
|
|
39
|
+
queueMicrotask(() => {
|
|
40
|
+
preview.root.unmount();
|
|
41
|
+
preview.host.remove();
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type Claim = { block: HTMLElement; mount: HTMLElement; root: Root; code: string; complete: boolean; rendered: string; painted: MutationObserver | null; preview: { host: HTMLElement; root: Root } | null };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the card has actually painted something a reader can see.
|
|
49
|
+
*
|
|
50
|
+
* **Compiling is not the same as having something to look at.** Mid-stream the default
|
|
51
|
+
* export usually exists while the body is still an empty shell, so hiding the source block
|
|
52
|
+
* at claim time leaves a blank gap that fills in with a pop seconds later — and the source
|
|
53
|
+
* was sitting right there the whole time. Text, or an element that draws its own
|
|
54
|
+
* pixels **and has a box**, is the signal; a wrapper with layout classes is not.
|
|
55
|
+
*
|
|
56
|
+
* Measured against the alternatives: a bare `getBoundingClientRect()` test passes a styled-but-
|
|
57
|
+
* empty `div` — `height: 200px`, a grid with a gap, a padded skeleton card — which is precisely
|
|
58
|
+
* the mid-stream shell this exists to reject. Enumerating tags alone missed `<video>`, custom
|
|
59
|
+
* elements and iframes. Requiring both catches everything the tag list caught, plus those three,
|
|
60
|
+
* and still rejects all three empty shells.
|
|
61
|
+
*/
|
|
62
|
+
/** Elements that paint their own pixels; a custom element (any tag with a dash) counts too. */
|
|
63
|
+
const DRAWS = new Set(["SVG", "CANVAS", "IMG", "VIDEO", "IFRAME", "PICTURE"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* partial-react's error boundary renders a bare text node — `ERROR` or `ERROR: <message>`.
|
|
67
|
+
* That is text, so a naive check reads it as a painted card and hides the source block
|
|
68
|
+
* underneath it, leaving the reader one red line and no way to see what the model wrote.
|
|
69
|
+
* Measured: exactly the case where the source is most worth keeping.
|
|
70
|
+
*/
|
|
71
|
+
const BOUNDARY_ERROR = /^ERROR(:|$)/;
|
|
72
|
+
|
|
73
|
+
/** Split out from `hasPainted` so the rule can be tested without a DOM. */
|
|
74
|
+
export const isPaintedText = (text: string) => {
|
|
75
|
+
const trimmed = text.trim();
|
|
76
|
+
return trimmed !== "" && !BOUNDARY_ERROR.test(trimmed);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const hasPainted = (mount: HTMLElement) => {
|
|
80
|
+
if (isPaintedText(mount.textContent ?? "")) return true;
|
|
81
|
+
for (const el of mount.querySelectorAll("*")) {
|
|
82
|
+
const tag = el.tagName.toUpperCase();
|
|
83
|
+
if (!DRAWS.has(tag) && !tag.includes("-")) continue;
|
|
84
|
+
const box = el.getBoundingClientRect();
|
|
85
|
+
if (box.width > 0 && box.height > 0) return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The block's source. `pre` when the grammar was unknown, the highlighted div otherwise. */
|
|
91
|
+
const codeOf = (block: HTMLElement) => block.querySelector("pre")?.textContent ?? "";
|
|
92
|
+
|
|
93
|
+
/** CodeBlock trims one trailing newline for display, so compare on trimmed ends. */
|
|
94
|
+
export const sameCode = (a: string, b: string) => a.trimEnd() === b.trimEnd();
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The segment a rendered block belongs to.
|
|
98
|
+
*
|
|
99
|
+
* Mid-stream the block shows a prefix of its segment; once settled the two are equal.
|
|
100
|
+
*/
|
|
101
|
+
export const matchSegment = (segments: readonly Ui4aSegment[], rendered: string) => segments.find((segment) => sameCode(segment.code, rendered) || segment.code.startsWith(rendered));
|
|
102
|
+
|
|
103
|
+
export type InlineFenceOptions = {
|
|
104
|
+
/** Every ui4a segment currently in the transcript, in document order. */
|
|
105
|
+
segments: () => readonly Ui4aSegment[];
|
|
106
|
+
render: (props: { code: string; streaming: boolean }) => ReactElement;
|
|
107
|
+
scope?: HTMLElement;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function claimInlineFences({ segments, render, scope }: InlineFenceOptions): () => void {
|
|
111
|
+
const claims = new Map<HTMLElement, Claim>();
|
|
112
|
+
const root = scope ?? document.body;
|
|
113
|
+
|
|
114
|
+
const release = (claim: Claim, restore: boolean) => {
|
|
115
|
+
claim.painted?.disconnect();
|
|
116
|
+
claim.root.unmount();
|
|
117
|
+
claim.mount.remove();
|
|
118
|
+
dropPreview(claim);
|
|
119
|
+
if (restore && claim.block.isConnected) {
|
|
120
|
+
claim.block.style.display = "";
|
|
121
|
+
claim.block.removeAttribute(CLAIMED);
|
|
122
|
+
}
|
|
123
|
+
claims.delete(claim.block);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const sweep = () => {
|
|
127
|
+
const current = segments();
|
|
128
|
+
|
|
129
|
+
for (const block of root.querySelectorAll<HTMLElement>(`.md-code-block:not([${CLAIMED}])`)) {
|
|
130
|
+
const code = codeOf(block);
|
|
131
|
+
if (code === "") continue;
|
|
132
|
+
// A streaming block's rendered text is a prefix of its segment; a settled one equals it.
|
|
133
|
+
const segment = matchSegment(current, code);
|
|
134
|
+
if (segment === undefined) continue;
|
|
135
|
+
block.setAttribute(CLAIMED, "");
|
|
136
|
+
const mount = document.createElement("div");
|
|
137
|
+
mount.setAttribute(MOUNT, "");
|
|
138
|
+
block.parentElement?.insertBefore(mount, block.nextSibling);
|
|
139
|
+
// Swap the host's block for our own CodeBlock while the card is still compiling. Two
|
|
140
|
+
// reasons, both reported from a real transcript: the host keys highlighting on the fence
|
|
141
|
+
// language, and its markdown parser truncates `ui4a/tsx` at the slash — so `ui4a` reaches
|
|
142
|
+
// shiki, matches no grammar, and the source renders as unhighlighted plain text. Passing
|
|
143
|
+
// `lang="tsx"` is all it takes; `dsh-client-ui-primitives` is in the platform table, so
|
|
144
|
+
// this resolves to the shell's own component and costs nothing to bundle. The second
|
|
145
|
+
// reason is height: a 300-line card streams for over a minute with all of it on screen,
|
|
146
|
+
// and this preview is capped.
|
|
147
|
+
const previewHost = document.createElement("div");
|
|
148
|
+
previewHost.setAttribute(PREVIEW, "");
|
|
149
|
+
block.parentElement?.insertBefore(previewHost, block);
|
|
150
|
+
block.style.display = "none";
|
|
151
|
+
const claim: Claim = { block, mount, root: createRoot(mount), code: "", complete: false, rendered: "", painted: null, preview: { host: previewHost, root: createRoot(previewHost) } };
|
|
152
|
+
// The source stays visible until the card paints. Checked at most once per frame and
|
|
153
|
+
// torn down the moment it fires: a streaming card mutates thousands of times, and
|
|
154
|
+
// `textContent` walks the whole subtree, so a per-mutation check would be
|
|
155
|
+
// O(mutations x subtree). The cost only exists during the gap it closes.
|
|
156
|
+
let queued = 0;
|
|
157
|
+
claim.painted = new MutationObserver(() => {
|
|
158
|
+
if (queued !== 0) return;
|
|
159
|
+
queued = requestAnimationFrame(() => {
|
|
160
|
+
queued = 0;
|
|
161
|
+
if (!hasPainted(mount)) return;
|
|
162
|
+
claim.painted?.disconnect();
|
|
163
|
+
claim.painted = null;
|
|
164
|
+
dropPreview(claim);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
claim.painted.observe(mount, { childList: true, subtree: true, characterData: true });
|
|
168
|
+
claims.set(block, claim);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const claim of claims.values()) {
|
|
172
|
+
if (!claim.block.isConnected) {
|
|
173
|
+
release(claim, false);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const rendered = codeOf(claim.block);
|
|
177
|
+
// The block's own text is what locates its segment, so an unchanged block cannot
|
|
178
|
+
// have changed its match — skip the scan rather than re-run it every frame.
|
|
179
|
+
//
|
|
180
|
+
// Only once it has settled, though. `complete` flips on the segment, not in the block, so
|
|
181
|
+
// a card whose last token closes the fence without changing the rendered text would never
|
|
182
|
+
// leave the streaming path — and the streaming path cuts back the still-being-typed tail,
|
|
183
|
+
// so it would keep rendering a card with its last statement missing. The skip is an
|
|
184
|
+
// optimization for the steady state (most blocks in a long transcript), and a claim that
|
|
185
|
+
// is still streaming is being re-scanned every frame regardless.
|
|
186
|
+
if (rendered === claim.rendered && claim.code !== "" && claim.complete) continue;
|
|
187
|
+
claim.rendered = rendered;
|
|
188
|
+
const segment = matchSegment(current, rendered);
|
|
189
|
+
// The snapshot is authoritative while it still describes this block: mid-stream its
|
|
190
|
+
// code runs ahead of what markdown has painted. Once it stops describing it — an
|
|
191
|
+
// older page dropped out of the loaded window — the last good frame stands, because
|
|
192
|
+
// re-deriving from the hidden block would hand the renderer a truncated prefix and
|
|
193
|
+
// blank a card that was already complete.
|
|
194
|
+
//
|
|
195
|
+
// Unless the block stopped being this card's at all: React reconciles `.md-code-block`
|
|
196
|
+
// wrappers positionally, so a re-render can drop unrelated content into the very node
|
|
197
|
+
// we hid. Its text is then no longer a prefix of what we rendered, and holding the
|
|
198
|
+
// claim would leave a stale card on screen with the real block invisible behind it.
|
|
199
|
+
if (segment === undefined) {
|
|
200
|
+
if (claim.code.startsWith(rendered)) continue;
|
|
201
|
+
release(claim, true);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const { code, complete } = segment;
|
|
205
|
+
if (code === claim.code && complete === claim.complete) continue;
|
|
206
|
+
claim.code = code;
|
|
207
|
+
claim.complete = complete;
|
|
208
|
+
claim.root.render(render({ code, streaming: !complete }));
|
|
209
|
+
// The preview follows the SEGMENT, not the block: mid-stream the snapshot runs ahead of
|
|
210
|
+
// what markdown has painted, so this is the newer text and the one the reader wants while
|
|
211
|
+
// waiting. Dropped the moment the card paints.
|
|
212
|
+
claim.preview?.root.render(createElement(CodeBlock, { code, lang: "tsx" }));
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const stop = observeTranscript(sweep);
|
|
217
|
+
|
|
218
|
+
return () => {
|
|
219
|
+
stop();
|
|
220
|
+
for (const claim of claims.values()) release(claim, true);
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One document observer, shared by every consumer that reacts to transcript mutations.
|
|
3
|
+
*
|
|
4
|
+
* Both the inline-fence claimer and the canvas host are driven by the same event — a
|
|
5
|
+
* streamed token landing in the chat DOM — so a second observer over the same subtree only
|
|
6
|
+
* doubles the browser's mutation bookkeeping and the number of frames scheduled. The
|
|
7
|
+
* coalescing is not optional either: a streaming reply mutates the transcript dozens of
|
|
8
|
+
* times per second, and one sweep per mutation is how a renderer melts the main thread.
|
|
9
|
+
*/
|
|
10
|
+
type Listener = () => void;
|
|
11
|
+
|
|
12
|
+
const listeners = new Set<Listener>();
|
|
13
|
+
let observer: MutationObserver | null = null;
|
|
14
|
+
let frame = 0;
|
|
15
|
+
|
|
16
|
+
const flush = () => {
|
|
17
|
+
frame = 0;
|
|
18
|
+
for (const listener of listeners) listener();
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const schedule = () => {
|
|
22
|
+
if (frame !== 0) return;
|
|
23
|
+
frame = requestAnimationFrame(flush);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Runs `listener` at most once per frame while the document changes, starting immediately.
|
|
28
|
+
* @returns a disposer that also tears the observer down once nothing is left listening.
|
|
29
|
+
*/
|
|
30
|
+
export function observeTranscript(listener: Listener): () => void {
|
|
31
|
+
listeners.add(listener);
|
|
32
|
+
if (observer === null) {
|
|
33
|
+
observer = new MutationObserver(schedule);
|
|
34
|
+
observer.observe(document.body, { subtree: true, childList: true, characterData: true });
|
|
35
|
+
}
|
|
36
|
+
schedule();
|
|
37
|
+
return () => {
|
|
38
|
+
listeners.delete(listener);
|
|
39
|
+
if (listeners.size > 0) return;
|
|
40
|
+
observer?.disconnect();
|
|
41
|
+
observer = null;
|
|
42
|
+
if (frame !== 0) cancelAnimationFrame(frame);
|
|
43
|
+
frame = 0;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Requests a frame outside a mutation — for state that changed without the DOM changing. */
|
|
48
|
+
export const scheduleSweep = schedule;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Drop every listener and tear the observer down.
|
|
52
|
+
*
|
|
53
|
+
* The set above is module scope, so it is shared by everything in a process — which is right in
|
|
54
|
+
* a browser (one transcript, one observer) and is a trap in a test run, where a listener left by
|
|
55
|
+
* one file goes on being swept by every later one. A sweep captures its root at registration, so
|
|
56
|
+
* a stale one runs against a document that has since been replaced.
|
|
57
|
+
*
|
|
58
|
+
* Nothing in the plugin calls this: the shell disposes each host and that is the real path.
|
|
59
|
+
*/
|
|
60
|
+
export function resetTranscriptObservers(): void {
|
|
61
|
+
listeners.clear();
|
|
62
|
+
observer?.disconnect();
|
|
63
|
+
observer = null;
|
|
64
|
+
frame = 0;
|
|
65
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything generated code may import as a "local" module.
|
|
3
|
+
*
|
|
4
|
+
* The react family MUST be here: generated code shares the shell's single React
|
|
5
|
+
* instance or hooks blow up with an invalid-hook-call. All five come from the
|
|
6
|
+
* shell's platform table (see tsdown.config.ts), so registering them costs no
|
|
7
|
+
* bundle weight — it only republishes the instances we already received.
|
|
8
|
+
*
|
|
9
|
+
* We deliberately pre-register no component library. Anything else resolves
|
|
10
|
+
* through the esm.sh fallback at compile time.
|
|
11
|
+
*/
|
|
12
|
+
import * as React from "react";
|
|
13
|
+
import * as ReactJsxRuntime from "react/jsx-runtime";
|
|
14
|
+
import * as ReactDom from "react-dom";
|
|
15
|
+
import * as ReactDomClient from "react-dom/client";
|
|
16
|
+
import { registerModules, registryImports } from "./registry.ts";
|
|
17
|
+
|
|
18
|
+
let installed = false;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Installs a real `<script type="importmap">`.
|
|
22
|
+
*
|
|
23
|
+
* Compile-time rewriting only covers the code we generate. Packages pulled from
|
|
24
|
+
* esm.sh arrive as already-compiled JS whose `import ... from "react"` survives
|
|
25
|
+
* verbatim, and the browser resolves those bare specifiers against the document
|
|
26
|
+
* import map alone — without it, any generated UI that uses a third-party
|
|
27
|
+
* package dies on `Failed to resolve module specifier "react"`.
|
|
28
|
+
*
|
|
29
|
+
* It can only be installed once, and only before the first module resolution.
|
|
30
|
+
*/
|
|
31
|
+
function installDocumentImportMap(): void {
|
|
32
|
+
if (installed || typeof document === "undefined") return;
|
|
33
|
+
installed = true;
|
|
34
|
+
// A host-owned map wins: overwriting it would break whoever installed it, and a
|
|
35
|
+
// second map is ignored by the browser anyway.
|
|
36
|
+
if (document.querySelector('script[type="importmap"]') !== null) {
|
|
37
|
+
console.warn("[dsh-generative-ui] the shell already installs an import map; bare specifiers from esm.sh packages may not resolve");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const script = document.createElement("script");
|
|
41
|
+
script.type = "importmap";
|
|
42
|
+
script.textContent = JSON.stringify({ imports: registryImports() });
|
|
43
|
+
document.head.prepend(script);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerRuntimeModules(): void {
|
|
47
|
+
registerModules({
|
|
48
|
+
react: React,
|
|
49
|
+
"react/jsx-runtime": ReactJsxRuntime,
|
|
50
|
+
"react/jsx-dev-runtime": ReactJsxRuntime,
|
|
51
|
+
"react-dom": ReactDom,
|
|
52
|
+
"react-dom/client": ReactDomClient,
|
|
53
|
+
});
|
|
54
|
+
installDocumentImportMap();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const hostReactVersion = React.version;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated TSX is imported as a blob URL, so an import map's targets must be real
|
|
3
|
+
* URLs. Vite hosts can point at `/src/lib/react.ts`; dsh plugin bundles have no
|
|
4
|
+
* such "source is a URL" escape hatch (and CJS factories have no `import.meta`).
|
|
5
|
+
*
|
|
6
|
+
* So this goes the other way round: register the module namespaces the shell has
|
|
7
|
+
* already loaded, then synthesize, per specifier, a blob module that reads the
|
|
8
|
+
* table and named-re-exports it. Export names are enumerated at runtime, so there
|
|
9
|
+
* is no hand-written list to drift. React is a singleton for free — everyone
|
|
10
|
+
* imports the same blob URL, and that URL reads the shell's instance.
|
|
11
|
+
* (Ported from ui4a-playground/src/runtime/registry.ts.)
|
|
12
|
+
*/
|
|
13
|
+
const REGISTRY_KEY = "__DSH_GENERATIVE_UI_MODULES__";
|
|
14
|
+
type Registry = Record<string, Record<string, unknown>>;
|
|
15
|
+
|
|
16
|
+
const registry: Registry = ((globalThis as Record<string, unknown>)[REGISTRY_KEY] ??= {}) as Registry;
|
|
17
|
+
const urls = new Map<string, string>();
|
|
18
|
+
|
|
19
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
20
|
+
|
|
21
|
+
/** Exported for `test/registry.test.ts`: it generates code, so a bug here is a blank card with an empty console. */
|
|
22
|
+
export function buildModuleSource(specifier: string): string {
|
|
23
|
+
const namespace = registry[specifier] ?? {};
|
|
24
|
+
const lines = [`const ns = globalThis[${JSON.stringify(REGISTRY_KEY)}][${JSON.stringify(specifier)}];`];
|
|
25
|
+
// One `export const` per name rather than a spread: ESM export names must be statically visible.
|
|
26
|
+
// Freezing getters into consts is safe here — every registered namespace is a settled module.
|
|
27
|
+
for (const name of Object.keys(namespace)) {
|
|
28
|
+
if (name !== "default" && IDENTIFIER.test(name)) lines.push(`export const ${name} = ns[${JSON.stringify(name)}];`);
|
|
29
|
+
}
|
|
30
|
+
lines.push("default" in namespace ? "export default ns.default;" : "export default ns;");
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function registerModules(modules: Record<string, Record<string, unknown>>): void {
|
|
35
|
+
for (const [specifier, namespace] of Object.entries(modules)) {
|
|
36
|
+
// Re-registering the same namespace must keep the URL. The document import map is
|
|
37
|
+
// installed once and points at these blobs for the tab's life, so revoking one leaves
|
|
38
|
+
// every esm.sh package resolving `react` to a dead URL — the module graph dies and the
|
|
39
|
+
// card renders blank with nothing in the console. Only a genuine hot-swap invalidates.
|
|
40
|
+
if (registry[specifier] === namespace) continue;
|
|
41
|
+
registry[specifier] = namespace;
|
|
42
|
+
// A namespace that really changed: the old blob would re-export stale bindings.
|
|
43
|
+
const stale = urls.get(specifier);
|
|
44
|
+
if (stale !== undefined) {
|
|
45
|
+
URL.revokeObjectURL(stale);
|
|
46
|
+
urls.delete(specifier);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function moduleUrl(specifier: string): string {
|
|
52
|
+
const cached = urls.get(specifier);
|
|
53
|
+
if (cached !== undefined) return cached;
|
|
54
|
+
const url = URL.createObjectURL(new Blob([buildModuleSource(specifier)], { type: "text/javascript" }));
|
|
55
|
+
urls.set(specifier, url);
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const registryImports = (): Record<string, string> => Object.fromEntries(Object.keys(registry).map((specifier) => [specifier, moduleUrl(specifier)]));
|
|
60
|
+
|
|
61
|
+
/** Drops every synthesized blob. The plugin's dispose path must call this or each HMR round leaks one URL per specifier. */
|
|
62
|
+
export function disposeRegistry(): void {
|
|
63
|
+
for (const url of urls.values()) URL.revokeObjectURL(url);
|
|
64
|
+
urls.clear();
|
|
65
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends a card's compile error back to the model as a chat message.
|
|
3
|
+
*
|
|
4
|
+
* Until this existed, `onError` had no consumer at all: a card that failed to compile painted a
|
|
5
|
+
* red panel for the reader and the model never learned anything. Measured on a real session —
|
|
6
|
+
* a card imported `MonacoEditor` from `modern-monaco`, the surface reported *"has no export named
|
|
7
|
+
* 'MonacoEditor'; module exports: Workspace, errors, hydrate, init, lazy"*, and the transcript
|
|
8
|
+
* ends there: `MonacoEditor` appears 6 times in the records after that point and `no export named`
|
|
9
|
+
* zero. The reader saw the answer on screen; the one party who could act on it did not.
|
|
10
|
+
*
|
|
11
|
+
* Three constraints, each of which this got wrong in an obvious first version:
|
|
12
|
+
*
|
|
13
|
+
* - **Only errors that survived.** `GenUISurface` already separates a mid-stream prefix failure
|
|
14
|
+
* and a retryable network blip from a real one — the `report` branch of its error action. That
|
|
15
|
+
* branch is the only caller, so a half-written expression never reaches the model.
|
|
16
|
+
*
|
|
17
|
+
* It is not sufficient on its own, though. `isUnfinishedFrame` excludes the RENDER phase by
|
|
18
|
+
* design — a card whose own render throws is usually a real error — but a half-written
|
|
19
|
+
* component rendering mid-stream throws too, and the next frame is fine. Measured in a browser:
|
|
20
|
+
* a card that ultimately rendered correctly reported `Cannot read properties of undefined
|
|
21
|
+
* (reading 'getCurrentStack')`, a React internal, to the model. Painting a red panel for a
|
|
22
|
+
* frame costs nothing; sending the model a message about a card that then worked costs it a
|
|
23
|
+
* turn spent fixing what is not broken. So the send is DEFERRED, and a paint cancels it.
|
|
24
|
+
* - **Once per card, not once per frame.** A settled card that fails re-renders on every later
|
|
25
|
+
* frame of the transcript, and a message per render is a loop the user has to kill. Keyed on
|
|
26
|
+
* the message text.
|
|
27
|
+
* - **Announced as automatic.** The model is about to read a user-role message it was not sent.
|
|
28
|
+
* Saying where it came from is what stops it replying "sorry about that" to a person who typed
|
|
29
|
+
* nothing.
|
|
30
|
+
*/
|
|
31
|
+
const sent = new Set<string>();
|
|
32
|
+
|
|
33
|
+
/** Exported for the test: a fresh card in a fresh session should be able to report again. */
|
|
34
|
+
export const forgetReportedErrors = () => {
|
|
35
|
+
sent.clear();
|
|
36
|
+
cardRendered();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ErrorReporter = (text: string) => void;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The message body. Kept short and factual: it is spent from the user's context window, and the
|
|
43
|
+
* one thing the model needs is what failed and that nobody typed it.
|
|
44
|
+
*
|
|
45
|
+
* English, like the prompt and the skill it sits beside. This message is the only text this
|
|
46
|
+
* plugin puts into the conversation, and writing it in Chinese did two things: it read as a
|
|
47
|
+
* different voice from everything else the plugin says, and — because a card must be written in
|
|
48
|
+
* the language the USER wrote in — it pushed the model toward answering a Spanish or French
|
|
49
|
+
* speaker in the wrong language for the rest of the turn.
|
|
50
|
+
*/
|
|
51
|
+
export const reportBody = (message: string, phase: string) =>
|
|
52
|
+
`[automatic] The card you just wrote did not render. It failed at the ${phase} step:\n\n${message}\n\nThis was sent by the renderer, not by the user — nobody typed it, so do not apologise or address it as a request. If the error names the correct usage (the available exports, for instance), fix the card and send it again. If it does not, look it up before you change anything, and answer in the language the user has been writing in.`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How long an error must stand before the model hears about it. A streaming card recompiles many
|
|
56
|
+
* times a second, so a frame that throws and a frame that paints are milliseconds apart; a second
|
|
57
|
+
* is far longer than that gap and far shorter than a reader's patience with a broken card.
|
|
58
|
+
*/
|
|
59
|
+
const SETTLE_MS = 1000;
|
|
60
|
+
|
|
61
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
62
|
+
|
|
63
|
+
/** Called when a surface paints. Cancels a report the very next frame made untrue. */
|
|
64
|
+
export function cardRendered(): void {
|
|
65
|
+
if (pending === null) return;
|
|
66
|
+
clearTimeout(pending);
|
|
67
|
+
pending = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function reportCardError(send: ErrorReporter | undefined, message: string, phase: string): void {
|
|
71
|
+
if (send === undefined) return;
|
|
72
|
+
if (sent.has(message)) return;
|
|
73
|
+
sent.add(message);
|
|
74
|
+
if (pending !== null) clearTimeout(pending);
|
|
75
|
+
pending = setTimeout(() => {
|
|
76
|
+
pending = null;
|
|
77
|
+
send(reportBody(message, phase));
|
|
78
|
+
}, SETTLE_MS);
|
|
79
|
+
}
|