paseo-form-render 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -2
- package/client/container.tsx +289 -0
- package/client/host-markdown.tsx +24 -0
- package/client/markdown.tsx +470 -0
- package/client/messages.ts +147 -0
- package/client/project-file-modal.tsx +690 -0
- package/client/react-native-dataset.d.ts +10 -0
- package/client/renderer.tsx +1056 -0
- package/client/rpc-bridge.tsx +27 -0
- package/client/settings-screen.tsx +163 -0
- package/index.client.tsx +103 -0
- package/index.server.ts +38 -0
- package/package.json +50 -4
- package/paseo-plugin.json +8 -0
- package/server/auto-fix.ts +217 -0
- package/server/demo.ts +20 -0
- package/server/file-picker.ts +253 -0
- package/server/settings.ts +26 -0
- package/shared/demo.ts +96 -0
- package/shared/form-errors.ts +390 -0
- package/shared/form-fix.ts +77 -0
- package/shared/form-helpers.ts +214 -0
- package/shared/parser.ts +119 -0
- package/shared/pickers.ts +50 -0
- package/shared/prompt.ts +96 -0
- package/shared/schema.ts +330 -0
- package/shared/settings.ts +18 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import type { ZodIssue } from "zod";
|
|
2
|
+
import { PFR_FIELD_TYPES, PFR_NODE_TYPES } from "./schema";
|
|
3
|
+
|
|
4
|
+
/** One problem found in a pfr-form block. `path` locates it (empty when not applicable). */
|
|
5
|
+
export interface FormIssue {
|
|
6
|
+
path: string;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const MAX_FORM_ISSUES = 10;
|
|
11
|
+
|
|
12
|
+
type PathSegment = string | number;
|
|
13
|
+
|
|
14
|
+
/** Common wrong component names mapped to what the agent should have written. */
|
|
15
|
+
const TYPE_ALIASES: Record<string, string> = {
|
|
16
|
+
textarea: 'Input with "multiline": true',
|
|
17
|
+
textfield: "Input",
|
|
18
|
+
textinput: "Input",
|
|
19
|
+
checkbox: "Toggle",
|
|
20
|
+
switch: "Toggle",
|
|
21
|
+
dropdown: "Select",
|
|
22
|
+
label: "Text",
|
|
23
|
+
paragraph: "Text",
|
|
24
|
+
title: "Heading",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Formatting helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export function formatIssue(issue: FormIssue): string {
|
|
32
|
+
return issue.path ? `${issue.path}: ${issue.message}` : issue.message;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatIssues(issues: readonly FormIssue[]): string[] {
|
|
36
|
+
return issues.map(formatIssue);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Keeps the first `max` issues and replaces the rest with a single "...and N more" entry. */
|
|
40
|
+
export function capIssues(issues: readonly FormIssue[], max = MAX_FORM_ISSUES): FormIssue[] {
|
|
41
|
+
if (issues.length <= max) return [...issues];
|
|
42
|
+
return [...issues.slice(0, max), { path: "", message: `...and ${issues.length - max} more` }];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function truncate(text: string, max: number): string {
|
|
46
|
+
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function describeValue(value: unknown): string {
|
|
54
|
+
if (value === undefined) return "undefined";
|
|
55
|
+
if (value === null) return "null";
|
|
56
|
+
if (Array.isArray(value)) return "array";
|
|
57
|
+
if (typeof value === "string") return `string ${JSON.stringify(truncate(value, 40))}`;
|
|
58
|
+
if (typeof value === "number" || typeof value === "boolean") return `${typeof value} ${value}`;
|
|
59
|
+
return typeof value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Path rendering
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function nodeLabel(node: Record<string, unknown>): string | undefined {
|
|
67
|
+
for (const key of ["label", "title", "id", "text"]) {
|
|
68
|
+
const value = node[key];
|
|
69
|
+
if (typeof value === "string" && value) return truncate(value, 40);
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `Input "Release Tag"`, or just `Input`; undefined when the value is not a node. */
|
|
75
|
+
function describeNode(value: unknown): string | undefined {
|
|
76
|
+
if (!isRecord(value) || typeof value.type !== "string") return undefined;
|
|
77
|
+
const label = nodeLabel(value);
|
|
78
|
+
return label ? `${value.type} ${JSON.stringify(label)}` : value.type;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Renders a location like `Card > children[2] (Input "Release Tag")` by walking `segments` through
|
|
83
|
+
* the parsed JSON so each node on the way can be named.
|
|
84
|
+
*/
|
|
85
|
+
export function renderPath(root: unknown, segments: readonly PathSegment[]): string {
|
|
86
|
+
const parts: string[] = [describeNode(root) ?? "root"];
|
|
87
|
+
let current: unknown = root;
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < segments.length; i++) {
|
|
90
|
+
const segment = segments[i];
|
|
91
|
+
let text: string;
|
|
92
|
+
if (typeof segment === "number") {
|
|
93
|
+
text = `[${segment}]`;
|
|
94
|
+
current = Array.isArray(current) ? current[segment] : undefined;
|
|
95
|
+
} else {
|
|
96
|
+
text = segment;
|
|
97
|
+
current = isRecord(current) ? current[segment] : undefined;
|
|
98
|
+
const next = segments[i + 1];
|
|
99
|
+
if (typeof next === "number") {
|
|
100
|
+
current = Array.isArray(current) ? current[next] : undefined;
|
|
101
|
+
text = `${segment}[${next}]`;
|
|
102
|
+
i++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const node = describeNode(current);
|
|
106
|
+
parts.push(node ? `${text} (${node})` : text);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return parts.join(" > ");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function valueAt(root: unknown, segments: readonly PathSegment[]): unknown {
|
|
113
|
+
let current = root;
|
|
114
|
+
for (const segment of segments) {
|
|
115
|
+
if (typeof segment === "number") {
|
|
116
|
+
current = Array.isArray(current) ? current[segment] : undefined;
|
|
117
|
+
} else {
|
|
118
|
+
current = isRecord(current) ? current[segment] : undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return current;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Unknown component types
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function editDistance(a: string, b: string): number {
|
|
129
|
+
const prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
130
|
+
for (let i = 1; i <= a.length; i++) {
|
|
131
|
+
let diagonal = prev[0];
|
|
132
|
+
prev[0] = i;
|
|
133
|
+
for (let j = 1; j <= b.length; j++) {
|
|
134
|
+
const above = prev[j];
|
|
135
|
+
prev[j] = Math.min(
|
|
136
|
+
prev[j] + 1,
|
|
137
|
+
prev[j - 1] + 1,
|
|
138
|
+
diagonal + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
139
|
+
);
|
|
140
|
+
diagonal = above;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return prev[b.length];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Suggests a valid type (or replacement) for a wrong one, or undefined when nothing is close. */
|
|
147
|
+
export function suggestNodeType(
|
|
148
|
+
given: string,
|
|
149
|
+
validTypes: readonly string[] = PFR_NODE_TYPES,
|
|
150
|
+
): string | undefined {
|
|
151
|
+
const lower = given.toLowerCase();
|
|
152
|
+
const alias = TYPE_ALIASES[lower];
|
|
153
|
+
if (alias) return alias;
|
|
154
|
+
|
|
155
|
+
let best: { type: string; distance: number } | undefined;
|
|
156
|
+
for (const type of validTypes) {
|
|
157
|
+
const distance = editDistance(lower, type.toLowerCase());
|
|
158
|
+
if (!best || distance < best.distance) best = { type, distance };
|
|
159
|
+
}
|
|
160
|
+
if (best && best.distance <= Math.max(2, Math.floor(best.type.length / 3))) return best.type;
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function unknownTypeMessage(value: unknown, validTypes: readonly string[]): string {
|
|
165
|
+
const valid = validTypes.join(", ");
|
|
166
|
+
if (value === undefined) {
|
|
167
|
+
return `missing required field "type". Valid types: ${valid}`;
|
|
168
|
+
}
|
|
169
|
+
if (typeof value !== "string") {
|
|
170
|
+
return `"type" must be a string naming a component (got ${describeValue(value)}). Valid types: ${valid}`;
|
|
171
|
+
}
|
|
172
|
+
const suggestion = suggestNodeType(value, validTypes);
|
|
173
|
+
const hint = suggestion ? ` Did you mean ${suggestion}?` : "";
|
|
174
|
+
return `unknown component type ${JSON.stringify(value)}. Valid types: ${valid}.${hint}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Zod issues
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
function quoted(field: string | undefined): string {
|
|
182
|
+
return field === undefined ? "value" : `"${field}"`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function issueMessage(issue: ZodIssue, field: string | undefined, value: unknown): string {
|
|
186
|
+
const record = issue as unknown as Record<string, unknown>;
|
|
187
|
+
|
|
188
|
+
switch (issue.code) {
|
|
189
|
+
case "invalid_union": {
|
|
190
|
+
if (record.note === "No matching discriminator" || record.discriminator === "type") {
|
|
191
|
+
const options = Array.isArray(record.options) ? record.options.map(String) : [...PFR_NODE_TYPES];
|
|
192
|
+
return unknownTypeMessage(value, options);
|
|
193
|
+
}
|
|
194
|
+
const expected = Array.isArray(record.errors)
|
|
195
|
+
? [
|
|
196
|
+
...new Set(
|
|
197
|
+
(record.errors as unknown[][])
|
|
198
|
+
.map((branch) => (branch[0] as { expected?: unknown } | undefined)?.expected)
|
|
199
|
+
.filter((e): e is string => typeof e === "string"),
|
|
200
|
+
),
|
|
201
|
+
]
|
|
202
|
+
: [];
|
|
203
|
+
const suffix = expected.length ? `; expected ${expected.join(" or ")}` : "";
|
|
204
|
+
return `${quoted(field)} has an invalid value (got ${describeValue(value)})${suffix}`;
|
|
205
|
+
}
|
|
206
|
+
case "invalid_type": {
|
|
207
|
+
if (value === undefined) {
|
|
208
|
+
return field === undefined
|
|
209
|
+
? `missing required value (expected ${record.expected})`
|
|
210
|
+
: `missing required field "${field}" (expected ${record.expected})`;
|
|
211
|
+
}
|
|
212
|
+
return `${quoted(field)} must be ${article(String(record.expected))} (got ${describeValue(value)})`;
|
|
213
|
+
}
|
|
214
|
+
case "invalid_value": {
|
|
215
|
+
const values = Array.isArray(record.values) ? record.values : [];
|
|
216
|
+
const allowed = values.map((v) => (typeof v === "string" ? v : JSON.stringify(v))).join(", ");
|
|
217
|
+
if (value === undefined) {
|
|
218
|
+
return `missing required field ${quoted(field)}; must be one of: ${allowed}`;
|
|
219
|
+
}
|
|
220
|
+
return `${quoted(field)} must be one of: ${allowed} (got ${truncate(JSON.stringify(value) ?? String(value), 40)})`;
|
|
221
|
+
}
|
|
222
|
+
case "too_small": {
|
|
223
|
+
const min = Number(record.minimum);
|
|
224
|
+
if (record.origin === "array") {
|
|
225
|
+
return `${quoted(field)} must contain at least ${min} item${min === 1 ? "" : "s"}`;
|
|
226
|
+
}
|
|
227
|
+
if (record.origin === "string") {
|
|
228
|
+
return min <= 1
|
|
229
|
+
? `${quoted(field)} must not be empty`
|
|
230
|
+
: `${quoted(field)} must be at least ${min} characters`;
|
|
231
|
+
}
|
|
232
|
+
return `${quoted(field)} must be ${record.inclusive === false ? ">" : ">="} ${min} (got ${describeValue(value)})`;
|
|
233
|
+
}
|
|
234
|
+
case "too_big": {
|
|
235
|
+
const max = Number(record.maximum);
|
|
236
|
+
if (record.origin === "array") {
|
|
237
|
+
return `${quoted(field)} must contain at most ${max} item${max === 1 ? "" : "s"}`;
|
|
238
|
+
}
|
|
239
|
+
if (record.origin === "string") {
|
|
240
|
+
return `${quoted(field)} must be at most ${max} characters`;
|
|
241
|
+
}
|
|
242
|
+
return `${quoted(field)} must be ${record.inclusive === false ? "<" : "<="} ${max} (got ${describeValue(value)})`;
|
|
243
|
+
}
|
|
244
|
+
default:
|
|
245
|
+
return field === undefined ? issue.message : `${quoted(field)}: ${issue.message}`;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function article(expected: string): string {
|
|
250
|
+
return /^[aeiou]/.test(expected) ? `an ${expected}` : `a ${expected}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Converts zod issues into issues that name the offending node and say how to fix it. */
|
|
254
|
+
export function formatZodIssues(zodIssues: readonly ZodIssue[], json: unknown): FormIssue[] {
|
|
255
|
+
return zodIssues.map((issue) => {
|
|
256
|
+
const segments = issue.path.filter(
|
|
257
|
+
(segment): segment is PathSegment => typeof segment === "string" || typeof segment === "number",
|
|
258
|
+
);
|
|
259
|
+
const last = segments[segments.length - 1];
|
|
260
|
+
const field = typeof last === "string" ? last : undefined;
|
|
261
|
+
const parent = field === undefined ? segments : segments.slice(0, -1);
|
|
262
|
+
return {
|
|
263
|
+
path: renderPath(json, parent),
|
|
264
|
+
message: issueMessage(issue, field, valueAt(json, segments)),
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Semantic checks beyond the schema
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/** Reports field ids used by more than one input node. Tolerates arbitrary (even invalid) JSON. */
|
|
274
|
+
export function findDuplicateIdIssues(json: unknown): FormIssue[] {
|
|
275
|
+
const fieldTypes: readonly string[] = PFR_FIELD_TYPES;
|
|
276
|
+
const seen = new Map<string, { descriptions: string[]; lastPath: PathSegment[] }>();
|
|
277
|
+
|
|
278
|
+
const visit = (node: unknown, path: PathSegment[], depth: number) => {
|
|
279
|
+
if (!isRecord(node) || depth > 50) return;
|
|
280
|
+
if (typeof node.type === "string" && fieldTypes.includes(node.type) && typeof node.id === "string" && node.id) {
|
|
281
|
+
const entry = seen.get(node.id) ?? { descriptions: [], lastPath: path };
|
|
282
|
+
entry.descriptions.push(describeNode(node) ?? node.type);
|
|
283
|
+
entry.lastPath = path;
|
|
284
|
+
seen.set(node.id, entry);
|
|
285
|
+
}
|
|
286
|
+
if (Array.isArray(node.children)) {
|
|
287
|
+
node.children.forEach((child, index) => visit(child, [...path, "children", index], depth + 1));
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
visit(json, [], 0);
|
|
291
|
+
|
|
292
|
+
const issues: FormIssue[] = [];
|
|
293
|
+
for (const [id, entry] of seen) {
|
|
294
|
+
if (entry.descriptions.length < 2) continue;
|
|
295
|
+
issues.push({
|
|
296
|
+
path: renderPath(json, entry.lastPath),
|
|
297
|
+
message: `duplicate field id ${JSON.stringify(id)} is used by ${entry.descriptions.length} inputs (${entry.descriptions.join(", ")}); every input needs a unique "id"`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return issues;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// JSON syntax errors
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
const GENERIC_JSON_HINT =
|
|
308
|
+
"Common causes: trailing commas, comments (// or /* */), single quotes instead of double quotes, unquoted keys, or unescaped line breaks inside strings";
|
|
309
|
+
|
|
310
|
+
interface JsonProblem {
|
|
311
|
+
hint: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Looks for the usual hand-written-JSON mistakes outside of string literals. */
|
|
315
|
+
function findCommonJsonProblem(json: string): JsonProblem | undefined {
|
|
316
|
+
let inString = false;
|
|
317
|
+
for (let i = 0; i < json.length; i++) {
|
|
318
|
+
const ch = json[i];
|
|
319
|
+
if (inString) {
|
|
320
|
+
if (ch === "\\") i++;
|
|
321
|
+
else if (ch === '"') inString = false;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (ch === '"') {
|
|
325
|
+
inString = true;
|
|
326
|
+
} else if (ch === "/" && (json[i + 1] === "/" || json[i + 1] === "*")) {
|
|
327
|
+
return { hint: "comments are not allowed in JSON; remove them" };
|
|
328
|
+
} else if (ch === "'") {
|
|
329
|
+
return { hint: 'strings and keys must use double quotes ("), not single quotes' };
|
|
330
|
+
} else if (ch === ",") {
|
|
331
|
+
const after = /^\s*([}\]])/.exec(json.slice(i + 1));
|
|
332
|
+
if (after) return { hint: `trailing comma before "${after[1]}"; remove it` };
|
|
333
|
+
} else if (/[A-Za-z_$]/.test(ch) && /[{,]\s*$/.test(json.slice(Math.max(0, i - 20), i))) {
|
|
334
|
+
const key = /^[A-Za-z_$][\w$]*(?=\s*:)/.exec(json.slice(i));
|
|
335
|
+
if (key) return { hint: `object keys must be quoted: use "${key[0]}"` };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function lineAndColumn(text: string, position: number): { line: number; column: number } {
|
|
342
|
+
const before = text.slice(0, position);
|
|
343
|
+
const line = before.split("\n").length;
|
|
344
|
+
return { line, column: position - (before.lastIndexOf("\n") + 1) + 1 };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Explains a JSON.parse failure with line/column, the offending line, and likely causes. */
|
|
348
|
+
export function formatJsonParseError(jsonText: string, error: unknown): FormIssue[] {
|
|
349
|
+
if (!jsonText.trim()) {
|
|
350
|
+
return [{ path: "JSON", message: "the pfr-form block is empty; it must contain one JSON object" }];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const engineMessage = error instanceof Error ? error.message : String(error);
|
|
354
|
+
const reported = /position (\d+)/.exec(engineMessage);
|
|
355
|
+
const lineReported = /line (\d+) column (\d+)/.exec(engineMessage);
|
|
356
|
+
|
|
357
|
+
let location = "";
|
|
358
|
+
let snippet = "";
|
|
359
|
+
let position: number | undefined = reported ? Number(reported[1]) : undefined;
|
|
360
|
+
if (position !== undefined && position > jsonText.length) position = jsonText.length;
|
|
361
|
+
|
|
362
|
+
if (position !== undefined) {
|
|
363
|
+
const { line, column } = lineAndColumn(jsonText, position);
|
|
364
|
+
location = ` at line ${line}, column ${column}`;
|
|
365
|
+
const lineText = jsonText.split("\n")[line - 1] ?? "";
|
|
366
|
+
snippet = ` near \`${truncate(lineText.trim(), 80)}\``;
|
|
367
|
+
} else if (lineReported) {
|
|
368
|
+
location = ` at line ${lineReported[1]}, column ${lineReported[2]}`;
|
|
369
|
+
const lineText = jsonText.split("\n")[Number(lineReported[1]) - 1] ?? "";
|
|
370
|
+
snippet = ` near \`${truncate(lineText.trim(), 80)}\``;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const reason = engineMessage
|
|
374
|
+
.replace(/\s*\(line \d+ column \d+\)/, "")
|
|
375
|
+
.replace(/\s*(in JSON )?at position \d+/, "")
|
|
376
|
+
.replace(/^JSON Parse error:\s*/i, "")
|
|
377
|
+
.trim();
|
|
378
|
+
|
|
379
|
+
const truncated = /end of (JSON|data)|unterminated|unexpected end/i.test(engineMessage);
|
|
380
|
+
const hint = truncated
|
|
381
|
+
? "the JSON looks truncated; check for missing closing brackets, braces, or quotes"
|
|
382
|
+
: (findCommonJsonProblem(jsonText)?.hint ?? GENERIC_JSON_HINT);
|
|
383
|
+
|
|
384
|
+
return [
|
|
385
|
+
{
|
|
386
|
+
path: "JSON",
|
|
387
|
+
message: `syntax error${location}${snippet}: ${reason}. Hint: ${hint}`,
|
|
388
|
+
},
|
|
389
|
+
];
|
|
390
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { formatIssues, type FormIssue } from "./form-errors";
|
|
2
|
+
import { parseMessageContent } from "./parser";
|
|
3
|
+
|
|
4
|
+
/** First token of every message that asks the agent to repair a pfr-form block. */
|
|
5
|
+
export const PFR_FIX_MARKER = "[pfr-form-fix]";
|
|
6
|
+
|
|
7
|
+
/** One pfr-form block that failed validation. `blockNumber` is its 1-based position among the form blocks of the reply. */
|
|
8
|
+
export interface FormFailure {
|
|
9
|
+
issues: FormIssue[];
|
|
10
|
+
blockNumber?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The part of a Paseo `AgentTimelineItem` these helpers read; the real type is assignable to it. */
|
|
14
|
+
export interface TimelineEntry {
|
|
15
|
+
readonly type: string;
|
|
16
|
+
readonly text?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds the message that asks the agent to repair failed blocks. It lists issues only: the agent
|
|
21
|
+
* already has its own JSON in context.
|
|
22
|
+
*/
|
|
23
|
+
export function buildFixMessage(failures: readonly FormFailure[]): string {
|
|
24
|
+
const sections = failures.map((failure, index) => {
|
|
25
|
+
const lines = formatIssues(failure.issues).map((line) => `- ${line}`);
|
|
26
|
+
return [`Block ${failure.blockNumber ?? index + 1}:`, ...lines].join("\n");
|
|
27
|
+
});
|
|
28
|
+
return [
|
|
29
|
+
`${PFR_FIX_MARKER} Your previous pfr-form block(s) failed validation and could not be rendered:`,
|
|
30
|
+
sections.join("\n\n"),
|
|
31
|
+
"Please re-output the corrected, complete pfr-form block(s). Keep conversational text minimal.",
|
|
32
|
+
].join("\n\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Counts the consecutive fix requests at the end of the user's side of the conversation.
|
|
37
|
+
* Assistant and other items are skipped; the first user message that is not a fix request stops the count.
|
|
38
|
+
*/
|
|
39
|
+
export function countTrailingFixAttempts(timeline: readonly TimelineEntry[]): number {
|
|
40
|
+
let count = 0;
|
|
41
|
+
for (let i = timeline.length - 1; i >= 0; i--) {
|
|
42
|
+
const item = timeline[i];
|
|
43
|
+
if (item.type !== "user_message") continue;
|
|
44
|
+
if (!(item.text ?? "").trimStart().startsWith(PFR_FIX_MARKER)) break;
|
|
45
|
+
count++;
|
|
46
|
+
}
|
|
47
|
+
return count;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Collects failed pfr-form blocks from the assistant messages after the last user message
|
|
52
|
+
* (all assistant messages when there is no user message). Each message is parsed on its own,
|
|
53
|
+
* the way the client renders it.
|
|
54
|
+
*/
|
|
55
|
+
export function collectTurnFormFailures(timeline: readonly TimelineEntry[]): FormFailure[] {
|
|
56
|
+
let start = 0;
|
|
57
|
+
for (let i = timeline.length - 1; i >= 0; i--) {
|
|
58
|
+
if (timeline[i].type === "user_message") {
|
|
59
|
+
start = i + 1;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const failures: FormFailure[] = [];
|
|
65
|
+
let blockNumber = 0;
|
|
66
|
+
for (const item of timeline.slice(start)) {
|
|
67
|
+
if (item.type !== "assistant_message") continue;
|
|
68
|
+
for (const block of parseMessageContent(item.text ?? "")) {
|
|
69
|
+
if (block.type !== "form") continue;
|
|
70
|
+
blockNumber++;
|
|
71
|
+
if (block.errors && block.errors.length > 0) {
|
|
72
|
+
failures.push({ issues: block.errors, blockNumber });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return failures;
|
|
77
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { PfrForm, PfrNode, normalizeExtensions } from "./schema";
|
|
2
|
+
|
|
3
|
+
export function extractInitialValues(node: PfrNode, result: Record<string, any> = {}): Record<string, any> {
|
|
4
|
+
switch (node.type) {
|
|
5
|
+
case "Input":
|
|
6
|
+
result[node.id] = node.defaultValue !== undefined ? String(node.defaultValue) : "";
|
|
7
|
+
break;
|
|
8
|
+
case "Select":
|
|
9
|
+
result[node.id] = node.defaultValue ?? (node.options[0]?.value ?? "");
|
|
10
|
+
break;
|
|
11
|
+
case "MultiSelect":
|
|
12
|
+
result[node.id] = node.defaultValue ?? [];
|
|
13
|
+
break;
|
|
14
|
+
case "Toggle":
|
|
15
|
+
result[node.id] = node.defaultValue ?? false;
|
|
16
|
+
break;
|
|
17
|
+
case "FilePicker":
|
|
18
|
+
result[node.id] = node.defaultValue ?? "";
|
|
19
|
+
break;
|
|
20
|
+
case "Card":
|
|
21
|
+
case "Stack":
|
|
22
|
+
for (const child of node.children) {
|
|
23
|
+
extractInitialValues(child, result);
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validateForm(
|
|
31
|
+
node: PfrNode,
|
|
32
|
+
values: Record<string, any>,
|
|
33
|
+
errors: Record<string, string> = {}
|
|
34
|
+
): Record<string, string> {
|
|
35
|
+
switch (node.type) {
|
|
36
|
+
case "Input": {
|
|
37
|
+
const rawVal = values[node.id];
|
|
38
|
+
const strVal = rawVal !== undefined && rawVal !== null ? String(rawVal).trim() : "";
|
|
39
|
+
const isFilled = strVal.length > 0;
|
|
40
|
+
|
|
41
|
+
if (node.required && !isFilled) {
|
|
42
|
+
errors[node.id] = `${node.label} is required.`;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!isFilled) {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const inputType = node.inputType ?? "text";
|
|
51
|
+
|
|
52
|
+
if (inputType === "integer") {
|
|
53
|
+
const intRegex = /^-?\d+$/;
|
|
54
|
+
if (!intRegex.test(strVal)) {
|
|
55
|
+
errors[node.id] = `${node.label} must be an integer.`;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
const num = parseInt(strVal, 10);
|
|
59
|
+
if (node.min !== undefined && num < node.min) {
|
|
60
|
+
errors[node.id] = `${node.label} must be at least ${node.min}.`;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
if (node.max !== undefined && num > node.max) {
|
|
64
|
+
errors[node.id] = `${node.label} cannot be greater than ${node.max}.`;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
} else if (inputType === "number") {
|
|
68
|
+
const num = Number(strVal);
|
|
69
|
+
if (isNaN(num)) {
|
|
70
|
+
errors[node.id] = `${node.label} must be a valid number.`;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
if (node.min !== undefined && num < node.min) {
|
|
74
|
+
errors[node.id] = `${node.label} must be at least ${node.min}.`;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
if (node.max !== undefined && num > node.max) {
|
|
78
|
+
errors[node.id] = `${node.label} cannot be greater than ${node.max}.`;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
if (inputType === "email") {
|
|
83
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
84
|
+
if (!emailRegex.test(strVal)) {
|
|
85
|
+
errors[node.id] = `${node.label} must be a valid email address.`;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
} else if (inputType === "url") {
|
|
89
|
+
try {
|
|
90
|
+
new URL(strVal);
|
|
91
|
+
} catch {
|
|
92
|
+
errors[node.id] = `${node.label} must be a valid URL.`;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const minLen = node.minLength ?? node.min;
|
|
98
|
+
if (minLen !== undefined && strVal.length < minLen) {
|
|
99
|
+
errors[node.id] = `${node.label} must be at least ${minLen} characters.`;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const maxLen = node.maxLength ?? node.max;
|
|
104
|
+
if (maxLen !== undefined && strVal.length > maxLen) {
|
|
105
|
+
errors[node.id] = `${node.label} cannot exceed ${maxLen} characters.`;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (node.pattern) {
|
|
110
|
+
try {
|
|
111
|
+
const regex = new RegExp(node.pattern);
|
|
112
|
+
if (!regex.test(strVal)) {
|
|
113
|
+
errors[node.id] = `${node.label} format is invalid.`;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// ignore invalid regex pattern
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "Select":
|
|
124
|
+
if (node.required) {
|
|
125
|
+
const val = values[node.id];
|
|
126
|
+
if (!val || typeof val !== "string" || val.trim().length === 0) {
|
|
127
|
+
errors[node.id] = `Please select an option for ${node.label}.`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
case "MultiSelect": {
|
|
132
|
+
const val = values[node.id];
|
|
133
|
+
const count = Array.isArray(val) ? val.length : 0;
|
|
134
|
+
if (node.required && count === 0) {
|
|
135
|
+
errors[node.id] = `Please select at least one option for ${node.label}.`;
|
|
136
|
+
} else if (node.min !== undefined && count < node.min) {
|
|
137
|
+
errors[node.id] = `Please select at least ${node.min} options for ${node.label}.`;
|
|
138
|
+
} else if (node.max !== undefined && count > node.max) {
|
|
139
|
+
errors[node.id] = `You can select at most ${node.max} options for ${node.label}.`;
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case "Toggle":
|
|
144
|
+
if (node.required) {
|
|
145
|
+
const val = values[node.id];
|
|
146
|
+
if (!val) {
|
|
147
|
+
errors[node.id] = `${node.label} must be checked.`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
case "FilePicker": {
|
|
152
|
+
const val = values[node.id];
|
|
153
|
+
const hasValue = typeof val === "string" && val.trim().length > 0;
|
|
154
|
+
if (node.required && !hasValue) {
|
|
155
|
+
errors[node.id] = `Please select a path for ${node.label}.`;
|
|
156
|
+
} else if (hasValue && node.pickerType !== "folder") {
|
|
157
|
+
const cleanExts = normalizeExtensions(node.extensions, node.extension);
|
|
158
|
+
if (cleanExts && cleanExts.length > 0) {
|
|
159
|
+
const valLower = val.trim().toLowerCase();
|
|
160
|
+
const matches = cleanExts.some((ext) => valLower.endsWith("." + ext));
|
|
161
|
+
if (!matches) {
|
|
162
|
+
errors[node.id] = `${node.label} must be a file of type: ${cleanExts.map((e) => "." + e).join(", ")}`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case "Card":
|
|
169
|
+
case "Stack":
|
|
170
|
+
for (const child of node.children) {
|
|
171
|
+
validateForm(child, values, errors);
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
return errors;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function formatSubmissionMessage(
|
|
179
|
+
actionMessage: string | undefined,
|
|
180
|
+
node: PfrNode,
|
|
181
|
+
values: Record<string, any>
|
|
182
|
+
): string {
|
|
183
|
+
const lines: string[] = ["[User Form Submission]"];
|
|
184
|
+
if (actionMessage) {
|
|
185
|
+
lines.push(`Action: ${actionMessage}`);
|
|
186
|
+
}
|
|
187
|
+
lines.push("Values:");
|
|
188
|
+
|
|
189
|
+
const fieldLabels: Record<string, string> = {};
|
|
190
|
+
function collectLabels(n: PfrNode) {
|
|
191
|
+
if ("id" in n && "label" in n) {
|
|
192
|
+
fieldLabels[n.id] = n.label;
|
|
193
|
+
}
|
|
194
|
+
if ("children" in n && Array.isArray(n.children)) {
|
|
195
|
+
n.children.forEach(collectLabels);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
collectLabels(node);
|
|
199
|
+
|
|
200
|
+
for (const [id, val] of Object.entries(values)) {
|
|
201
|
+
const label = fieldLabels[id] || id;
|
|
202
|
+
let formattedVal = "";
|
|
203
|
+
if (Array.isArray(val)) {
|
|
204
|
+
formattedVal = val.join(", ");
|
|
205
|
+
} else if (typeof val === "boolean") {
|
|
206
|
+
formattedVal = val ? "Yes" : "No";
|
|
207
|
+
} else {
|
|
208
|
+
formattedVal = String(val ?? "");
|
|
209
|
+
}
|
|
210
|
+
lines.push(`- ${label}: ${formattedVal}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|