Files
Chris Tate dc489601be add yaml wire format and universal edit modes (#219)
* add yaml wire format and universal edit modes

**Summary**

- Add `@json-render/yaml` package with streaming parser, YAML prompt generation, and AI SDK transform
- Add universal edit modes (patch, merge, diff) to `@json-render/core`, usable across both JSONL and YAML formats
- Integrate YAML support and edit mode selection into the web playground with format toggle, token usage display, and prompt caching

* improvements

* fixes

* fixes ci

* docs
2026-03-13 13:37:21 -05:00

317 lines
8.9 KiB
TypeScript

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");
}