@uicast/core 0.0.1-beta.1
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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +52 -0
- package/src/def/create-component-definition.ts +21 -0
- package/src/entry-error.ts +104 -0
- package/src/expr/analyze.ts +190 -0
- package/src/expr/ast-utils.ts +28 -0
- package/src/expr/evaluate.ts +74 -0
- package/src/expr/extract-deps.ts +32 -0
- package/src/expr/globals.ts +118 -0
- package/src/expr/safer-eval.ts +233 -0
- package/src/expr/validate.ts +149 -0
- package/src/index.ts +28 -0
- package/src/prompt/get-common-instructions-partial-prompt.ts +20 -0
- package/src/prompt/get-components-partial-prompt.ts +153 -0
- package/src/prompt/get-edit-request-prompt.ts +37 -0
- package/src/prompt/get-error-recovery-prompt.ts +37 -0
- package/src/prompt/get-expressions-partial-prompt.ts +33 -0
- package/src/prompt/get-functions-partial-prompt.ts +62 -0
- package/src/prompt/get-scope-partial-prompt.ts +57 -0
- package/src/prompt/index.ts +32 -0
- package/src/prompt/md/EXPRESSIONS.json +1 -0
- package/src/prompt/md/EXPRESSIONS.md +29 -0
- package/src/prompt/md/INSTRUCTIONS.json +1 -0
- package/src/prompt/md/INSTRUCTIONS.md +172 -0
- package/src/prompt-utils/json-schema-to-ts.ts +225 -0
- package/src/scope/create-proxy-scope.ts +131 -0
- package/src/scope/parse-scope.ts +36 -0
- package/src/stream/stream-json-lines.ts +56 -0
- package/src/types.ts +124 -0
- package/src/utils/utils.ts +64 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import * as acorn from "acorn";
|
|
2
|
+
import {
|
|
3
|
+
containsAwait,
|
|
4
|
+
extractFreeIdentifiers,
|
|
5
|
+
extractScopeReads,
|
|
6
|
+
} from "./analyze";
|
|
7
|
+
import { GLOBALS_TO_SHADOW } from "./globals";
|
|
8
|
+
import { SaferEvalError, validateNode } from "./validate";
|
|
9
|
+
|
|
10
|
+
// Compiles and runs the expressions validate.ts approves: parse + analyse once
|
|
11
|
+
// (cached), compile to a Function (AsyncFunction when the expr uses `await`),
|
|
12
|
+
// shadow ambient globals as undefined params, run in-realm so Proxy state works.
|
|
13
|
+
// Guardrail, not a sandbox — see the Expressions docs page, § Safety.
|
|
14
|
+
|
|
15
|
+
// Re-exported from validate.ts for callers of this module.
|
|
16
|
+
export { SaferEvalError };
|
|
17
|
+
|
|
18
|
+
// The AsyncFunction constructor, for expressions that use `await`.
|
|
19
|
+
const AsyncFunction = Object.getPrototypeOf(async () => {})
|
|
20
|
+
.constructor as typeof Function;
|
|
21
|
+
|
|
22
|
+
type CacheEntry = {
|
|
23
|
+
isAsync: boolean;
|
|
24
|
+
scopeReads: string[];
|
|
25
|
+
freeIds: string[];
|
|
26
|
+
// Built lazily on first compile(); validate()/scopeReads() leave it unset.
|
|
27
|
+
fn?: (context: Record<string, unknown>) => unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class SaferEval {
|
|
31
|
+
#cache = new Map<string, CacheEntry>();
|
|
32
|
+
#maxCacheSize: number;
|
|
33
|
+
#shadowParams: string[];
|
|
34
|
+
#allowGlobals: Set<string>;
|
|
35
|
+
#enforceAllowlist: boolean;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
options: {
|
|
39
|
+
maxCacheSize?: number;
|
|
40
|
+
extraGlobalsToShadow?: string[];
|
|
41
|
+
allowGlobals?: string[];
|
|
42
|
+
enforceAllowlist?: boolean;
|
|
43
|
+
} = {},
|
|
44
|
+
) {
|
|
45
|
+
const {
|
|
46
|
+
maxCacheSize = 500,
|
|
47
|
+
extraGlobalsToShadow = [],
|
|
48
|
+
allowGlobals = [],
|
|
49
|
+
enforceAllowlist = false,
|
|
50
|
+
} = options;
|
|
51
|
+
|
|
52
|
+
this.#maxCacheSize = maxCacheSize;
|
|
53
|
+
this.#enforceAllowlist = enforceAllowlist;
|
|
54
|
+
|
|
55
|
+
const allowSet = new Set(allowGlobals);
|
|
56
|
+
this.#allowGlobals = allowSet;
|
|
57
|
+
this.#shadowParams = [...GLOBALS_TO_SHADOW, ...extraGlobalsToShadow].filter(
|
|
58
|
+
(g) => !allowSet.has(g),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Parse + validate without running. Throws SaferEvalError if invalid; returns
|
|
63
|
+
// isAsync + scopeReads and caches the analysis (compile() builds the fn later).
|
|
64
|
+
validate(expression: string): {
|
|
65
|
+
isAsync: boolean;
|
|
66
|
+
scopeReads: string[];
|
|
67
|
+
freeIds: string[];
|
|
68
|
+
} {
|
|
69
|
+
if (typeof expression !== "string") {
|
|
70
|
+
throw new SaferEvalError("Expression must be a string", "expression-syntax");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const trimmed = expression.trim();
|
|
74
|
+
if (!trimmed) {
|
|
75
|
+
throw new SaferEvalError("Expression cannot be empty", "expression-syntax");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cached = this.#cache.get(expression);
|
|
79
|
+
if (cached) {
|
|
80
|
+
return {
|
|
81
|
+
isAsync: cached.isAsync,
|
|
82
|
+
scopeReads: cached.scopeReads,
|
|
83
|
+
freeIds: cached.freeIds,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Parse as `"use strict"; void (expr)`: void(...) forces expression context
|
|
88
|
+
// (so `{...}` is an object literal, not a block), and strict mode rejects
|
|
89
|
+
// sloppy-only constructs (octal, `with`, dup params) here, not at compile.
|
|
90
|
+
const wrapper = `"use strict"; void (${trimmed})`;
|
|
91
|
+
let ast: acorn.Program;
|
|
92
|
+
try {
|
|
93
|
+
ast = acorn.parse(wrapper, {
|
|
94
|
+
ecmaVersion: 2022,
|
|
95
|
+
sourceType: "script",
|
|
96
|
+
allowAwaitOutsideFunction: true,
|
|
97
|
+
});
|
|
98
|
+
} catch (e: unknown) {
|
|
99
|
+
throw new SaferEvalError(
|
|
100
|
+
`Syntax error: ${e instanceof Error ? e.message : String(e)}. Expression: ${expression}`,
|
|
101
|
+
"expression-syntax",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
validateNode(ast);
|
|
106
|
+
|
|
107
|
+
const analysis = {
|
|
108
|
+
isAsync: containsAwait(ast),
|
|
109
|
+
scopeReads: extractScopeReads(ast),
|
|
110
|
+
freeIds: extractFreeIdentifiers(ast),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Cache the analysis (no fn yet); evict oldest when full.
|
|
114
|
+
if (this.#cache.size >= this.#maxCacheSize) {
|
|
115
|
+
const firstKey = this.#cache.keys().next().value;
|
|
116
|
+
if (firstKey !== undefined) this.#cache.delete(firstKey);
|
|
117
|
+
}
|
|
118
|
+
this.#cache.set(expression, { ...analysis });
|
|
119
|
+
|
|
120
|
+
return analysis;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Compile to a reusable fn (returns a Promise if the expr uses `await`).
|
|
124
|
+
compile(expression: string): (context: Record<string, unknown>) => unknown {
|
|
125
|
+
const { isAsync } = this.validate(expression);
|
|
126
|
+
const entry = this.#cache.get(expression);
|
|
127
|
+
if (!entry) {
|
|
128
|
+
// Unreachable — validate() just inserted this entry.
|
|
129
|
+
throw new SaferEvalError(
|
|
130
|
+
`Cache entry missing for expression: ${expression}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (entry.fn) return entry.fn;
|
|
134
|
+
|
|
135
|
+
const expr = expression.trim();
|
|
136
|
+
const Ctor = isAsync ? AsyncFunction : Function;
|
|
137
|
+
|
|
138
|
+
// Single-slot cache: new Ctor bakes in the param names (context keys ∪ shadow
|
|
139
|
+
// params), so it recompiles only if the context shape changes — which it
|
|
140
|
+
// doesn't in practice (a given expr always sees the same keys).
|
|
141
|
+
let cachedSig: string | undefined;
|
|
142
|
+
let cachedFn: ((...args: unknown[]) => unknown) | undefined;
|
|
143
|
+
|
|
144
|
+
const evaluator = (context: Record<string, unknown>) => {
|
|
145
|
+
const contextKeys = Object.keys(context);
|
|
146
|
+
const sig = contextKeys.join("\u0000");
|
|
147
|
+
if (sig !== cachedSig) {
|
|
148
|
+
// Context keys become params; shadow params follow, bound to undefined.
|
|
149
|
+
const allParams = [...contextKeys, ...this.#shadowParams];
|
|
150
|
+
try {
|
|
151
|
+
cachedFn = new Ctor(
|
|
152
|
+
...allParams,
|
|
153
|
+
`"use strict"; return (${expr})`,
|
|
154
|
+
) as (...args: unknown[]) => unknown;
|
|
155
|
+
} catch (e: unknown) {
|
|
156
|
+
// Parsed but won't compile — surface as SaferEvalError, not a raw one.
|
|
157
|
+
throw new SaferEvalError(
|
|
158
|
+
`Failed to compile expression: ${
|
|
159
|
+
e instanceof Error ? e.message : String(e)
|
|
160
|
+
}. Expression: ${expression}`,
|
|
161
|
+
"expression-syntax",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
cachedSig = sig;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Args match allParams: context values (Object.keys order) then shadow fills.
|
|
168
|
+
const allArgs = [
|
|
169
|
+
...Object.values(context),
|
|
170
|
+
...new Array(this.#shadowParams.length).fill(undefined),
|
|
171
|
+
];
|
|
172
|
+
return (cachedFn as (...args: unknown[]) => unknown)(...allArgs);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
entry.fn = evaluator;
|
|
176
|
+
return evaluator;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
isAsync(expression: string): boolean {
|
|
180
|
+
const cached = this.#cache.get(expression);
|
|
181
|
+
if (cached) return cached.isAsync;
|
|
182
|
+
return this.validate(expression).isAsync;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Every `scopes.X.Y` path the expression reads — the renderer auto-subscribes
|
|
186
|
+
// to these instead of the LLM writing a deps array.
|
|
187
|
+
scopeReads(expression: string): string[] {
|
|
188
|
+
const cached = this.#cache.get(expression);
|
|
189
|
+
if (cached) return cached.scopeReads;
|
|
190
|
+
return this.validate(expression).scopeReads;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Allowlist gate: every free identifier must be an injected context name, a
|
|
194
|
+
// base allowed global, or one the host opted into via allowedGlobals.
|
|
195
|
+
#checkAllowlist(
|
|
196
|
+
freeIds: string[],
|
|
197
|
+
contextKeys: string[],
|
|
198
|
+
allowedGlobals: string[],
|
|
199
|
+
): void {
|
|
200
|
+
const allowed = new Set([
|
|
201
|
+
...contextKeys,
|
|
202
|
+
...this.#allowGlobals,
|
|
203
|
+
...allowedGlobals,
|
|
204
|
+
]);
|
|
205
|
+
for (const id of freeIds) {
|
|
206
|
+
if (!allowed.has(id)) {
|
|
207
|
+
// The expression named something that doesn't exist here — an
|
|
208
|
+
// unregistered function or a global outside the allowlist.
|
|
209
|
+
throw new SaferEvalError(
|
|
210
|
+
`"${id}" is not available in expressions`,
|
|
211
|
+
"unknown-reference",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
eval(
|
|
218
|
+
expression: string,
|
|
219
|
+
context: Record<string, unknown> = {},
|
|
220
|
+
allowedGlobals: string[] = [],
|
|
221
|
+
): unknown {
|
|
222
|
+
if (this.#enforceAllowlist) {
|
|
223
|
+
const { freeIds } = this.validate(expression);
|
|
224
|
+
this.#checkAllowlist(freeIds, Object.keys(context), allowedGlobals);
|
|
225
|
+
}
|
|
226
|
+
const evaluator = this.compile(expression);
|
|
227
|
+
return evaluator(context);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
clearCache(): void {
|
|
231
|
+
this.#cache.clear();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type * as acorn from "acorn";
|
|
2
|
+
import { isNode } from "./ast-utils";
|
|
3
|
+
|
|
4
|
+
// Expression validation — the guardrail. Walks a parsed expression and throws on
|
|
5
|
+
// anything that shouldn't run; safer-eval.ts compiles only what this approves.
|
|
6
|
+
// Guardrail, NOT a sandbox: a computed key (`obj["con"+"structor"]`) escapes it.
|
|
7
|
+
// Full threat model: the Expressions docs page, § Safety.
|
|
8
|
+
|
|
9
|
+
// Which EntryError reason an evaluation failure maps to. Defaults to
|
|
10
|
+
// "sandbox-violation" (every policy rejection in this file); the parse and
|
|
11
|
+
// allowlist sites in safer-eval.ts override it.
|
|
12
|
+
export type SaferEvalErrorReason =
|
|
13
|
+
| "expression-syntax"
|
|
14
|
+
| "sandbox-violation"
|
|
15
|
+
| "unknown-reference";
|
|
16
|
+
|
|
17
|
+
export class SaferEvalError extends Error {
|
|
18
|
+
readonly reason: SaferEvalErrorReason;
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
reason: SaferEvalErrorReason = "sandbox-violation",
|
|
23
|
+
) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "SaferEvalError";
|
|
26
|
+
this.reason = reason;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// `eval` / `arguments` can't be shadowed as strict-mode params, so reject them as
|
|
31
|
+
// references here instead (the rest are shadowed — see GLOBALS_TO_SHADOW).
|
|
32
|
+
const FORBIDDEN_IDENTIFIERS = new Set(["eval", "arguments"]);
|
|
33
|
+
|
|
34
|
+
// Most dangerous syntax never reaches this walk: every expression is parsed as
|
|
35
|
+
// `"use strict"; void (EXPR)`, so a top-level statement / declaration / `with` /
|
|
36
|
+
// octal / import is a syntax error. What survives, to reject by hand, is below.
|
|
37
|
+
|
|
38
|
+
// Forbidden everywhere, even in a function body: dynamic `import()` loads code,
|
|
39
|
+
// and `import.meta` / `new.target` (MetaProperty) reach host / constructor
|
|
40
|
+
// internals. Keywords, so shadowing can't touch them — block structurally.
|
|
41
|
+
const FORBIDDEN_EXPRESSIONS = new Set(["ImportExpression", "MetaProperty"]);
|
|
42
|
+
|
|
43
|
+
// Forbidden as the whole expression (which must be one pure value); fine inside a
|
|
44
|
+
// callback body where imperative code is allowed.
|
|
45
|
+
const BODY_ONLY_EXPRESSIONS = new Set([
|
|
46
|
+
"AssignmentExpression", // x = 1, x += 1
|
|
47
|
+
"UpdateExpression", // x++, --x
|
|
48
|
+
"SequenceExpression", // a, b — comma operator
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// Escape-hatch property names: `.constructor` reaches the Function constructor,
|
|
52
|
+
// `.__proto__` the prototype chain. Only static keys are caught; a computed key
|
|
53
|
+
// escapes (the guardrail gap — banning it would ban all `arr[i]` access).
|
|
54
|
+
const FORBIDDEN_PROPERTIES = new Set([
|
|
55
|
+
"constructor",
|
|
56
|
+
"__proto__",
|
|
57
|
+
"prototype",
|
|
58
|
+
"__defineGetter__",
|
|
59
|
+
"__defineSetter__",
|
|
60
|
+
"__lookupGetter__",
|
|
61
|
+
"__lookupSetter__",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
// Recursively walk the parsed expression, throwing on anything disallowed.
|
|
65
|
+
// insideFunctionBody flips once inside an arrow / function body, where the
|
|
66
|
+
// BODY_ONLY_EXPRESSIONS become legal. Entry point is acorn's Program node.
|
|
67
|
+
export function validateNode(
|
|
68
|
+
node: acorn.AnyNode | null | undefined,
|
|
69
|
+
insideFunctionBody = false,
|
|
70
|
+
): void {
|
|
71
|
+
if (!node || typeof node !== "object") return;
|
|
72
|
+
|
|
73
|
+
// The wrapped program must hold exactly one expression (the filter drops the
|
|
74
|
+
// "use strict" directive). More than one means the expr smuggled a `)` to break
|
|
75
|
+
// out of `void (...)` — e.g. `1); evil(); (2`.
|
|
76
|
+
if (node.type === "Program") {
|
|
77
|
+
const statements = node.body.filter(
|
|
78
|
+
(s) => !(s.type === "ExpressionStatement" && s.directive),
|
|
79
|
+
);
|
|
80
|
+
if (statements.length !== 1) {
|
|
81
|
+
throw new SaferEvalError(
|
|
82
|
+
`Expression must be a single expression, got ${statements.length} statements`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
validateNode(statements[0]);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Dynamic import / meta-properties: never allowed, not even in a body.
|
|
90
|
+
if (FORBIDDEN_EXPRESSIONS.has(node.type)) {
|
|
91
|
+
throw new SaferEvalError(`"${node.type}" is not allowed in expressions`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Assignment / update / comma: allowed in a callback body, not as the whole expr.
|
|
95
|
+
if (BODY_ONLY_EXPRESSIONS.has(node.type) && !insideFunctionBody) {
|
|
96
|
+
throw new SaferEvalError(
|
|
97
|
+
`"${node.type}" is only allowed inside a function body, not as the whole expression`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Tagged templates invoke an arbitrary tag function.
|
|
102
|
+
if (node.type === "TaggedTemplateExpression") {
|
|
103
|
+
throw new SaferEvalError("Tagged template literals are not allowed");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Static access to escape-hatch properties; dynamic keys slip past.
|
|
107
|
+
if (node.type === "MemberExpression") {
|
|
108
|
+
const { property, computed } = node;
|
|
109
|
+
const staticKey =
|
|
110
|
+
!computed && property.type === "Identifier"
|
|
111
|
+
? property.name
|
|
112
|
+
: computed &&
|
|
113
|
+
property.type === "Literal" &&
|
|
114
|
+
typeof property.value === "string"
|
|
115
|
+
? property.value
|
|
116
|
+
: null;
|
|
117
|
+
if (staticKey !== null && FORBIDDEN_PROPERTIES.has(staticKey)) {
|
|
118
|
+
throw new SaferEvalError(`Access to "${staticKey}" is not allowed`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// `eval` / `arguments` in reference position (the recursion below skips a
|
|
123
|
+
// non-computed member's `.property`, so a harmless `row.arguments` isn't hit).
|
|
124
|
+
if (node.type === "Identifier" && FORBIDDEN_IDENTIFIERS.has(node.name)) {
|
|
125
|
+
throw new SaferEvalError(`Access to "${node.name}" is not allowed`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Recurse. Two carries: descending into a function `body` sets
|
|
129
|
+
// insideFunctionBody for the subtree; a non-computed member's `.property` is a
|
|
130
|
+
// name already checked, so skip it (a computed `obj[eval]` is not skipped).
|
|
131
|
+
const fields = node as unknown as Record<string, unknown>;
|
|
132
|
+
const isFunction =
|
|
133
|
+
node.type === "ArrowFunctionExpression" ||
|
|
134
|
+
node.type === "FunctionExpression";
|
|
135
|
+
for (const key of Object.keys(node)) {
|
|
136
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
137
|
+
if (node.type === "MemberExpression" && !node.computed && key === "property")
|
|
138
|
+
continue;
|
|
139
|
+
const child = fields[key];
|
|
140
|
+
const childInside = insideFunctionBody || (isFunction && key === "body");
|
|
141
|
+
if (Array.isArray(child)) {
|
|
142
|
+
for (const c of child) {
|
|
143
|
+
if (isNode(c)) validateNode(c, childInside);
|
|
144
|
+
}
|
|
145
|
+
} else if (isNode(child)) {
|
|
146
|
+
validateNode(child, childInside);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// uicast public API. Prompt-partial builders live in uicast/prompt.
|
|
2
|
+
|
|
3
|
+
export { createComponentDefinition } from "./def/create-component-definition";
|
|
4
|
+
|
|
5
|
+
export { evaluate } from "./expr/evaluate";
|
|
6
|
+
export { extractDeps } from "./expr/extract-deps";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
EntryError,
|
|
10
|
+
type EntryErrorReason,
|
|
11
|
+
type EntryFault,
|
|
12
|
+
} from "./entry-error";
|
|
13
|
+
|
|
14
|
+
export { createProxyScope, type ReactiveProxy } from "./scope/create-proxy-scope";
|
|
15
|
+
export { parseScope } from "./scope/parse-scope";
|
|
16
|
+
|
|
17
|
+
export { buildElementsById } from "./utils/utils";
|
|
18
|
+
|
|
19
|
+
export { streamJsonLines } from "./stream/stream-json-lines";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
type ComponentEntry,
|
|
23
|
+
type ComponentListEntry,
|
|
24
|
+
type ComponentDefinition,
|
|
25
|
+
type CombinedSpec,
|
|
26
|
+
isComponentEntry,
|
|
27
|
+
isComponentListEntry,
|
|
28
|
+
} from "./types";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import INSTRUCTIONS from "./md/INSTRUCTIONS.json" with { type: "json" };
|
|
2
|
+
|
|
3
|
+
/** No options yet — the type is reserved so the signature can grow without breaking callers. */
|
|
4
|
+
export type CommonInstructionsPromptOptions = Record<string, never>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The LLM-facing instruction block — the contract authored in
|
|
8
|
+
* `md/INSTRUCTIONS.md` and imported here as a JSON string via the md-to-json
|
|
9
|
+
* pipeline (`npm run md-to-json` regenerates the `.json` sibling after edits).
|
|
10
|
+
*
|
|
11
|
+
* A partial-prompt primitive: the consuming app's prompt assembler composes
|
|
12
|
+
* this — alongside the other `get*PartialPrompt` builders — into the full
|
|
13
|
+
* system prompt it sends to the generation LLM. core ships the pieces; the app
|
|
14
|
+
* owns the assembly.
|
|
15
|
+
*/
|
|
16
|
+
export function getCommonInstructionsPartialPrompt(
|
|
17
|
+
_options: CommonInstructionsPromptOptions = {},
|
|
18
|
+
): string {
|
|
19
|
+
return INSTRUCTIONS;
|
|
20
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { type JSONSchema, JSONSchemaToTs } from "../prompt-utils/json-schema-to-ts";
|
|
2
|
+
import type { CombinedSpec, ComponentDefinition } from "../types";
|
|
3
|
+
|
|
4
|
+
const toJSONSchema = (spec: CombinedSpec): unknown =>
|
|
5
|
+
spec["~standard"].jsonSchema.input({ target: "draft-2020-12" });
|
|
6
|
+
|
|
7
|
+
const readId = (jsonSchema: unknown): string | undefined =>
|
|
8
|
+
(jsonSchema as { $id?: string } | null)?.$id;
|
|
9
|
+
|
|
10
|
+
export type ComponentsPromptOptions = {
|
|
11
|
+
/** The component defs to advertise — the catalog's `allDefinitions` (plus any app-local defs). */
|
|
12
|
+
definitions: ComponentDefinition[];
|
|
13
|
+
/** Shared event schemas (each with a JSON Schema `$id`), rendered once under `# Common Events`. */
|
|
14
|
+
commonEvents?: CombinedSpec[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render an object schema's properties as an indented, described bullet list:
|
|
19
|
+
*
|
|
20
|
+
* - count?: number — The number to display
|
|
21
|
+
*
|
|
22
|
+
* Each field's type comes from `JSONSchemaToTs`; its `description` (when set)
|
|
23
|
+
* follows after an em-dash. A non-object schema, or one without properties,
|
|
24
|
+
* yields `[]`. Shared by a component's props and a typed event's options — both
|
|
25
|
+
* are "object schema → described fields".
|
|
26
|
+
*/
|
|
27
|
+
const describeFields = (jsonSchema: unknown, indent: string): string[] => {
|
|
28
|
+
const schema = jsonSchema as JSONSchema | null;
|
|
29
|
+
if (!schema?.properties) return [];
|
|
30
|
+
const required = schema.required ?? [];
|
|
31
|
+
return Object.entries(schema.properties).map(([name, field]) => {
|
|
32
|
+
const optional = required.includes(name) ? "" : "?";
|
|
33
|
+
const description = field.description;
|
|
34
|
+
return `${indent}- ${name}${optional}: ${JSONSchemaToTs(field)}${
|
|
35
|
+
description ? ` — ${description}` : ""
|
|
36
|
+
}`;
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Render an array of component defs into the prompt's component section — a
|
|
42
|
+
* `# Available Components` names list, an optional `# Common Events` block, then
|
|
43
|
+
* `# Component Details`, one entry per visible def: its name + description, a
|
|
44
|
+
* `Props:` list (each prop's type + description) and an `Event handlers:` list
|
|
45
|
+
* (each handler's signature + description, a typed event's options described one
|
|
46
|
+
* level deeper). Types come from `JSONSchemaToTs`; every description rides after
|
|
47
|
+
* an em-dash, so nothing the author wrote on a prop, option, or handler is lost.
|
|
48
|
+
*
|
|
49
|
+
* `commonEvents` hoists handlers shared across many components (e.g. `onClick`)
|
|
50
|
+
* out of every entry. Each passed schema must carry a JSON Schema `$id`; its
|
|
51
|
+
* payload is rendered once as a named type under `# Common Events`, and a
|
|
52
|
+
* component whose callback is one of them references it as `evt: <$id>` instead
|
|
53
|
+
* of re-inlining the payload — killing the per-component duplication. A callback
|
|
54
|
+
* is matched by its `$id`, so it stays library-agnostic (anything that emits
|
|
55
|
+
* `$id` participates); non-common callbacks render inline as before.
|
|
56
|
+
*
|
|
57
|
+
* Host-only defs (`hidden: true`, e.g. RootFragment) are filtered out so the LLM
|
|
58
|
+
* never sees host infrastructure in its component menu. Catalog-agnostic: the
|
|
59
|
+
* caller passes whatever def set + common events it exposes.
|
|
60
|
+
*
|
|
61
|
+
* Mirrors `getFunctionsPartialPrompt` — same `# Available X` / `# X Details`
|
|
62
|
+
* two-section shape.
|
|
63
|
+
*
|
|
64
|
+
* Throws on a duplicate component `name`, a common event whose schema has no
|
|
65
|
+
* `$id`, or a duplicate common event `$id`.
|
|
66
|
+
*/
|
|
67
|
+
export function getComponentsPartialPrompt({
|
|
68
|
+
definitions: defs,
|
|
69
|
+
commonEvents = [],
|
|
70
|
+
}: ComponentsPromptOptions): string {
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
for (const def of defs) {
|
|
73
|
+
if (seen.has(def.name)) {
|
|
74
|
+
throw new Error(`Duplicate component name: "${def.name}"`);
|
|
75
|
+
}
|
|
76
|
+
seen.add(def.name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Index the common events by `$id`, rendering each once. A missing `$id`
|
|
80
|
+
// would leave the handler unmatchable (it would silently inline on every
|
|
81
|
+
// component), so it's a hard error — as is a duplicate id, which would make
|
|
82
|
+
// the per-component reference ambiguous.
|
|
83
|
+
const commonIds = new Set<string>();
|
|
84
|
+
const commonLines: string[] = [];
|
|
85
|
+
for (const schema of commonEvents) {
|
|
86
|
+
const jsonSchema = toJSONSchema(schema);
|
|
87
|
+
const id = readId(jsonSchema);
|
|
88
|
+
if (!id) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
'Common event schema is missing a JSON Schema `$id` (add e.g. .meta({ $id: "onClick" }))',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (commonIds.has(id)) {
|
|
94
|
+
throw new Error(`Duplicate common event id: "${id}"`);
|
|
95
|
+
}
|
|
96
|
+
commonIds.add(id);
|
|
97
|
+
const ts = JSONSchemaToTs(jsonSchema);
|
|
98
|
+
const description = (jsonSchema as { description?: string }).description;
|
|
99
|
+
commonLines.push(`- ${id}: ${ts}${description ? ` — ${description}` : ""}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const visible = defs.filter((def) => !def.hidden);
|
|
103
|
+
|
|
104
|
+
const detail = visible
|
|
105
|
+
.map(({ name, description, props, callbacks }) => {
|
|
106
|
+
const lines = [`- ${name} — ${description}`];
|
|
107
|
+
|
|
108
|
+
const propLines = describeFields(toJSONSchema(props), " ");
|
|
109
|
+
if (propLines.length) lines.push(" Props:", ...propLines);
|
|
110
|
+
|
|
111
|
+
const callbackLines = Object.entries(callbacks || {}).flatMap(
|
|
112
|
+
([cbName, cbDef]) => {
|
|
113
|
+
const cbJSONSchema = toJSONSchema(cbDef);
|
|
114
|
+
const id = readId(cbJSONSchema);
|
|
115
|
+
if (id && commonIds.has(id)) {
|
|
116
|
+
// Common event — its payload is named + described once under
|
|
117
|
+
// `# Common Events`; reference it instead of re-inlining here.
|
|
118
|
+
return [` - ${cbName}(evt: ${id})`];
|
|
119
|
+
}
|
|
120
|
+
// The description documents the handler itself, not its `evt`
|
|
121
|
+
// argument — append it after the signature, as common events do.
|
|
122
|
+
const cbDescription = (cbJSONSchema as JSONSchema | null)?.description;
|
|
123
|
+
const tail = cbDescription ? ` — ${cbDescription}` : "";
|
|
124
|
+
// A `null` payload means the handler carries no event data — render it
|
|
125
|
+
// as a no-arg call rather than `(evt: null)`.
|
|
126
|
+
if ((cbJSONSchema as JSONSchema | null)?.type === "null") {
|
|
127
|
+
return [` - ${cbName}()${tail}`];
|
|
128
|
+
}
|
|
129
|
+
// An object payload's fields are the event's options — list them like
|
|
130
|
+
// props, one level deeper. Anything else keeps the inline `evt` type.
|
|
131
|
+
const optionLines = describeFields(cbJSONSchema, " ");
|
|
132
|
+
if (optionLines.length) {
|
|
133
|
+
return [` - ${cbName}(evt)${tail}`, ...optionLines];
|
|
134
|
+
}
|
|
135
|
+
return [` - ${cbName}(evt: ${JSONSchemaToTs(cbJSONSchema)})${tail}`];
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
if (callbackLines.length) lines.push(" Event handlers:", ...callbackLines);
|
|
139
|
+
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
})
|
|
142
|
+
.join("\n\n");
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
"# Available Components\n\n" +
|
|
146
|
+
visible.map((def) => def.name).join(", ") +
|
|
147
|
+
(commonLines.length
|
|
148
|
+
? `\n\n# Common Events\n\n${commonLines.join("\n")}`
|
|
149
|
+
: "") +
|
|
150
|
+
"\n\n# Component Details\n\n" +
|
|
151
|
+
detail
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type EditRequestPromptOptions = {
|
|
2
|
+
/** The user's change request, e.g. "Add pagination to the orders table". */
|
|
3
|
+
request: string;
|
|
4
|
+
/**
|
|
5
|
+
* Keys referenced as children in the replayed tree but never emitted —
|
|
6
|
+
* typically the tail of an earlier response that was cut off. Listing them
|
|
7
|
+
* tells the model those elements do NOT exist, so it re-emits them instead
|
|
8
|
+
* of keeping them by reference.
|
|
9
|
+
*/
|
|
10
|
+
missingKeys?: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The user-turn message for an incremental edit. The host replays the page's
|
|
15
|
+
* current JSONL as an assistant turn, then sends this as the next user turn:
|
|
16
|
+
* the change request plus the delta convention (re-emit a key to replace its
|
|
17
|
+
* subtree — the Partial Replacement rule the system prompt already teaches).
|
|
18
|
+
* The model then emits only the elements that change instead of the whole
|
|
19
|
+
* page — cheaper and faster, since output tokens dominate both.
|
|
20
|
+
*
|
|
21
|
+
* Not a system-prompt partial: this is per-turn message content, composed by
|
|
22
|
+
* the host into `messages`, not into `system`.
|
|
23
|
+
*/
|
|
24
|
+
export function getEditRequestPrompt({
|
|
25
|
+
request,
|
|
26
|
+
missingKeys = [],
|
|
27
|
+
}: EditRequestPromptOptions): string {
|
|
28
|
+
return [
|
|
29
|
+
request,
|
|
30
|
+
"Update the previously generated page above. Emit ONLY the elements that change: re-emit an existing key to replace that element (its new `children` array defines the new subtree — children you still reference are kept as-is, so do not re-emit them), use fresh keys for new elements, and do not re-emit unchanged elements. Never re-emit the whole page. `seed` on re-emitted keys does NOT re-run — initialize any NEW state via the `seed` of a newly-keyed element instead.",
|
|
31
|
+
missingKeys.length > 0
|
|
32
|
+
? `Warning: the page above references child keys that were never emitted (the earlier response was cut off): ${missingKeys.join(", ")}. These elements do NOT exist — emit them now, or re-emit their parents with corrected children.`
|
|
33
|
+
: "",
|
|
34
|
+
]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join("\n\n");
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type RenderFailure = {
|
|
2
|
+
/** The `key` of the element whose render failed. */
|
|
3
|
+
key: string;
|
|
4
|
+
/** The runtime error message, verbatim. */
|
|
5
|
+
message: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ErrorRecoveryPromptOptions = {
|
|
9
|
+
/** The failures to report, one per failed element. */
|
|
10
|
+
failures: RenderFailure[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The user-turn message for user-triggered error recovery: an element failed
|
|
15
|
+
* at runtime (its error slot rendered), and the host asks the model to re-emit
|
|
16
|
+
* it corrected. Deliberately surface-neutral — it states the failures and the
|
|
17
|
+
* fix request, while the output conventions come from the surface's own
|
|
18
|
+
* prompt: on a page surface the host passes this through its edit pipeline
|
|
19
|
+
* (replayed document + `getEditRequestPrompt`, which carries the delta
|
|
20
|
+
* convention), on a chat surface it goes out as a plain user message and the
|
|
21
|
+
* model replies with a corrected fence.
|
|
22
|
+
*
|
|
23
|
+
* Not a system-prompt partial: this is per-turn message content, composed by
|
|
24
|
+
* the host into `messages`, not into `system`.
|
|
25
|
+
*/
|
|
26
|
+
export function getErrorRecoveryPrompt({
|
|
27
|
+
failures,
|
|
28
|
+
}: ErrorRecoveryPromptOptions): string {
|
|
29
|
+
const list = failures
|
|
30
|
+
.map((failure) => `- Element \`${failure.key}\`: ${failure.message}`)
|
|
31
|
+
.join("\n");
|
|
32
|
+
return [
|
|
33
|
+
"The generated UI hit runtime errors:",
|
|
34
|
+
list,
|
|
35
|
+
"Diagnose each failure and emit a corrected version of the failed element, keeping its `key`. Fix the cause, not just the reported line — if the error comes from state or an expression that another element sets up (e.g. a `seed` elsewhere), correct that element as well.",
|
|
36
|
+
].join("\n\n");
|
|
37
|
+
}
|