dsh-generative-ui 0.0.2 → 0.0.3
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/README.md +9 -2
- package/lib/client.js +484 -98
- package/lib/client.js.map +19 -17
- package/lib/index.js +523 -34
- package/lib/types/card-failure.d.ts +88 -0
- package/lib/types/client/canvas/CanvasPanel.d.ts +1 -1
- package/lib/types/client/canvas/index.d.ts +1 -1
- package/lib/types/client/runtime/GenUISurface.d.ts +65 -35
- package/lib/types/client/runtime/inline-fence.d.ts +28 -1
- package/lib/types/client/runtime/report-error.d.ts +59 -12
- package/lib/types/contract-assets.d.ts +10 -0
- package/lib/types/index.d.ts +50 -7
- package/package.json +4 -2
- package/src/card-failure.ts +106 -0
- package/src/client/canvas/CanvasPanel.tsx +10 -4
- package/src/client/canvas/index.ts +1 -1
- package/src/client/index.ts +22 -9
- package/src/client/runtime/GenUISurface.tsx +172 -48
- package/src/client/runtime/bindings.ts +3 -2
- package/src/client/runtime/compiler.ts +24 -2
- package/src/client/runtime/inline-fence.ts +185 -8
- package/src/client/runtime/register.ts +16 -4
- package/src/client/runtime/report-error.ts +89 -34
- package/src/client/runtime/state.ts +13 -1
- package/src/client/runtime/uno-config.ts +13 -0
- package/src/client/session.ts +10 -3
- package/src/contract-assets.ts +11 -0
- package/src/index.ts +113 -10
- package/src/prompt.ts +154 -10
- package/src/skill.ts +296 -22
- package/types/fs.d.ts +15 -1
|
@@ -28,7 +28,7 @@ export type CanvasPanelProps = {
|
|
|
28
28
|
/** A canvas that failed to compile; see `runtime/report-error.ts`. */
|
|
29
29
|
onCardError?: (message: string, phase: string) => void;
|
|
30
30
|
/** A canvas that painted; see `runtime/report-error.ts`. */
|
|
31
|
-
onCardRendered?: () => void;
|
|
31
|
+
onCardRendered?: (restored: boolean) => void;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -228,9 +228,15 @@ export function CanvasPanel({ canvases, offerable, cwd, onOpen, onClose, onWidth
|
|
|
228
228
|
{active === undefined ? (
|
|
229
229
|
<div className="dgu-empty">写入 ui4a/canvases/<id>.ui4a.tsx 后,画布会出现在这里</div>
|
|
230
230
|
) : (
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
231
|
+
// `preserveState` was false here, on the theory that a whole-file replace could
|
|
232
|
+
// "silently keep the old render". It cannot: both of partial-react's reuse branches
|
|
233
|
+
// (`runtime.ts:317` and `:404`) require `updateMode === "push"`, and a canvas arrives
|
|
234
|
+
// whole, so it always runs in `"render"` — measured, the edit lands either way.
|
|
235
|
+
// What preserving buys is the state: `:426` calls the generated component from inside
|
|
236
|
+
// a stable slot wrapper, so its hooks stay on the same fiber across a recompile, and
|
|
237
|
+
// `:424` keys the error boundary off the hook signature instead of the render round.
|
|
238
|
+
// Without it every edit remounted the tree and a running timer went back to zero.
|
|
239
|
+
<GenUISurface key={active.id} code={resolved} streaming={active.streaming} onError={(error, phase) => onCardError?.(error.message, phase)} onRendered={onCardRendered} />
|
|
234
240
|
)}
|
|
235
241
|
</div>
|
|
236
242
|
</div>
|
|
@@ -25,7 +25,7 @@ export type CanvasHostOptions = {
|
|
|
25
25
|
*/
|
|
26
26
|
onCardError?: (message: string, phase: string) => void;
|
|
27
27
|
/** A canvas that painted; cancels a deferred error report the next frame made untrue. */
|
|
28
|
-
onCardRendered?: () => void;
|
|
28
|
+
onCardRendered?: (restored: boolean) => void;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
const EMPTY: ReadonlySet<string> = new Set();
|
package/src/client/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client";
|
|
|
8
8
|
import type {} from "@deepseek-ai/dsh-client-ui-layout/client";
|
|
9
9
|
import type {} from "@deepseek-ai/dsh-client-ui-conversation/client";
|
|
10
10
|
import { GenUISurface } from "./runtime/GenUISurface.tsx";
|
|
11
|
-
import { cardRendered, reportCardError } from "./runtime/report-error.ts";
|
|
11
|
+
import { cancelPendingReport, cardRendered, reportCardError } from "./runtime/report-error.ts";
|
|
12
12
|
import { disposeCompiler } from "./runtime/compiler.ts";
|
|
13
13
|
import { dropSharedCompiler } from "./runtime/GenUISurface.tsx";
|
|
14
14
|
import { disposeRegistry } from "./runtime/registry.ts";
|
|
@@ -20,6 +20,7 @@ import { chatNodes, perNode, type ChatNodeView } from "./session.ts";
|
|
|
20
20
|
import { mountCanvasHost } from "./canvas/index.ts";
|
|
21
21
|
import { toolCallsOf, type CallBlock, type ToolCallView } from "./canvas/collect.ts";
|
|
22
22
|
import { canvasIdOf } from "../contract.ts";
|
|
23
|
+
import { CARD_ERROR_PATH } from "../contract-assets.ts";
|
|
23
24
|
|
|
24
25
|
export const inject = ["sessions"];
|
|
25
26
|
|
|
@@ -84,6 +85,11 @@ export function apply(ctx: ClientContext): void {
|
|
|
84
85
|
// its URL is revoked (the module graph holds it), so this only reclaims URLs nothing can
|
|
85
86
|
// reach any more. Without it every HMR round leaks one per registered specifier.
|
|
86
87
|
ctx.effect(() => disposeRegistry, "dsh-generative-ui: blob module URLs");
|
|
88
|
+
// An error report waits a second before it is sent (see `SETTLE_MS`), and an unload inside that
|
|
89
|
+
// second leaves the timer holding a closure over a conversation that is being torn down. There
|
|
90
|
+
// is nothing to flush — a report nobody will read is not worth delivering — so cancelling is
|
|
91
|
+
// the whole disposer, and `cardRendered` already is one.
|
|
92
|
+
ctx.effect(() => () => cancelPendingReport(), "dsh-generative-ui: pending error report");
|
|
87
93
|
// The wasm half of the same problem: ~16MB per instance, one per HMR round, and upstream
|
|
88
94
|
// offers no dispose — dropping the reference is all there is (see `disposeCompiler`).
|
|
89
95
|
ctx.effect(
|
|
@@ -129,14 +135,19 @@ export function apply(ctx: ClientContext): void {
|
|
|
129
135
|
});
|
|
130
136
|
// A card that fails to compile used to be a red panel the reader saw and the model never did.
|
|
131
137
|
// `onError` fires only for a failure that survived settling and retries, so this is the real
|
|
132
|
-
// ones — see `report-error.ts` for why it is once per message and why it
|
|
133
|
-
|
|
138
|
+
// ones — see `report-error.ts` for why it is once per message and why it waits a beat.
|
|
139
|
+
//
|
|
140
|
+
// A route rather than `conversation.send`: the detail belongs in the model's CONTEXT, which is
|
|
141
|
+
// assembled host-side, and a chat message could never be taken back once the card was fixed.
|
|
142
|
+
// `card-failure.ts` has the rest.
|
|
143
|
+
const sendToModel = (report: { message: string; phase: string } | null) => {
|
|
134
144
|
const id = currentSession();
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
if (id === undefined) return;
|
|
146
|
+
void fetch(`${CARD_ERROR_PATH}?session=${encodeURIComponent(id)}`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "content-type": "application/json" },
|
|
149
|
+
body: JSON.stringify(report ?? {}),
|
|
150
|
+
}).catch((error: unknown) => console.error("[dsh-generative-ui] card error report failed", error));
|
|
140
151
|
};
|
|
141
152
|
|
|
142
153
|
// Mounted inside the effect, not beside it: `mountCanvasHost` reaches for MutationObserver
|
|
@@ -180,7 +191,9 @@ export function apply(ctx: ClientContext): void {
|
|
|
180
191
|
() =>
|
|
181
192
|
claimInlineFences({
|
|
182
193
|
segments,
|
|
183
|
-
|
|
194
|
+
// The SAME gate on both callbacks. A card that may not report a failure may not retract
|
|
195
|
+
// one either — see `cardRendered`.
|
|
196
|
+
render: ({ code, streaming, last }) => createElement(GenUISurface, { code, streaming, onError: (error, phase) => reportCardError(sendToModel, error.message, phase, last), onRendered: (restored) => cardRendered(restored, last) }),
|
|
184
197
|
}),
|
|
185
198
|
"dsh-generative-ui: inline fences",
|
|
186
199
|
);
|
|
@@ -16,16 +16,18 @@ export type GenUISurfaceProps = {
|
|
|
16
16
|
/** True while `code` is still a prefix, so partial frames get normalized before compiling. */
|
|
17
17
|
streaming?: boolean;
|
|
18
18
|
/**
|
|
19
|
-
* Keep React state across recompiles
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* Keep React state across recompiles, by rendering through partial-react's stable slot
|
|
20
|
+
* wrapper so hooks stay on the same fiber. Off, every recompile renders a freshly
|
|
21
|
+
* compiled function type, which React remounts — a running timer restarts at zero.
|
|
22
|
+
*
|
|
23
|
+
* It does NOT risk dropping an edit: the renderer's two reuse branches only fire in
|
|
24
|
+
* `push` mode, so a whole-file replacement always renders.
|
|
23
25
|
*/
|
|
24
26
|
preserveState?: boolean;
|
|
25
|
-
/** Real compile diagnostics.
|
|
27
|
+
/** Real compile diagnostics. A streaming frame never reaches this — see `errorAction`. */
|
|
26
28
|
onError?: (error: Error, phase: "transform" | "compile" | "render") => void;
|
|
27
29
|
/** Fires whenever a frame actually painted. Use it to clear a previously shown error. */
|
|
28
|
-
onRendered?: () => void;
|
|
30
|
+
onRendered?: (restored: boolean) => void;
|
|
29
31
|
className?: string;
|
|
30
32
|
};
|
|
31
33
|
|
|
@@ -58,15 +60,6 @@ export const compiler = () => {
|
|
|
58
60
|
return sharedCompiler;
|
|
59
61
|
};
|
|
60
62
|
|
|
61
|
-
/**
|
|
62
|
-
* Mid-stream frames legitimately fail: a prefix that has not reached `export default`
|
|
63
|
-
* yet, or a half-written expression. partial-react treats these as transient and keeps
|
|
64
|
-
* the last good frame, so surfacing them would just make the UI flash errors while the
|
|
65
|
-
* model types. Only a failure that survives settling is the caller's business.
|
|
66
|
-
*/
|
|
67
|
-
/** Exported for `test/transient.test.ts`: this decides whether the reader sees an error. */
|
|
68
|
-
export const TRANSIENT = /No default export found|Unexpected (end of|eof)/i;
|
|
69
|
-
|
|
70
63
|
/**
|
|
71
64
|
* A dependency that failed to arrive, not code that is wrong. esm.sh cold-starts and the
|
|
72
65
|
* network drops, and the symptom is identical to a broken component — a blank surface — so
|
|
@@ -83,16 +76,6 @@ const MAX_RETRIES = 3;
|
|
|
83
76
|
* each of which sends the reader somewhere different when it is wrong. Exported for
|
|
84
77
|
* `test/retry.test.ts`.
|
|
85
78
|
*/
|
|
86
|
-
/**
|
|
87
|
-
* Whether a mid-stream error is the stream not being finished yet.
|
|
88
|
-
*
|
|
89
|
-
* Both patterns come from the parse stages — `No default export found` is thrown inside
|
|
90
|
-
* `importCompiledComponent` (compile), and an unexpected EOF is the transform rejecting a
|
|
91
|
-
* prefix. A card whose own render throws a message that happens to match is a real error, so
|
|
92
|
-
* the phase is part of the question rather than the message alone.
|
|
93
|
-
*/
|
|
94
|
-
export const isUnfinishedFrame = (message: string, phase: string, streaming: boolean) => streaming && phase !== "render" && TRANSIENT.test(message);
|
|
95
|
-
|
|
96
79
|
export const shouldRetry = (message: string, phase: string, streaming: boolean, attempts: number) => phase === "compile" && !streaming && TRANSIENT_LOAD.test(message) && attempts < MAX_RETRIES;
|
|
97
80
|
/**
|
|
98
81
|
* What to do with a frame, given what the surface already holds.
|
|
@@ -152,6 +135,21 @@ export const deliver = (renderer: RendererCalls, delivery: Delivery): boolean =>
|
|
|
152
135
|
*/
|
|
153
136
|
export const importSignature = (code: string) => [...code.matchAll(/from\s+["']([^"']+)["']/g)].map((match) => match[1]).join(" ");
|
|
154
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Whether a failure suppressed during streaming still has to be told to someone.
|
|
140
|
+
*
|
|
141
|
+
* The hole this closes: `errorAction` ignores every streaming frame (a truncated one is not a
|
|
142
|
+
* broken card), and `deliveryFor` answers `nothing` when the settled frame is byte-identical to
|
|
143
|
+
* the last streamed one. Both are right on their own, and together they mean a card that really
|
|
144
|
+
* is broken recompiles never and reports never.
|
|
145
|
+
*
|
|
146
|
+
* Pure, and exported, because it is three conditions and each one is a distinct bug when wrong:
|
|
147
|
+
* without `!streaming` it fires mid-stream and undoes the fix it belongs to; without `stranded`
|
|
148
|
+
* it reports nothing; without the `reportedFor` guard a settled card re-rendered by every later
|
|
149
|
+
* frame of the transcript reports on each one.
|
|
150
|
+
*/
|
|
151
|
+
export const reportStranded = (streaming: boolean, stranded: unknown, code: string, reportedFor: string) => !streaming && stranded !== null && reportedFor !== code;
|
|
152
|
+
|
|
155
153
|
/** Only what `deliver` touches — the real renderer has far more. */
|
|
156
154
|
export type RendererCalls = {
|
|
157
155
|
render: (code: string) => void;
|
|
@@ -166,20 +164,34 @@ export type RendererCalls = {
|
|
|
166
164
|
* - `retry` a dependency failed to arrive, and busting the import URLs is the fix
|
|
167
165
|
* - `report` tell the reader
|
|
168
166
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
167
|
+
* **A streaming frame is never reported, whatever it says.** This used to test the message
|
|
168
|
+
* against `TRANSIENT` (`No default export found`, unexpected EOF) and report anything else, on
|
|
169
|
+
* the theory that a truncated frame fails to parse. It does not have to: a cut that lands
|
|
170
|
+
* mid-identifier leaves valid syntax and throws at module evaluation instead. Measured on one
|
|
171
|
+
* real session — five consecutive reports, five regenerations, and every final card was fine:
|
|
172
|
+
* `Mouse is not defined` from a card whose only such name is `MousePointer2`, `type is not
|
|
173
|
+
* defined` from `type ToolGroup =`, and `icon is not defined` from a card containing no `icon` at
|
|
174
|
+
* all. The frame, not the card, was broken. There is no message that distinguishes the two, so
|
|
175
|
+
* the phase does it: only a settled surface has anything worth saying about.
|
|
175
176
|
*/
|
|
176
177
|
export const errorAction = (message: string, phase: string, streaming: boolean, attempts: number): "ignore" | "retry" | "report" => {
|
|
177
|
-
if (
|
|
178
|
+
if (streaming) return "ignore";
|
|
178
179
|
return shouldRetry(message, phase, streaming, attempts) ? "retry" : "report";
|
|
179
180
|
};
|
|
180
181
|
|
|
181
|
-
/**
|
|
182
|
-
|
|
182
|
+
/**
|
|
183
|
+
* 0.5s / 2s / 8s. **Linear 0.4/0.8/1.2 did not cover a cold start** — it spends 2.4 seconds
|
|
184
|
+
* total, and a first request for a package esm.sh has never built waits on that build: measured
|
|
185
|
+
* 2.27s cold against 0.50s warm for the same URL, so all three attempts landed inside one
|
|
186
|
+
* unfinished build and the reader got `failed to fetch dynamically imported module` on a package
|
|
187
|
+
* that resolves fine a second later. Seen in a real session on `@headlessui/react`, twice in a
|
|
188
|
+
* row, on a card the model had been asked to write with it.
|
|
189
|
+
*
|
|
190
|
+
* Backing off ×4 spends 10.5s across the same three attempts, which covers a cold build with room
|
|
191
|
+
* and still gives up fast enough that a genuinely missing package does not hang the card.
|
|
192
|
+
*/
|
|
193
|
+
const RETRY_BACKOFF_MS = 500;
|
|
194
|
+
const RETRY_FACTOR = 4;
|
|
183
195
|
|
|
184
196
|
/**
|
|
185
197
|
* What `onError` DOES with the three outcomes, separated from where they come from. The decision
|
|
@@ -196,17 +208,29 @@ const RETRY_BACKOFF_MS = 400;
|
|
|
196
208
|
*
|
|
197
209
|
* - **stale** — a later frame's probe won the race. Applying this one reverts the map to an
|
|
198
210
|
* older import set, and the newer frame's packages go missing.
|
|
199
|
-
* - **redeliver** —
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
211
|
+
* - **redeliver** — nothing else is going to apply the map. Always true of a settled surface,
|
|
212
|
+
* and true while streaming too when no frame has been delivered since the probe fired:
|
|
213
|
+
* `setImportMap` only stores, so that buffer stays compiled against a map without these
|
|
214
|
+
* entries — and an unresolvable bare specifier kills the whole module graph, so the card is
|
|
215
|
+
* blank rather than wrong. Measured 2026-08-27 on a card importing `@radix-ui/react-tabs`:
|
|
216
|
+
* one delivery with `streaming: true` renders 0 characters **for good**, while the identical
|
|
217
|
+
* code with `streaming: false` renders. The error is swallowed on top of it — `errorAction`
|
|
218
|
+
* answers `ignore` while streaming, so the reader gets an empty card and the model gets no
|
|
219
|
+
* report. A cold probe is 1.0s and the import behind it another 2.4s, so the window this
|
|
220
|
+
* covers is seconds wide, not a frame.
|
|
221
|
+
* - **store** — a newer frame has already been delivered, and it will apply the map itself.
|
|
222
|
+
* Re-delivering here instead would replace the buffer with the prefix that was current when
|
|
223
|
+
* the probe fired, truncating the stream mid-flight.
|
|
224
|
+
*
|
|
225
|
+
* `redeliver` renders `deliveredRef.current`, which is read at SETTLE time — the newest buffer,
|
|
226
|
+
* not the captured one — so the append the next frame computes still lines up.
|
|
204
227
|
*
|
|
205
228
|
* The `delivered !== ""` part is not defensive: re-rendering an empty buffer clears the surface.
|
|
206
229
|
*/
|
|
207
|
-
export const probeOutcome = (signature: string, current: string, streaming: boolean, delivered: string): "stale" | "redeliver" | "store" => {
|
|
230
|
+
export const probeOutcome = (signature: string, current: string, streaming: boolean, delivered: string, probed: string): "stale" | "redeliver" | "store" => {
|
|
208
231
|
if (signature !== current) return "stale";
|
|
209
|
-
|
|
232
|
+
if (delivered === "") return "store";
|
|
233
|
+
return !streaming || delivered === probed ? "redeliver" : "store";
|
|
210
234
|
};
|
|
211
235
|
|
|
212
236
|
export const dispatchError = (action: "ignore" | "retry" | "report", effects: { attempts: () => number; setAttempts: (n: number) => void; schedule: (ms: number) => void; report: () => void }) => {
|
|
@@ -214,7 +238,7 @@ export const dispatchError = (action: "ignore" | "retry" | "report", effects: {
|
|
|
214
238
|
if (action === "retry") {
|
|
215
239
|
const next = effects.attempts() + 1;
|
|
216
240
|
effects.setAttempts(next);
|
|
217
|
-
effects.schedule(RETRY_BACKOFF_MS * next);
|
|
241
|
+
effects.schedule(RETRY_BACKOFF_MS * RETRY_FACTOR ** (next - 1));
|
|
218
242
|
return;
|
|
219
243
|
}
|
|
220
244
|
effects.report();
|
|
@@ -227,6 +251,56 @@ export const dispatchError = (action: "ignore" | "retry" | "report", effects: {
|
|
|
227
251
|
* fresh, and appending a query to a `blob:` URL makes it unresolvable — which would break every
|
|
228
252
|
* card rather than fixing one.
|
|
229
253
|
*/
|
|
254
|
+
/**
|
|
255
|
+
* The same import map with `bundle`/`external` dropped from every esm.sh entry.
|
|
256
|
+
*
|
|
257
|
+
* esm.sh serves two different builds and only one of them can fail: `?bundle` runs esbuild over
|
|
258
|
+
* the package's whole tree, and a version skew anywhere in it is a hard 500. Measured on
|
|
259
|
+
* `mermaid`, three attempts, deterministic — `?bundle&target=es2022&external=react,react-dom,scheduler`
|
|
260
|
+
* answers **500 `esbuild: No matching export in "node_modules/d3/src/index.js" for import
|
|
261
|
+
* "curveBumpX"`**, while the plain `https://esm.sh/mermaid?target=es2022` answers 200 and the
|
|
262
|
+
* module imports fine. The card meanwhile renders **completely blank with nothing in the
|
|
263
|
+
* console**, because an unresolvable import kills the whole module graph.
|
|
264
|
+
*
|
|
265
|
+
* So the retry that only busts the query re-requests the same broken build. Unbundling is a
|
|
266
|
+
* genuinely different artefact on esm.sh's side, which is what makes it a second chance rather
|
|
267
|
+
* than a second identical failure. It is not the first choice — the bundled build is one request
|
|
268
|
+
* instead of a waterfall — which is why this is on the retry path and not on the happy one.
|
|
269
|
+
*/
|
|
270
|
+
export const unbundleFetchedImports = (imports: Record<string, string>): Record<string, string> =>
|
|
271
|
+
Object.fromEntries(
|
|
272
|
+
Object.entries(imports).map(([key, url]) => {
|
|
273
|
+
if (!url.startsWith("https://esm.sh/")) return [key, url];
|
|
274
|
+
const parsed = new URL(url);
|
|
275
|
+
parsed.searchParams.delete("bundle");
|
|
276
|
+
// `external` STAYS. Dropping it was in this function for one day and it broke rendering in a
|
|
277
|
+
// way far worse than the 500 it was meant to route around. Measured on the real URLs:
|
|
278
|
+
//
|
|
279
|
+
// ?bundle&target=es2022&external=react,react-dom,scheduler -> 500 (the bug being fixed)
|
|
280
|
+
// ?target=es2022&external=react,react-dom,scheduler -> 200 (fixed, react shared)
|
|
281
|
+
// ?target=es2022 -> 200 (fixed, react NOT shared)
|
|
282
|
+
//
|
|
283
|
+
// The 500 comes from `bundle` alone; `external` has nothing to do with it. What `external`
|
|
284
|
+
// does do is make esm.sh emit a BARE `import … from "react"`, which the document import map
|
|
285
|
+
// resolves to the host's single instance. Without it the same build emits
|
|
286
|
+
// `import "/react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0?target=es2022"` — an ABSOLUTE URL
|
|
287
|
+
// an import map keyed on the bare specifier cannot redirect, so the package gets esm.sh's own
|
|
288
|
+
// React (19, against this host's 18). Elements it creates then carry a `$$typeof` the host
|
|
289
|
+
// React does not recognise, and the card dies on **`Minified React error #31`, args
|
|
290
|
+
// `object with keys {$$typeof, type, key, ref, props}`**.
|
|
291
|
+
//
|
|
292
|
+
// Seen end to end in one session: a card rendered correctly, a later retry re-fetched its
|
|
293
|
+
// icons without `external`, every lucide element became a foreign object, and the model was
|
|
294
|
+
// handed a render-failure notice for a card that had been fine — it rewrote it, blaming
|
|
295
|
+
// itself for "rendering icons/objects directly".
|
|
296
|
+
//
|
|
297
|
+
// `bundle` is a valueless flag, and `URLSearchParams` writes it back as `bundle=` — which
|
|
298
|
+
// esm.sh reads as the flag being present. Deleting is enough; re-serialising is what would
|
|
299
|
+
// put it back.
|
|
300
|
+
return [key, parsed.toString()];
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
|
|
230
304
|
export const bustFetchedImports = (imports: Record<string, string>, attempt: number): Record<string, string> => Object.fromEntries(Object.entries(imports).map(([key, url]) => [key, url.startsWith("https://esm.sh/") ? `${url}${url.includes("?") ? "&" : "?"}ui4a-retry=${attempt}` : url]));
|
|
231
305
|
|
|
232
306
|
export function GenUISurface({ code, streaming = false, preserveState = true, onError, onRendered, className }: GenUISurfaceProps) {
|
|
@@ -238,6 +312,22 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
|
|
|
238
312
|
// through refs rather than captured — a new handler identity must not re-attach it.
|
|
239
313
|
const onErrorRef = useLatest(onError);
|
|
240
314
|
const onRenderedRef = useLatest(onRendered);
|
|
315
|
+
// Set by a RENDER throw, consumed by the paint that follows it — see `onRendered` below.
|
|
316
|
+
const threwRef = useRef(false);
|
|
317
|
+
/**
|
|
318
|
+
* The last failure `errorAction` suppressed because the surface was still streaming.
|
|
319
|
+
*
|
|
320
|
+
* Suppressing them is right — a truncated frame is not a broken card — but it leaves a hole at
|
|
321
|
+
* the other end: when the settled frame is byte-identical to the last streamed one,
|
|
322
|
+
* `deliveryFor` answers `nothing`, nothing recompiles, and a card that really is broken reports
|
|
323
|
+
* NOTHING at all. The last streamed frame's failure is the surface's actual state at that
|
|
324
|
+
* point, so it is what gets reported. Cleared by a real paint, which is the proof it was
|
|
325
|
+
* transient after all.
|
|
326
|
+
*/
|
|
327
|
+
const strandedRef = useRef<{ error: Error; phase: "transform" | "compile" | "render" } | null>(null);
|
|
328
|
+
/** The code a stranded failure was already reported for, so a re-render does not report it again. */
|
|
329
|
+
const strandedReportedRef = useRef("");
|
|
330
|
+
const retryTimers = useRef(new Set<ReturnType<typeof setTimeout>>());
|
|
241
331
|
const streamingRef = useLatest(streaming);
|
|
242
332
|
// Read once at attach: the renderer takes it as a construction option.
|
|
243
333
|
const preserveStateRef = useRef(preserveState);
|
|
@@ -259,7 +349,10 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
|
|
|
259
349
|
const attempt = retriesRef.current;
|
|
260
350
|
void mergeFallbackImports(localImports(), code).then((imports) => {
|
|
261
351
|
// Only the fetched esm.sh entries need busting; the local blob URLs are already fresh.
|
|
262
|
-
|
|
352
|
+
// From the second attempt the bundled build is dropped as well: if the first failure was a
|
|
353
|
+
// bundle-side 500 (see `unbundleFetchedImports`) no amount of cache-busting can clear it.
|
|
354
|
+
const fresh = attempt > 0 ? unbundleFetchedImports(imports) : imports;
|
|
355
|
+
renderer.setImportMap({ imports: bustFetchedImports(fresh, attempt) });
|
|
263
356
|
renderer.clear({ preserveVisualState: true });
|
|
264
357
|
renderer.render(code);
|
|
265
358
|
});
|
|
@@ -276,18 +369,36 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
|
|
|
276
369
|
preserveStateOnUpdate: preserveStateRef.current,
|
|
277
370
|
callbacks: {
|
|
278
371
|
onError: (error, phase) => {
|
|
279
|
-
|
|
372
|
+
if (phase === "render") threwRef.current = true;
|
|
373
|
+
const action = errorAction(error.message, phase, streamingRef.current, retriesRef.current);
|
|
374
|
+
// Remember what was swallowed. Only the newest matters: each frame supersedes the last,
|
|
375
|
+
// so this is the state of the buffer the stream stopped on.
|
|
376
|
+
if (action === "ignore") strandedRef.current = { error, phase };
|
|
377
|
+
dispatchError(action, {
|
|
280
378
|
attempts: () => retriesRef.current,
|
|
281
379
|
setAttempts: (n) => {
|
|
282
380
|
retriesRef.current = n;
|
|
283
381
|
},
|
|
284
|
-
|
|
382
|
+
// Tracked so the cleanup below can cancel it: a retry is scheduled up to 1.2s out,
|
|
383
|
+
// and a surface can unmount well inside that (a canvas tab closed, a message scrolled
|
|
384
|
+
// out of the host's window). Firing after that runs the whole import-probe chain
|
|
385
|
+
// against a renderer that has already been detached.
|
|
386
|
+
schedule: (ms) => void retryTimers.current.add(setTimeout(() => retryRef.current(), ms)),
|
|
285
387
|
report: () => onErrorRef.current?.(error, phase),
|
|
286
388
|
});
|
|
287
389
|
},
|
|
288
390
|
onRendered: () => {
|
|
289
391
|
retriesRef.current = 0;
|
|
290
|
-
|
|
392
|
+
// partial-react repaints the LAST GOOD component after a render throw (`preserve` is on
|
|
393
|
+
// for every inline card), so this fires for a card whose new code is broken. The throw
|
|
394
|
+
// set `threwRef` a tick earlier; consuming it here is what tells the caller that this
|
|
395
|
+
// paint is a restore, not the new code working.
|
|
396
|
+
const restored = threwRef.current;
|
|
397
|
+
threwRef.current = false;
|
|
398
|
+
// A restore paints the LAST GOOD component, which says nothing about the current code —
|
|
399
|
+
// clearing on it would lose exactly the failure this exists to carry.
|
|
400
|
+
if (!restored) strandedRef.current = null;
|
|
401
|
+
onRenderedRef.current?.(restored);
|
|
291
402
|
},
|
|
292
403
|
},
|
|
293
404
|
}).then((created) => {
|
|
@@ -304,6 +415,8 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
|
|
|
304
415
|
attached?.detach();
|
|
305
416
|
attached = null;
|
|
306
417
|
setRenderer(null);
|
|
418
|
+
for (const timer of retryTimers.current) clearTimeout(timer);
|
|
419
|
+
retryTimers.current.clear();
|
|
307
420
|
};
|
|
308
421
|
// Attach exactly once. The refs above are how later prop values reach the renderer
|
|
309
422
|
// without tearing it down, so they deliberately do not belong in these deps.
|
|
@@ -328,7 +441,7 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
|
|
|
328
441
|
importedRef.current = signature;
|
|
329
442
|
// A later frame's probe can settle first; without this the map reverts to an older import set.
|
|
330
443
|
void mergeFallbackImports(localImports(), code).then((imports) => {
|
|
331
|
-
const settle = probeOutcome(signature, importedRef.current, streamingRef.current, deliveredRef.current);
|
|
444
|
+
const settle = probeOutcome(signature, importedRef.current, streamingRef.current, deliveredRef.current, code);
|
|
332
445
|
if (settle === "stale") return;
|
|
333
446
|
renderer.setImportMap({ imports });
|
|
334
447
|
if (settle === "redeliver") renderer.render(deliveredRef.current);
|
|
@@ -340,7 +453,18 @@ export function GenUISurface({ code, streaming = false, preserveState = true, on
|
|
|
340
453
|
// whatever is already mounted, so a card paints unstyled for at most a frame instead of
|
|
341
454
|
// holding up every delivery behind a generator that has to boot on the first call.
|
|
342
455
|
void ensureUnoStyles(code, streaming);
|
|
343
|
-
if (!deliver(renderer, deliveryFor(code, deliveredRef.current, streaming)))
|
|
456
|
+
if (!deliver(renderer, deliveryFor(code, deliveredRef.current, streaming))) {
|
|
457
|
+
// Nothing was delivered, so nothing will recompile and no error will be raised — but the
|
|
458
|
+
// buffer on screen may already have failed while streaming. This is the only moment that
|
|
459
|
+
// failure can still reach anyone. Keyed on the code so a settled card re-rendered by every
|
|
460
|
+
// later frame of the transcript reports once, not once per frame.
|
|
461
|
+
const stranded = strandedRef.current;
|
|
462
|
+
if (stranded !== null && reportStranded(streaming, stranded, code, strandedReportedRef.current)) {
|
|
463
|
+
strandedReportedRef.current = code;
|
|
464
|
+
onErrorRef.current?.(stranded.error, stranded.phase);
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
344
468
|
// `deliveredRef` must follow every delivery, or a later streaming frame diffs against a
|
|
345
469
|
// prefix this render already superseded.
|
|
346
470
|
deliveredRef.current = code;
|
|
@@ -183,8 +183,9 @@ async function request<T>(method: "GET" | "POST", path: string, content?: string
|
|
|
183
183
|
const response = await fetch(`${FS_PATH}?${query}`, content === undefined ? { method } : { method, headers: { "content-type": "application/json" }, body: JSON.stringify({ content }) });
|
|
184
184
|
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
|
185
185
|
// A denial is not an outage. Naming it lets the card say "this session is read-only"
|
|
186
|
-
// rather than "something went wrong"
|
|
187
|
-
|
|
186
|
+
// rather than "something went wrong" — and `denied` carries that as a field, because the
|
|
187
|
+
// alternative is every card string-matching our message, which then cannot be reworded.
|
|
188
|
+
if (!response.ok) throw Object.assign(new Error(`[dsh-generative-ui] $dsh/fs ${path}: ${body.error ?? response.statusText}`), { code: body.error, denied: response.status === 403 });
|
|
188
189
|
return body as T;
|
|
189
190
|
}
|
|
190
191
|
|
|
@@ -49,6 +49,17 @@ export function disposeCompiler(): void {
|
|
|
49
49
|
initPromise = null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Does this source still export a component to mount?
|
|
54
|
+
*
|
|
55
|
+
* Deliberately a source test rather than a compile test: the failure being caught is a module that
|
|
56
|
+
* compiles perfectly and has nothing in it. Matches the two spellings the renderer accepts, and is
|
|
57
|
+
* written to ignore both comment forms so a `// export default` in prose cannot fake it.
|
|
58
|
+
*/
|
|
59
|
+
const hasDefaultExport = (source: string) =>
|
|
60
|
+
/^\s*export\s+default\s/m.test(source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "")) ||
|
|
61
|
+
/\bexport\s*\{[^}]*\bas\s+default\b/.test(source);
|
|
62
|
+
|
|
52
63
|
export function createBrowserTsxCompiler(): TsxCompiler {
|
|
53
64
|
return {
|
|
54
65
|
async compile(code, options = {}) {
|
|
@@ -68,12 +79,23 @@ export function createBrowserTsxCompiler(): TsxCompiler {
|
|
|
68
79
|
if (options.partial === true) return build(normalizeGeneratedTsx(code, { mode: "streaming" }));
|
|
69
80
|
try {
|
|
70
81
|
return build(normalizeGeneratedTsx(code, { mode: "final" }));
|
|
71
|
-
} catch {
|
|
82
|
+
} catch (error) {
|
|
72
83
|
// **The final compile must never be more fragile than a streaming frame.** The only
|
|
73
84
|
// difference between the modes is that `streaming` first cuts back the still-being-typed
|
|
74
85
|
// tail, and some damage (an unterminated string, typically) is only recoverable by
|
|
75
86
|
// cutting. Losing the last half-sentence beats going blank on the last frame.
|
|
76
|
-
|
|
87
|
+
const cut = normalizeGeneratedTsx(code, { mode: "streaming" });
|
|
88
|
+
// **But only if anything is left to render.** The cut is bounded by the FIRST thing it
|
|
89
|
+
// cannot parse, so a card that puts its data above its component — the common shape when
|
|
90
|
+
// the data is long — loses the component too, and what comes back is a module of imports
|
|
91
|
+
// and type aliases. That compiles. It exports nothing, mounts nothing, and the surface
|
|
92
|
+
// reports no error, so the reader gets a card of zero height and the model is never told.
|
|
93
|
+
// Measured on a real session: the model wrote `{ name: "questions: "list[...]" }` in the
|
|
94
|
+
// first array element, `streaming` returned the same 265 characters for all 938 frames
|
|
95
|
+
// while the source grew to 13693, and the final frame took this fallback and went blank.
|
|
96
|
+
// The `final` error is the useful one here — it names the line and column of the typo.
|
|
97
|
+
if (!hasDefaultExport(cut)) throw error;
|
|
98
|
+
return build(cut);
|
|
77
99
|
}
|
|
78
100
|
},
|
|
79
101
|
};
|