Files
writing-agent/src/parse/json-doc.ts
T
chenmoranzhiandCursor bd5d88abfa Add capability-probed structured outputs and a prototype/instance creation graph.
Probe API profiles for json_schema/tools, flatten worker artifacts onto a strict schema, and let users spawn or revise graph nodes instead of a one-shot DAG.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 01:57:22 +08:00

227 lines
6.7 KiB
TypeScript

/** 从模型正文里抽出 JSON:先按原文 parse,再宽松修,避免弯引号/围栏/夹叙把合法稿撕开。 */
export function stripCodeFences(text: string): string {
let t = String(text || "").trim();
t = t.replace(/^```(?:json|yaml|yml)?\s*\r?\n?/i, "");
t = t.replace(/\r?\n?```\s*$/i, "");
const fenced = t.match(/```(?:json|yaml|yml)?\s*\r?\n([\s\S]*?)\r?\n```/i);
if (fenced) t = fenced[1]!.trim();
return t.trim();
}
export function escapeRawControlsInJsonStrings(s: string): string {
let out = "";
let inString = false;
let escaped = false;
for (let i = 0; i < s.length; i++) {
const c = s[i]!;
if (inString) {
if (escaped) {
out += c;
escaped = false;
continue;
}
if (c === "\\") {
out += c;
escaped = true;
continue;
}
if (c === '"') {
out += c;
inString = false;
continue;
}
if (c === "\n") {
out += "\\n";
continue;
}
if (c === "\r") {
out += "\\r";
continue;
}
if (c === "\t") {
out += "\\t";
continue;
}
out += c;
continue;
}
if (c === '"') {
inString = true;
out += c;
continue;
}
out += c;
}
return out;
}
export function convertStructuralSmartQuotes(s: string): string {
const text = String(s || "");
let out = "";
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
const isOpen = c === "\u201C" || c === "\u201E" || c === "\u201F";
const isClose = c === "\u201D" || c === "\u2033" || c === "\u2036";
if (!isOpen && !isClose) {
out += c;
continue;
}
let prev = i - 1;
while (prev >= 0 && /\s/.test(text[prev]!)) prev -= 1;
let next = i + 1;
while (next < text.length && /\s/.test(text[next]!)) next += 1;
const prevCh = prev >= 0 ? text[prev]! : "";
const nextCh = next < text.length ? text[next]! : "";
const afterOpen = !prevCh || "{[:,[".includes(prevCh);
const beforeClose = !nextCh || ":,}]".includes(nextCh);
if (isOpen && afterOpen) out += '"';
else if (isClose && beforeClose) out += '"';
else out += c;
}
return out;
}
export function softenJsonText(s: string): string {
let t = String(s || "")
.replace(/^\uFEFF/, "")
.replace(/\u2026/g, "")
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "");
t = t.replace(/^\s*\/\/.*$/gm, "");
t = t.replace(/\/\*[\s\S]*?\*\//g, "");
t = escapeRawControlsInJsonStrings(t);
t = t.replace(/,\s*([\]}])/g, "$1");
return t;
}
export function repairTruncatedJsonObject(slice: string): string {
let s = String(slice || "").trim();
if (!s.startsWith("{")) return s;
s = s.replace(/,\s*"[^"]*$/u, "");
s = s.replace(/,\s*$/u, "");
const opens = (s.match(/\{/g) || []).length;
const closes = (s.match(/\}/g) || []).length;
const openBrackets = (s.match(/\[/g) || []).length;
const closeBrackets = (s.match(/\]/g) || []).length;
const quoteCount = (s.match(/(?<!\\)"/g) || []).length;
if (quoteCount % 2 === 1) s += '"';
if (openBrackets > closeBrackets) s += "]".repeat(openBrackets - closeBrackets);
if (opens > closes) s += "}".repeat(opens - closes);
return s;
}
function tryParse(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return null;
}
}
const PRODUCT_HINT_KEYS = [
"outputs",
"schema",
"正文",
"body",
"技能",
"workers",
"steps",
"brief",
"追问",
] as const;
export function looksLikeProductJson(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const row = value as Record<string, unknown>;
return PRODUCT_HINT_KEYS.some((k) => k in row);
}
function parseSlice(slice: string): unknown {
return (
tryParse(slice) ??
tryParse(softenJsonText(slice)) ??
tryParse(softenJsonText(convertStructuralSmartQuotes(slice))) ??
tryParse(repairTruncatedJsonObject(slice)) ??
tryParse(softenJsonText(repairTruncatedJsonObject(slice)))
);
}
/** 思维链末尾常整段 JSON;从后往前找像产物的对象,避免吃进前面的举例 `{ }` */
export function extractLastProductJson(text: string): unknown {
const s = String(text || "");
const starts: number[] = [];
for (let i = s.length - 1; i >= 0 && starts.length < 8; i--) {
if (s[i] === "{") starts.push(i);
}
let fallback: unknown = null;
for (const start of starts) {
const slice = s.slice(start);
const end = slice.lastIndexOf("}");
if (end < 1) continue;
const parsed = parseSlice(slice.slice(0, end + 1));
if (parsed == null) continue;
if (looksLikeProductJson(parsed)) return parsed;
if (fallback == null) fallback = parsed;
}
return fallback;
}
/** 模型常把弯引号写进字符串;必须先按原文 parse,再做宽松修复 */
export function tryParseJsonDoc(text: string): unknown {
const stripped = stripCodeFences(text);
const attempts = [
stripped,
softenJsonText(stripped),
softenJsonText(convertStructuralSmartQuotes(stripped)),
];
for (const candidate of attempts) {
const parsed = tryParse(candidate);
if (parsed != null) return parsed;
}
const cleaned = softenJsonText(convertStructuralSmartQuotes(stripped));
if (
(cleaned.startsWith('"') && cleaned.endsWith('"')) ||
(cleaned.startsWith("'") && cleaned.endsWith("'"))
) {
const inner = tryParse(cleaned);
if (typeof inner === "string") {
const parsed = tryParse(softenJsonText(inner)) ?? tryParse(inner);
if (parsed != null) return parsed;
}
}
const fromTail = extractLastProductJson(stripped) ?? extractLastProductJson(cleaned);
if (fromTail != null) return fromTail;
const arrStart = cleaned.indexOf("[");
const arrEnd = cleaned.lastIndexOf("]");
if (arrStart >= 0 && arrEnd > arrStart) {
const parsed = tryParse(cleaned.slice(arrStart, arrEnd + 1));
if (parsed != null) return parsed;
}
return null;
}
/**
* 正文可能是闲聊/英文收束,JSON 在思维链里。
* 能 parse 的一边优先;两边都能 parse 时用更像产物的那边。
*/
export function selectJsonPayload(content: string, reasoning?: string): string {
const body = String(content ?? "").trim();
const think = String(reasoning ?? "").trim();
const fromBody = body ? tryParseJsonDoc(body) : null;
const fromThink = think ? tryParseJsonDoc(think) : null;
const bodyOk = fromBody != null && typeof fromBody === "object";
const thinkOk = fromThink != null && typeof fromThink === "object";
if (bodyOk && thinkOk) {
if (looksLikeProductJson(fromThink) && !looksLikeProductJson(fromBody)) {
return think;
}
return body;
}
if (bodyOk) return body;
if (thinkOk) return think;
return body || think;
}