1212
1313import { utils } from '@opentiny/tiny-engine-utils'
1414import * as jsonpatch from 'fast-json-patch'
15+ import { jsonrepair } from 'jsonrepair'
1516import {
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+
376457const 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 ( / ` ` ` (?: j s o n | s c h e m a ) ? ( [ \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