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,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits assistant prose into markdown and inline-ui4a segments.
|
|
3
|
+
*
|
|
4
|
+
* Ported from ui4a-playground/src/components/chat/segments.ts. We only consume the ui4a
|
|
5
|
+
* side — the host renders the markdown — but the parser must still walk the whole text,
|
|
6
|
+
* because a fence's position depends on everything before it.
|
|
7
|
+
*/
|
|
8
|
+
import { FENCE_LANG } from "../../contract.ts";
|
|
9
|
+
|
|
10
|
+
export type Ui4aSegment = { code: string; complete: boolean };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Opening fence. Both tolerances here were measured; do not tighten them.
|
|
14
|
+
*
|
|
15
|
+
* - **The fence need not start a line.** The prompt says "on its own line", but the model
|
|
16
|
+
* routinely continues it straight after a sentence (`……完整元素周期表。\`\`\`\`ui4a/tsx`).
|
|
17
|
+
* Anchoring with `^` drops that whole reply back to markdown, and hundreds of lines of
|
|
18
|
+
* TSX get pasted into the conversation as prose — far worse than a loose match. So the
|
|
19
|
+
* only requirement is that a backtick does not precede it (which would cut a longer
|
|
20
|
+
* fence in half).
|
|
21
|
+
* - **No newline is required after the language.** Same cause: the model glues the first
|
|
22
|
+
* line of code onto the fence line. `[^\n]*` was there for meta like `title=`, and it
|
|
23
|
+
* swallows that code too — hence `inlineCode` below hands it back.
|
|
24
|
+
*/
|
|
25
|
+
const FENCE = new RegExp(String.raw`(?<!\x60)(\x60{3,})${FENCE_LANG.replace("/", "\\/")}([^\n]*)(\n|$)`);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where the closing fence is, or `-1`.
|
|
29
|
+
*
|
|
30
|
+
* Try the line-anchored form first (nearly every case) and only then scan for an inline
|
|
31
|
+
* one. Running the permissive regex alone would make the lookbehind re-test at every
|
|
32
|
+
* character — and the closing fence is absent for the whole streaming phase, so that is
|
|
33
|
+
* the most expensive possible scan of the whole body, once per frame (measured on a 25KB
|
|
34
|
+
* body: 1µs → 121µs).
|
|
35
|
+
*/
|
|
36
|
+
function findClose(body: string, fence: string): number {
|
|
37
|
+
const anchored = body.indexOf(`\n${fence}`);
|
|
38
|
+
if (anchored >= 0 && /^[^\S\n]*(?:\n|$)/.test(body.slice(anchored + 1 + fence.length)) && body[anchored + 1 + fence.length] !== "`") return anchored + 1;
|
|
39
|
+
for (let at = body.indexOf(fence); at >= 0; at = body.indexOf(fence, at + 1)) {
|
|
40
|
+
if (body[at - 1] === "`" || body[at + fence.length] === "`") continue;
|
|
41
|
+
if (/^[^\S\n]*(?:\n|$)/.test(body.slice(at + fence.length))) return at;
|
|
42
|
+
}
|
|
43
|
+
// A closer SHORTER than the opener. Markdown says this does not close the fence, and the
|
|
44
|
+
// model writes it anyway: 18 of 385 openers in the corpus are closed by a shorter run
|
|
45
|
+
// (`open=6 close=4` nine times), and each one is a card that streams forever because nothing
|
|
46
|
+
// ever ends it. Accepting any standalone run of three-plus rescues 16 and cuts 0 short — the
|
|
47
|
+
// single card where a shorter run precedes the matching one had closed itself twice, so
|
|
48
|
+
// cutting at the first is the same body. Tried last so an exact match always wins.
|
|
49
|
+
const short = /(?:^|\n)[^\S\n]*(`{3,})[^\S\n]*(?:\n|$)/.exec(body);
|
|
50
|
+
return short === undefined || short === null ? -1 : short.index + (short[0].startsWith("\n") ? 1 : 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Tool-call markup the model leaked into its own prose. The reply ends mid-fence with the
|
|
55
|
+
* closing tags glued to the last line of TSX, so the body reaches the compiler with those tags
|
|
56
|
+
* in it and fails to parse — the whole card is lost, not just the closing fence.
|
|
57
|
+
*
|
|
58
|
+
* Two spellings, and the rarer one was found first: `</parameter></invoke>` appeared once in
|
|
59
|
+
* the corpus, while the model's own `</||DSML||parameter>` form accounts for three more and
|
|
60
|
+
* was invisible to a regex written from that single sample. Those full-width bars are U+FF5C,
|
|
61
|
+
* not ASCII `|`. Only stripped at the very end of an unterminated body, where nothing
|
|
62
|
+
* legitimate can follow — which is true of a closed fence too: the model leaks the tags and then
|
|
63
|
+
* still writes the closing fence, and stripping only the unterminated case loses that card outright.
|
|
64
|
+
*/
|
|
65
|
+
export const TOOL_CALL_MARKUP = /\n?(?:<\/(?:antml:|||DSML||)?(?:parameter|invoke|tool_calls)>\s*)+$/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The prompt asks for four backticks — generated TSX contains triple-backtick strings often
|
|
69
|
+
* enough that a triple-backtick fence would be closed early by its own body. Three or more
|
|
70
|
+
* are accepted anyway: models do not always comply, and one backtick short should not
|
|
71
|
+
* demote the whole block to a plain code listing. The closing fence matches the opening
|
|
72
|
+
* length, so the four-backtick form still tolerates triples inside.
|
|
73
|
+
*
|
|
74
|
+
* An unterminated fence still yields a segment with `complete: false` — that is exactly the
|
|
75
|
+
* frame a streaming reply is in, and rendering it is the entire point.
|
|
76
|
+
*/
|
|
77
|
+
const CODE_LINE = /^(import|export|const|function|type|interface|let|\/\/)\b/;
|
|
78
|
+
const FENCE_META = /^[\w-]+=/;
|
|
79
|
+
|
|
80
|
+
export function parseUi4aSegments(text: string): Ui4aSegment[] {
|
|
81
|
+
const segments: Ui4aSegment[] = [];
|
|
82
|
+
let rest = text;
|
|
83
|
+
while (true) {
|
|
84
|
+
const open = FENCE.exec(rest);
|
|
85
|
+
if (open === null) return segments;
|
|
86
|
+
// Leftovers after `ui4a/tsx` on the fence line: normally empty (or meta like `title=`),
|
|
87
|
+
// but the model sometimes puts the first line of code there. If it looks like code,
|
|
88
|
+
// hand it back as the body's first line rather than dropping it.
|
|
89
|
+
const trailing = open[2].trim();
|
|
90
|
+
const bodyStart = open.index + open[0].length;
|
|
91
|
+
// A fence opener the model is *talking about* rather than opening. Measured: 19 of 405
|
|
92
|
+
// openers in the corpus are prose, and 14 of them put the sentence right after the
|
|
93
|
+
// language (`\`\`\`\`ui4a/tsx\`\`\`\` 块,原地渲染成…`). Anything that is not code and not
|
|
94
|
+
// `key=value` meta is one; skipping the whole opener costs 0 of 390 real cards.
|
|
95
|
+
if (trailing !== "" && !CODE_LINE.test(trailing) && !FENCE_META.test(trailing)) {
|
|
96
|
+
rest = rest.slice(bodyStart);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// A wrapper: the model opened a wider fence and then opened the real one inside it, the way
|
|
100
|
+
// this project's own prompt shows the block (`\`\`\`\`\`` around `\`\`\`\`ui4a/tsx`). Taking the
|
|
101
|
+
// outer one gives a body that is the inner fence AS TEXT — which compiles, silently, to a
|
|
102
|
+
// card that renders nothing. Once in 389 corpus openers, and it costs the reader the card.
|
|
103
|
+
if (/^[^\S\n]*`{3,}ui4a\/tsx/.test(rest.slice(bodyStart))) {
|
|
104
|
+
rest = rest.slice(bodyStart);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const inlineCode = CODE_LINE.test(trailing) ? `${trailing}\n` : "";
|
|
108
|
+
const closeIndex = findClose(rest.slice(bodyStart), open[1]);
|
|
109
|
+
if (closeIndex === -1) {
|
|
110
|
+
segments.push({ code: (inlineCode + rest.slice(bodyStart)).replace(TOOL_CALL_MARKUP, ""), complete: false });
|
|
111
|
+
return segments;
|
|
112
|
+
}
|
|
113
|
+
segments.push({ code: (inlineCode + rest.slice(bodyStart, bodyStart + closeIndex)).replace(TOOL_CALL_MARKUP, ""), complete: true });
|
|
114
|
+
rest = rest.slice(bodyStart + closeIndex).replace(new RegExp(String.raw`^${open[1]}[^\n]*\n?`), "");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$dsh/state` — the one capability the model asks for without being told it exists.
|
|
3
|
+
*
|
|
4
|
+
* Three runs of a habit-tracker prompt each wrote `import { usePersistedState } from "$dsh/state"`
|
|
5
|
+
* against a module that did not exist, which does not degrade: the browser refuses the module and
|
|
6
|
+
* the card renders blank. Rewording the skill to deny it did not help — the prior survives the
|
|
7
|
+
* denial. So the module exists now, with the signature all three runs assumed.
|
|
8
|
+
*
|
|
9
|
+
* Unlike the other capabilities this needs nothing from the host: `localStorage` and React are
|
|
10
|
+
* both already there. That is also why it is worth having — the alternative the skill used to
|
|
11
|
+
* prescribe is fifteen lines of try/catch that every card rewrites and half of them skip.
|
|
12
|
+
*/
|
|
13
|
+
import * as React from "react";
|
|
14
|
+
|
|
15
|
+
/** Namespaced so two cards picking the same obvious key ("todos") do not read each other's data. */
|
|
16
|
+
const scope = (key: string) => `dsh-genui:${key}`;
|
|
17
|
+
|
|
18
|
+
function read<T>(key: string, initial: T | (() => T)): T {
|
|
19
|
+
// `initial` may be a lazy initialiser — the idiom `useState` teaches, and what a card writing
|
|
20
|
+
// `usePersistedState(KEY, loadFromSomewhere())` ends up passing by accident either way.
|
|
21
|
+
const fallback = () => (typeof initial === "function" ? (initial as () => T)() : initial);
|
|
22
|
+
try {
|
|
23
|
+
const raw = globalThis.localStorage?.getItem(scope(key));
|
|
24
|
+
return raw === null || raw === undefined ? fallback() : (JSON.parse(raw) as T);
|
|
25
|
+
} catch {
|
|
26
|
+
// Private mode, a full quota, or a value someone else wrote that is not JSON. A tracker that
|
|
27
|
+
// starts empty is worth more than one that throws during render.
|
|
28
|
+
return fallback();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `useState`, except the value survives a reload — and, more often, survives the remount that
|
|
34
|
+
* every canvas revision and every inline transcript re-render causes.
|
|
35
|
+
*/
|
|
36
|
+
export function usePersistedState<T>(key: string, initial: T | (() => T)): [T, React.Dispatch<React.SetStateAction<T>>] {
|
|
37
|
+
const [value, setValue] = React.useState<T>(() => read(key, initial));
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
try {
|
|
40
|
+
globalThis.localStorage?.setItem(scope(key), JSON.stringify(value));
|
|
41
|
+
} catch {
|
|
42
|
+
// Quota or private mode. The card keeps working in memory; failing the write must not
|
|
43
|
+
// fail the render.
|
|
44
|
+
}
|
|
45
|
+
}, [key, value]);
|
|
46
|
+
return [value, setValue];
|
|
47
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { presetWind4 } from "@unocss/preset-wind4";
|
|
2
|
+
import type { UserConfig } from "@unocss/core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Two things the host forces on this config, both non-negotiable:
|
|
6
|
+
*
|
|
7
|
+
* `important` receives a SELECTOR STRING, which is how UnoCSS scopes: every rule comes out
|
|
8
|
+
* `.ui4a-root :is(.gap-4){…}`. The runtime sheet is appended to `<head>` last, so an unscoped
|
|
9
|
+
* `hidden` written by a card would win over the shell's own `hidden` and make part of the app
|
|
10
|
+
* vanish. The playground has that bug on record (a sidebar disappearing); we start scoped.
|
|
11
|
+
*
|
|
12
|
+
* `preflights: { reset: false }` drops presetWind4's global reset — 3.5KB of `*, ::before,
|
|
13
|
+
* ::after { margin: 0; border: 0 solid }` that would land on the HOST's DOM, not just ours.
|
|
14
|
+
* The `theme` layer survives it and is the part we need: `--spacing` and `--radius-*`, which
|
|
15
|
+
* every `gap-*` and `rounded-*` resolves against. Without preflights entirely those rules
|
|
16
|
+
* generate but compute to nothing.
|
|
17
|
+
*/
|
|
18
|
+
export const unoConfig = (scope: string): UserConfig => ({
|
|
19
|
+
presets: [presetWind4({ important: scope, preflights: { reset: false } })],
|
|
20
|
+
// The reset above is dropped because it targets `*` and would land on the HOST's DOM. But
|
|
21
|
+
// dropping it leaves form controls carrying the browser's own chrome, and that is not
|
|
22
|
+
// theme-aware: measured on a card in dark mode, two unselected `<button>`s rendered as light
|
|
23
|
+
// grey blocks with black text, because a button with no background class falls back to the UA's
|
|
24
|
+
// `buttonface`. The card looked right in light and broken in dark, which is the failure this
|
|
25
|
+
// whole colour system exists to prevent.
|
|
26
|
+
//
|
|
27
|
+
// So: the same normalisation, scoped to our root.
|
|
28
|
+
//
|
|
29
|
+
// `box-sizing: border-box` is in here for the same reason, and I left it out at first on the
|
|
30
|
+
// theory that cards set their own sizing. They cannot: `w-full` is `width: 100%`, and under the
|
|
31
|
+
// UA's `content-box` that 100% is the content alone, so every `<input className="w-full px-3
|
|
32
|
+
// border">` is padding-plus-border wider than its parent. Measured on wave 2 — every card with a
|
|
33
|
+
// text field overflowed its own edge by 10px at 320, 440 AND 720, which is the tell that it was
|
|
34
|
+
// never a breakpoint problem. The clip in a screenshot is taken at the card width, so the
|
|
35
|
+
// overflowing strip is not cut off, it is absent.
|
|
36
|
+
preflights: [
|
|
37
|
+
{
|
|
38
|
+
// Same class of bug as box-sizing: a card writes `<ul className="grid gap-1.5">`, which is
|
|
39
|
+
// correct — a grid list has no use for UA markers — and dropping the reset put them back, so
|
|
40
|
+
// a column of stray bullets sits OUTSIDE the card border at every width. Not something to
|
|
41
|
+
// ask the model to write `list-none` for on every list.
|
|
42
|
+
getCSS: () => `${scope} *, ${scope} *::before, ${scope} *::after { box-sizing: border-box; }
|
|
43
|
+
${scope} ul, ${scope} ol { list-style: none; margin: 0; padding: 0; }
|
|
44
|
+
${scope} button, ${scope} input, ${scope} select, ${scope} textarea {
|
|
45
|
+
background: transparent; color: inherit; font: inherit; border: 0 solid; cursor: pointer;
|
|
46
|
+
}
|
|
47
|
+
${scope} input, ${scope} select, ${scope} textarea { cursor: auto; }`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
theme: {
|
|
51
|
+
colors: {
|
|
52
|
+
// The host's 12 semantic tokens, under names short enough to write in a class.
|
|
53
|
+
// A card can still reach any variable through an arbitrary value: `bg-[var(--dsw-…)]`.
|
|
54
|
+
// NOT `base`: `text-base` is Wind4's body font size, and a colour of that name wins the
|
|
55
|
+
// token, so `<h2 className="text-base">` computed `color: #ffffff` on a white card — present,
|
|
56
|
+
// laid out, invisible, and no probe can see it. 18 corpus cards wrote `text-base`.
|
|
57
|
+
page: "var(--dsw-alias-bg-base)",
|
|
58
|
+
layer: "var(--dsw-alias-bg-layer-1)",
|
|
59
|
+
"layer-2": "var(--dsw-alias-bg-layer-2)",
|
|
60
|
+
line: "var(--dsw-alias-border-l1)",
|
|
61
|
+
"line-2": "var(--dsw-alias-border-l2)",
|
|
62
|
+
label: "var(--dsw-alias-label-primary)",
|
|
63
|
+
muted: "var(--dsw-alias-label-secondary)",
|
|
64
|
+
accent: "var(--dsw-alias-state-business-primary)",
|
|
65
|
+
hover: "var(--dsw-alias-interactive-bg-hover)",
|
|
66
|
+
danger: "var(--dsw-alias-state-error-primary)",
|
|
67
|
+
success: "var(--dsw-alias-state-success-primary)",
|
|
68
|
+
warn: "var(--dsw-alias-state-warn-primary)",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createGenerator, type UnoGenerator } from "@unocss/core";
|
|
2
|
+
import { unoConfig } from "./uno-config.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runtime UnoCSS for generated cards.
|
|
6
|
+
*
|
|
7
|
+
* A build-time pass would scan OUR source, and the classes a card is written with do not exist
|
|
8
|
+
* there — they are typed by the model seconds ago. Responsive is where that shows worst: not one
|
|
9
|
+
* `@container` breakpoint would be generated, so every card would be single-column at any width.
|
|
10
|
+
* The CSS therefore has to be produced in the browser, as the code streams in.
|
|
11
|
+
*
|
|
12
|
+
* Accumulate rather than replace: several cards share one document, and each one's classes must
|
|
13
|
+
* stay in the sheet after another card is added.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The class every generated rule is prefixed with, and the one the surface carries.
|
|
17
|
+
*
|
|
18
|
+
* Named after the contract rather than after this plugin: the same class exists in
|
|
19
|
+
* `ui4a-playground` under the same constant name, so the two runtimes can be diffed line for
|
|
20
|
+
* line. It is also the only marker on the surface node — a second `data-*` hook naming the same
|
|
21
|
+
* thing was removed because nothing read it.
|
|
22
|
+
*/
|
|
23
|
+
export const UI4A_ROOT_CLASS = "ui4a-root";
|
|
24
|
+
const PLUGIN_ID = "dsh-generative-ui";
|
|
25
|
+
|
|
26
|
+
let generator: Promise<UnoGenerator> | null = null;
|
|
27
|
+
const tokens = new Set<string>();
|
|
28
|
+
let sheet: HTMLStyleElement | null = null;
|
|
29
|
+
/** Whether the sheet already carries the preflight block. Reset with the sheet, not with the tokens. */
|
|
30
|
+
let preflighted = false;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per frame this does the two cheap things only: EXTRACT the class names out of the code
|
|
34
|
+
* (no CSS generated), and generate CSS for the ones not seen before.
|
|
35
|
+
*
|
|
36
|
+
* The expensive spellings, both measured in the playground this is ported from:
|
|
37
|
+
* `uno.generate(code)` regenerates every class in the file each time — 119s of main thread over
|
|
38
|
+
* one streaming canvas; and regenerating the whole accumulated token set on each new class costs
|
|
39
|
+
* more the longer the file gets. Throttling does not help when a single call is what is
|
|
40
|
+
* expensive.
|
|
41
|
+
*
|
|
42
|
+
* Appended rules sort after existing ones, so two same-priority utilities can resolve differently
|
|
43
|
+
* than a single authoritative pass would. Once the stream settles we regenerate the whole set to
|
|
44
|
+
* restore that order.
|
|
45
|
+
*/
|
|
46
|
+
export async function ensureUnoStyles(code: string, streaming = false): Promise<void> {
|
|
47
|
+
if (typeof document === "undefined") return;
|
|
48
|
+
const uno = await (generator ??= createGenerator(unoConfig(`.${UI4A_ROOT_CLASS}`)));
|
|
49
|
+
const extracted = await uno.applyExtractors(code);
|
|
50
|
+
const fresh = [...extracted].filter((token) => !tokens.has(token));
|
|
51
|
+
if (fresh.length === 0 && streaming && preflighted) return;
|
|
52
|
+
for (const token of fresh) tokens.add(token);
|
|
53
|
+
sheet ??= createSheet();
|
|
54
|
+
if (streaming) {
|
|
55
|
+
// Preflights FIRST, on the very first streaming frame, and never again. They were reachable
|
|
56
|
+
// only through the settled path, so for the whole of a stream the card rendered without
|
|
57
|
+
// `box-sizing: border-box` and without the button/list reset — measured on a first card in a
|
|
58
|
+
// fresh page: 31 seconds of streaming with `pre: false` and a painting card whose `<input>`
|
|
59
|
+
// computed `content-box`. That is not "unstyled until it settles", it is *wrongly* styled
|
|
60
|
+
// while the reader watches: a `w-full px-3 border` input is padding-plus-border wider than
|
|
61
|
+
// its parent, so the layout is visibly broken and then jumps when the stream ends.
|
|
62
|
+
//
|
|
63
|
+
// They are a fixed block that depends on nothing the model types, so there is no reason for
|
|
64
|
+
// them to wait for anything, and generating them once costs a single call.
|
|
65
|
+
if (!preflighted) {
|
|
66
|
+
preflighted = true;
|
|
67
|
+
const { css } = await uno.generate([], { preflights: true });
|
|
68
|
+
sheet.textContent += splitVendorRules(css);
|
|
69
|
+
}
|
|
70
|
+
if (fresh.length === 0) return;
|
|
71
|
+
const { css } = await uno.generate(fresh, { preflights: false });
|
|
72
|
+
sheet.textContent += splitVendorRules(css);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
preflighted = true;
|
|
76
|
+
const { css } = await uno.generate(tokens, { preflights: true });
|
|
77
|
+
sheet.textContent = splitVendorRules(css);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Split a rule whose selector list mixes vendor pseudo-elements into one rule per vendor.
|
|
82
|
+
*
|
|
83
|
+
* UnoCSS merges selectors that share a declaration, so a card styling a slider for both engines
|
|
84
|
+
* gets `…::-moz-range-thumb, …::-webkit-slider-thumb { height: … }` as ONE rule — and Chromium
|
|
85
|
+
* drops the whole rule because it does not recognise the `-moz-` half. Measured: the browser
|
|
86
|
+
* parsed 75 of the 87 rules in a real card's sheet, the slider came out `height: 0px`, and the
|
|
87
|
+
* card shipped three invisible controls. Order does not matter and neither does which vendor is
|
|
88
|
+
* first; one unknown pseudo-element poisons the list.
|
|
89
|
+
*
|
|
90
|
+
* The model is doing the right thing by writing both prefixes, so the fix belongs here.
|
|
91
|
+
*/
|
|
92
|
+
export function splitVendorRules(css: string): string {
|
|
93
|
+
return css.replaceAll(/(^|\})\s*([^{}]*::-moz-[^{}]*)\{([^}]*)\}/g, (whole, lead: string, selectors: string, body: string) => {
|
|
94
|
+
const parts = selectors
|
|
95
|
+
.split(",")
|
|
96
|
+
.map((s) => s.trim())
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
if (parts.length < 2) return whole;
|
|
99
|
+
const moz = parts.filter((s) => s.includes("::-moz-"));
|
|
100
|
+
const rest = parts.filter((s) => !s.includes("::-moz-"));
|
|
101
|
+
if (moz.length === 0 || rest.length === 0) return whole;
|
|
102
|
+
return `${lead}\n${rest.join(",\n")}{${body}}\n${moz.join(",\n")}{${body}}`;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `data-plugin` is required, not decorative — see `canvas/mount.ts`: the loader claims every
|
|
108
|
+
* unmarked `<style>` for whichever plugin is materializing, and would tear this one out with it.
|
|
109
|
+
*/
|
|
110
|
+
function createSheet(): HTMLStyleElement {
|
|
111
|
+
const style = document.createElement("style");
|
|
112
|
+
style.setAttribute("data-plugin", PLUGIN_ID);
|
|
113
|
+
style.setAttribute("data-plugin-css", `${PLUGIN_ID}/uno`);
|
|
114
|
+
return document.head.appendChild(style);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Drops the sheet and the generator. HMR reloads the module; the old sheet must not survive it. */
|
|
118
|
+
export function disposeUnoStyles(): void {
|
|
119
|
+
sheet?.remove();
|
|
120
|
+
sheet = null;
|
|
121
|
+
generator = null;
|
|
122
|
+
preflighted = false;
|
|
123
|
+
tokens.clear();
|
|
124
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the current session's chat nodes.
|
|
3
|
+
*
|
|
4
|
+
* Both consumers — inline fences and canvases — need the same unwrap, and both run it on
|
|
5
|
+
* every frame while a reply streams. Sharing it keeps the guards in one place, and lets
|
|
6
|
+
* the per-node work be cached against a node's identity rather than redone per frame.
|
|
7
|
+
*/
|
|
8
|
+
import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client";
|
|
9
|
+
|
|
10
|
+
export type ChatNodeView = { readonly kind: string; readonly data: unknown; readonly anchorSeq: number };
|
|
11
|
+
|
|
12
|
+
/** The current session's chat nodes, or an empty list when no session is open. */
|
|
13
|
+
export function chatNodes(ctx: ClientContext): readonly ChatNodeView[] {
|
|
14
|
+
const sessionId = ctx.sessions.list.getSnapshot().current;
|
|
15
|
+
if (sessionId === undefined) return [];
|
|
16
|
+
const snapshot = ctx.sessions.binding(sessionId)?.session.getSnapshot();
|
|
17
|
+
if (snapshot === undefined) return [];
|
|
18
|
+
return [...snapshot.chat.nodes.values()] as readonly ChatNodeView[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Derives a value per chat node, reusing the previous result when the node has not changed.
|
|
23
|
+
*
|
|
24
|
+
* A sweep runs on every frame of a streaming reply, but only the tail node is actually
|
|
25
|
+
* growing — re-deriving finished nodes means re-scanning the whole transcript dozens of
|
|
26
|
+
* times a second, which grows with session length rather than with what changed.
|
|
27
|
+
*
|
|
28
|
+
* @param key - identity of a node's current content; equal keys must mean equal results.
|
|
29
|
+
* @param derive - the per-node work to memoize.
|
|
30
|
+
*/
|
|
31
|
+
export function perNode<T>(key: (node: ChatNodeView) => string, derive: (node: ChatNodeView) => T) {
|
|
32
|
+
let cache = new Map<string, T>();
|
|
33
|
+
return (nodes: readonly ChatNodeView[]): T[] => {
|
|
34
|
+
const next = new Map<string, T>();
|
|
35
|
+
const results: T[] = [];
|
|
36
|
+
for (const node of nodes) {
|
|
37
|
+
const id = key(node);
|
|
38
|
+
const value = cache.get(id) ?? derive(node);
|
|
39
|
+
next.set(id, value);
|
|
40
|
+
results.push(value);
|
|
41
|
+
}
|
|
42
|
+
// Replacing the map drops entries for nodes that left the loaded window.
|
|
43
|
+
cache = next;
|
|
44
|
+
return results;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset URLs shared by both halves. Kept apart from index.ts so the browser half
|
|
3
|
+
* can import them without dragging node:fs and createRequire into its bundle.
|
|
4
|
+
*/
|
|
5
|
+
export const ASSET_PREFIX = "/dsh-generative-ui/assets";
|
|
6
|
+
export const WASM_PATH = `${ASSET_PREFIX}/tsx_bg.wasm`;
|
|
7
|
+
|
|
8
|
+
/** Reads one canvas file from the session's workspace: `?cwd=<workspace>&id=<canvas>`. */
|
|
9
|
+
export const CANVAS_READ_PATH = "/dsh-generative-ui/canvas";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Streams one model call for a generated card: POST `{prompt|messages, system?}`.
|
|
13
|
+
*
|
|
14
|
+
* The host owns the credentials and the provider route, so this forwards to `ctx.llm`
|
|
15
|
+
* rather than carrying a key of its own.
|
|
16
|
+
*/
|
|
17
|
+
export const AI_STREAM_PATH = "/dsh-generative-ui/ai";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Filesystem access for a generated card: `?cwd=<workspace>&path=<path>`.
|
|
21
|
+
*
|
|
22
|
+
* GET reads (or lists, with `?list=1`), POST writes. Both go through the host's `ctx.fs`
|
|
23
|
+
* and carry the session's own sandbox policy, so what a card may do is exactly what the
|
|
24
|
+
* session may do — `read-only` denies the write at the fence rather than here.
|
|
25
|
+
*/
|
|
26
|
+
export const FS_PATH = "/dsh-generative-ui/fs";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Runs one command for a generated card: `?cwd=<workspace>&session=<id>`, POST `{command}`.
|
|
30
|
+
*
|
|
31
|
+
* Under the session's own sandbox policy, exactly as `FS_PATH` is — a read-only session gets
|
|
32
|
+
* a read-only shell rather than a different fence. Foreground only: a card that wants a
|
|
33
|
+
* long-running process wants a different product.
|
|
34
|
+
*/
|
|
35
|
+
export const EXEC_PATH = "/dsh-generative-ui/exec";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One web search for a generated card: `?cwd=<workspace>`, POST `{query, maxResults?}`.
|
|
39
|
+
*
|
|
40
|
+
* Search only. `ctx.web` also exposes `fetch`, and this deliberately does not forward it: the
|
|
41
|
+
* deployment's own `tool-web` is configured `fetch: false`, and the doc says why — *"the local
|
|
42
|
+
* backend does not block private-network targets; do not enable web_fetch where it can reach
|
|
43
|
+
* sensitive internal ones."* A card is model-written code firing on a reader's keystrokes, so
|
|
44
|
+
* re-opening from here what the host closed for its own tools is not ours to do.
|
|
45
|
+
*/
|
|
46
|
+
export const WEB_SEARCH_PATH = "/dsh-generative-ui/web-search";
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ui4a path contract — the single place that decides what counts as a canvas.
|
|
3
|
+
* Shared by both halves; never re-derive these patterns with an inline regex.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where generated files live, under the workspace's own dsh directory.
|
|
8
|
+
*
|
|
9
|
+
* `.dsh/` is the harness's project convention, not ours — `dsh-skill-filesystem` reads
|
|
10
|
+
* `join(projectRoot, ".dsh/skills")` and labels that source `project-dsh`. Sitting beside
|
|
11
|
+
* it keeps a plain `ls` of the user's repo clean and puts our files where they would look
|
|
12
|
+
* for anything dsh wrote. `ui4a` beneath it names the format, which is the honest nesting:
|
|
13
|
+
* this is a dsh plugin writing ui4a files, not a ui4a project with a dsh corner.
|
|
14
|
+
*/
|
|
15
|
+
export const UI4A_DIR = ".dsh/ui4a";
|
|
16
|
+
export const CANVAS_DIR = `${UI4A_DIR}/canvases`;
|
|
17
|
+
export const CANVAS_SUFFIX = ".ui4a.tsx";
|
|
18
|
+
/**
|
|
19
|
+
* Info string of an inline fence, as the model writes it. Slash, not dash — matches
|
|
20
|
+
* ui4a-playground. Note the host's markdown renderer truncates it at the first
|
|
21
|
+
* non-identifier character, so it reaches the DOM as `ui4a`; nothing matches on it.
|
|
22
|
+
*/
|
|
23
|
+
export const FENCE_LANG = "ui4a/tsx";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Import prefix for the capabilities the plugin lends to generated code.
|
|
27
|
+
*
|
|
28
|
+
* `$dsh/`, not `$ui4a/`: what these expose is the harness — the conversation, its model,
|
|
29
|
+
* its filesystem — and none of it is part of the ui4a rendering contract that `FENCE_LANG`
|
|
30
|
+
* and the canvas paths above define. A card written against them only runs inside dsh.
|
|
31
|
+
*/
|
|
32
|
+
export const CAPABILITY_PREFIX = "$dsh";
|
|
33
|
+
export const capabilityModule = (group: string) => `${CAPABILITY_PREFIX}/${group}`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Canvas ids are path segments, so anything that could escape the directory is not one.
|
|
37
|
+
*
|
|
38
|
+
* Stated as an exclusion rather than an allowlist: `[\w-]+` reads as "safe" but is really
|
|
39
|
+
* "ASCII", and a model answering in Chinese names the file in Chinese — `背单词.ui4a.tsx`
|
|
40
|
+
* was silently not a canvas, so the panel never opened and the reply still said it had.
|
|
41
|
+
* What actually has to be barred is separators and traversal; the rest is a filename.
|
|
42
|
+
*/
|
|
43
|
+
const CANVAS_ID = /^[^/\\.\s]+$/;
|
|
44
|
+
|
|
45
|
+
export const isCanvasId = (id: string) => CANVAS_ID.test(id);
|
|
46
|
+
|
|
47
|
+
export const canvasPath = (id: string) => `${CANVAS_DIR}/${id}${CANVAS_SUFFIX}`;
|
|
48
|
+
export const canvasChildDir = (id: string) => `${CANVAS_DIR}/${id}`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves a relative specifier written inside a canvas to a workspace path.
|
|
52
|
+
*
|
|
53
|
+
* `from` is the path the specifier was written in — the canvas file itself, or one of its
|
|
54
|
+
* children — because **a relative specifier is relative to its importer, not to the canvas
|
|
55
|
+
* root**. The entry writes `./<id>/board`; a child of that entry writes `./types` for its
|
|
56
|
+
* sibling, and resolving both against the canvases directory sends the second one nowhere.
|
|
57
|
+
* Measured on a real split: the model produced 7 files whose cross-imports are all sibling
|
|
58
|
+
* form, and every one of them resolved to null before `from` existed.
|
|
59
|
+
*
|
|
60
|
+
* Every segment goes through the same exclusion test as an id, and the result must stay
|
|
61
|
+
* inside `canvasChildDir(id)` — `..` is rejected outright rather than normalised, so there
|
|
62
|
+
* is no arithmetic that could walk out.
|
|
63
|
+
*
|
|
64
|
+
* Returns null for anything outside that shape rather than throwing: the caller is a route
|
|
65
|
+
* answering an arbitrary page, and a bad specifier is a 400, not a crash.
|
|
66
|
+
*/
|
|
67
|
+
export function canvasChildPath(id: string, specifier: string, from?: string): string | null {
|
|
68
|
+
if (!isCanvasId(id)) return null;
|
|
69
|
+
const segments = specifier.replace(/^\.\//, "").split("/");
|
|
70
|
+
if (segments.length === 0 || !segments.every(isCanvasId)) return null;
|
|
71
|
+
const root = canvasChildDir(id);
|
|
72
|
+
// No `from`, or one naming the entry file: the specifier is written beside the canvas, so
|
|
73
|
+
// it must open with the id. With a `from` inside the child directory, it is written beside
|
|
74
|
+
// that file instead and the id never appears.
|
|
75
|
+
const within = from === undefined ? null : from.replace(/\\/g, "/").split(`${root}/`)[1];
|
|
76
|
+
if (within === undefined || within === null) {
|
|
77
|
+
if (segments.length < 2 || segments[0] !== id) return null;
|
|
78
|
+
return `${root}/${segments.slice(1).join("/")}`;
|
|
79
|
+
}
|
|
80
|
+
const dir = within.split("/").slice(0, -1);
|
|
81
|
+
return [root, ...dir, ...segments].join("/");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reduces a path to its workspace-relative form.
|
|
86
|
+
*
|
|
87
|
+
* Tool arguments carry absolute paths, so the contract is matched on the trailing
|
|
88
|
+
* `ui4a/canvases/…` portion rather than anchored at the string start.
|
|
89
|
+
*/
|
|
90
|
+
const normalize = (path: string) => {
|
|
91
|
+
const at = path.replace(/\\/g, "/").lastIndexOf(`${CANVAS_DIR}/`);
|
|
92
|
+
return at === -1 ? path.replace(/^\.?\//, "") : path.slice(at);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** The canvas id of an entry path, or null when the path is not a canvas entry. */
|
|
96
|
+
export function canvasIdOf(path: string): string | null {
|
|
97
|
+
const relative = normalize(path);
|
|
98
|
+
if (!relative.startsWith(`${CANVAS_DIR}/`) || !relative.endsWith(CANVAS_SUFFIX)) return null;
|
|
99
|
+
const id = relative.slice(CANVAS_DIR.length + 1, -CANVAS_SUFFIX.length);
|
|
100
|
+
return isCanvasId(id) ? id : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The owning canvas of any path under the contract — entry file or child module. */
|
|
104
|
+
export function owningCanvasIdOf(path: string): string | null {
|
|
105
|
+
const direct = canvasIdOf(path);
|
|
106
|
+
if (direct !== null) return direct;
|
|
107
|
+
const relative = normalize(path);
|
|
108
|
+
if (!relative.startsWith(`${CANVAS_DIR}/`)) return null;
|
|
109
|
+
const id = relative.slice(CANVAS_DIR.length + 1).split("/")[0];
|
|
110
|
+
return id !== undefined && isCanvasId(id) ? id : null;
|
|
111
|
+
}
|