Co-authored-by: Jonathan Dobson <jon.m.dobson@gmail.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { AutoResizingTextarea } from "../AutoResizingTextarea/AutoResizingTextar
|
|||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { AxiosError } from "axios";
|
import { AxiosError } from "axios";
|
||||||
import { toast } from "../ui/use-toast";
|
import { toast } from "../ui/use-toast";
|
||||||
|
import { TSON } from "@/util/tson";
|
||||||
import { cn } from "@/util/utils";
|
import { cn } from "@/util/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -36,42 +37,13 @@ function WorkflowDataSchemaInputGroup({
|
|||||||
const [generateWithAIActive, setGenerateWithAIActive] = useState(false);
|
const [generateWithAIActive, setGenerateWithAIActive] = useState(false);
|
||||||
const [generateWithAIPrompt, setGenerateWithAIPrompt] = useState("");
|
const [generateWithAIPrompt, setGenerateWithAIPrompt] = useState("");
|
||||||
|
|
||||||
function computeJsonError(
|
const tsonResult = useMemo(() => {
|
||||||
jsonText: string,
|
|
||||||
): { message: string; line?: number; column?: number } | null {
|
|
||||||
try {
|
|
||||||
JSON.parse(jsonText);
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
const message = e instanceof Error ? e.message : "Invalid JSON";
|
|
||||||
// Try to extract position and compute line/column for friendlier feedback
|
|
||||||
const match = message.match(/position\s+(\d+)/i);
|
|
||||||
if (!match) {
|
|
||||||
return { message };
|
|
||||||
}
|
|
||||||
const pos = Number(match[1]);
|
|
||||||
if (Number.isNaN(pos)) {
|
|
||||||
return { message };
|
|
||||||
}
|
|
||||||
let line = 1;
|
|
||||||
let col = 1;
|
|
||||||
for (let i = 0; i < Math.min(pos, jsonText.length); i++) {
|
|
||||||
if (jsonText[i] === "\n") {
|
|
||||||
line += 1;
|
|
||||||
col = 1;
|
|
||||||
} else {
|
|
||||||
col += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { message, line, column: col };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonError = useMemo(() => {
|
|
||||||
if (value === "null") return null;
|
if (value === "null") return null;
|
||||||
return computeJsonError(value);
|
return TSON.parse(value);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
console.log({ tsonResult });
|
||||||
|
|
||||||
const getDataSchemaSuggestionMutation = useMutation({
|
const getDataSchemaSuggestionMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const client = await getClient(credentialGetter);
|
const client = await getClient(credentialGetter);
|
||||||
@@ -161,7 +133,9 @@ function WorkflowDataSchemaInputGroup({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-md",
|
"rounded-md",
|
||||||
jsonError ? "ring-1 ring-red-500" : undefined,
|
tsonResult && !tsonResult.success
|
||||||
|
? "ring-1 ring-red-500"
|
||||||
|
: undefined,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
@@ -172,12 +146,8 @@ function WorkflowDataSchemaInputGroup({
|
|||||||
fontSize={8}
|
fontSize={8}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{jsonError && (
|
{tsonResult !== null && !tsonResult.success && tsonResult.error && (
|
||||||
<div className="text-xs text-red-400">
|
<div className="text-xs text-red-400">{tsonResult.error}</div>
|
||||||
{jsonError.line && jsonError.column
|
|
||||||
? `Invalid JSON (${jsonError.line}:${jsonError.column}) — ${jsonError.message}`
|
|
||||||
: `Invalid JSON — ${jsonError.message}`}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import Dagre from "@dagrejs/dagre";
|
|||||||
import type { Node } from "@xyflow/react";
|
import type { Node } from "@xyflow/react";
|
||||||
import { Edge } from "@xyflow/react";
|
import { Edge } from "@xyflow/react";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
import { TSON } from "@/util/tson";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WorkflowBlockType,
|
WorkflowBlockType,
|
||||||
WorkflowBlockTypes,
|
WorkflowBlockTypes,
|
||||||
@@ -2188,10 +2191,12 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
|
|||||||
}
|
}
|
||||||
// Validate Task data schema JSON when enabled (value different from "null")
|
// Validate Task data schema JSON when enabled (value different from "null")
|
||||||
if (node.data.dataSchema && node.data.dataSchema !== "null") {
|
if (node.data.dataSchema && node.data.dataSchema !== "null") {
|
||||||
try {
|
const result = TSON.parse(node.data.dataSchema);
|
||||||
JSON.parse(node.data.dataSchema);
|
|
||||||
} catch {
|
if (!result.success) {
|
||||||
errors.push(`${node.data.label}: Data schema is not valid JSON.`);
|
errors.push(
|
||||||
|
`${node.data.label}: Data schema has invalid templated JSON: ${result.error ?? "-"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2227,10 +2232,12 @@ function getWorkflowErrors(nodes: Array<AppNode>): Array<string> {
|
|||||||
}
|
}
|
||||||
// Validate Extraction data schema JSON when enabled (value different from "null")
|
// Validate Extraction data schema JSON when enabled (value different from "null")
|
||||||
if (node.data.dataSchema && node.data.dataSchema !== "null") {
|
if (node.data.dataSchema && node.data.dataSchema !== "null") {
|
||||||
try {
|
const result = TSON.parse(node.data.dataSchema);
|
||||||
JSON.parse(node.data.dataSchema);
|
|
||||||
} catch {
|
if (!result.success) {
|
||||||
errors.push(`${node.data.label}: Data schema is not valid JSON.`);
|
errors.push(
|
||||||
|
`${node.data.label}: Data schema has invalid templated JSON: ${result.error ?? "-"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
91
skyvern-frontend/src/util/tson.test.ts
Normal file
91
skyvern-frontend/src/util/tson.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// TSON.parse.test.ts
|
||||||
|
import { TSON } from "./tson";
|
||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("TSON.parse", () => {
|
||||||
|
test("single top-level template works", () => {
|
||||||
|
const input = "{{ hello }}";
|
||||||
|
const result = TSON.parse(input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual("<STUB>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves double braces inside quoted strings", () => {
|
||||||
|
const input = '{"a": "{{ hello }}"}';
|
||||||
|
const result = TSON.parse(input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual({ a: "{{ hello }}" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaces double braces outside strings with stub", () => {
|
||||||
|
const input = '{"a": {{ hello }} }';
|
||||||
|
const result = TSON.parse(input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual({ a: "<STUB>" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles double braces in keys and values", () => {
|
||||||
|
const input = `
|
||||||
|
{
|
||||||
|
"hello": "world",
|
||||||
|
{{foo}}: "bar",
|
||||||
|
"baz": {{quux}}
|
||||||
|
}`;
|
||||||
|
const result = TSON.parse(input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual({
|
||||||
|
hello: "world",
|
||||||
|
"<STUB>": "bar",
|
||||||
|
baz: "<STUB>",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not allow trailing commas", () => {
|
||||||
|
const input = `
|
||||||
|
{
|
||||||
|
"hello": "world",
|
||||||
|
{{foo}}: "bar",
|
||||||
|
"baz": {{quux}},
|
||||||
|
}`;
|
||||||
|
const result = TSON.parse(input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain("Expected double-quoted property name");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects unclosed double braces", () => {
|
||||||
|
const input = "{{ unclosed";
|
||||||
|
const result = TSON.parse(input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain("Unclosed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects unmatched closing double braces", () => {
|
||||||
|
const input = "closed }}";
|
||||||
|
const result = TSON.parse(input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain("Unmatched");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles nested double braces", () => {
|
||||||
|
const input = "{{ {{ nested }} }}";
|
||||||
|
const result = TSON.parse(input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual("<STUB>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles double braces in arrays", () => {
|
||||||
|
const input = '[{{ }}, {{ }}, "normal"]';
|
||||||
|
const result = TSON.parse(input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual(["<STUB>", "<STUB>", "normal"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
223
skyvern-frontend/src/util/tson.ts
Normal file
223
skyvern-frontend/src/util/tson.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
type JSONValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| JSONValue[]
|
||||||
|
| { [key: string]: JSONValue };
|
||||||
|
|
||||||
|
interface ParseResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: JSONValue;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = () => "<STUB>";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TSON ("templated JSON") is a superset of JSON, where double curly braces {{...}} can:
|
||||||
|
* - exist anywhere outside of string literals, and
|
||||||
|
* - are treated as placeholders
|
||||||
|
*/
|
||||||
|
const TSON = {
|
||||||
|
parse(input: string): ParseResult {
|
||||||
|
try {
|
||||||
|
const balanceCheck = checkDoubleBraceBalance(input);
|
||||||
|
|
||||||
|
if (!balanceCheck.balanced) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: balanceCheck.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipeline = [
|
||||||
|
replaceBracesOutsideQuotes,
|
||||||
|
// removeTrailingCommas,
|
||||||
|
JSON.parse,
|
||||||
|
];
|
||||||
|
|
||||||
|
const parsed = pipeline.reduce((acc, fn) => fn(acc), input) as JSONValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: parsed,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function checkDoubleBraceBalance(input: string): {
|
||||||
|
balanced: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
let inString = false;
|
||||||
|
let escapeNext = false;
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const char = input[i];
|
||||||
|
const nextChar = input[i + 1];
|
||||||
|
|
||||||
|
// handle escape sequences
|
||||||
|
if (escapeNext) {
|
||||||
|
escapeNext = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\\") {
|
||||||
|
escapeNext = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inside-string tracking
|
||||||
|
if (char === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// double braces counts (only outside strings)
|
||||||
|
if (!inString) {
|
||||||
|
if (char === "{" && nextChar === "{") {
|
||||||
|
depth++;
|
||||||
|
i++; // skip next char
|
||||||
|
} else if (char === "}" && nextChar === "}") {
|
||||||
|
depth--;
|
||||||
|
if (depth < 0) {
|
||||||
|
return {
|
||||||
|
balanced: false,
|
||||||
|
error: `Unmatched closing }} at position ${i}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
i++; // skip next char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth > 0) {
|
||||||
|
return {
|
||||||
|
balanced: false,
|
||||||
|
error: `Unclosed {{ - missing ${depth} closing }}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { balanced: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceBracesOutsideQuotes(input: string): string {
|
||||||
|
let result = "";
|
||||||
|
let inString = false;
|
||||||
|
let escapeNext = false;
|
||||||
|
let inDoubleBrace = 0; // track nesting depth of {{...}}
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const char = input[i];
|
||||||
|
const nextChar = input[i + 1];
|
||||||
|
|
||||||
|
// escape sequences
|
||||||
|
if (escapeNext) {
|
||||||
|
if (inDoubleBrace === 0) {
|
||||||
|
result += char;
|
||||||
|
}
|
||||||
|
escapeNext = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\\") {
|
||||||
|
if (inDoubleBrace === 0) {
|
||||||
|
result += char;
|
||||||
|
}
|
||||||
|
escapeNext = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inside-string tracking
|
||||||
|
if (char === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
if (inDoubleBrace === 0) {
|
||||||
|
result += char;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// double braces (only outside strings)
|
||||||
|
if (!inString) {
|
||||||
|
if (char === "{" && nextChar === "{") {
|
||||||
|
if (inDoubleBrace === 0) {
|
||||||
|
result += `"${placeholder()}"`;
|
||||||
|
}
|
||||||
|
inDoubleBrace++;
|
||||||
|
i++; // skip next char
|
||||||
|
continue;
|
||||||
|
} else if (char === "}" && nextChar === "}") {
|
||||||
|
inDoubleBrace--;
|
||||||
|
i++; // skip next char
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add characters when we're not inside double braces
|
||||||
|
if (inDoubleBrace === 0) {
|
||||||
|
result += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTrailingCommas(input: string): string {
|
||||||
|
let result = "";
|
||||||
|
let inString = false;
|
||||||
|
let escapeNext = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const char = input[i];
|
||||||
|
|
||||||
|
// escape sequences
|
||||||
|
if (escapeNext) {
|
||||||
|
result += char;
|
||||||
|
escapeNext = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "\\") {
|
||||||
|
result += char;
|
||||||
|
escapeNext = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// inside-string tracking
|
||||||
|
if (char === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
result += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check for trailing commas (outside strings)
|
||||||
|
if (!inString && char === ",") {
|
||||||
|
// look-ahead for the next non-whitespace character
|
||||||
|
let j = i + 1;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
while (j < input.length && /\s/.test(input[j])) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if next non-whitespace is } or ], skip the comma
|
||||||
|
if (j < input.length && (input[j] === "}" || input[j] === "]")) {
|
||||||
|
continue; // Skip this comma
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TSON };
|
||||||
Reference in New Issue
Block a user