edit-modes.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import type { Spec } from "./types";
  2. /**
  3. * Edit mode for modifying an existing spec.
  4. *
  5. * - `"patch"` — RFC 6902 JSON Patch. One operation per line.
  6. * - `"merge"` — RFC 7396 JSON Merge Patch. Partial object deep-merged; `null` deletes.
  7. * - `"diff"` — Unified diff (POSIX). Line-level text edits against the serialized spec.
  8. */
  9. export type EditMode = "patch" | "merge" | "diff";
  10. export interface EditConfig {
  11. /** Which edit modes are enabled. When >1, the AI chooses per edit. */
  12. modes: EditMode[];
  13. }
  14. const DEFAULT_MODES: EditMode[] = ["patch"];
  15. function normalizeModes(config?: EditConfig): EditMode[] {
  16. if (!config?.modes?.length) return DEFAULT_MODES;
  17. return config.modes;
  18. }
  19. // ── JSON-format instructions ──
  20. function jsonPatchInstructions(): string {
  21. return [
  22. "PATCH MODE (RFC 6902 JSON Patch):",
  23. "Output one JSON object per line. Each line is a patch operation.",
  24. '- Add: {"op":"add","path":"/elements/new-key","value":{...}}',
  25. '- Replace: {"op":"replace","path":"/elements/existing-key","value":{...}}',
  26. '- Remove: {"op":"remove","path":"/elements/old-key"}',
  27. "Only output patches for what needs to change.",
  28. ].join("\n");
  29. }
  30. function jsonMergeInstructions(): string {
  31. return [
  32. "MERGE MODE (RFC 7396 JSON Merge Patch):",
  33. "Output a single JSON object on one line with __json_edit set to true.",
  34. "Include only the keys that changed. Unmentioned keys are preserved.",
  35. "Set a key to null to delete it.",
  36. "",
  37. "Example (update a title and add an element):",
  38. '{"__json_edit":true,"elements":{"main":{"props":{"title":"New Title"}},"new-el":{"type":"Card","props":{},"children":[]}}}',
  39. "",
  40. "Example (delete an element):",
  41. '{"__json_edit":true,"elements":{"old-widget":null}}',
  42. ].join("\n");
  43. }
  44. function jsonDiffInstructions(): string {
  45. return [
  46. "DIFF MODE (unified diff):",
  47. "Output a unified diff inside a ```diff code fence.",
  48. "The diff applies against the JSON-serialized current spec.",
  49. "",
  50. "Example:",
  51. "```diff",
  52. "--- a/spec.json",
  53. "+++ b/spec.json",
  54. "@@ -3,1 +3,1 @@",
  55. '- "title": "Login"',
  56. '+ "title": "Welcome Back"',
  57. "```",
  58. ].join("\n");
  59. }
  60. // ── YAML-format instructions ──
  61. function yamlPatchInstructions(): string {
  62. return [
  63. "PATCH MODE (RFC 6902 JSON Patch):",
  64. "Output RFC 6902 JSON Patch lines inside a ```yaml-patch code fence.",
  65. "Each line is one JSON patch operation.",
  66. "",
  67. "Example:",
  68. "```yaml-patch",
  69. '{"op":"replace","path":"/elements/main/props/title","value":"New Title"}',
  70. '{"op":"add","path":"/elements/new-el","value":{"type":"Card","props":{},"children":[]}}',
  71. "```",
  72. ].join("\n");
  73. }
  74. function yamlMergeInstructions(): string {
  75. return [
  76. "MERGE MODE (RFC 7396 JSON Merge Patch):",
  77. "Output only the changed parts in a ```yaml-edit code fence.",
  78. "Uses deep merge semantics: only keys you include are updated. Unmentioned elements and props are preserved.",
  79. "Set a key to null to delete it.",
  80. "",
  81. "Example edit (update title, add a new element):",
  82. "```yaml-edit",
  83. "elements:",
  84. " main:",
  85. " props:",
  86. " title: Updated Title",
  87. " new-chart:",
  88. " type: Card",
  89. " props: {}",
  90. " children: []",
  91. "```",
  92. "",
  93. "Example deletion:",
  94. "```yaml-edit",
  95. "elements:",
  96. " old-widget: null",
  97. "```",
  98. ].join("\n");
  99. }
  100. function yamlDiffInstructions(): string {
  101. return [
  102. "DIFF MODE (unified diff):",
  103. "Output a unified diff inside a ```diff code fence.",
  104. "The diff applies against the YAML-serialized current spec.",
  105. "",
  106. "Example:",
  107. "```diff",
  108. "--- a/spec.yaml",
  109. "+++ b/spec.yaml",
  110. "@@ -6,1 +6,1 @@",
  111. "- title: Login",
  112. "+ title: Welcome Back",
  113. "```",
  114. ].join("\n");
  115. }
  116. // ── Mode selection guidance ──
  117. function modeSelectionGuidance(modes: EditMode[]): string {
  118. if (modes.length === 1) return "";
  119. const parts = ["Choose the best edit strategy for the requested change:"];
  120. if (modes.includes("patch")) {
  121. parts.push("- PATCH: best for precise, targeted single-field updates");
  122. }
  123. if (modes.includes("merge")) {
  124. parts.push(
  125. "- MERGE: best for structural changes (add/remove elements, reparent children, update multiple props at once)",
  126. );
  127. }
  128. if (modes.includes("diff")) {
  129. parts.push(
  130. "- DIFF: best for small text-level changes when you can see the exact lines to change",
  131. );
  132. }
  133. return parts.join("\n");
  134. }
  135. /**
  136. * Generate the prompt section describing available edit modes.
  137. * Only documents the modes that are enabled.
  138. */
  139. export function buildEditInstructions(
  140. config: EditConfig | undefined,
  141. format: "json" | "yaml",
  142. ): string {
  143. const modes = normalizeModes(config);
  144. const sections: string[] = [];
  145. sections.push("EDITING EXISTING SPECS:");
  146. sections.push("");
  147. const guidance = modeSelectionGuidance(modes);
  148. if (guidance) {
  149. sections.push(guidance);
  150. sections.push("");
  151. }
  152. for (const mode of modes) {
  153. if (format === "json") {
  154. switch (mode) {
  155. case "patch":
  156. sections.push(jsonPatchInstructions());
  157. break;
  158. case "merge":
  159. sections.push(jsonMergeInstructions());
  160. break;
  161. case "diff":
  162. sections.push(jsonDiffInstructions());
  163. break;
  164. }
  165. } else {
  166. switch (mode) {
  167. case "patch":
  168. sections.push(yamlPatchInstructions());
  169. break;
  170. case "merge":
  171. sections.push(yamlMergeInstructions());
  172. break;
  173. case "diff":
  174. sections.push(yamlDiffInstructions());
  175. break;
  176. }
  177. }
  178. sections.push("");
  179. }
  180. return sections.join("\n");
  181. }
  182. function addLineNumbers(text: string): string {
  183. const lines = text.split("\n");
  184. const width = String(lines.length).length;
  185. return lines
  186. .map((line, i) => `${String(i + 1).padStart(width)}| ${line}`)
  187. .join("\n");
  188. }
  189. export function isNonEmptySpec(spec: unknown): spec is Spec {
  190. if (!spec || typeof spec !== "object") return false;
  191. const s = spec as Record<string, unknown>;
  192. return (
  193. typeof s.root === "string" &&
  194. typeof s.elements === "object" &&
  195. s.elements !== null &&
  196. Object.keys(s.elements as object).length > 0
  197. );
  198. }
  199. export interface BuildEditUserPromptOptions {
  200. prompt: string;
  201. currentSpec?: Spec | null;
  202. config?: EditConfig;
  203. format: "json" | "yaml";
  204. maxPromptLength?: number;
  205. /** Serialise the spec. Defaults to JSON.stringify for json, must be provided for yaml. */
  206. serializer?: (spec: Spec) => string;
  207. }
  208. /**
  209. * Generate the user prompt for edits, including the current spec
  210. * (with line numbers when diff mode is enabled) and mode instructions.
  211. */
  212. export function buildEditUserPrompt(
  213. options: BuildEditUserPromptOptions,
  214. ): string {
  215. const { prompt, currentSpec, config, format, maxPromptLength, serializer } =
  216. options;
  217. let userText = String(prompt || "");
  218. if (maxPromptLength !== undefined && maxPromptLength > 0) {
  219. userText = userText.slice(0, maxPromptLength);
  220. }
  221. if (!isNonEmptySpec(currentSpec)) {
  222. return userText;
  223. }
  224. const modes = normalizeModes(config);
  225. const showLineNumbers = modes.includes("diff");
  226. const serialize = serializer ?? ((s: Spec) => JSON.stringify(s, null, 2));
  227. const specText = serialize(currentSpec);
  228. const parts: string[] = [];
  229. if (showLineNumbers) {
  230. parts.push("CURRENT UI STATE (line numbers for reference):");
  231. parts.push("```");
  232. parts.push(addLineNumbers(specText));
  233. parts.push("```");
  234. } else {
  235. parts.push(
  236. "CURRENT UI STATE (already loaded, DO NOT recreate existing elements):",
  237. );
  238. parts.push("```");
  239. parts.push(specText);
  240. parts.push("```");
  241. }
  242. parts.push("");
  243. parts.push(`USER REQUEST: ${userText}`);
  244. parts.push("");
  245. if (modes.length === 1) {
  246. const mode = modes[0]!;
  247. switch (mode) {
  248. case "patch":
  249. parts.push(
  250. format === "yaml"
  251. ? "Output ONLY the patches in a ```yaml-patch fence."
  252. : "Output ONLY the JSON Patch lines needed for the change.",
  253. );
  254. break;
  255. case "merge":
  256. parts.push(
  257. format === "yaml"
  258. ? "Output ONLY the changes in a ```yaml-edit fence. Include only keys that need to change."
  259. : "Output ONLY a single JSON merge line with __json_edit set to true. Include only keys that need to change.",
  260. );
  261. break;
  262. case "diff":
  263. parts.push("Output ONLY the unified diff in a ```diff fence.");
  264. break;
  265. }
  266. } else {
  267. const modeNames = modes.map((m) => {
  268. switch (m) {
  269. case "patch":
  270. return format === "yaml" ? "```yaml-patch fence" : "JSON Patch lines";
  271. case "merge":
  272. return format === "yaml"
  273. ? "```yaml-edit fence"
  274. : "JSON merge line (__json_edit)";
  275. case "diff":
  276. return "```diff fence";
  277. }
  278. });
  279. parts.push(
  280. `Choose the best edit strategy and output using one of: ${modeNames.join(", ")}`,
  281. );
  282. }
  283. return parts.join("\n");
  284. }