Skip to content

Commit 65bda5a

Browse files
authored
Merge pull request #141 from opentiny/feat/json-patch-zod
feat: json patch zod
2 parents 006b824 + 11eecce commit 65bda5a

5 files changed

Lines changed: 336 additions & 32 deletions

File tree

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sites/playground/server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
"vite-commit-hash-plugin": "workspace:*",
3535
"uuid": "^11.1.0",
3636
"ws": "^8.18.1",
37-
"zod": "^3.25.76"
37+
"zod": "^3.25.76",
38+
"zod-to-json-schema": "^3.25.1"
3839
},
3940
"devDependencies": {
4041
"@types/express": "^5.0.1",

sites/playground/server/src/json-patch-prompt.ts

Lines changed: 176 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,160 @@
1-
export const generateJsonPatchPrompt = (
2-
) => `根据提供的 JSON schema 和修改指令,生成符合 JSON PATCH (RFC 6902) 规范的增量修改操作,使用 \`\`\`jsonPatch\`\`\` 标记包裹输出。
1+
import { z } from 'zod';
2+
import { zodToJsonSchema } from 'zod-to-json-schema';
3+
4+
/**
5+
* RFC 6901 JSON Pointer 校验
6+
* 精确匹配:必须为空或以 / 开头,且 ~ 后面必须跟着 0 或 1
7+
*/
8+
const jsonPointerBaseSchema = z
9+
.string()
10+
.regex(
11+
/^(?:|(?:\/(?:[^~/]|~[01])*)+)$/,
12+
'Invalid JSON Pointer format. Must start with "/" and use ~0, ~1 for escaping.',
13+
);
14+
15+
/** 是否存在 "-" 引用 token(JSON Patch add 的数组末尾 sentinel;replace/copy/test 不允许) */
16+
function jsonPointerHasAppendSentinel(pointer: string): boolean {
17+
if (pointer === '') return false;
18+
return pointer
19+
.slice(1)
20+
.split('/')
21+
.some((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~') === '-');
22+
}
23+
24+
/** add 的 path:允许 `/-` 表示插入数组末尾 */
25+
const jsonPointerSchemaAdd = jsonPointerBaseSchema.describe(
26+
"RFC 6901 Pointer (e.g., '/foo/0', '/a~1b'). Use '/-' as the last segment to append to an array.",
27+
);
28+
29+
/** replace/copy/test 的 path 与 copy 的 from:必须指向已有位置,禁止 `-` 索引 */
30+
const jsonPointerSchemaExisting = jsonPointerBaseSchema
31+
.refine(
32+
(s) => !jsonPointerHasAppendSentinel(s),
33+
'Invalid JSON Pointer: "-" (array append) is only valid for op "add". Use a numeric index or property name.',
34+
)
35+
.describe(
36+
"RFC 6901 Pointer to an existing value (e.g., '/foo/0', '/a~1b'). Do not use '/-' — that is only for op 'add'.",
37+
);
38+
39+
/**
40+
* 递归 JSON 值定义
41+
*/
42+
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
43+
type JsonValue = z.infer<typeof literalSchema> | { [key: string]: JsonValue } | JsonValue[];
44+
const jsonPatchValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
45+
z.union([literalSchema, z.array(jsonPatchValueSchema), z.record(jsonPatchValueSchema)]),
46+
);
47+
48+
/**
49+
* RFC 6902 JSON Patch 操作集
50+
* 增加 .describe() 以优化 LLM 的 Function Calling 或 JSON 生成表现
51+
*/
52+
const baseOperationSchema = z.object({
53+
id: z.string().min(1).describe('Target component id in current schema.'),
54+
});
55+
56+
const movePositionSchema = z
57+
.enum(['before', 'after', 'inside'])
58+
.describe('Relative insertion position to positionId.');
59+
60+
// 添加
61+
const addOperation = z
62+
.object({
63+
op: z.literal('add'),
64+
path: jsonPointerSchemaAdd,
65+
value: jsonPatchValueSchema.describe('The value to add at the specified path.'),
66+
})
67+
.extend(baseOperationSchema.shape)
68+
.strict()
69+
.describe('Adds a value to an object or inserts it into an array.');
70+
71+
// 移除
72+
const removeOperation = z
73+
.object({
74+
op: z.literal('remove'),
75+
})
76+
.extend(baseOperationSchema.shape)
77+
.strict()
78+
.describe('Removes the target component by id.');
79+
80+
// 替换
81+
const replaceOperation = z
82+
.object({
83+
op: z.literal('replace'),
84+
path: jsonPointerSchemaExisting,
85+
value: jsonPatchValueSchema.describe('The new value to replace the current one.'),
86+
})
87+
.extend(baseOperationSchema.shape)
88+
.strict()
89+
.describe('Replaces the value at the target location with a new value.');
90+
91+
// 移动
92+
const moveOperation = z
93+
.object({
94+
op: z.literal('move'),
95+
positionId: z.string().min(1).describe('Anchor component id used as move destination reference.'),
96+
position: movePositionSchema,
97+
})
98+
.extend(baseOperationSchema.shape)
99+
.strict()
100+
.describe("Moves component `id` relative to `positionId` by `position`.");
101+
102+
// 复制
103+
const copyOperation = z
104+
.object({
105+
op: z.literal('copy'),
106+
from: jsonPointerSchemaExisting.describe('Reference to the location to copy the value from.'),
107+
path: jsonPointerSchemaExisting.describe('The destination path.'),
108+
})
109+
.extend(baseOperationSchema.shape)
110+
.strict()
111+
.describe("Copies a value from 'from' to 'path'.");
112+
113+
// 测试
114+
const testOperation = z
115+
.object({
116+
op: z.literal('test'),
117+
path: jsonPointerSchemaExisting,
118+
value: jsonPatchValueSchema.describe('The value to compare against.'),
119+
})
120+
.extend(baseOperationSchema.shape)
121+
.strict()
122+
.describe('Tests that a value at the target location is equal to a specified value.');
123+
124+
/**
125+
* 最终导出的 JSON Patch Schema
126+
*/
127+
export const jsonPatchOperationSchema = z.discriminatedUnion('op', [
128+
addOperation,
129+
removeOperation,
130+
replaceOperation,
131+
moveOperation,
132+
copyOperation,
133+
testOperation,
134+
]);
135+
136+
export const jsonPatchSchema = z
137+
.array(jsonPatchOperationSchema)
138+
.describe('An array of JSON Patch operations (RFC 6902) to be applied in order.');
139+
140+
export type JsonPatchOperation = z.infer<typeof jsonPatchOperationSchema>;
141+
export type JsonPatch = z.infer<typeof jsonPatchSchema>;
142+
143+
const jsonPatchSchemaAsJsonSchema = zodToJsonSchema(jsonPatchSchema, {
144+
name: 'JsonPatchOperations',
145+
});
146+
const jsonPatchSchemaText = JSON.stringify(jsonPatchSchemaAsJsonSchema, null, 2);
147+
148+
export const generateJsonPatchPrompt =
149+
() => `根据提供的 JSON schema 和修改指令,生成符合基于 JSON PATCH (RFC 6902) 规范扩展的 JSON PATCH 操作序列,使用 \`\`\`jsonPatch\`\`\` 标记包裹输出。
150+
151+
## JSON PATCH 格式规范(由 jsonPatchSchema 转换)
152+
153+
请严格按以下 JSON Schema 生成操作序列:顶层必须是 JSON 数组(\`[]\`),按顺序包含零条或多条操作对象;不要只输出单条操作对象。
154+
155+
\`\`\`json
156+
${jsonPatchSchemaText}
157+
\`\`\`
3158
4159
## ⚠️ 最重要:ID 来源规则(必须严格遵守)
5160
@@ -64,9 +219,9 @@ export const generateJsonPatchPrompt = (
64219
- 如果要移动到一个元素的后面,清使用下一个元素的前面,或者父元素的里面
65220
- after:移动到目标对象的后面
66221
**示例:**
67-
- ✅ 正确:\`{ "op": "move", "id": "targetId", "positionId": "", "position": "before" }\`
68-
- ✅ 正确:\`{ "op": "move", "id": "targetId", "positionId": "sourceId", "position": "inside" }\`
69-
- ✅ 正确:\`{ "op": "move", "id": "targetId", "positionId": "sourceId", "position": "after" }\`
222+
- ✅ 正确:\`{ "op": "move", "id": "targetId", "positionId": "anchorId", "position": "before" }\`
223+
- ✅ 正确:\`{ "op": "move", "id": "targetId", "positionId": "anchorId", "position": "inside" }\`
224+
- ✅ 正确:\`{ "op": "move", "id": "targetId", "positionId": "anchorId", "position": "after" }\`
70225
71226
**属性操作:使用目标组件本身的 id + 组件内相对路径**
72227
- ✅ 正确:修改 children[0].children[0].props.text → 找到该组件本身的 id,使用 \`{ "id": "deep123", "path": "/props/text" }\`
@@ -116,38 +271,28 @@ export const generateJsonPatchPrompt = (
116271
{"op": "remove", "id": "comp123", "path": "/children/3"}, // 删除 D
117272
{"op": "remove", "id": "comp123", "path": "/children/1"} // 删除 B(索引不变)
118273
]
119-
\`\`\`
120274
121-
## JSON PATCH 格式规范
275+
move 示例(相对位置语义):
276+
初始状态:children = [A(id:a), B(id:b), C(id:c), D(id:d)]
277+
目标:把 D 移动到 B 前面,期望结果 [A, D, B, C]
122278
123-
### 操作对象结构
279+
❌ 错误方式(把 positionId 误用成被移动元素自身):
280+
[
281+
{"op": "move", "id": "d", "positionId": "d", "position": "before"}
282+
]
124283
125-
\`\`\`typescript
126-
interface JsonPatchOperation {
127-
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
128-
id: string; // 组件 id(必需)
129-
path?: string; // 组件内相对路径(属性操作需要)
130-
value?: any; // 值(add/replace/test 需要)
131-
from?: string; // 源路径(move/copy 需要)
132-
}
284+
✅ 正确方式(positionId 指向目标锚点 B):
285+
[
286+
{"op": "move", "id": "d", "positionId": "b", "position": "before"}
287+
]
133288
\`\`\`
134289
135-
### 操作类型规则
136-
137-
**组件操作(操作整个组件):**
138-
- remove/replace:使用目标组件本身的 id,**不需要 path**
139-
- add:使用父组件 id + path 指定位置
140-
141-
**属性操作(操作组件属性):**
142-
- 使用目标组件本身的 id + 组件内相对路径
143-
- path 格式:\`/props/text\`、\`/children/0/props/text\`(从组件根开始)
144-
145-
### JSON Pointer 路径格式
290+
### Schema 使用补充
146291
147-
- 对象属性:\`/propertyName\`(如 \`/props/text\`)
148-
- 数组元素:\`/array/0\`(索引从 0 开始
149-
- 数组末尾:\`/children/-\`
150-
- 特殊字符:\`~0\` = \`~\`,\`~1\` = \`/\`
292+
- 所有 \`path\` / \`from\` 字段都必须遵循 RFC 6901 JSON Pointer
293+
- \`path\` 支持数组末尾写法 \`/-\`(仅在 add 到数组末尾时使用
294+
- \`move\` 操作使用 \`positionId + position(before/after/inside)\`,不使用 \`from/path\`
295+
- 组件编辑语义(id、positionId、position 等)必须同时满足本提示词上文的业务规则
151296
152297
## 验证检查清单(VF 两阶段流程)
153298
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export {
2+
jsonPatchOperationSchema,
3+
jsonPatchSchema,
4+
type JsonPatch,
5+
type JsonPatchOperation,
6+
} from './schema.js';

0 commit comments

Comments
 (0)