| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- import type { Spec } from "./types";
- /**
- * Edit mode for modifying an existing spec.
- *
- * - `"patch"` — RFC 6902 JSON Patch. One operation per line.
- * - `"merge"` — RFC 7396 JSON Merge Patch. Partial object deep-merged; `null` deletes.
- * - `"diff"` — Unified diff (POSIX). Line-level text edits against the serialized spec.
- */
- export type EditMode = "patch" | "merge" | "diff";
- export interface EditConfig {
- /** Which edit modes are enabled. When >1, the AI chooses per edit. */
- modes: EditMode[];
- }
- const DEFAULT_MODES: EditMode[] = ["patch"];
- function normalizeModes(config?: EditConfig): EditMode[] {
- if (!config?.modes?.length) return DEFAULT_MODES;
- return config.modes;
- }
- // ── JSON-format instructions ──
- function jsonPatchInstructions(): string {
- return [
- "PATCH MODE (RFC 6902 JSON Patch):",
- "Output one JSON object per line. Each line is a patch operation.",
- '- Add: {"op":"add","path":"/elements/new-key","value":{...}}',
- '- Replace: {"op":"replace","path":"/elements/existing-key","value":{...}}',
- '- Remove: {"op":"remove","path":"/elements/old-key"}',
- "Only output patches for what needs to change.",
- ].join("\n");
- }
- function jsonMergeInstructions(): string {
- return [
- "MERGE MODE (RFC 7396 JSON Merge Patch):",
- "Output a single JSON object on one line with __json_edit set to true.",
- "Include only the keys that changed. Unmentioned keys are preserved.",
- "Set a key to null to delete it.",
- "",
- "Example (update a title and add an element):",
- '{"__json_edit":true,"elements":{"main":{"props":{"title":"New Title"}},"new-el":{"type":"Card","props":{},"children":[]}}}',
- "",
- "Example (delete an element):",
- '{"__json_edit":true,"elements":{"old-widget":null}}',
- ].join("\n");
- }
- function jsonDiffInstructions(): string {
- return [
- "DIFF MODE (unified diff):",
- "Output a unified diff inside a ```diff code fence.",
- "The diff applies against the JSON-serialized current spec.",
- "",
- "Example:",
- "```diff",
- "--- a/spec.json",
- "+++ b/spec.json",
- "@@ -3,1 +3,1 @@",
- '- "title": "Login"',
- '+ "title": "Welcome Back"',
- "```",
- ].join("\n");
- }
- // ── YAML-format instructions ──
- function yamlPatchInstructions(): string {
- return [
- "PATCH MODE (RFC 6902 JSON Patch):",
- "Output RFC 6902 JSON Patch lines inside a ```yaml-patch code fence.",
- "Each line is one JSON patch operation.",
- "",
- "Example:",
- "```yaml-patch",
- '{"op":"replace","path":"/elements/main/props/title","value":"New Title"}',
- '{"op":"add","path":"/elements/new-el","value":{"type":"Card","props":{},"children":[]}}',
- "```",
- ].join("\n");
- }
- function yamlMergeInstructions(): string {
- return [
- "MERGE MODE (RFC 7396 JSON Merge Patch):",
- "Output only the changed parts in a ```yaml-edit code fence.",
- "Uses deep merge semantics: only keys you include are updated. Unmentioned elements and props are preserved.",
- "Set a key to null to delete it.",
- "",
- "Example edit (update title, add a new element):",
- "```yaml-edit",
- "elements:",
- " main:",
- " props:",
- " title: Updated Title",
- " new-chart:",
- " type: Card",
- " props: {}",
- " children: []",
- "```",
- "",
- "Example deletion:",
- "```yaml-edit",
- "elements:",
- " old-widget: null",
- "```",
- ].join("\n");
- }
- function yamlDiffInstructions(): string {
- return [
- "DIFF MODE (unified diff):",
- "Output a unified diff inside a ```diff code fence.",
- "The diff applies against the YAML-serialized current spec.",
- "",
- "Example:",
- "```diff",
- "--- a/spec.yaml",
- "+++ b/spec.yaml",
- "@@ -6,1 +6,1 @@",
- "- title: Login",
- "+ title: Welcome Back",
- "```",
- ].join("\n");
- }
- // ── Mode selection guidance ──
- function modeSelectionGuidance(modes: EditMode[]): string {
- if (modes.length === 1) return "";
- const parts = ["Choose the best edit strategy for the requested change:"];
- if (modes.includes("patch")) {
- parts.push("- PATCH: best for precise, targeted single-field updates");
- }
- if (modes.includes("merge")) {
- parts.push(
- "- MERGE: best for structural changes (add/remove elements, reparent children, update multiple props at once)",
- );
- }
- if (modes.includes("diff")) {
- parts.push(
- "- DIFF: best for small text-level changes when you can see the exact lines to change",
- );
- }
- return parts.join("\n");
- }
- /**
- * Generate the prompt section describing available edit modes.
- * Only documents the modes that are enabled.
- */
- export function buildEditInstructions(
- config: EditConfig | undefined,
- format: "json" | "yaml",
- ): string {
- const modes = normalizeModes(config);
- const sections: string[] = [];
- sections.push("EDITING EXISTING SPECS:");
- sections.push("");
- const guidance = modeSelectionGuidance(modes);
- if (guidance) {
- sections.push(guidance);
- sections.push("");
- }
- for (const mode of modes) {
- if (format === "json") {
- switch (mode) {
- case "patch":
- sections.push(jsonPatchInstructions());
- break;
- case "merge":
- sections.push(jsonMergeInstructions());
- break;
- case "diff":
- sections.push(jsonDiffInstructions());
- break;
- }
- } else {
- switch (mode) {
- case "patch":
- sections.push(yamlPatchInstructions());
- break;
- case "merge":
- sections.push(yamlMergeInstructions());
- break;
- case "diff":
- sections.push(yamlDiffInstructions());
- break;
- }
- }
- sections.push("");
- }
- return sections.join("\n");
- }
- function addLineNumbers(text: string): string {
- const lines = text.split("\n");
- const width = String(lines.length).length;
- return lines
- .map((line, i) => `${String(i + 1).padStart(width)}| ${line}`)
- .join("\n");
- }
- export function isNonEmptySpec(spec: unknown): spec is Spec {
- if (!spec || typeof spec !== "object") return false;
- const s = spec as Record<string, unknown>;
- return (
- typeof s.root === "string" &&
- typeof s.elements === "object" &&
- s.elements !== null &&
- Object.keys(s.elements as object).length > 0
- );
- }
- export interface BuildEditUserPromptOptions {
- prompt: string;
- currentSpec?: Spec | null;
- config?: EditConfig;
- format: "json" | "yaml";
- maxPromptLength?: number;
- /** Serialise the spec. Defaults to JSON.stringify for json, must be provided for yaml. */
- serializer?: (spec: Spec) => string;
- }
- /**
- * Generate the user prompt for edits, including the current spec
- * (with line numbers when diff mode is enabled) and mode instructions.
- */
- export function buildEditUserPrompt(
- options: BuildEditUserPromptOptions,
- ): string {
- const { prompt, currentSpec, config, format, maxPromptLength, serializer } =
- options;
- let userText = String(prompt || "");
- if (maxPromptLength !== undefined && maxPromptLength > 0) {
- userText = userText.slice(0, maxPromptLength);
- }
- if (!isNonEmptySpec(currentSpec)) {
- return userText;
- }
- const modes = normalizeModes(config);
- const showLineNumbers = modes.includes("diff");
- const serialize = serializer ?? ((s: Spec) => JSON.stringify(s, null, 2));
- const specText = serialize(currentSpec);
- const parts: string[] = [];
- if (showLineNumbers) {
- parts.push("CURRENT UI STATE (line numbers for reference):");
- parts.push("```");
- parts.push(addLineNumbers(specText));
- parts.push("```");
- } else {
- parts.push(
- "CURRENT UI STATE (already loaded, DO NOT recreate existing elements):",
- );
- parts.push("```");
- parts.push(specText);
- parts.push("```");
- }
- parts.push("");
- parts.push(`USER REQUEST: ${userText}`);
- parts.push("");
- if (modes.length === 1) {
- const mode = modes[0]!;
- switch (mode) {
- case "patch":
- parts.push(
- format === "yaml"
- ? "Output ONLY the patches in a ```yaml-patch fence."
- : "Output ONLY the JSON Patch lines needed for the change.",
- );
- break;
- case "merge":
- parts.push(
- format === "yaml"
- ? "Output ONLY the changes in a ```yaml-edit fence. Include only keys that need to change."
- : "Output ONLY a single JSON merge line with __json_edit set to true. Include only keys that need to change.",
- );
- break;
- case "diff":
- parts.push("Output ONLY the unified diff in a ```diff fence.");
- break;
- }
- } else {
- const modeNames = modes.map((m) => {
- switch (m) {
- case "patch":
- return format === "yaml" ? "```yaml-patch fence" : "JSON Patch lines";
- case "merge":
- return format === "yaml"
- ? "```yaml-edit fence"
- : "JSON merge line (__json_edit)";
- case "diff":
- return "```diff fence";
- }
- });
- parts.push(
- `Choose the best edit strategy and output using one of: ${modeNames.join(", ")}`,
- );
- }
- return parts.join("\n");
- }
|