prompt.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. import { stringify } from "yaml";
  2. import type { Catalog, EditMode, SchemaDefinition } from "@json-render/core";
  3. import { buildEditInstructions } from "@json-render/core";
  4. interface ZodLike {
  5. _def?: Record<string, unknown>;
  6. }
  7. export interface YamlPromptOptions {
  8. /** Custom system message intro. */
  9. system?: string;
  10. /**
  11. * - `"standalone"` (default): LLM outputs only the YAML spec (no prose).
  12. * - `"inline"`: LLM responds conversationally, then wraps YAML in a fence.
  13. */
  14. mode?: "standalone" | "inline";
  15. /** Additional rules appended to the RULES section. */
  16. customRules?: string[];
  17. /** Edit modes to document. Default: `["merge"]` (yaml-edit). */
  18. editModes?: EditMode[];
  19. }
  20. interface CatalogComponentDef {
  21. props?: ZodLike;
  22. description?: string;
  23. slots?: string[];
  24. events?: string[];
  25. example?: Record<string, unknown>;
  26. }
  27. // ── Zod introspection (local, minimal) ──────────────────────────────────────
  28. function getZodTypeName(schema: ZodLike): string {
  29. if (!schema?._def) return "";
  30. const def = schema._def;
  31. return (
  32. (def.typeName as string | undefined) ??
  33. (typeof def.type === "string" ? (def.type as string) : "") ??
  34. ""
  35. );
  36. }
  37. function formatZodType(schema: ZodLike): string {
  38. if (!schema?._def) return "unknown";
  39. const def = schema._def;
  40. const typeName = getZodTypeName(schema);
  41. switch (typeName) {
  42. case "ZodString":
  43. case "string":
  44. return "string";
  45. case "ZodNumber":
  46. case "number":
  47. return "number";
  48. case "ZodBoolean":
  49. case "boolean":
  50. return "boolean";
  51. case "ZodLiteral":
  52. case "literal":
  53. return JSON.stringify(def.value);
  54. case "ZodEnum":
  55. case "enum": {
  56. let values: string[];
  57. if (Array.isArray(def.values)) {
  58. values = def.values as string[];
  59. } else if (def.entries && typeof def.entries === "object") {
  60. values = Object.values(def.entries as Record<string, string>);
  61. } else {
  62. return "enum";
  63. }
  64. return values.map((v) => `"${v}"`).join(" | ");
  65. }
  66. case "ZodArray":
  67. case "array": {
  68. const inner = (
  69. typeof def.element === "object"
  70. ? def.element
  71. : typeof def.type === "object"
  72. ? def.type
  73. : undefined
  74. ) as ZodLike | undefined;
  75. return inner ? `Array<${formatZodType(inner)}>` : "Array<unknown>";
  76. }
  77. case "ZodObject":
  78. case "object": {
  79. const shape =
  80. typeof def.shape === "function"
  81. ? (def.shape as () => Record<string, ZodLike>)()
  82. : (def.shape as Record<string, ZodLike>);
  83. if (!shape) return "object";
  84. const props = Object.entries(shape)
  85. .map(([key, value]) => {
  86. const innerTypeName = getZodTypeName(value);
  87. const isOptional =
  88. innerTypeName === "ZodOptional" ||
  89. innerTypeName === "ZodNullable" ||
  90. innerTypeName === "optional" ||
  91. innerTypeName === "nullable";
  92. return `${key}${isOptional ? "?" : ""}: ${formatZodType(value)}`;
  93. })
  94. .join(", ");
  95. return `{ ${props} }`;
  96. }
  97. case "ZodOptional":
  98. case "optional":
  99. case "ZodNullable":
  100. case "nullable": {
  101. const inner = (def.innerType as ZodLike) ?? (def.wrapped as ZodLike);
  102. return inner ? formatZodType(inner) : "unknown";
  103. }
  104. case "ZodUnion":
  105. case "union": {
  106. const options = def.options as ZodLike[] | undefined;
  107. return options
  108. ? options.map((opt) => formatZodType(opt)).join(" | ")
  109. : "unknown";
  110. }
  111. default:
  112. return "unknown";
  113. }
  114. }
  115. function getExampleProps(def: CatalogComponentDef): Record<string, unknown> {
  116. if (def.example && Object.keys(def.example).length > 0) return def.example;
  117. if (!def.props?._def) return {};
  118. const zodDef = def.props._def;
  119. const typeName = getZodTypeName(def.props);
  120. if (typeName !== "ZodObject" && typeName !== "object") return {};
  121. const shape =
  122. typeof zodDef.shape === "function"
  123. ? (zodDef.shape as () => Record<string, ZodLike>)()
  124. : (zodDef.shape as Record<string, ZodLike>);
  125. if (!shape) return {};
  126. const result: Record<string, unknown> = {};
  127. for (const [key, value] of Object.entries(shape)) {
  128. const inner = getZodTypeName(value);
  129. if (
  130. inner === "ZodOptional" ||
  131. inner === "optional" ||
  132. inner === "ZodNullable" ||
  133. inner === "nullable"
  134. )
  135. continue;
  136. result[key] = exampleValue(value);
  137. }
  138. return result;
  139. }
  140. function exampleValue(schema: ZodLike): unknown {
  141. if (!schema?._def) return "...";
  142. const def = schema._def;
  143. const t = getZodTypeName(schema);
  144. switch (t) {
  145. case "ZodString":
  146. case "string":
  147. return "example";
  148. case "ZodNumber":
  149. case "number":
  150. return 0;
  151. case "ZodBoolean":
  152. case "boolean":
  153. return true;
  154. case "ZodLiteral":
  155. case "literal":
  156. return def.value;
  157. case "ZodEnum":
  158. case "enum": {
  159. if (Array.isArray(def.values) && (def.values as unknown[]).length > 0)
  160. return (def.values as unknown[])[0];
  161. if (def.entries && typeof def.entries === "object") {
  162. const vals = Object.values(def.entries as Record<string, string>);
  163. return vals.length > 0 ? vals[0] : "example";
  164. }
  165. return "example";
  166. }
  167. case "ZodOptional":
  168. case "optional":
  169. case "ZodNullable":
  170. case "nullable":
  171. case "ZodDefault":
  172. case "default": {
  173. const inner = (def.innerType as ZodLike) ?? (def.wrapped as ZodLike);
  174. return inner ? exampleValue(inner) : null;
  175. }
  176. case "ZodArray":
  177. case "array":
  178. return [];
  179. case "ZodObject":
  180. case "object":
  181. return getExampleProps({ props: schema } as CatalogComponentDef);
  182. case "ZodUnion":
  183. case "union": {
  184. const options = def.options as ZodLike[] | undefined;
  185. return options && options.length > 0 ? exampleValue(options[0]!) : "...";
  186. }
  187. default:
  188. return "...";
  189. }
  190. }
  191. // ── YAML helper ──
  192. /** Render a value as an indented YAML string (2-space indent). */
  193. function toYaml(value: unknown): string {
  194. return stringify(value, { indent: 2 }).trimEnd();
  195. }
  196. // ── Prompt generation ──
  197. /**
  198. * Generate a YAML-format system prompt from any json-render catalog.
  199. *
  200. * Works with catalogs from any renderer (`@json-render/react`,
  201. * `@json-render/vue`, etc.) — it only reads the catalog metadata.
  202. *
  203. * @example
  204. * ```ts
  205. * import { yamlPrompt } from "@json-render/yaml";
  206. * const systemPrompt = yamlPrompt(catalog, { mode: "inline" });
  207. * ```
  208. */
  209. export function yamlPrompt(
  210. catalog: Catalog<SchemaDefinition, unknown>,
  211. options?: YamlPromptOptions,
  212. ): string {
  213. const {
  214. system = "You are a UI generator that outputs YAML.",
  215. mode = "standalone",
  216. customRules = [],
  217. editModes = ["merge"],
  218. } = options ?? {};
  219. const lines: string[] = [];
  220. lines.push(system);
  221. lines.push("");
  222. // ── Output format ──
  223. if (mode === "inline") {
  224. lines.push("OUTPUT FORMAT (text + YAML):");
  225. lines.push(
  226. "You respond conversationally. When generating UI, first write a brief explanation (1-3 sentences), then output the YAML spec wrapped in a ```yaml-spec code fence.",
  227. );
  228. lines.push(
  229. "If the user's message does not require a UI (e.g. a greeting or clarifying question), respond with text only — no YAML.",
  230. );
  231. } else {
  232. lines.push("OUTPUT FORMAT (YAML):");
  233. lines.push(
  234. "Output a YAML document that describes a UI tree. Wrap it in a ```yaml-spec code fence.",
  235. );
  236. }
  237. lines.push("");
  238. lines.push(
  239. "The YAML document has three top-level keys: root, elements, and state (optional).",
  240. );
  241. lines.push(
  242. "Stream progressively — output elements one at a time so the UI fills in as you write.",
  243. );
  244. lines.push("");
  245. // ── Example ──
  246. const allComponents = (catalog.data as Record<string, unknown>).components as
  247. | Record<string, CatalogComponentDef>
  248. | undefined;
  249. const cn = catalog.componentNames;
  250. const comp1 = cn[0] || "Component";
  251. const comp2 = cn.length > 1 ? cn[1]! : comp1;
  252. const comp1Def = allComponents?.[comp1];
  253. const comp2Def = allComponents?.[comp2];
  254. const comp1Props = comp1Def ? getExampleProps(comp1Def) : {};
  255. const comp2Props = comp2Def ? getExampleProps(comp2Def) : {};
  256. const exampleSpec = {
  257. root: "main",
  258. elements: {
  259. main: {
  260. type: comp1,
  261. props: comp1Props,
  262. children: ["child-1", "list"],
  263. },
  264. "child-1": {
  265. type: comp2,
  266. props: comp2Props,
  267. children: [],
  268. },
  269. list: {
  270. type: comp1,
  271. props: comp1Props,
  272. repeat: { statePath: "/items", key: "id" },
  273. children: ["item"],
  274. },
  275. item: {
  276. type: comp2,
  277. props: { ...comp2Props },
  278. children: [],
  279. },
  280. },
  281. state: {
  282. items: [
  283. { id: "1", title: "First Item" },
  284. { id: "2", title: "Second Item" },
  285. ],
  286. },
  287. };
  288. lines.push("Example:");
  289. lines.push("");
  290. lines.push("```yaml-spec");
  291. lines.push(toYaml(exampleSpec));
  292. lines.push("```");
  293. lines.push("");
  294. // ── Edit modes (dynamic based on config) ──
  295. lines.push(buildEditInstructions({ modes: editModes }, "yaml"));
  296. // ── Initial state ──
  297. lines.push("INITIAL STATE:");
  298. lines.push(
  299. "The spec includes a top-level `state` key to seed the state model. Components using $state, $bindState, $bindItem, $item, or $index read from this state.",
  300. );
  301. lines.push(
  302. "CRITICAL: You MUST include state whenever your UI displays data via these expressions or uses repeat to iterate over arrays.",
  303. );
  304. lines.push(
  305. "Include realistic sample data. For lists: 3-5 items with relevant fields. Never leave arrays empty.",
  306. );
  307. lines.push(
  308. 'When content comes from the state model, use { "$state": "/some/path" } dynamic props instead of hardcoding values.',
  309. );
  310. lines.push(
  311. 'State paths use RFC 6901 JSON Pointer syntax (e.g. "/todos/0/title"). Do NOT use dot notation.',
  312. );
  313. lines.push("");
  314. // ── Dynamic lists ──
  315. lines.push("DYNAMIC LISTS (repeat field):");
  316. lines.push(
  317. "Any element can have a top-level `repeat` field to render its children once per item in a state array.",
  318. );
  319. lines.push("Example:");
  320. lines.push("");
  321. lines.push(
  322. toYaml({
  323. list: {
  324. type: comp1,
  325. props: comp1Props,
  326. repeat: { statePath: "/todos", key: "id" },
  327. children: ["todo-item"],
  328. },
  329. }),
  330. );
  331. lines.push("");
  332. lines.push(
  333. 'Inside repeated children, use { "$item": "field" } for item data and { "$index": true } for the array index.',
  334. );
  335. lines.push(
  336. "ALWAYS use repeat for lists backed by state arrays. NEVER hardcode individual elements per item.",
  337. );
  338. lines.push(
  339. "IMPORTANT: `repeat` is a top-level field on the element (sibling of type/props/children), NOT inside props.",
  340. );
  341. lines.push("");
  342. // ── Array state actions ──
  343. lines.push("ARRAY STATE ACTIONS:");
  344. lines.push(
  345. 'Use "pushState" to append items to arrays. Use "removeState" to remove items by index.',
  346. );
  347. lines.push('Use "$id" inside pushState values to auto-generate a unique ID.');
  348. lines.push("");
  349. // ── Components ──
  350. if (allComponents) {
  351. lines.push(`AVAILABLE COMPONENTS (${catalog.componentNames.length}):`);
  352. lines.push("");
  353. for (const [name, def] of Object.entries(allComponents)) {
  354. const propsStr = def.props ? formatZodType(def.props) : "{}";
  355. const hasChildren = def.slots && def.slots.length > 0;
  356. const childrenStr = hasChildren ? " [accepts children]" : "";
  357. const eventsStr =
  358. def.events && def.events.length > 0
  359. ? ` [events: ${def.events.join(", ")}]`
  360. : "";
  361. const descStr = def.description ? ` - ${def.description}` : "";
  362. lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
  363. }
  364. lines.push("");
  365. }
  366. // ── Actions ──
  367. const actions = (catalog.data as Record<string, unknown>).actions as
  368. | Record<string, { params?: ZodLike; description?: string }>
  369. | undefined;
  370. const builtInActions = catalog.schema.builtInActions ?? [];
  371. const hasCustomActions = actions && catalog.actionNames.length > 0;
  372. const hasBuiltInActions = builtInActions.length > 0;
  373. if (hasCustomActions || hasBuiltInActions) {
  374. lines.push("AVAILABLE ACTIONS:");
  375. lines.push("");
  376. for (const action of builtInActions) {
  377. lines.push(`- ${action.name}: ${action.description} [built-in]`);
  378. }
  379. if (hasCustomActions) {
  380. for (const [name, def] of Object.entries(actions)) {
  381. lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
  382. }
  383. }
  384. lines.push("");
  385. }
  386. // ── Events ──
  387. lines.push("EVENTS (the `on` field):");
  388. lines.push(
  389. "Elements can have an optional `on` field to bind events to actions. It is a top-level field (sibling of type/props/children), NOT inside props.",
  390. );
  391. lines.push("Example:");
  392. lines.push("");
  393. lines.push(
  394. toYaml({
  395. "save-btn": {
  396. type: comp1,
  397. props: comp1Props,
  398. on: {
  399. press: {
  400. action: "setState",
  401. params: { statePath: "/saved", value: true },
  402. },
  403. },
  404. children: [],
  405. },
  406. }),
  407. );
  408. lines.push("");
  409. lines.push(
  410. 'Action params can use dynamic references: { "$state": "/statePath" }.',
  411. );
  412. lines.push(
  413. "IMPORTANT: Do NOT put action/actionParams inside props. Always use the `on` field.",
  414. );
  415. lines.push("");
  416. // ── Visibility ──
  417. lines.push("VISIBILITY CONDITIONS:");
  418. lines.push(
  419. "Elements can have an optional `visible` field to conditionally show/hide based on state. It is a top-level field (sibling of type/props/children).",
  420. );
  421. lines.push("Conditions:");
  422. lines.push('- { "$state": "/path" } — visible when truthy');
  423. lines.push('- { "$state": "/path", "not": true } — visible when falsy');
  424. lines.push('- { "$state": "/path", "eq": "value" } — visible when equal');
  425. lines.push(
  426. '- { "$state": "/path", "neq": "value" } — visible when not equal',
  427. );
  428. lines.push(
  429. '- { "$state": "/path", "gt": N } / gte / lt / lte — numeric comparisons',
  430. );
  431. lines.push("- Use ONE operator per condition. Do not combine multiple.");
  432. lines.push('- Add "not": true to any condition to invert it.');
  433. lines.push("- [cond, cond] — implicit AND (all must be true)");
  434. lines.push('- { "$and": [...] } — explicit AND');
  435. lines.push('- { "$or": [...] } — at least one must be true');
  436. lines.push("- true / false — always visible/hidden");
  437. lines.push("");
  438. // ── Dynamic props ──
  439. lines.push("DYNAMIC PROPS:");
  440. lines.push("Any prop value can be a dynamic expression:");
  441. lines.push(
  442. '1. Read-only: { "$state": "/path" } — resolves to the value at that state path.',
  443. );
  444. lines.push(
  445. '2. Two-way binding: { "$bindState": "/path" } — read + write. Use on form inputs.',
  446. );
  447. lines.push(
  448. ' Inside repeat scopes: { "$bindItem": "field" } for item-level binding.',
  449. );
  450. lines.push(
  451. '3. Conditional: { "$cond": <condition>, "$then": <val>, "$else": <val> }',
  452. );
  453. lines.push(
  454. '4. Template: { "$template": "Hello, ${/name}!" } — interpolates state references.',
  455. );
  456. lines.push("");
  457. // ── $computed (only if catalog has functions) ──
  458. const catalogFunctions = (catalog.data as Record<string, unknown>).functions;
  459. if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
  460. lines.push(
  461. '5. Computed: { "$computed": "<fn>", "args": { "key": <expr> } }',
  462. );
  463. lines.push(" Available functions:");
  464. for (const name of Object.keys(
  465. catalogFunctions as Record<string, unknown>,
  466. )) {
  467. lines.push(` - ${name}`);
  468. }
  469. lines.push("");
  470. }
  471. // ── Validation (only if components have checks) ──
  472. const hasChecks = allComponents
  473. ? Object.values(allComponents).some((def) => {
  474. if (!def.props) return false;
  475. return formatZodType(def.props).includes("checks");
  476. })
  477. : false;
  478. if (hasChecks) {
  479. lines.push("VALIDATION:");
  480. lines.push(
  481. "Form components with a `checks` prop support client-side validation.",
  482. );
  483. lines.push(
  484. "Built-in types: required, email, minLength, maxLength, pattern, min, max, numeric, url, matches, equalTo, lessThan, greaterThan, requiredIf.",
  485. );
  486. lines.push(
  487. "IMPORTANT: Components with checks must also use $bindState or $bindItem for two-way binding.",
  488. );
  489. lines.push("");
  490. }
  491. // ── State watchers ──
  492. if (hasCustomActions || hasBuiltInActions) {
  493. lines.push("STATE WATCHERS:");
  494. lines.push(
  495. "Elements can have an optional `watch` field to trigger actions when state changes. Top-level field, NOT inside props.",
  496. );
  497. lines.push(
  498. "Maps state paths to action bindings. Fires when the watched value changes (not on initial render).",
  499. );
  500. lines.push("");
  501. }
  502. // ── Rules ──
  503. lines.push("RULES:");
  504. const baseRules =
  505. mode === "inline"
  506. ? [
  507. "When generating UI, wrap the YAML in a ```yaml-spec code fence",
  508. "Write a brief conversational response before the YAML",
  509. "When editing existing UI, use a ```yaml-edit fence with only the changed parts",
  510. "The document must have: root (string), elements (map of elements), and optionally state",
  511. "Each element must have: type, props, children (list of child keys)",
  512. "ONLY use components listed above",
  513. "Use unique, descriptive keys for elements (e.g. 'header', 'metric-1', 'chart-revenue')",
  514. "Include state whenever using $state, $bindState, $bindItem, $item, $index, or repeat",
  515. ]
  516. : [
  517. "Output ONLY the YAML spec inside a ```yaml-spec fence — no prose, no extra markdown",
  518. "When editing existing UI, use a ```yaml-edit fence with only the changed parts",
  519. "The document must have: root (string), elements (map of elements), and optionally state",
  520. "Each element must have: type, props, children (list of child keys)",
  521. "ONLY use components listed above",
  522. "Use unique, descriptive keys for elements (e.g. 'header', 'metric-1', 'chart-revenue')",
  523. "Include state whenever using $state, $bindState, $bindItem, $item, $index, or repeat",
  524. ];
  525. const schemaRules = catalog.schema.defaultRules ?? [];
  526. const allRules = [...baseRules, ...schemaRules, ...customRules];
  527. allRules.forEach((rule, i) => {
  528. lines.push(`${i + 1}. ${rule}`);
  529. });
  530. return lines.join("\n");
  531. }