mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-09-14 13:41:32 +08:00
dc489601be
* 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
317 lines
8.9 KiB
TypeScript
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");
|
|
}
|