Skip to content

Commit a605953

Browse files
committed
feat:添加AI返回JSON处理
1 parent 294a051 commit a605953

1 file changed

Lines changed: 251 additions & 14 deletions

File tree

packages/canvas/container/src/composables/useAIChat.ts

Lines changed: 251 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import { utils } from '@opentiny/tiny-engine-utils'
1414
import * as jsonpatch from 'fast-json-patch'
15+
import { jsonrepair } from 'jsonrepair'
1516
import {
1617
useCanvas,
1718
useMaterial,
@@ -373,6 +374,86 @@ const shouldShowNodeAILoading = (nodeId: string): boolean => {
373374
return status?.state === 'loading'
374375
}
375376

377+
/**
378+
* 逐个解析 JSON Patch 数组中的顶级 patch 对象
379+
* 当整个数组因括号不匹配等原因无法解析时,通过顶层 `{"op":` 模式分割后分别解析
380+
* 对每个单独的对象尝试 JSON.parse → jsonrepair → 修复多余括号
381+
*/
382+
const parseIndividualPatches = (jsonStr: string): any[] => {
383+
const patches: any[] = []
384+
385+
// 移除外层数组的 [ ] 包裹
386+
let str = jsonStr.trim()
387+
if (str.startsWith('[')) str = str.slice(1)
388+
if (str.endsWith(']')) str = str.slice(0, -1)
389+
390+
// 通过顶层 {"op": 模式分割各个 patch 对象
391+
const segments: string[] = []
392+
let depth = 0
393+
let inString = false
394+
let escape = false
395+
let segStart = -1
396+
397+
for (let i = 0; i < str.length; i++) {
398+
const ch = str[i]
399+
400+
if (escape) {
401+
escape = false
402+
continue
403+
}
404+
if (ch === '\\' && inString) {
405+
escape = true
406+
continue
407+
}
408+
if (ch === '"') {
409+
inString = !inString
410+
continue
411+
}
412+
if (inString) continue
413+
414+
if (ch === '{') {
415+
if (depth === 0) segStart = i
416+
depth++
417+
} else if (ch === '}') {
418+
depth--
419+
if (depth === 0 && segStart >= 0) {
420+
segments.push(str.slice(segStart, i + 1))
421+
segStart = -1
422+
}
423+
}
424+
}
425+
426+
// 对每个 segment 尝试解析,逐步增强容错
427+
for (const seg of segments) {
428+
let patch = null
429+
try {
430+
patch = JSON.parse(seg)
431+
} catch {
432+
try {
433+
patch = JSON.parse(jsonrepair(seg))
434+
} catch {
435+
// 尝试修复多余的尾部 }
436+
// 找到最后一个合法的 } 位置(通过从后往前逐步移除多余的 })
437+
let fixed = seg
438+
for (let trim = 0; trim < 5; trim++) {
439+
fixed = fixed.replace(/\}(\}*)$/, '$1') // 移除最末尾的一个 }
440+
try {
441+
patch = JSON.parse(fixed)
442+
break
443+
} catch {
444+
// 继续尝试
445+
}
446+
}
447+
}
448+
}
449+
if (patch && patch.op && patch.path) {
450+
patches.push(patch)
451+
}
452+
}
453+
454+
return patches
455+
}
456+
376457
const findJsonPatchPath = (node, targetId, path = []) => {
377458
if (!node || typeof node !== 'object') return null
378459

@@ -401,6 +482,169 @@ const findJsonPatchPath = (node, targetId, path = []) => {
401482
return null
402483
}
403484

485+
/**
486+
* 解析 AI 返回的 JSON Patch 字符串,逐步增强容错:
487+
* 1. 剥离 Markdown 代码块包裹
488+
* 2. 直接 JSON.parse
489+
* 3. 修复未转义换行符后再 parse
490+
* 4. jsonrepair 修复
491+
* 5. 逐个 patch 解析(跳过损坏的单个 patch)
492+
*/
493+
const parseJsonPatches = (content: string): any[] | null => {
494+
let jsonStr = content
495+
// 剥离 Markdown 代码块
496+
const codeBlockMatch = jsonStr.match(/```(?:json|schema)?([\s\S]*?)```/)
497+
if (codeBlockMatch) {
498+
jsonStr = codeBlockMatch[1].trim()
499+
}
500+
501+
let patches: any[] = []
502+
try {
503+
// 策略1:直接解析
504+
try {
505+
patches = JSON.parse(jsonStr)
506+
} catch {
507+
// 策略2:修复 JSON 字符串值中未转义的换行符
508+
try {
509+
const fixedStr = jsonStr.replace(
510+
/"((?:[^"\\]|\\.)*)"/g,
511+
(match, inner) => '"' + inner.replace(/\n/g, '\\n').replace(/\r/g, '\\r') + '"'
512+
)
513+
patches = JSON.parse(fixedStr)
514+
} catch {
515+
// 策略3:使用 jsonrepair 修复
516+
try {
517+
patches = JSON.parse(jsonrepair(jsonStr))
518+
} catch {
519+
// 策略4:逐个 patch 解析 — AI 常在深层嵌套的 children 中产生多余的括号,
520+
// 导致整个数组解析失败。逐个提取顶级 patch 对象分别解析,跳过损坏的。
521+
patches = parseIndividualPatches(jsonStr)
522+
}
523+
}
524+
}
525+
} catch (error) {
526+
return null
527+
}
528+
529+
if (!Array.isArray(patches)) {
530+
patches = [patches]
531+
}
532+
return patches
533+
}
534+
535+
/**
536+
* 辅助函数:根据路径段从对象中安全取值
537+
*/
538+
const getValueBySegments = (obj: any, segments: string[]): any => {
539+
return segments.reduce((o, key) => (o !== null ? o[key] : undefined), obj)
540+
}
541+
542+
/**
543+
* 辅助函数:将路径段拼接为 JSON Pointer 格式(以 / 开头)
544+
*/
545+
const toPointer = (segments: string[]): string => '/' + segments.filter(Boolean).join('/')
546+
547+
/**
548+
* 将 JSON Patch 数组应用到页面 schema 上
549+
* 按操作类型分步应用:先 replace/remove,再 add,最后其他
550+
* add 操作会自动处理:目标数组不存在时初始化、索引越界时追加到末尾
551+
* @param patches JSON Patch 数组
552+
* @param pageSchema 当前页面 schema
553+
* @param parentPath 当前节点在 schema 中的路径
554+
* @returns 应用后的新 schema
555+
*/
556+
const applyPatchesToSchema = (patches: any[], pageSchema: object, parentPath: string): object => {
557+
// 分离操作类型
558+
const replacePatches = patches.filter((p) => p.op === 'replace' || p.op === 'remove')
559+
const addPatches = patches.filter((p) => p.op === 'add')
560+
const otherPatches = patches.filter((p) => p.op !== 'replace' && p.op !== 'remove' && p.op !== 'add')
561+
562+
// 先应用 replace/remove 操作
563+
let newSchema = replacePatches.reduce((acc, patch) => {
564+
try {
565+
const fullPatch = {
566+
...patch,
567+
path: parentPath + patch.path
568+
}
569+
return jsonpatch.applyPatch(acc, [fullPatch], false, false).newDocument
570+
} catch (error) {
571+
return acc
572+
}
573+
}, pageSchema)
574+
575+
// 再应用 add 操作
576+
// 需要处理的情况:
577+
// 1. 目标数组不存在 → 先初始化空数组,再追加
578+
// 2. 索引超出数组长度 → 降级为追加到末尾(/-)
579+
// 3. 最后一段不是数字索引且目标是数组 → 追加到末尾(/-)
580+
newSchema = addPatches.reduce((acc, patch) => {
581+
try {
582+
const pathSegments = patch.path.split('/').filter(Boolean)
583+
const lastSegment = pathSegments[pathSegments.length - 1]
584+
585+
if (!lastSegment) {
586+
return acc
587+
}
588+
589+
const parentSegments = pathSegments.slice(0, -1)
590+
const fullParentSegments = (parentPath + '/' + parentSegments.join('/')).split('/').filter(Boolean)
591+
let parentValue = getValueBySegments(acc, fullParentSegments)
592+
let fixedPath = patch.path
593+
594+
// 父路径对应的值不是数组但路径暗示要往数组中插入(如 /children/0),
595+
// 需要先初始化空数组
596+
if (!Array.isArray(parentValue) && /^\d+$/.test(lastSegment)) {
597+
const arrayPath = toPointer(parentPath.split('/').filter(Boolean).concat(parentSegments))
598+
try {
599+
const patched = jsonpatch.applyPatch(
600+
acc,
601+
[{ op: 'add', path: arrayPath, value: [] }],
602+
false,
603+
false
604+
).newDocument
605+
acc = patched
606+
parentValue = getValueBySegments(acc, fullParentSegments)
607+
} catch {
608+
// 路径已存在或初始化失败,尝试继续
609+
}
610+
}
611+
612+
// 根据目标数组的状态修正路径
613+
if (Array.isArray(parentValue)) {
614+
const index = Number(lastSegment)
615+
if (Number.isNaN(index)) {
616+
fixedPath = toPointer(pathSegments) + '/-'
617+
} else if (index >= parentValue.length) {
618+
fixedPath = toPointer(parentSegments) + '/-'
619+
}
620+
}
621+
622+
const fullPatch = {
623+
...patch,
624+
path: parentPath + fixedPath
625+
}
626+
return jsonpatch.applyPatch(acc, [fullPatch], false, false).newDocument
627+
} catch (error) {
628+
return acc
629+
}
630+
}, newSchema)
631+
632+
// 最后应用其他操作(move, copy, test 等)
633+
newSchema = otherPatches.reduce((acc, patch) => {
634+
try {
635+
const fullPatch = {
636+
...patch,
637+
path: parentPath + patch.path
638+
}
639+
return jsonpatch.applyPatch(acc, [fullPatch], false, false).newDocument
640+
} catch (error) {
641+
return acc
642+
}
643+
}, newSchema)
644+
645+
return newSchema
646+
}
647+
404648
/**
405649
* 应用AI返回的JSON Patch到页面schema,完成画布更新
406650
* 逻辑:设置chatContent、设置aiModifiedNodeData为AI修改后的节点schema、修改画布节点schema为AI的schema
@@ -418,20 +662,13 @@ const applyAIPatches = (nodeId: string, chatResponse: any, chatContent?: string)
418662
const { getPageSchema, getNode, updatePageSchema, setSaved } = useCanvas()
419663

420664
const content = chatResponse.choices[0].message.content
421-
const validJsonPatches = JSON.parse(content)
422-
const parentPath = findJsonPatchPath(getPageSchema(), nodeId)
665+
const validJsonPatches = parseJsonPatches(content)
666+
if (!validJsonPatches) {
667+
return false
668+
}
423669

424-
const newSchema = validJsonPatches.reduce((acc, patch) => {
425-
try {
426-
const fullPatch = {
427-
...patch,
428-
path: parentPath + patch.path
429-
}
430-
return jsonpatch.applyPatch(acc, [fullPatch], false, false).newDocument
431-
} catch (error) {
432-
return acc
433-
}
434-
}, getPageSchema())
670+
const parentPath = findJsonPatchPath(getPageSchema(), nodeId)
671+
const newSchema = applyPatchesToSchema(validJsonPatches, getPageSchema(), parentPath)
435672

436673
fixMethods(newSchema.methods)
437674
schemaAutoFix(newSchema.children)
@@ -441,7 +678,7 @@ const applyAIPatches = (nodeId: string, chatResponse: any, chatContent?: string)
441678

442679
// 设置 AI 状态:chatContent、aiModifiedNodeData
443680
const modifiedNode = getNode(nodeId)
444-
const modifiedNodeData = modifiedNode ? deepClone(modifiedNode) : validJsonPatches[0].value
681+
const modifiedNodeData = modifiedNode ? deepClone(modifiedNode) : validJsonPatches[0]?.value
445682

446683
updateNodeAIStatus(nodeId, {
447684
state: 'confirm',

0 commit comments

Comments
 (0)