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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the model is told about a card that will not render.
|
|
3
|
+
*
|
|
4
|
+
* Split in two, deliberately, because the two halves want opposite lifetimes:
|
|
5
|
+
*
|
|
6
|
+
* - **The detail is state.** It goes out as a runtime-context snapshot, re-evaluated on every
|
|
7
|
+
* assembly and superseded by the next one. A card that gets fixed simply stops being mentioned.
|
|
8
|
+
* As a chat message it was the opposite — permanent, and still there three turns after the card
|
|
9
|
+
* started working.
|
|
10
|
+
* - **The nudge is an event.** One short line through `followup`, whose only job is to open a turn
|
|
11
|
+
* so the model looks at the detail now rather than whenever the user next types.
|
|
12
|
+
*
|
|
13
|
+
* Neither is a user-role message any more, and that removes the paragraph the old body had to
|
|
14
|
+
* carry: *"This was sent by the renderer, not by the user — nobody typed it, so do not apologise
|
|
15
|
+
* or address it as a request."* That existed because the report arrived wearing the user's face.
|
|
16
|
+
* A `kind: "plugin"` source does not, so the model is told what happened and nothing else — the
|
|
17
|
+
* disclaimer cost more tokens than the error it was wrapped around.
|
|
18
|
+
*/
|
|
19
|
+
/** A card failure as the browser half reported it. */
|
|
20
|
+
export type CardFailure = {
|
|
21
|
+
readonly message: string;
|
|
22
|
+
readonly phase: string;
|
|
23
|
+
};
|
|
24
|
+
/** The runtime-context section name. Stable: it is how a later snapshot supersedes an earlier one. */
|
|
25
|
+
export declare const CARD_FAILURE_CONTEXT = "ui4a:card-failure";
|
|
26
|
+
/**
|
|
27
|
+
* Ordered after the plugin's own guidance so the model reads how cards work before it reads that
|
|
28
|
+
* one is broken.
|
|
29
|
+
*/
|
|
30
|
+
export declare const CARD_FAILURE_CONTEXT_ORDER = 250;
|
|
31
|
+
/**
|
|
32
|
+
* The detail, as the model sees it.
|
|
33
|
+
*
|
|
34
|
+
* It says it is current state rather than an event on purpose: the same text is re-delivered on
|
|
35
|
+
* every step while the card stays broken, and a model that reads it as a fresh report tries to
|
|
36
|
+
* fix the card again on each one.
|
|
37
|
+
*/
|
|
38
|
+
export declare const failureText: (failure: CardFailure) => string;
|
|
39
|
+
/**
|
|
40
|
+
* The one line that opens a turn. The detail is already in context; this only asks for attention.
|
|
41
|
+
*
|
|
42
|
+
* The second sentence is not padding — it closes a race the split design makes possible. The nudge
|
|
43
|
+
* is an event and cannot be recalled once queued; the detail is state and is re-evaluated when the
|
|
44
|
+
* turn assembles. A card that recovers in between (a retry succeeding is the common way) clears
|
|
45
|
+
* the state, so the turn opens with "go read the runtime context" pointing at a context that says
|
|
46
|
+
* nothing about a card.
|
|
47
|
+
*
|
|
48
|
+
* Seen in a real session: two notices, and the SECOND one had no card-failure section anywhere in
|
|
49
|
+
* the turn that followed it. Without a defined outcome for that, the model has been told something
|
|
50
|
+
* is broken, cannot find it, and goes looking — in that session it rewrote a card that was fine.
|
|
51
|
+
*/
|
|
52
|
+
export declare const WAKE_TEXT = "A ui4a card you wrote is not rendering \u2014 the failure is in the runtime context. If there is no card failure there, it recovered on its own between this notice and now: say nothing about it and carry on with what you were doing.";
|
|
53
|
+
/** Shown as the context row's label in the transcript, so a reader can see what fired without opening it. */
|
|
54
|
+
export declare const WAKE_SUMMARY = "ui4a card failed to render";
|
|
55
|
+
/**
|
|
56
|
+
* The current failing card per session.
|
|
57
|
+
*
|
|
58
|
+
* Per session rather than per card: the nudge exists to get one turn spent on the problem, and a
|
|
59
|
+
* reply that breaks three cards does not want three turns. The newest failure wins because it is
|
|
60
|
+
* the one the model just wrote.
|
|
61
|
+
*/
|
|
62
|
+
export declare class CardFailures {
|
|
63
|
+
private readonly bySession;
|
|
64
|
+
/**
|
|
65
|
+
* Record a failure.
|
|
66
|
+
*
|
|
67
|
+
* @returns whether the session went from HEALTHY to failing — the caller wakes the model only
|
|
68
|
+
* then. Dedup lives here rather than in the browser half because that half is reloaded by every
|
|
69
|
+
* navigation, and a dedup set that resets on reload wakes the model again for a card it already
|
|
70
|
+
* knows about.
|
|
71
|
+
*
|
|
72
|
+
* **Not "is this a different message".** That was the first version, and it is wrong for a
|
|
73
|
+
* nudge whose entire payload is *go read the context*: two different failures produce two
|
|
74
|
+
* BYTE-IDENTICAL notices, so a reader watching a long turn sees the same sentence queued twice
|
|
75
|
+
* and the model gets two turns pointed at one current-state snapshot. Measured on a real
|
|
76
|
+
* session — three genuinely different failures (`@react-three/fiber` unresolvable, `maps is not
|
|
77
|
+
* defined`, `@react-three/cannon` unresolvable) over eighty minutes, each "news" by the old
|
|
78
|
+
* test, and the third's notice was still sitting unsent in the composer beside the second.
|
|
79
|
+
*
|
|
80
|
+
* While a failure is already recorded the model is either about to look or has looked, and the
|
|
81
|
+
* detail it will read is re-evaluated every step anyway — so a changed message needs no second
|
|
82
|
+
* nudge. `clear()` on recovery is what re-arms this.
|
|
83
|
+
*/
|
|
84
|
+
set(session: string, failure: CardFailure): boolean;
|
|
85
|
+
clear(session: string): void;
|
|
86
|
+
/** The context text for one session; empty contributes nothing to the assembly. */
|
|
87
|
+
text(session: string | undefined): string;
|
|
88
|
+
}
|
|
@@ -16,7 +16,7 @@ export type CanvasPanelProps = {
|
|
|
16
16
|
/** A canvas that failed to compile; see `runtime/report-error.ts`. */
|
|
17
17
|
onCardError?: (message: string, phase: string) => void;
|
|
18
18
|
/** A canvas that painted; see `runtime/report-error.ts`. */
|
|
19
|
-
onCardRendered?: () => void;
|
|
19
|
+
onCardRendered?: (restored: boolean) => void;
|
|
20
20
|
};
|
|
21
21
|
/**
|
|
22
22
|
* Rewrites a canvas's relative sub-page imports into blob URLs before it is compiled.
|
|
@@ -13,7 +13,7 @@ export type CanvasHostOptions = {
|
|
|
13
13
|
*/
|
|
14
14
|
onCardError?: (message: string, phase: string) => void;
|
|
15
15
|
/** A canvas that painted; cancels a deferred error report the next frame made untrue. */
|
|
16
|
-
onCardRendered?: () => void;
|
|
16
|
+
onCardRendered?: (restored: boolean) => void;
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
19
|
* A call whose arguments carry executable code rather than a file operation.
|
|
@@ -4,29 +4,23 @@ export type GenUISurfaceProps = {
|
|
|
4
4
|
/** True while `code` is still a prefix, so partial frames get normalized before compiling. */
|
|
5
5
|
streaming?: boolean;
|
|
6
6
|
/**
|
|
7
|
-
* Keep React state across recompiles
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Keep React state across recompiles, by rendering through partial-react's stable slot
|
|
8
|
+
* wrapper so hooks stay on the same fiber. Off, every recompile renders a freshly
|
|
9
|
+
* compiled function type, which React remounts — a running timer restarts at zero.
|
|
10
|
+
*
|
|
11
|
+
* It does NOT risk dropping an edit: the renderer's two reuse branches only fire in
|
|
12
|
+
* `push` mode, so a whole-file replacement always renders.
|
|
11
13
|
*/
|
|
12
14
|
preserveState?: boolean;
|
|
13
|
-
/** Real compile diagnostics.
|
|
15
|
+
/** Real compile diagnostics. A streaming frame never reaches this — see `errorAction`. */
|
|
14
16
|
onError?: (error: Error, phase: "transform" | "compile" | "render") => void;
|
|
15
17
|
/** Fires whenever a frame actually painted. Use it to clear a previously shown error. */
|
|
16
|
-
onRendered?: () => void;
|
|
18
|
+
onRendered?: (restored: boolean) => void;
|
|
17
19
|
className?: string;
|
|
18
20
|
};
|
|
19
21
|
/** Paired with `disposeCompiler`: the shared instance must go with the wasm behind it. */
|
|
20
22
|
export declare const dropSharedCompiler: () => void;
|
|
21
23
|
export declare const compiler: () => import("./compiler.ts").TsxCompiler;
|
|
22
|
-
/**
|
|
23
|
-
* Mid-stream frames legitimately fail: a prefix that has not reached `export default`
|
|
24
|
-
* yet, or a half-written expression. partial-react treats these as transient and keeps
|
|
25
|
-
* the last good frame, so surfacing them would just make the UI flash errors while the
|
|
26
|
-
* model types. Only a failure that survives settling is the caller's business.
|
|
27
|
-
*/
|
|
28
|
-
/** Exported for `test/transient.test.ts`: this decides whether the reader sees an error. */
|
|
29
|
-
export declare const TRANSIENT: RegExp;
|
|
30
24
|
/**
|
|
31
25
|
* A dependency that failed to arrive, not code that is wrong. esm.sh cold-starts and the
|
|
32
26
|
* network drops, and the symptom is identical to a broken component — a blank surface — so
|
|
@@ -41,15 +35,6 @@ export declare const TRANSIENT_LOAD: RegExp;
|
|
|
41
35
|
* each of which sends the reader somewhere different when it is wrong. Exported for
|
|
42
36
|
* `test/retry.test.ts`.
|
|
43
37
|
*/
|
|
44
|
-
/**
|
|
45
|
-
* Whether a mid-stream error is the stream not being finished yet.
|
|
46
|
-
*
|
|
47
|
-
* Both patterns come from the parse stages — `No default export found` is thrown inside
|
|
48
|
-
* `importCompiledComponent` (compile), and an unexpected EOF is the transform rejecting a
|
|
49
|
-
* prefix. A card whose own render throws a message that happens to match is a real error, so
|
|
50
|
-
* the phase is part of the question rather than the message alone.
|
|
51
|
-
*/
|
|
52
|
-
export declare const isUnfinishedFrame: (message: string, phase: string, streaming: boolean) => boolean;
|
|
53
38
|
export declare const shouldRetry: (message: string, phase: string, streaming: boolean, attempts: number) => boolean;
|
|
54
39
|
/**
|
|
55
40
|
* What to do with a frame, given what the surface already holds.
|
|
@@ -95,6 +80,20 @@ export declare const deliver: (renderer: RendererCalls, delivery: Delivery) => b
|
|
|
95
80
|
* code to get wrong than the re-probe costs.
|
|
96
81
|
*/
|
|
97
82
|
export declare const importSignature: (code: string) => string;
|
|
83
|
+
/**
|
|
84
|
+
* Whether a failure suppressed during streaming still has to be told to someone.
|
|
85
|
+
*
|
|
86
|
+
* The hole this closes: `errorAction` ignores every streaming frame (a truncated one is not a
|
|
87
|
+
* broken card), and `deliveryFor` answers `nothing` when the settled frame is byte-identical to
|
|
88
|
+
* the last streamed one. Both are right on their own, and together they mean a card that really
|
|
89
|
+
* is broken recompiles never and reports never.
|
|
90
|
+
*
|
|
91
|
+
* Pure, and exported, because it is three conditions and each one is a distinct bug when wrong:
|
|
92
|
+
* without `!streaming` it fires mid-stream and undoes the fix it belongs to; without `stranded`
|
|
93
|
+
* it reports nothing; without the `reportedFor` guard a settled card re-rendered by every later
|
|
94
|
+
* frame of the transcript reports on each one.
|
|
95
|
+
*/
|
|
96
|
+
export declare const reportStranded: (streaming: boolean, stranded: unknown, code: string, reportedFor: string) => boolean;
|
|
98
97
|
/** Only what `deliver` touches — the real renderer has far more. */
|
|
99
98
|
export type RendererCalls = {
|
|
100
99
|
render: (code: string) => void;
|
|
@@ -110,12 +109,15 @@ export type RendererCalls = {
|
|
|
110
109
|
* - `retry` a dependency failed to arrive, and busting the import URLs is the fix
|
|
111
110
|
* - `report` tell the reader
|
|
112
111
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
112
|
+
* **A streaming frame is never reported, whatever it says.** This used to test the message
|
|
113
|
+
* against `TRANSIENT` (`No default export found`, unexpected EOF) and report anything else, on
|
|
114
|
+
* the theory that a truncated frame fails to parse. It does not have to: a cut that lands
|
|
115
|
+
* mid-identifier leaves valid syntax and throws at module evaluation instead. Measured on one
|
|
116
|
+
* real session — five consecutive reports, five regenerations, and every final card was fine:
|
|
117
|
+
* `Mouse is not defined` from a card whose only such name is `MousePointer2`, `type is not
|
|
118
|
+
* defined` from `type ToolGroup =`, and `icon is not defined` from a card containing no `icon` at
|
|
119
|
+
* all. The frame, not the card, was broken. There is no message that distinguishes the two, so
|
|
120
|
+
* the phase does it: only a settled surface has anything worth saying about.
|
|
119
121
|
*/
|
|
120
122
|
export declare const errorAction: (message: string, phase: string, streaming: boolean, attempts: number) => "ignore" | "retry" | "report";
|
|
121
123
|
/**
|
|
@@ -133,15 +135,26 @@ export declare const errorAction: (message: string, phase: string, streaming: bo
|
|
|
133
135
|
*
|
|
134
136
|
* - **stale** — a later frame's probe won the race. Applying this one reverts the map to an
|
|
135
137
|
* older import set, and the newer frame's packages go missing.
|
|
136
|
-
* - **redeliver** —
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
138
|
+
* - **redeliver** — nothing else is going to apply the map. Always true of a settled surface,
|
|
139
|
+
* and true while streaming too when no frame has been delivered since the probe fired:
|
|
140
|
+
* `setImportMap` only stores, so that buffer stays compiled against a map without these
|
|
141
|
+
* entries — and an unresolvable bare specifier kills the whole module graph, so the card is
|
|
142
|
+
* blank rather than wrong. Measured 2026-08-27 on a card importing `@radix-ui/react-tabs`:
|
|
143
|
+
* one delivery with `streaming: true` renders 0 characters **for good**, while the identical
|
|
144
|
+
* code with `streaming: false` renders. The error is swallowed on top of it — `errorAction`
|
|
145
|
+
* answers `ignore` while streaming, so the reader gets an empty card and the model gets no
|
|
146
|
+
* report. A cold probe is 1.0s and the import behind it another 2.4s, so the window this
|
|
147
|
+
* covers is seconds wide, not a frame.
|
|
148
|
+
* - **store** — a newer frame has already been delivered, and it will apply the map itself.
|
|
149
|
+
* Re-delivering here instead would replace the buffer with the prefix that was current when
|
|
150
|
+
* the probe fired, truncating the stream mid-flight.
|
|
151
|
+
*
|
|
152
|
+
* `redeliver` renders `deliveredRef.current`, which is read at SETTLE time — the newest buffer,
|
|
153
|
+
* not the captured one — so the append the next frame computes still lines up.
|
|
141
154
|
*
|
|
142
155
|
* The `delivered !== ""` part is not defensive: re-rendering an empty buffer clears the surface.
|
|
143
156
|
*/
|
|
144
|
-
export declare const probeOutcome: (signature: string, current: string, streaming: boolean, delivered: string) => "stale" | "redeliver" | "store";
|
|
157
|
+
export declare const probeOutcome: (signature: string, current: string, streaming: boolean, delivered: string, probed: string) => "stale" | "redeliver" | "store";
|
|
145
158
|
export declare const dispatchError: (action: "ignore" | "retry" | "report", effects: {
|
|
146
159
|
attempts: () => number;
|
|
147
160
|
setAttempts: (n: number) => void;
|
|
@@ -155,5 +168,22 @@ export declare const dispatchError: (action: "ignore" | "retry" | "report", effe
|
|
|
155
168
|
* fresh, and appending a query to a `blob:` URL makes it unresolvable — which would break every
|
|
156
169
|
* card rather than fixing one.
|
|
157
170
|
*/
|
|
171
|
+
/**
|
|
172
|
+
* The same import map with `bundle`/`external` dropped from every esm.sh entry.
|
|
173
|
+
*
|
|
174
|
+
* esm.sh serves two different builds and only one of them can fail: `?bundle` runs esbuild over
|
|
175
|
+
* the package's whole tree, and a version skew anywhere in it is a hard 500. Measured on
|
|
176
|
+
* `mermaid`, three attempts, deterministic — `?bundle&target=es2022&external=react,react-dom,scheduler`
|
|
177
|
+
* answers **500 `esbuild: No matching export in "node_modules/d3/src/index.js" for import
|
|
178
|
+
* "curveBumpX"`**, while the plain `https://esm.sh/mermaid?target=es2022` answers 200 and the
|
|
179
|
+
* module imports fine. The card meanwhile renders **completely blank with nothing in the
|
|
180
|
+
* console**, because an unresolvable import kills the whole module graph.
|
|
181
|
+
*
|
|
182
|
+
* So the retry that only busts the query re-requests the same broken build. Unbundling is a
|
|
183
|
+
* genuinely different artefact on esm.sh's side, which is what makes it a second chance rather
|
|
184
|
+
* than a second identical failure. It is not the first choice — the bundled build is one request
|
|
185
|
+
* instead of a waterfall — which is why this is on the retry path and not on the happy one.
|
|
186
|
+
*/
|
|
187
|
+
export declare const unbundleFetchedImports: (imports: Record<string, string>) => Record<string, string>;
|
|
158
188
|
export declare const bustFetchedImports: (imports: Record<string, string>, attempt: number) => Record<string, string>;
|
|
159
189
|
export declare function GenUISurface({ code, streaming, preserveState, onError, onRendered, className }: GenUISurfaceProps): import("react").JSX.Element;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { type Ui4aSegment } from "./segments.ts";
|
|
3
3
|
/** Split out from `hasPainted` so the rule can be tested without a DOM. */
|
|
4
4
|
export declare const isPaintedText: (text: string) => boolean;
|
|
5
5
|
export declare const hasPainted: (mount: HTMLElement) => boolean;
|
|
@@ -14,10 +14,37 @@ export declare const matchSegment: (segments: readonly Ui4aSegment[], rendered:
|
|
|
14
14
|
export type InlineFenceOptions = {
|
|
15
15
|
/** Every ui4a segment currently in the transcript, in document order. */
|
|
16
16
|
segments: () => readonly Ui4aSegment[];
|
|
17
|
+
/** `last` answers whether this card is still the transcript's newest — asked at report time, not render time. */
|
|
17
18
|
render: (props: {
|
|
18
19
|
code: string;
|
|
19
20
|
streaming: boolean;
|
|
21
|
+
last: () => boolean;
|
|
20
22
|
}) => ReactElement;
|
|
21
23
|
scope?: HTMLElement;
|
|
22
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* Whether this card's code is the last ui4a segment in the transcript.
|
|
27
|
+
*
|
|
28
|
+
* **Only the last block's failure is worth telling the model about.** An earlier card that cannot
|
|
29
|
+
* render stays in the transcript and re-renders on every later frame, so it fails again, and
|
|
30
|
+
* again, for the rest of the session — measured on a real one where the model wrote a card
|
|
31
|
+
* importing `Github` from `lucide-react` (removed upstream), was told, fixed it to `GitBranch` in
|
|
32
|
+
* the very next reply, and the failure notice kept coming back from the reply it had already
|
|
33
|
+
* superseded. The model cannot act on it: editing a message it already sent is not a thing it
|
|
34
|
+
* can do.
|
|
35
|
+
*
|
|
36
|
+
* Evaluated when the report is about to be SENT, not when the error is raised. A card is the last
|
|
37
|
+
* one at the moment it breaks and stops being so as soon as the next card appears, which is
|
|
38
|
+
* exactly the second in between.
|
|
39
|
+
*
|
|
40
|
+
* **Segments, not mounts.** Comparing DOM nodes was wrong three separate ways, all of them
|
|
41
|
+
* silent: `release` calls `claim.mount.remove()` on every markdown re-render, so a broken card
|
|
42
|
+
* followed by any trailing prose compared a detached node and dropped its own report; `defer`
|
|
43
|
+
* parks a far-offscreen block without ever creating a mount, so a reader scrolled up while the fix
|
|
44
|
+
* streams left an older card holding the last mount and reporting as though it were newest; and an
|
|
45
|
+
* empty node list has to mean false, since a session switch inside the settle window empties it
|
|
46
|
+
* while `sendToModel` reads `currentSession()` at send time — session A's error into session B's
|
|
47
|
+
* store. The segment list is the transcript's own order and survives all three.
|
|
48
|
+
*/
|
|
49
|
+
export declare const isLastSegment: (segments: readonly Ui4aSegment[], code: string) => boolean;
|
|
23
50
|
export declare function claimInlineFences({ segments, render, scope }: InlineFenceOptions): () => void;
|
|
@@ -1,17 +1,64 @@
|
|
|
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
|
+
* Four 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 turn per render is a loop the user has to kill. Deduplication
|
|
26
|
+
* lives HOST-side now (`CardFailures.set`), because this half is thrown away by every
|
|
27
|
+
* navigation and a dedup set that resets on reload wakes the model about a card it already
|
|
28
|
+
* knows. What stays here is the settling, which is about frames, not about turns.
|
|
29
|
+
* - **Recovery is reported too.** The report is state, not an event: a card that starts working
|
|
30
|
+
* sends `null`, and the host drops it out of the model's context. As a chat message that was
|
|
31
|
+
* impossible, which is why the old body had to carry a paragraph explaining that nobody had
|
|
32
|
+
* typed it.
|
|
33
|
+
* - **Only the newest card counts.** An earlier card that cannot render stays in the transcript
|
|
34
|
+
* and re-renders on every later frame, so it goes on failing for the rest of the session.
|
|
35
|
+
* Measured on a real one: the model wrote a card importing `Github` from `lucide-react`
|
|
36
|
+
* (removed upstream), was told, fixed it to `GitBranch` in its very next reply — and the
|
|
37
|
+
* failure notice kept coming back from the message it had already superseded. There is nothing
|
|
38
|
+
* the model can do with that; it cannot edit a reply it has sent. `isLastSegment` is the gate, on both the report and the retraction.
|
|
39
|
+
*/
|
|
1
40
|
/** Exported for the test: a fresh card in a fresh session should be able to report again. */
|
|
2
41
|
export declare const forgetReportedErrors: () => void;
|
|
3
|
-
|
|
42
|
+
/** `null` means the card recovered. */
|
|
43
|
+
export type ErrorReporter = (report: {
|
|
44
|
+
message: string;
|
|
45
|
+
phase: string;
|
|
46
|
+
} | null) => void;
|
|
47
|
+
/** Drop a report the page is going away before it can deliver. NOT a recovery — nothing is fixed. */
|
|
48
|
+
export declare function cancelPendingReport(): void;
|
|
4
49
|
/**
|
|
5
|
-
*
|
|
6
|
-
* one thing the model needs is what failed and that nobody typed it.
|
|
50
|
+
* Called when a surface paints. Cancels a report the very next frame made untrue.
|
|
7
51
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
52
|
+
* **`restored` is not a detail.** partial-react answers a render throw by re-mounting the LAST
|
|
53
|
+
* GOOD component (`runtime.ts:416-419`, whenever `preserve` is on — which is every inline card),
|
|
54
|
+
* and that re-mount paints, and painting used to cancel the report that the very same throw had
|
|
55
|
+
* just armed. So the one case the reporting exists for — a card that worked, then broke on an
|
|
56
|
+
* edit — showed the reader stale content and told the model nothing. A paint only means the card
|
|
57
|
+
* is fine when it is the NEW code that painted.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* @param current whether the painting card is the one the gate would let report. Defaults to
|
|
61
|
+
* permissive for the canvas path, which shows one card at a time.
|
|
13
62
|
*/
|
|
14
|
-
export declare
|
|
15
|
-
|
|
16
|
-
export declare function cardRendered(): void;
|
|
17
|
-
export declare function reportCardError(send: ErrorReporter | undefined, message: string, phase: string): void;
|
|
63
|
+
export declare function cardRendered(restored?: boolean, current?: () => boolean): void;
|
|
64
|
+
export declare function reportCardError(send: ErrorReporter | undefined, message: string, phase: string, current?: () => boolean): void;
|
|
@@ -39,3 +39,13 @@ export declare const EXEC_PATH = "/dsh-generative-ui/exec";
|
|
|
39
39
|
* re-opening from here what the host closed for its own tools is not ours to do.
|
|
40
40
|
*/
|
|
41
41
|
export declare const WEB_SEARCH_PATH = "/dsh-generative-ui/web-search";
|
|
42
|
+
/**
|
|
43
|
+
* A card's surviving failure, reported by the browser half: `?session=<id>`, POST
|
|
44
|
+
* `{message, phase}` to set it and `{}` to clear it.
|
|
45
|
+
*
|
|
46
|
+
* The detail does NOT come back as a chat message. It becomes a runtime-context snapshot
|
|
47
|
+
* (`ui4a:card-failure`), which is re-evaluated per assembly and superseded by the next one, so a
|
|
48
|
+
* card that gets fixed stops being mentioned instead of leaving a stale complaint in history. The
|
|
49
|
+
* route only carries the state; `wakeAgent` is what asks the model to look at it.
|
|
50
|
+
*/
|
|
51
|
+
export declare const CARD_ERROR_PATH = "/dsh-generative-ui/card-error";
|
package/lib/types/index.d.ts
CHANGED
|
@@ -11,6 +11,24 @@
|
|
|
11
11
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
12
|
import type { Context } from "@deepseek-ai/cordis";
|
|
13
13
|
import z from "@deepseek-ai/schemastery";
|
|
14
|
+
import { CardFailures } from "./card-failure.ts";
|
|
15
|
+
/**
|
|
16
|
+
* The one field of the assembling agent this plugin reads: its id, which IS the session id.
|
|
17
|
+
*
|
|
18
|
+
* Declared here rather than by importing `@deepseek-ai/dsh-agent`, which owns the real
|
|
19
|
+
* augmentation. That package also augments cordis `Context` with the HOST's `sessions` service,
|
|
20
|
+
* and the augmentation is global — pulling it in retyped `ctx.sessions` inside `src/client/`,
|
|
21
|
+
* where the session store is the browser runtime's and has `getSnapshot`/`binding` instead.
|
|
22
|
+
* Six type errors in files this change does not touch. One optional field is the whole
|
|
23
|
+
* dependency, so it is cheaper to state it than to import the package that carries it.
|
|
24
|
+
*/
|
|
25
|
+
declare module "@deepseek-ai/dsh-system-prompt" {
|
|
26
|
+
interface AssembleContext {
|
|
27
|
+
agent?: {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
14
32
|
export declare const name = "dsh-generative-ui";
|
|
15
33
|
export declare const inject: string[];
|
|
16
34
|
/** The settings section this plugin owns; the key under `dsh-generative-ui:` in settings.yaml. */
|
|
@@ -20,13 +38,24 @@ export declare const SETTINGS_NAMESPACE: import("@deepseek-ai/dsh-settings").Set
|
|
|
20
38
|
* `settings.yaml` section against it and builds the settings UI from it, so a plain interface
|
|
21
39
|
* would be a switch nobody can find and nobody can check.
|
|
22
40
|
*
|
|
23
|
-
* `allowExec` is
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
41
|
+
* `allowExec` is ON by default, and the trade it makes is worth stating rather than assuming.
|
|
42
|
+
*
|
|
43
|
+
* What it is NOT: an escape from the fence. The route resolves `ctx.sandboxPolicy` for the session
|
|
44
|
+
* and hands it to `ctx.shell`, so a card's command opens nothing the agent's own bash has not
|
|
45
|
+
* already opened, in a workdir pinned to a live session's workspace, killed after 15s and on the
|
|
46
|
+
* reader closing the page.
|
|
47
|
+
*
|
|
48
|
+
* What it IS: the approval layer does not reach here. `approval.request()` needs an open turn and
|
|
49
|
+
* an agent, and a card fires on a reader's keystroke long after its turn ended — so the per-command
|
|
50
|
+
* fence is the sandbox policy alone. Under `workspace-write` a card can therefore delete inside the
|
|
51
|
+
* workspace with nobody agreeing to it command by command. The prompt answers that where it can
|
|
52
|
+
* ("observe, never change"; anything destructive belongs in a `sendMessage` the user agrees to),
|
|
53
|
+
* which is guidance, not a fence.
|
|
54
|
+
*
|
|
55
|
+
* Turned on because the capability it gates is the ordinary case, not the exotic one: search
|
|
56
|
+
* (`fd`, `rg`) that no `$dsh/fs` call expresses, `lint` / `check` / test runs whose output IS the
|
|
57
|
+
* card, `git log`, and the twenty-`readdir` walks a single `ls -R` replaces. Off, a model reasoning
|
|
58
|
+
* from a five-capability set writes those as file-by-file loops or does not write the card at all.
|
|
30
59
|
*/
|
|
31
60
|
export declare const Config: z<Schemastery.ObjectS<{
|
|
32
61
|
allowExec: z<boolean, boolean>;
|
|
@@ -253,3 +282,17 @@ type LlmCtx = {
|
|
|
253
282
|
/** Exported for `test/ai-route.test.ts`. */
|
|
254
283
|
export declare function serveAi(ctx: LlmCtx, liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
255
284
|
export declare function apply(ctx: Context, config?: Config): void;
|
|
285
|
+
/**
|
|
286
|
+
* Record or clear one session's failing card, and wake the model when the failure is news.
|
|
287
|
+
*
|
|
288
|
+
* `POST ?session=<id>` with `{message, phase}` to set it and `{}` to clear it. The detail never
|
|
289
|
+
* comes back as a chat message — it becomes the `ui4a:card-failure` runtime context, which the
|
|
290
|
+
* assembly re-reads each step. See `card-failure.ts` for why the two halves are split.
|
|
291
|
+
*
|
|
292
|
+
* The wake is looked up per request rather than captured: the agent registry is scoped, so the
|
|
293
|
+
* function it hands out can go away while this route stays up, and a stale capture would call
|
|
294
|
+
* into a disposed fiber.
|
|
295
|
+
*
|
|
296
|
+
* Exported for `test/card-error-route.test.ts`.
|
|
297
|
+
*/
|
|
298
|
+
export declare function serveCardError(failures: CardFailures, wake: () => ((session: string) => void) | null, req: IncomingMessage, res: ServerResponse): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-generative-ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Generative UI for DeepSeek Harness — the agent writes TSX, dsh web renders it live, inline in chat and in a canvas panel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"react": "^18.2.0"
|
|
95
95
|
},
|
|
96
96
|
"devDependencies": {
|
|
97
|
+
"@babel/parser": "^8.0.4",
|
|
97
98
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
98
99
|
"@deepseek-ai/dsh-client-locale": "0.1.0-rc.8",
|
|
99
100
|
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.8",
|
|
@@ -109,10 +110,11 @@
|
|
|
109
110
|
"@types/node": "^26.3.0",
|
|
110
111
|
"@types/react": "~18.3.31",
|
|
111
112
|
"@types/react-dom": "~18.3.7",
|
|
113
|
+
"@types/scheduler": "^0.26.0",
|
|
112
114
|
"micromatch": "^4.0.8",
|
|
113
115
|
"minimatch": "^10.2.6",
|
|
114
116
|
"motion": "^13.1.1",
|
|
115
|
-
"oxfmt": "^0.
|
|
117
|
+
"oxfmt": "^0.66.0",
|
|
116
118
|
"oxlint": "^1.80.0",
|
|
117
119
|
"picomatch": "^4.0.7",
|
|
118
120
|
"pkg-pr-new": "^0.0.88",
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the model is told about a card that will not render.
|
|
3
|
+
*
|
|
4
|
+
* Split in two, deliberately, because the two halves want opposite lifetimes:
|
|
5
|
+
*
|
|
6
|
+
* - **The detail is state.** It goes out as a runtime-context snapshot, re-evaluated on every
|
|
7
|
+
* assembly and superseded by the next one. A card that gets fixed simply stops being mentioned.
|
|
8
|
+
* As a chat message it was the opposite — permanent, and still there three turns after the card
|
|
9
|
+
* started working.
|
|
10
|
+
* - **The nudge is an event.** One short line through `followup`, whose only job is to open a turn
|
|
11
|
+
* so the model looks at the detail now rather than whenever the user next types.
|
|
12
|
+
*
|
|
13
|
+
* Neither is a user-role message any more, and that removes the paragraph the old body had to
|
|
14
|
+
* carry: *"This was sent by the renderer, not by the user — nobody typed it, so do not apologise
|
|
15
|
+
* or address it as a request."* That existed because the report arrived wearing the user's face.
|
|
16
|
+
* A `kind: "plugin"` source does not, so the model is told what happened and nothing else — the
|
|
17
|
+
* disclaimer cost more tokens than the error it was wrapped around.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A card failure as the browser half reported it. */
|
|
21
|
+
export type CardFailure = { readonly message: string; readonly phase: string };
|
|
22
|
+
|
|
23
|
+
/** The runtime-context section name. Stable: it is how a later snapshot supersedes an earlier one. */
|
|
24
|
+
export const CARD_FAILURE_CONTEXT = "ui4a:card-failure";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ordered after the plugin's own guidance so the model reads how cards work before it reads that
|
|
28
|
+
* one is broken.
|
|
29
|
+
*/
|
|
30
|
+
export const CARD_FAILURE_CONTEXT_ORDER = 250;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The detail, as the model sees it.
|
|
34
|
+
*
|
|
35
|
+
* It says it is current state rather than an event on purpose: the same text is re-delivered on
|
|
36
|
+
* every step while the card stays broken, and a model that reads it as a fresh report tries to
|
|
37
|
+
* fix the card again on each one.
|
|
38
|
+
*/
|
|
39
|
+
export const failureText = (failure: CardFailure) =>
|
|
40
|
+
`A ui4a card in this session is not rendering. It failed at the ${failure.phase} step:\n\n${failure.message}\n\nThis is current state, not a new event — it is re-read every step and disappears once the card renders. 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.`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The one line that opens a turn. The detail is already in context; this only asks for attention.
|
|
44
|
+
*
|
|
45
|
+
* The second sentence is not padding — it closes a race the split design makes possible. The nudge
|
|
46
|
+
* is an event and cannot be recalled once queued; the detail is state and is re-evaluated when the
|
|
47
|
+
* turn assembles. A card that recovers in between (a retry succeeding is the common way) clears
|
|
48
|
+
* the state, so the turn opens with "go read the runtime context" pointing at a context that says
|
|
49
|
+
* nothing about a card.
|
|
50
|
+
*
|
|
51
|
+
* Seen in a real session: two notices, and the SECOND one had no card-failure section anywhere in
|
|
52
|
+
* the turn that followed it. Without a defined outcome for that, the model has been told something
|
|
53
|
+
* is broken, cannot find it, and goes looking — in that session it rewrote a card that was fine.
|
|
54
|
+
*/
|
|
55
|
+
export const WAKE_TEXT =
|
|
56
|
+
"A ui4a card you wrote is not rendering — the failure is in the runtime context. If there is no card failure there, it recovered on its own between this notice and now: say nothing about it and carry on with what you were doing.";
|
|
57
|
+
|
|
58
|
+
/** Shown as the context row's label in the transcript, so a reader can see what fired without opening it. */
|
|
59
|
+
export const WAKE_SUMMARY = "ui4a card failed to render";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The current failing card per session.
|
|
63
|
+
*
|
|
64
|
+
* Per session rather than per card: the nudge exists to get one turn spent on the problem, and a
|
|
65
|
+
* reply that breaks three cards does not want three turns. The newest failure wins because it is
|
|
66
|
+
* the one the model just wrote.
|
|
67
|
+
*/
|
|
68
|
+
export class CardFailures {
|
|
69
|
+
private readonly bySession = new Map<string, CardFailure>();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Record a failure.
|
|
73
|
+
*
|
|
74
|
+
* @returns whether the session went from HEALTHY to failing — the caller wakes the model only
|
|
75
|
+
* then. Dedup lives here rather than in the browser half because that half is reloaded by every
|
|
76
|
+
* navigation, and a dedup set that resets on reload wakes the model again for a card it already
|
|
77
|
+
* knows about.
|
|
78
|
+
*
|
|
79
|
+
* **Not "is this a different message".** That was the first version, and it is wrong for a
|
|
80
|
+
* nudge whose entire payload is *go read the context*: two different failures produce two
|
|
81
|
+
* BYTE-IDENTICAL notices, so a reader watching a long turn sees the same sentence queued twice
|
|
82
|
+
* and the model gets two turns pointed at one current-state snapshot. Measured on a real
|
|
83
|
+
* session — three genuinely different failures (`@react-three/fiber` unresolvable, `maps is not
|
|
84
|
+
* defined`, `@react-three/cannon` unresolvable) over eighty minutes, each "news" by the old
|
|
85
|
+
* test, and the third's notice was still sitting unsent in the composer beside the second.
|
|
86
|
+
*
|
|
87
|
+
* While a failure is already recorded the model is either about to look or has looked, and the
|
|
88
|
+
* detail it will read is re-evaluated every step anyway — so a changed message needs no second
|
|
89
|
+
* nudge. `clear()` on recovery is what re-arms this.
|
|
90
|
+
*/
|
|
91
|
+
set(session: string, failure: CardFailure): boolean {
|
|
92
|
+
const wasHealthy = !this.bySession.has(session);
|
|
93
|
+
this.bySession.set(session, failure);
|
|
94
|
+
return wasHealthy;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
clear(session: string): void {
|
|
98
|
+
this.bySession.delete(session);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The context text for one session; empty contributes nothing to the assembly. */
|
|
102
|
+
text(session: string | undefined): string {
|
|
103
|
+
const failure = session === undefined ? undefined : this.bySession.get(session);
|
|
104
|
+
return failure === undefined ? "" : failureText(failure);
|
|
105
|
+
}
|
|
106
|
+
}
|