@originkit/ai-engine 0.1.0
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/dist/benchmark/agents.d.ts +162 -0
- package/dist/benchmark/agents.d.ts.map +1 -0
- package/dist/benchmark/agents.js +1187 -0
- package/dist/benchmark/agents.js.map +1 -0
- package/dist/benchmark/diff.d.ts +23 -0
- package/dist/benchmark/diff.d.ts.map +1 -0
- package/dist/benchmark/diff.js +117 -0
- package/dist/benchmark/diff.js.map +1 -0
- package/dist/benchmark/domain.d.ts +8 -0
- package/dist/benchmark/domain.d.ts.map +1 -0
- package/dist/benchmark/domain.js +322 -0
- package/dist/benchmark/domain.js.map +1 -0
- package/dist/benchmark/edit-pass.d.ts +38 -0
- package/dist/benchmark/edit-pass.d.ts.map +1 -0
- package/dist/benchmark/edit-pass.js +121 -0
- package/dist/benchmark/edit-pass.js.map +1 -0
- package/dist/benchmark/prompt.d.ts +128 -0
- package/dist/benchmark/prompt.d.ts.map +1 -0
- package/dist/benchmark/prompt.js +883 -0
- package/dist/benchmark/prompt.js.map +1 -0
- package/dist/benchmark/run-loop.d.ts +74 -0
- package/dist/benchmark/run-loop.d.ts.map +1 -0
- package/dist/benchmark/run-loop.js +138 -0
- package/dist/benchmark/run-loop.js.map +1 -0
- package/dist/benchmark/schema.d.ts +13 -0
- package/dist/benchmark/schema.d.ts.map +1 -0
- package/dist/benchmark/schema.js +63 -0
- package/dist/benchmark/schema.js.map +1 -0
- package/dist/benchmark/types.d.ts +113 -0
- package/dist/benchmark/types.d.ts.map +1 -0
- package/dist/benchmark/types.js +18 -0
- package/dist/benchmark/types.js.map +1 -0
- package/dist/packages/ai-engine/index.d.ts +13 -0
- package/dist/packages/ai-engine/index.d.ts.map +1 -0
- package/dist/packages/ai-engine/index.js +39 -0
- package/dist/packages/ai-engine/index.js.map +1 -0
- package/package.json +26 -0
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
import { HOUSE_RULES, recipesFor } from "./domain.js";
|
|
2
|
+
/*
|
|
3
|
+
* Two passes, and the model only pays for the second if it needs it.
|
|
4
|
+
*
|
|
5
|
+
* Pass one shows the prop schema and nothing else: a couple of thousand tokens.
|
|
6
|
+
* Most requests are answerable that way, and those models never see the file.
|
|
7
|
+
* Only a model that declines gets pass two, which carries the source.
|
|
8
|
+
*
|
|
9
|
+
* The alternative — offering both up front — charges every model the file's
|
|
10
|
+
* full input cost on every run whether or not anyone uses it, and forces an
|
|
11
|
+
* output ceiling big enough to rewrite it, which OpenRouter reserves against
|
|
12
|
+
* your balance before the request even runs.
|
|
13
|
+
*
|
|
14
|
+
* Stable prefix first, variable last: the cacheable layout from the docs.
|
|
15
|
+
*/
|
|
16
|
+
export function buildSystem(entry, values) {
|
|
17
|
+
return [
|
|
18
|
+
"You edit React component props. You never write code.",
|
|
19
|
+
"",
|
|
20
|
+
"Return ONLY a JSON object mapping prop names to new values. No prose, no",
|
|
21
|
+
"markdown fence. Include only the props you are changing. Every key must",
|
|
22
|
+
"exist in the schema and every value must satisfy its type, range or enum.",
|
|
23
|
+
"",
|
|
24
|
+
"If the request cannot be satisfied by changing props alone, decline — and",
|
|
25
|
+
"say what would be needed, so the next step is obvious:",
|
|
26
|
+
'{"error": "requires code change",',
|
|
27
|
+
' "needs": ["propName - one line on what it would control"]}',
|
|
28
|
+
"",
|
|
29
|
+
"Name only props that do not exist yet and that would make the request",
|
|
30
|
+
"answerable. Two or three at most. A refusal that names nothing leaves the",
|
|
31
|
+
"reader to guess what the component is missing.",
|
|
32
|
+
"",
|
|
33
|
+
"Do not guess. A prop that is not in the schema below does not exist, and",
|
|
34
|
+
"inventing one is worse than declining.",
|
|
35
|
+
"",
|
|
36
|
+
"Changing colour, size, speed, text, opacity, or any other quality that",
|
|
37
|
+
"already has a schema key is a prop patch of those keys.",
|
|
38
|
+
"",
|
|
39
|
+
"A gradient, a second colour, a palette, or any other look the schema has",
|
|
40
|
+
"no knob for cannot be faked by patching an existing key. Decline, name",
|
|
41
|
+
"the new prop(s), and the next step will add them to the file.",
|
|
42
|
+
"",
|
|
43
|
+
`COMPONENT: ${entry.name}`,
|
|
44
|
+
"SCHEMA:",
|
|
45
|
+
JSON.stringify(entry.props, null, 2),
|
|
46
|
+
"",
|
|
47
|
+
"CURRENT VALUES:",
|
|
48
|
+
JSON.stringify(values, null, 2),
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
/*
|
|
52
|
+
* Pass two. The model is given the source and returns a complete file.
|
|
53
|
+
* SEARCH/REPLACE is still accepted if a model emits it (older answers, habit),
|
|
54
|
+
* but nothing in the prompt asks for it or forbids a rewrite.
|
|
55
|
+
*/
|
|
56
|
+
export const PROPS_MARKER = "///NEW-PROPS";
|
|
57
|
+
export const SEARCH = "<<<<<<< SEARCH";
|
|
58
|
+
export const DIVIDER = "=======";
|
|
59
|
+
export const REPLACE = ">>>>>>> REPLACE";
|
|
60
|
+
/** Output ceiling for a whole-file rewrite. File size is the floor; the
|
|
61
|
+
* multiplier is reasoning headroom. Capped at 32k so a small balance does
|
|
62
|
+
* not 402 on the reservation. */
|
|
63
|
+
export function rewriteMaxTokens(source) {
|
|
64
|
+
const approx = Math.ceil(source.length / 4);
|
|
65
|
+
return Math.min(32768, Math.max(8192, Math.round(approx * 2.5) + 1024));
|
|
66
|
+
}
|
|
67
|
+
/* Above this, a whole-file rewrite stops being possible rather than merely
|
|
68
|
+
* expensive.
|
|
69
|
+
*
|
|
70
|
+
* Re-emitting the file costs roughly `length / 4` output tokens, and models
|
|
71
|
+
* stop well before their advertised ceiling on a long verbatim copy. Measured:
|
|
72
|
+
* a 93KB component asked of a cheap model produced 29,658 tokens and stopped
|
|
73
|
+
* with finishReason "stop" — not "length", so no token budget could have
|
|
74
|
+
* rescued it. The answer arrived truncated mid-expression every time, and the
|
|
75
|
+
* repair round that followed truncated in the same place at the same price.
|
|
76
|
+
*
|
|
77
|
+
* A file this size has to be edited in PLACE. `sourceFromAnswer` already reads
|
|
78
|
+
* SEARCH/REPLACE blocks — this only asks for them. */
|
|
79
|
+
const REWRITE_MAX_CHARS = 40_000;
|
|
80
|
+
export function tooBigToRewrite(source) {
|
|
81
|
+
return source.length > REWRITE_MAX_CHARS;
|
|
82
|
+
}
|
|
83
|
+
/** Did this answer stop mid-file rather than finish? */
|
|
84
|
+
export function wasCutOff(finishReason, error) {
|
|
85
|
+
if (finishReason === "length")
|
|
86
|
+
return true;
|
|
87
|
+
/* Size is a poor proxy and finishReason is an unreliable one — a 27KB file
|
|
88
|
+
* truncated with reason "stop". What is never ambiguous is the parser
|
|
89
|
+
* reaching the end of the text with a construct still open. */
|
|
90
|
+
return !!error && /end of file|unexpected end|unterminated/i.test(error);
|
|
91
|
+
}
|
|
92
|
+
export function buildEditSystem(name, source, needs,
|
|
93
|
+
/** Force in-place edits — used after a rewrite came back truncated. */
|
|
94
|
+
forceEdits) {
|
|
95
|
+
const edits = forceEdits || tooBigToRewrite(source);
|
|
96
|
+
return [
|
|
97
|
+
"Rewrite this React component so the result already does what the user",
|
|
98
|
+
"asked. Fulfil the request. You may change any part of the file.",
|
|
99
|
+
"",
|
|
100
|
+
...(edits
|
|
101
|
+
? [
|
|
102
|
+
...(forceEdits
|
|
103
|
+
? [
|
|
104
|
+
"EDIT THIS FILE IN PLACE. Do not return it in full. Everything",
|
|
105
|
+
"you do not touch must stay exactly as it is — a rewrite that",
|
|
106
|
+
"re-derives the file loses the edits earlier turns already made",
|
|
107
|
+
"and drifts the component away from what it is.",
|
|
108
|
+
]
|
|
109
|
+
: [
|
|
110
|
+
"This file is TOO LARGE to return in full — an attempt would be",
|
|
111
|
+
"cut off mid-expression and the turn wasted.",
|
|
112
|
+
]),
|
|
113
|
+
"Return ONLY the parts you are changing, as one or more",
|
|
114
|
+
"search/replace blocks:",
|
|
115
|
+
"",
|
|
116
|
+
SEARCH,
|
|
117
|
+
" the exact existing lines, copied character for character",
|
|
118
|
+
DIVIDER,
|
|
119
|
+
" what they become",
|
|
120
|
+
REPLACE,
|
|
121
|
+
"",
|
|
122
|
+
"Each SEARCH must appear EXACTLY ONCE in the file — include enough",
|
|
123
|
+
"surrounding lines to make it unique. Copy whitespace exactly; a",
|
|
124
|
+
"block that does not match is a failed turn, not a near miss.",
|
|
125
|
+
"Emit as many blocks as the change needs. No prose, no fence.",
|
|
126
|
+
]
|
|
127
|
+
: [
|
|
128
|
+
"Return the COMPLETE file and nothing else. No prose, no markdown fence.",
|
|
129
|
+
"The first character is an import or a keyword.",
|
|
130
|
+
]),
|
|
131
|
+
"",
|
|
132
|
+
"Imports may only come from: react, three, motion, ogl.",
|
|
133
|
+
"",
|
|
134
|
+
`If you added any new prop, finish with a line reading exactly ${PROPS_MARKER}`,
|
|
135
|
+
"followed by a JSON object MAPPING EACH PROP NAME to its spec. Every prop is",
|
|
136
|
+
"a top-level key of that object — never nested inside another prop's spec,",
|
|
137
|
+
"and never a bare spec on its own. Complete example, two new props:",
|
|
138
|
+
' {"spinDirection":{"type":"enum","values":["cw","ccw"],"default":"cw","desc":"one line"},',
|
|
139
|
+
' "spinSpeed":{"type":"number","min":0,"max":20,"default":4,"desc":"one line"}}',
|
|
140
|
+
"",
|
|
141
|
+
"A spec is one of:",
|
|
142
|
+
' {"type":"number","min":0,"max":10,"default":1,"desc":"one line"}',
|
|
143
|
+
' {"type":"enum","values":["a","b"],"default":"a","desc":"one line"}',
|
|
144
|
+
' {"type":"boolean","default":false,"desc":"one line"}',
|
|
145
|
+
' {"type":"color","default":"#RRGGBB","desc":"one line"}',
|
|
146
|
+
' {"type":"string","default":"text","desc":"one line"}',
|
|
147
|
+
' {"type":"json","default":["#FF3B30","#00E5FF","#FFD60A"],"desc":"a LIST, never a settings object"}',
|
|
148
|
+
"Every default must match what your edited code actually defaults to, and",
|
|
149
|
+
"must sit inside its own range.",
|
|
150
|
+
"",
|
|
151
|
+
"AN EXISTING PROP'S RANGE IS NOT A CEILING ON THE RESULT. If the request",
|
|
152
|
+
"needs a value the current min/max cannot express — `rounded` capped at 60",
|
|
153
|
+
"on a 180px card that has to become a circle — re-declare that prop here",
|
|
154
|
+
"with a range wide enough to do it, and set its default to the value the",
|
|
155
|
+
"request asked for. Implementing it fully is the job; stopping at the old",
|
|
156
|
+
"limit produces a file that compiles and a render nobody can tell apart",
|
|
157
|
+
"from the one before it. You may only WIDEN a range, never narrow it.",
|
|
158
|
+
"A new prop's default is the behaviour THIS REQUEST ASKED FOR, not the old",
|
|
159
|
+
"one. Adding `isCircle` with default false to answer \"make it a circle\"",
|
|
160
|
+
"leaves the component exactly as it was and makes the reader ask twice.",
|
|
161
|
+
"A `colors` (or palette) default of [\"#FFFFFF\"] or a single hex is the old",
|
|
162
|
+
"look. Default to at least two different colours so the preview changes.",
|
|
163
|
+
"EVERY NUMBER YOU HAD TO CHOOSE BECOMES A PROP. Building a feature means",
|
|
164
|
+
"inventing values, and every one of those is a decision the user will want",
|
|
165
|
+
"to change. They get a slider for each, so they can tune it themselves",
|
|
166
|
+
"instead of paying for another turn to nudge a number. That is the point of",
|
|
167
|
+
"the props: they are the deliverable, not paperwork about it.",
|
|
168
|
+
" LOOK — radius, count, size, opacity, tilt, colour, spacing, thickness.",
|
|
169
|
+
" MOTION — speed, duration, delay, easing, damping, direction, stagger,",
|
|
170
|
+
" amplitude. EVERYTHING IN THIS CATALOG ANIMATES, so if what you added",
|
|
171
|
+
" moves, fades, springs or transitions, its timing is a knob too. A pulse",
|
|
172
|
+
" nobody can slow down is half a feature, and 'too fast' is the single",
|
|
173
|
+
" most common thing anyone says about an animation.",
|
|
174
|
+
" Asked for a Saturn-like ring, `ringEnabled` alone is not enough. The ring",
|
|
175
|
+
" has an inner radius, an outer radius, a particle count or density, an",
|
|
176
|
+
" opacity, a tilt, a colour and — since it rotates — a spin speed. You",
|
|
177
|
+
" chose every one of those to draw it, so declare every one. Seven knobs is",
|
|
178
|
+
" a feature; one toggle is a light switch.",
|
|
179
|
+
" A hover or state change you add has a duration and an easing. Expose",
|
|
180
|
+
" both: a transition that cannot be retimed is a transition nobody can fix.",
|
|
181
|
+
"",
|
|
182
|
+
"ANYTHING YOU ANIMATE NEEDS AN EASING, NOT JUST A DURATION. A duration on",
|
|
183
|
+
"its own is a linear ramp — full speed from the first frame and a dead stop",
|
|
184
|
+
"on the last — which is why an added click or hover effect so often feels",
|
|
185
|
+
"like it snaps in and snaps out. Declare the curve as an enum:",
|
|
186
|
+
' {"type":"enum","values":["linear","ease-in","ease-out","ease-in-out"],',
|
|
187
|
+
' "default":"ease-out","desc":"one line"}',
|
|
188
|
+
"and APPLY it to the 0..1 progress before you use that progress for a",
|
|
189
|
+
"radius, an alpha or an offset. Ease-out for anything responding to a click",
|
|
190
|
+
"or a hover; ease-in for something leaving.",
|
|
191
|
+
"Expose the parameters that change how it LOOKS or how it MOVES. Do not",
|
|
192
|
+
"declare internals nobody would reach for.",
|
|
193
|
+
"",
|
|
194
|
+
"ONE PROP PER KNOB. NEVER A SETTINGS OBJECT. Declaring a single `ring` prop",
|
|
195
|
+
"of type json holding {innerRadius, outerRadius, colorInner, opacity, …}",
|
|
196
|
+
"is nine knobs the user cannot touch: it renders as one box of raw JSON",
|
|
197
|
+
"they have to hand-edit, instead of nine sliders and colour pickers. It is",
|
|
198
|
+
"the same as declaring nothing.",
|
|
199
|
+
"",
|
|
200
|
+
"USE FLAT NAMES. One prop per value, no dots, each read straight into the",
|
|
201
|
+
"code where its literal used to be:",
|
|
202
|
+
' {"ringInnerRadius":{"type":"number","min":1,"max":4,"default":1.3,"desc":"one line"},',
|
|
203
|
+
' "ringOuterRadius":{"type":"number","min":1,"max":6,"default":2.3,"desc":"one line"},',
|
|
204
|
+
' "ringColorInner":{"type":"color","default":"#D2B48C","desc":"one line"},',
|
|
205
|
+
' "ringOpacity":{"type":"number","min":0,"max":100,"default":85,"desc":"one line"},',
|
|
206
|
+
' "ringSpinSpeed":{"type":"number","min":0,"max":10,"default":2,"desc":"one line"}}',
|
|
207
|
+
"",
|
|
208
|
+
"A dotted name like \"ring.innerRadius\" is nested into an object before the",
|
|
209
|
+
"component is rendered — and that object REPLACES whatever the component",
|
|
210
|
+
"would otherwise use for `ring`. So if you declare ring.innerRadius and",
|
|
211
|
+
"ring.opacity but the code also reads ring.colors, `colors` arrives",
|
|
212
|
+
"undefined and the component falls back to its old value. The change",
|
|
213
|
+
"appears and then silently reverts, which is worse than never making it.",
|
|
214
|
+
"Only use dotted names when you declare EVERY field the code reads off that",
|
|
215
|
+
"object. Otherwise keep them flat — flat is always safe.",
|
|
216
|
+
"",
|
|
217
|
+
"`json` is for a genuine LIST — an array of colours, a set of points. Never",
|
|
218
|
+
"for a bag of unrelated settings.",
|
|
219
|
+
"",
|
|
220
|
+
"AND EVERY PROP YOU DECLARE MUST BE READ BY THE CODE. Declaring a prop and",
|
|
221
|
+
"leaving the value hard-coded produces a control that does nothing — the",
|
|
222
|
+
"worst possible outcome, because it looks like the feature was delivered.",
|
|
223
|
+
"If you write a ring at radius 1.4 and then declare ringInnerRadius with a",
|
|
224
|
+
"default of 1.4, the 1.4 in the code must become ringInnerRadius. A prop",
|
|
225
|
+
"whose name does not appear in the file is not a prop.",
|
|
226
|
+
"",
|
|
227
|
+
"These two rules point the same way: the literal you invented is the",
|
|
228
|
+
"default, and the name you gave it is the prop. Declaring fewer props to",
|
|
229
|
+
"stay safe is the wrong lesson — declare every knob the feature has, and",
|
|
230
|
+
"wire every one of them.",
|
|
231
|
+
"STRICT JSON: double quotes on every key and every string, no trailing",
|
|
232
|
+
"commas, no comments. A JS object literal is not JSON and will be refused.",
|
|
233
|
+
...(needs?.length
|
|
234
|
+
? [
|
|
235
|
+
"",
|
|
236
|
+
"The props pass already named what it thinks is missing. Add these",
|
|
237
|
+
"unless the source shows a better answer:",
|
|
238
|
+
...needs.map((n) => ` - ${n}`),
|
|
239
|
+
]
|
|
240
|
+
: []),
|
|
241
|
+
"",
|
|
242
|
+
`COMPONENT: ${name}`,
|
|
243
|
+
"CURRENT SOURCE:",
|
|
244
|
+
source,
|
|
245
|
+
"",
|
|
246
|
+
"Change the file so the request is already visible when it renders.",
|
|
247
|
+
"Returning this source unchanged fails the turn.",
|
|
248
|
+
].join("\n");
|
|
249
|
+
}
|
|
250
|
+
/* The user turn: the instruction, with any maths it needs attached to it.
|
|
251
|
+
*
|
|
252
|
+
* Recipes live HERE, not in the system prompt. They change with what was asked,
|
|
253
|
+
* and one varying line in the system prompt means the whole cached block misses
|
|
254
|
+
* — which for a 4,500-token source is the difference between paying 0.006/M and
|
|
255
|
+
* 0.03/M on every turn. */
|
|
256
|
+
export function buildUser(instruction, source = "") {
|
|
257
|
+
const recipes = recipesFor(instruction, source);
|
|
258
|
+
return recipes ? `${recipes}\n\n${instruction}` : instruction;
|
|
259
|
+
}
|
|
260
|
+
/** True when every `needs` entry is just an existing knob to turn.
|
|
261
|
+
* "color - make it red" is that. "color - support for a gradient" is not:
|
|
262
|
+
* `color` exists, the gradient does not, and treating it as a prop patch
|
|
263
|
+
* is what blocked adding the missing look. */
|
|
264
|
+
export function needsAlreadyInSchema(needs, props) {
|
|
265
|
+
if (!needs?.length)
|
|
266
|
+
return false;
|
|
267
|
+
const keys = Object.keys(props);
|
|
268
|
+
const newLook = /\b(gradient|palett|multi-?colou?rs?|second colou?r|color2|new |add a |missing)\b/i;
|
|
269
|
+
return needs.every((n) => {
|
|
270
|
+
if (newLook.test(n))
|
|
271
|
+
return false;
|
|
272
|
+
const head = n.trim().split(/[\s:.—–-]/)[0] ?? "";
|
|
273
|
+
return keys.includes(head);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const IDLE_HEX = /^(#fff|#ffffff|#000|#000000)$/i;
|
|
277
|
+
function asHexes(v) {
|
|
278
|
+
if (!Array.isArray(v))
|
|
279
|
+
return [];
|
|
280
|
+
return v.filter((x) => typeof x === "string" && x.startsWith("#"));
|
|
281
|
+
}
|
|
282
|
+
/** When a rewrite adds a colour-list prop defaulted to `["#FFFFFF"]`, the
|
|
283
|
+
* preview stays the old burst. Seed the session (and the spec default) with
|
|
284
|
+
* the current colour plus two more so the asked look is on screen without
|
|
285
|
+
* another turn. */
|
|
286
|
+
export function seedVisibleNewProps(ask, current, fresh) {
|
|
287
|
+
const wantsMany = /multi|gradient|palett|colou?rs\b/i.test(ask);
|
|
288
|
+
const now = typeof current.color === "string" && current.color.startsWith("#") && !IDLE_HEX.test(current.color)
|
|
289
|
+
? current.color
|
|
290
|
+
: "#FF3B30";
|
|
291
|
+
const list = [now, "#00E5FF", "#FFD60A"].filter((hex, i, all) => all.findIndex((h) => h.toLowerCase() === hex.toLowerCase()) === i);
|
|
292
|
+
while (list.length < 3)
|
|
293
|
+
list.push(list.length === 1 ? "#00E5FF" : "#FFD60A");
|
|
294
|
+
const props = {};
|
|
295
|
+
const values = {};
|
|
296
|
+
for (const [k, spec] of Object.entries(fresh)) {
|
|
297
|
+
let next = spec;
|
|
298
|
+
let val = spec.default;
|
|
299
|
+
const colourName = /colou?r|palett|gradient/i.test(k);
|
|
300
|
+
if (wantsMany && (spec.type === "json" || colourName) && spec.type !== "number") {
|
|
301
|
+
const hexes = asHexes(spec.default);
|
|
302
|
+
const unique = new Set(hexes.map((h) => h.toLowerCase()));
|
|
303
|
+
if (hexes.length < 2 || unique.size < 2) {
|
|
304
|
+
val = list;
|
|
305
|
+
next = { ...spec, default: list };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
props[k] = next;
|
|
309
|
+
values[k] = val;
|
|
310
|
+
}
|
|
311
|
+
return { props, values };
|
|
312
|
+
}
|
|
313
|
+
/** Follow-up after the rewritten file fails to compile. One retry. */
|
|
314
|
+
export function buildEditRepairUser(error, source,
|
|
315
|
+
/** Keep the answer in search/replace form — see below. */
|
|
316
|
+
edits) {
|
|
317
|
+
return [
|
|
318
|
+
`That file did not work: ${error}`,
|
|
319
|
+
"",
|
|
320
|
+
"CURRENT SOURCE (before your answer):",
|
|
321
|
+
source,
|
|
322
|
+
"",
|
|
323
|
+
/* A repair must answer in the SAME shape the attempt was asked for.
|
|
324
|
+
*
|
|
325
|
+
* Asking for the complete file here silently converts an in-place turn into
|
|
326
|
+
* a whole-file rewrite: the caller forced search/replace precisely so the
|
|
327
|
+
* parts nobody asked about could not change, and the repair then re-derives
|
|
328
|
+
* all of them anyway. Measured: an edit that emitted one correct block and
|
|
329
|
+
* one elided body was "repaired" by re-emitting the entire component.
|
|
330
|
+
*
|
|
331
|
+
* It is also the more likely repair to work. The fault is usually in one
|
|
332
|
+
* block — an elision, a mismatched search — and rewriting 7,000 tokens to
|
|
333
|
+
* fix twenty is a fresh roll of the dice, not a correction. */
|
|
334
|
+
...(edits
|
|
335
|
+
? [
|
|
336
|
+
"Return CORRECTED search/replace blocks in the same format, and only",
|
|
337
|
+
"the blocks that change. Never write `...` or `// unchanged` inside a",
|
|
338
|
+
"REPLACE body — every line you emit is written into the file exactly",
|
|
339
|
+
"as you type it, so an elision becomes a syntax error.",
|
|
340
|
+
]
|
|
341
|
+
: [
|
|
342
|
+
"Return the COMPLETE corrected file. It must differ from CURRENT SOURCE",
|
|
343
|
+
"and already do what was asked. No prose, no markdown fence.",
|
|
344
|
+
]),
|
|
345
|
+
].join("\n");
|
|
346
|
+
}
|
|
347
|
+
/*
|
|
348
|
+
* Near-JSON, made strict.
|
|
349
|
+
*
|
|
350
|
+
* Models write JavaScript object literals, not JSON — single-quoted strings,
|
|
351
|
+
* trailing commas, unquoted keys. Measured failure: a props pass suggested
|
|
352
|
+
* `['monochrome','gradient','multicolor']` in prose and the edit pass echoed
|
|
353
|
+
* that array verbatim into the declared block, which then failed to parse at
|
|
354
|
+
* the first apostrophe and cost the turn its props.
|
|
355
|
+
*
|
|
356
|
+
* A repair, not a parser. It only runs AFTER strict parsing has failed, and
|
|
357
|
+
* whatever it produces still goes through validateSchema — so a mangled result
|
|
358
|
+
* is refused exactly as before. The alternative is throwing away a correct
|
|
359
|
+
* answer over a quote character.
|
|
360
|
+
*/
|
|
361
|
+
export function repairJson(text) {
|
|
362
|
+
return (text
|
|
363
|
+
// Line and block comments, which models add to explain a field.
|
|
364
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
365
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, "$1")
|
|
366
|
+
// 'single quoted' -> "double quoted". Skips anything containing a double
|
|
367
|
+
// quote or a backslash, where a naive swap would corrupt the payload.
|
|
368
|
+
.replace(/'([^'"\\\n]*)'/g, '"$1"')
|
|
369
|
+
// Unquoted keys: { type: "number" } -> { "type": "number" }
|
|
370
|
+
.replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, '$1"$2":')
|
|
371
|
+
// Trailing commas before a close.
|
|
372
|
+
.replace(/,(\s*[}\]])/g, "$1")
|
|
373
|
+
.trim());
|
|
374
|
+
}
|
|
375
|
+
const SPEC_TYPES = new Set(["number", "enum", "boolean", "color", "string", "json"]);
|
|
376
|
+
/* What a model calls a type versus what PropSpec calls it.
|
|
377
|
+
*
|
|
378
|
+
* Measured: a declared `colors` prop came back as `"type":"array"`, which is
|
|
379
|
+
* not a PropSpec type, so it was not recognised as a spec at all and was lost
|
|
380
|
+
* along with everything nested near it. PropSpec's `json` covers arrays and
|
|
381
|
+
* objects both; "array" is the obvious word for it and the model reached for
|
|
382
|
+
* the obvious word. Renaming is not guessing — every one of these is the same
|
|
383
|
+
* type under a different name. */
|
|
384
|
+
const TYPE_ALIAS = {
|
|
385
|
+
array: "json",
|
|
386
|
+
object: "json",
|
|
387
|
+
int: "number",
|
|
388
|
+
integer: "number",
|
|
389
|
+
float: "number",
|
|
390
|
+
bool: "boolean",
|
|
391
|
+
hex: "color",
|
|
392
|
+
text: "string",
|
|
393
|
+
};
|
|
394
|
+
const canonicalType = (t) => typeof t === "string" ? (TYPE_ALIAS[t.toLowerCase()] ?? t.toLowerCase()) : t;
|
|
395
|
+
const looksLikeSpec = (v) => !!v &&
|
|
396
|
+
typeof v === "object" &&
|
|
397
|
+
!Array.isArray(v) &&
|
|
398
|
+
SPEC_TYPES.has(canonicalType(v.type));
|
|
399
|
+
/** Close whatever the model left open. Brackets inside strings are ignored, so
|
|
400
|
+
* a `desc` containing a brace cannot throw the count off. */
|
|
401
|
+
function balance(text) {
|
|
402
|
+
const stack = [];
|
|
403
|
+
let inStr = false;
|
|
404
|
+
let esc = false;
|
|
405
|
+
for (const ch of text) {
|
|
406
|
+
if (inStr) {
|
|
407
|
+
if (esc)
|
|
408
|
+
esc = false;
|
|
409
|
+
else if (ch === "\\")
|
|
410
|
+
esc = true;
|
|
411
|
+
else if (ch === '"')
|
|
412
|
+
inStr = false;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (ch === '"')
|
|
416
|
+
inStr = true;
|
|
417
|
+
else if (ch === "{" || ch === "[")
|
|
418
|
+
stack.push(ch === "{" ? "}" : "]");
|
|
419
|
+
else if (ch === "}" || ch === "]")
|
|
420
|
+
stack.pop();
|
|
421
|
+
}
|
|
422
|
+
return text + stack.reverse().join("");
|
|
423
|
+
}
|
|
424
|
+
const tryParse = (t) => {
|
|
425
|
+
try {
|
|
426
|
+
return JSON.parse(t);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
/**
|
|
433
|
+
* Turn whatever the model emitted after ///NEW-PROPS into a name -> spec object.
|
|
434
|
+
*
|
|
435
|
+
* Three real failures, all measured, all repaired here rather than lost:
|
|
436
|
+
*
|
|
437
|
+
* - JS instead of JSON: single quotes, unquoted keys, trailing commas.
|
|
438
|
+
* - Unbalanced braces, from a spec left open.
|
|
439
|
+
* - A sibling prop nested INSIDE another prop's spec. `colorScheme` came back
|
|
440
|
+
* carrying `colors` as one of its own fields; a spec cannot contain a spec,
|
|
441
|
+
* so a nested one is unambiguously a sibling that was mis-nested.
|
|
442
|
+
*
|
|
443
|
+
* A bare spec with no prop name is refused, not guessed: the name is the one
|
|
444
|
+
* piece of information that cannot be recovered from the block. Whatever comes
|
|
445
|
+
* out still goes through validateSchema, which is what actually decides.
|
|
446
|
+
*/
|
|
447
|
+
export function repairPropsBlock(text) {
|
|
448
|
+
const parsed = tryParse(text) ?? tryParse(repairJson(text)) ?? tryParse(balance(repairJson(text)));
|
|
449
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
450
|
+
return { ok: false, error: "the block is not a JSON object" };
|
|
451
|
+
}
|
|
452
|
+
if (looksLikeSpec(parsed)) {
|
|
453
|
+
return {
|
|
454
|
+
ok: false,
|
|
455
|
+
error: "returned a bare spec with no prop name — the block must map a name to a spec",
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const out = {};
|
|
459
|
+
for (const [name, spec] of Object.entries(parsed)) {
|
|
460
|
+
if (!looksLikeSpec(spec)) {
|
|
461
|
+
out[name] = spec;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const own = {};
|
|
465
|
+
for (const [field, value] of Object.entries(spec)) {
|
|
466
|
+
// A spec inside a spec is a sibling in the wrong place.
|
|
467
|
+
if (looksLikeSpec(value))
|
|
468
|
+
out[field] = { ...value, type: canonicalType(value.type) };
|
|
469
|
+
else
|
|
470
|
+
own[field] = value;
|
|
471
|
+
}
|
|
472
|
+
own.type = canonicalType(own.type);
|
|
473
|
+
out[name] = own;
|
|
474
|
+
}
|
|
475
|
+
return { ok: true, json: JSON.stringify(out) };
|
|
476
|
+
}
|
|
477
|
+
/** Pull the edit blocks and the optional new-prop JSON out of an answer. */
|
|
478
|
+
export function parseEdits(body) {
|
|
479
|
+
const marker = body.indexOf(`\n${PROPS_MARKER}`);
|
|
480
|
+
const head = marker === -1 ? body : body.slice(0, marker);
|
|
481
|
+
const props = marker === -1 ? null : body.slice(marker + PROPS_MARKER.length + 1).trim() || null;
|
|
482
|
+
const edits = [];
|
|
483
|
+
const lines = head.split("\n");
|
|
484
|
+
let i = 0;
|
|
485
|
+
while (i < lines.length) {
|
|
486
|
+
if (lines[i].trim() !== SEARCH) {
|
|
487
|
+
i++;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const div = lines.indexOf(DIVIDER, i + 1);
|
|
491
|
+
const end = lines.indexOf(REPLACE, div + 1);
|
|
492
|
+
// An unterminated block is dropped rather than guessed at: half an edit is
|
|
493
|
+
// worse than none, and applyEdits would report a mismatch nobody can act on.
|
|
494
|
+
if (div === -1 || end === -1)
|
|
495
|
+
break;
|
|
496
|
+
edits.push({
|
|
497
|
+
search: lines.slice(i + 1, div).join("\n"),
|
|
498
|
+
replace: lines.slice(div + 1, end).join("\n"),
|
|
499
|
+
});
|
|
500
|
+
i = end + 1;
|
|
501
|
+
}
|
|
502
|
+
return { edits, props };
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Apply edit blocks to a source file.
|
|
506
|
+
*
|
|
507
|
+
* Exact match first. Failing that, two fallbacks, both unique-or-fail:
|
|
508
|
+
* 1. each line trimmed — recovers the wrong-indent copy.
|
|
509
|
+
* 2. whitespace / trailing `;` / trailing `...` stripped — recovers
|
|
510
|
+
* `createLinearGradient(0,0,X,0)` vs `(0, 0, X, 0)` and a SEARCH that
|
|
511
|
+
* ends in an ellipsis. Anything looser would start matching code the
|
|
512
|
+
* model did not mean, and a wrong edit that compiles is the worst outcome
|
|
513
|
+
* available here — worse than a failed turn, because nobody sees it.
|
|
514
|
+
*
|
|
515
|
+
* Never partially applies. An edit that misses fails the whole turn, naming
|
|
516
|
+
* which block and why.
|
|
517
|
+
*/
|
|
518
|
+
export function applyEdits(source, edits) {
|
|
519
|
+
if (!edits.length)
|
|
520
|
+
return { ok: false, error: "no edit blocks in the answer" };
|
|
521
|
+
let code = source;
|
|
522
|
+
for (const [n, e] of edits.entries()) {
|
|
523
|
+
if (!e.search.trim())
|
|
524
|
+
return { ok: false, error: `edit ${n + 1}: empty SEARCH block` };
|
|
525
|
+
const hits = code.split(e.search).length - 1;
|
|
526
|
+
if (hits === 1) {
|
|
527
|
+
code = code.replace(e.search, () => e.replace);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (hits > 1) {
|
|
531
|
+
return {
|
|
532
|
+
ok: false,
|
|
533
|
+
error: `edit ${n + 1}: SEARCH matches ${hits} places — it must be unique`,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
const found = matchByTrimmedLines(code, e.search) ?? matchByNormalizedLines(code, e.search);
|
|
537
|
+
if (found === null) {
|
|
538
|
+
const first = e.search.split("\n")[0].trim().slice(0, 60);
|
|
539
|
+
return { ok: false, error: `edit ${n + 1}: SEARCH not found — "${first}…"` };
|
|
540
|
+
}
|
|
541
|
+
if (found === -2) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
error: `edit ${n + 1}: SEARCH matches several places once indentation is ignored`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const lines = code.split("\n");
|
|
548
|
+
const count = e.search.split("\n").length;
|
|
549
|
+
lines.splice(found, count, ...e.replace.split("\n"));
|
|
550
|
+
code = lines.join("\n");
|
|
551
|
+
}
|
|
552
|
+
if (code === source)
|
|
553
|
+
return { ok: false, error: "the edits changed nothing" };
|
|
554
|
+
return { ok: true, code, applied: edits.length };
|
|
555
|
+
}
|
|
556
|
+
/** Source from an edit-pass answer. SEARCH/REPLACE still applies when that is
|
|
557
|
+
* the shape of the answer; otherwise the body is the new file. An unchanged
|
|
558
|
+
* copy of the input is a miss — the preview would not move. */
|
|
559
|
+
export function sourceFromAnswer(previous, body) {
|
|
560
|
+
const trimmed = body.trim();
|
|
561
|
+
if (!trimmed)
|
|
562
|
+
return { ok: false, error: "empty file in the answer" };
|
|
563
|
+
const protocol = trimmed.startsWith(SEARCH) || trimmed.includes(`\n${SEARCH}`);
|
|
564
|
+
if (protocol) {
|
|
565
|
+
const { edits } = parseEdits(body);
|
|
566
|
+
if (edits.length) {
|
|
567
|
+
/* Report the miss, do not fall through.
|
|
568
|
+
*
|
|
569
|
+
* Falling through treats the SEARCH/REPLACE text ITSELF as the new file:
|
|
570
|
+
* it is not equal to `previous`, so the unchanged guard passes it, and
|
|
571
|
+
* the turn dies at compile with a syntax error pointing at `<<<<<<<`.
|
|
572
|
+
* The real fault is that a block did not match — which is a copying
|
|
573
|
+
* problem the next turn can fix, and a syntax error is not. Zero parsed
|
|
574
|
+
* blocks still falls through: that is a whole file that merely mentions
|
|
575
|
+
* the marker. */
|
|
576
|
+
return applyEdits(previous, edits);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const marker = body.indexOf(`\n${PROPS_MARKER}`);
|
|
580
|
+
const code = (marker === -1 ? trimmed : body.slice(0, marker).trim());
|
|
581
|
+
if (!code)
|
|
582
|
+
return { ok: false, error: "empty file in the answer" };
|
|
583
|
+
const same = code.replace(/\r\n/g, "\n").trim() === previous.replace(/\r\n/g, "\n").trim();
|
|
584
|
+
if (same)
|
|
585
|
+
return { ok: false, error: "the file was returned unchanged" };
|
|
586
|
+
return { ok: true, code, applied: 1 };
|
|
587
|
+
}
|
|
588
|
+
const COLOUR_DRAW = /addColorStop|fillStyle|strokeStyle|#(?:[0-9a-fA-F]{3,8})\b|rgba?\(|hsla?\(/;
|
|
589
|
+
function colourDrawLines(source) {
|
|
590
|
+
return source.split("\n").filter((l) => COLOUR_DRAW.test(l)).join("\n");
|
|
591
|
+
}
|
|
592
|
+
/** Colour asks that only add `color2` / a midpoint and leave every paint
|
|
593
|
+
* line on the old `cStar` compile and "succeed" — the preview does not
|
|
594
|
+
* move. Fail those so Iterate can repair instead of showing a new knob
|
|
595
|
+
* that does nothing. */
|
|
596
|
+
export function withColourDrawCheck(ask, before, result) {
|
|
597
|
+
if (!result.ok)
|
|
598
|
+
return result;
|
|
599
|
+
if (!/colou?r|hue|tint|shade|gradient|palett|warmer|cooler/i.test(ask)) {
|
|
600
|
+
return result;
|
|
601
|
+
}
|
|
602
|
+
if (colourDrawLines(before) !== colourDrawLines(result.code))
|
|
603
|
+
return result;
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
error: "the fill/stroke/gradient lines did not change, so the preview is identical. A new colour variable that is never painted is not a change — edit every addColorStop / fillStyle / rgba that currently uses the old colour.",
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/** Line index of the single trimmed-equal run, null for none, -2 for several. */
|
|
610
|
+
function matchByTrimmedLines(code, search) {
|
|
611
|
+
return matchRun(code.split("\n").map((l) => l.trim()), search.split("\n").map((l) => l.trim()));
|
|
612
|
+
}
|
|
613
|
+
/** Same uniqueness rule, after compacting spaces so a paraphrased call still
|
|
614
|
+
* hits the one real line. A SEARCH line that compactifies to empty (bare
|
|
615
|
+
* `...`) is refused — that is abbreviation, not a match. */
|
|
616
|
+
function matchByNormalizedLines(code, search) {
|
|
617
|
+
const needle = search.split("\n").map(compactLine);
|
|
618
|
+
if (needle.some((l) => !l))
|
|
619
|
+
return null;
|
|
620
|
+
return matchRun(code.split("\n").map(compactLine), needle);
|
|
621
|
+
}
|
|
622
|
+
function compactLine(s) {
|
|
623
|
+
return s
|
|
624
|
+
.trim()
|
|
625
|
+
.replace(/\s*(?:\.{3}|…)\s*$/, "")
|
|
626
|
+
.replace(/;+\s*$/, "")
|
|
627
|
+
.replace(/\s+/g, "");
|
|
628
|
+
}
|
|
629
|
+
function matchRun(hay, needle) {
|
|
630
|
+
let at = null;
|
|
631
|
+
for (let i = 0; i + needle.length <= hay.length; i++) {
|
|
632
|
+
let hit = true;
|
|
633
|
+
for (let j = 0; j < needle.length; j++) {
|
|
634
|
+
if (hay[i + j] !== needle[j]) {
|
|
635
|
+
hit = false;
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (!hit)
|
|
640
|
+
continue;
|
|
641
|
+
if (at !== null)
|
|
642
|
+
return -2;
|
|
643
|
+
at = i;
|
|
644
|
+
}
|
|
645
|
+
return at;
|
|
646
|
+
}
|
|
647
|
+
/*
|
|
648
|
+
* A heading the model glued to the front of its answer.
|
|
649
|
+
*
|
|
650
|
+
* The prompt forbids one, but models write `PROPS: {...}` anyway — an earlier
|
|
651
|
+
* prompt that numbered the options "1. PROPS" got exactly that back. Mistaking
|
|
652
|
+
* a labelled patch for a broken file turns a correct answer into a failed one,
|
|
653
|
+
* which is the kind of measurement error this benchmark exists to avoid.
|
|
654
|
+
*
|
|
655
|
+
* Both parts are optional and both are anchored, so real source is untouched:
|
|
656
|
+
* `const code: string = "x"` does not start with the word "code".
|
|
657
|
+
*/
|
|
658
|
+
const LABEL = /^(?:form\s+[ab]\b\s*[-–—:]?\s*)?(?:(?:props|code|json|patch)\s*:\s*)?/i;
|
|
659
|
+
export function unfence(raw) {
|
|
660
|
+
const m =
|
|
661
|
+
// A properly closed fence, opener and closer on their own lines.
|
|
662
|
+
raw.match(/^\s*```(?:[a-z]*)?\s*\n([\s\S]*?)\n?\s*```\s*$/) ??
|
|
663
|
+
// A closed fence with something either side of it.
|
|
664
|
+
raw.match(/```(?:[a-z]*)?\s*([\s\S]*?)```/) ??
|
|
665
|
+
/* An opener that was never closed.
|
|
666
|
+
*
|
|
667
|
+
* Measured on a real code-mode answer: the model wrote ```tsx, then the
|
|
668
|
+
* whole 40KB file, then simply stopped — no closing fence, and not a
|
|
669
|
+
* truncation (finish_reason was not "length"). Without this branch the
|
|
670
|
+
* opener survives into the source, JS reads the leading backticks as a
|
|
671
|
+
* template literal, and the parse dies at the first stray backtick in a
|
|
672
|
+
* comment 8 lines down. The reported error then points at a line the model
|
|
673
|
+
* never touched, which is a very expensive thing to debug — and the answer
|
|
674
|
+
* itself was fine. */
|
|
675
|
+
raw.match(/^\s*```(?:[a-z]*)?[ \t]*\n([\s\S]*)$/);
|
|
676
|
+
return m ? { body: m[1].trim(), fenced: true } : { body: raw.trim(), fenced: false };
|
|
677
|
+
}
|
|
678
|
+
const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
679
|
+
function checkValue(k, v, s) {
|
|
680
|
+
switch (s.type) {
|
|
681
|
+
case "number":
|
|
682
|
+
if (typeof v !== "number" || Number.isNaN(v))
|
|
683
|
+
return `${k}: not a number`;
|
|
684
|
+
return v < s.min || v > s.max ? `${k}: ${v} outside ${s.min}..${s.max}` : null;
|
|
685
|
+
case "enum":
|
|
686
|
+
return s.values.includes(v) ? null : `${k}: ${JSON.stringify(v)} not in enum`;
|
|
687
|
+
case "boolean":
|
|
688
|
+
return typeof v === "boolean" ? null : `${k}: not a boolean`;
|
|
689
|
+
case "color":
|
|
690
|
+
return typeof v === "string" && HEX.test(v) ? null : `${k}: not a hex colour`;
|
|
691
|
+
case "string":
|
|
692
|
+
return typeof v === "string" ? null : `${k}: not a string`;
|
|
693
|
+
case "json":
|
|
694
|
+
return Array.isArray(v) === Array.isArray(s.default) && typeof v === typeof s.default
|
|
695
|
+
? null
|
|
696
|
+
: `${k}: wrong shape`;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
export function validate(raw, schema) {
|
|
700
|
+
const out = {
|
|
701
|
+
json: false, schema: false, types: false, refused: false, patch: null, issues: [],
|
|
702
|
+
};
|
|
703
|
+
// Models fence their JSON despite being told not to. Strip it — measure the
|
|
704
|
+
// patch, not the formatting. `json` still reflects the unfenced parse.
|
|
705
|
+
const { body, fenced } = unfence(raw);
|
|
706
|
+
// Same stripping as routeOf: the two must agree on what the body is, or a
|
|
707
|
+
// labelled patch routes as props here and then fails to parse.
|
|
708
|
+
const text = body.replace(LABEL, "").trim();
|
|
709
|
+
let parsed;
|
|
710
|
+
try {
|
|
711
|
+
parsed = JSON.parse(text);
|
|
712
|
+
out.json = !fenced;
|
|
713
|
+
if (fenced)
|
|
714
|
+
out.issues.push("wrapped in a markdown fence");
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
out.issues.push("not valid JSON");
|
|
718
|
+
return out;
|
|
719
|
+
}
|
|
720
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
721
|
+
out.issues.push("not a JSON object");
|
|
722
|
+
return out;
|
|
723
|
+
}
|
|
724
|
+
const obj = parsed;
|
|
725
|
+
/* A refusal, optionally carrying the props it would take to say yes.
|
|
726
|
+
*
|
|
727
|
+
* `needs` is allowed alongside `error` — anything else next to `error` is a
|
|
728
|
+
* model mixing a decline with a patch, which is neither, so it falls through
|
|
729
|
+
* to the normal checks and fails as a hallucinated key. */
|
|
730
|
+
if ("error" in obj && Object.keys(obj).every((k) => k === "error" || k === "needs")) {
|
|
731
|
+
out.refused = true;
|
|
732
|
+
out.schema = true;
|
|
733
|
+
out.types = true;
|
|
734
|
+
if (Array.isArray(obj.needs)) {
|
|
735
|
+
out.needs = obj.needs.filter((n) => typeof n === "string");
|
|
736
|
+
}
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
const unknown = Object.keys(obj).filter((k) => !(k in schema));
|
|
740
|
+
out.schema = unknown.length === 0;
|
|
741
|
+
if (unknown.length)
|
|
742
|
+
out.issues.push(`hallucinated keys: ${unknown.join(", ")}`);
|
|
743
|
+
const bad = Object.entries(obj)
|
|
744
|
+
.filter(([k]) => k in schema)
|
|
745
|
+
.map(([k, v]) => checkValue(k, v, schema[k]))
|
|
746
|
+
.filter(Boolean);
|
|
747
|
+
out.types = bad.length === 0;
|
|
748
|
+
out.issues.push(...bad);
|
|
749
|
+
out.patch = obj;
|
|
750
|
+
return out;
|
|
751
|
+
}
|
|
752
|
+
/** The diff view: how the component would be written before and after.
|
|
753
|
+
* Dot-paths are re-nested so this reads as real JSX, and key order comes
|
|
754
|
+
* from the schema so before/after stay line-aligned. */
|
|
755
|
+
export function toJsx(name, flat) {
|
|
756
|
+
const out = [`<${name}`];
|
|
757
|
+
let group = null;
|
|
758
|
+
for (const [k, v] of Object.entries(flat)) {
|
|
759
|
+
const dot = k.indexOf(".");
|
|
760
|
+
const prefix = dot === -1 ? null : k.slice(0, dot);
|
|
761
|
+
if (prefix !== group) {
|
|
762
|
+
if (group)
|
|
763
|
+
out.push(" }}");
|
|
764
|
+
if (prefix)
|
|
765
|
+
out.push(` ${prefix}={{`);
|
|
766
|
+
group = prefix;
|
|
767
|
+
}
|
|
768
|
+
if (prefix)
|
|
769
|
+
out.push(` ${k.slice(dot + 1)}: ${JSON.stringify(v)},`);
|
|
770
|
+
else if (typeof v === "string")
|
|
771
|
+
out.push(` ${k}="${v}"`);
|
|
772
|
+
else
|
|
773
|
+
out.push(` ${k}={${JSON.stringify(v)}}`);
|
|
774
|
+
}
|
|
775
|
+
if (group)
|
|
776
|
+
out.push(" }}");
|
|
777
|
+
out.push("/>");
|
|
778
|
+
return out.join("\n");
|
|
779
|
+
}
|
|
780
|
+
/*
|
|
781
|
+
* ── Sections ──────────────────────────────────────────────────────────────
|
|
782
|
+
*
|
|
783
|
+
* A different question from the rest of this file. Everything above measures a
|
|
784
|
+
* model against a component that already exists: patch its props, or edit its
|
|
785
|
+
* source. A section starts from a sentence and a blank file, so there is no
|
|
786
|
+
* schema to show, nothing to refuse, and the first turn is always code.
|
|
787
|
+
*
|
|
788
|
+
* Styling is Tailwind, which is only true because the preview says so: the
|
|
789
|
+
* stage is an iframe carrying Tailwind's browser build. The app's own CSS is
|
|
790
|
+
* compiled from ITS source at build time, so a class a model invents at
|
|
791
|
+
* runtime does not exist in it — a section styled with `className` would
|
|
792
|
+
* render unstyled on this page and the model would be blamed for it. The
|
|
793
|
+
* iframe is what makes this instruction honest.
|
|
794
|
+
*/
|
|
795
|
+
const SECTION_RULES = [
|
|
796
|
+
"Rules, in the order they get things wrong:",
|
|
797
|
+
" - Return the WHOLE file and nothing else. No prose, no markdown fence, no",
|
|
798
|
+
" explanation before or after. The first character is an import or a",
|
|
799
|
+
" keyword.",
|
|
800
|
+
" - One default export, a component taking NO required props. It is rendered",
|
|
801
|
+
" as <Section /> and nothing is passed to it.",
|
|
802
|
+
" - Style with Tailwind utility classes. No CSS files, no styled-components,",
|
|
803
|
+
" no <style> tag, no Tailwind config — utilities only, plus inline style",
|
|
804
|
+
" for values Tailwind cannot express (a computed transform, a gradient",
|
|
805
|
+
" stop you animate).",
|
|
806
|
+
" - Imports may only come from: react, motion, three, ogl, lucide-react.",
|
|
807
|
+
" Most sections need only react and lucide-react. ANY OTHER IMPORT IS",
|
|
808
|
+
" REPLACED BY AN EMPTY PLACEHOLDER at render time — the page still loads,",
|
|
809
|
+
" but whatever you imported draws nothing. Inline SVG never has that",
|
|
810
|
+
" problem.",
|
|
811
|
+
" - NO REMOTE ASSETS. No <img src=\"http…\">, no icon package, no web font, no",
|
|
812
|
+
" fetch. The preview cannot load them. Draw icons and imagery as inline",
|
|
813
|
+
" SVG, CSS gradients or shapes.",
|
|
814
|
+
" - Full-bleed and responsive: the root fills its container's width and",
|
|
815
|
+
" reads correctly from 360px to 1440px. Never set a fixed page width.",
|
|
816
|
+
" - Real copy, real numbers, real names. Lorem ipsum and `Feature one` are",
|
|
817
|
+
" the difference between a section and a wireframe.",
|
|
818
|
+
" - Semantic HTML: <section>, one <h1>/<h2> in order, <button> for actions,",
|
|
819
|
+
" <a href=\"#\"> for links that go nowhere yet, alt text on meaningful",
|
|
820
|
+
" imagery and aria-hidden on decoration.",
|
|
821
|
+
" - Dark by default — the stage's background is #0f0f10. Paint your own",
|
|
822
|
+
" background on the root rather than inheriting it.",
|
|
823
|
+
"",
|
|
824
|
+
"ONE EXCEPTION. If the request explicitly asks for a standalone HTML page —",
|
|
825
|
+
"an index.html, a single self-contained file — return that document instead,",
|
|
826
|
+
"complete from <!doctype html>, and none of the TSX rules above apply: it is",
|
|
827
|
+
"rendered as its own page, so it carries its own <style> and its own script.",
|
|
828
|
+
"Everything about remote assets still holds — nothing loads from a network.",
|
|
829
|
+
].join("\n");
|
|
830
|
+
/** The edit rules for a source that turned out to be a whole HTML document.
|
|
831
|
+
* Short on purpose: the TSX rules would be actively wrong here, and a model
|
|
832
|
+
* reading instructions that contradict the file in front of it starts
|
|
833
|
+
* rewriting the file into what the instructions describe. */
|
|
834
|
+
const HTML_RULES = [
|
|
835
|
+
"This file is a standalone HTML document. Keep it one:",
|
|
836
|
+
" - Everything inline. No external CSS, JS, fonts or images — nothing loads",
|
|
837
|
+
" from a network in the preview.",
|
|
838
|
+
" - Keep the document valid and self-contained; do not convert it to a",
|
|
839
|
+
" framework component.",
|
|
840
|
+
].join("\n");
|
|
841
|
+
/*
|
|
842
|
+
* Which of the two things the answer is.
|
|
843
|
+
*
|
|
844
|
+
* The page asks for a TSX component and most requests get one. But "recreate
|
|
845
|
+
* this as a single self-contained index.html" is a real request people make,
|
|
846
|
+
* and a model that obeys it is not wrong — it was told to. The stage is an
|
|
847
|
+
* iframe either way, so a whole document is if anything easier to render than
|
|
848
|
+
* a component; failing the turn over it would be the tool refusing an answer
|
|
849
|
+
* it can display.
|
|
850
|
+
*/
|
|
851
|
+
export const isHtmlDoc = (source) => /^\s*(<!doctype\s+html|<html[\s>])/i.test(source);
|
|
852
|
+
/** Turn one: there is no file yet, so the model writes one. Every line of this
|
|
853
|
+
* prompt is stable, which makes the whole system block cacheable across every
|
|
854
|
+
* section a model is ever asked for. */
|
|
855
|
+
export function buildSectionSystem() {
|
|
856
|
+
return [
|
|
857
|
+
"You write one self-contained React section component — a hero, a pricing",
|
|
858
|
+
"table, a footer, whatever the request names — as a single TSX file.",
|
|
859
|
+
"",
|
|
860
|
+
SECTION_RULES,
|
|
861
|
+
"",
|
|
862
|
+
HOUSE_RULES,
|
|
863
|
+
].join("\n");
|
|
864
|
+
}
|
|
865
|
+
/*
|
|
866
|
+
* Turn two onward. Same contract as create: the whole file.
|
|
867
|
+
*/
|
|
868
|
+
export function buildSectionEditSystem(source) {
|
|
869
|
+
const html = isHtmlDoc(source);
|
|
870
|
+
return [
|
|
871
|
+
html
|
|
872
|
+
? "Rewrite this HTML page so it already does what the user asked. You may change any part of the file."
|
|
873
|
+
: "Rewrite this React section so it already does what the user asked. You may change any part of the file.",
|
|
874
|
+
"",
|
|
875
|
+
html ? HTML_RULES : SECTION_RULES,
|
|
876
|
+
"",
|
|
877
|
+
"CURRENT SOURCE:",
|
|
878
|
+
source,
|
|
879
|
+
"",
|
|
880
|
+
"Change the file so the request is already visible. Returning it unchanged fails.",
|
|
881
|
+
].join("\n");
|
|
882
|
+
}
|
|
883
|
+
//# sourceMappingURL=prompt.js.map
|