* Copyright (c) 2023 - present TinyEngine Authors.
* Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/
import { utils } from '@opentiny/tiny-engine-utils'
import * as jsonpatch from 'fast-json-patch'
import { jsonrepair } from 'jsonrepair'
import {
useCanvas,
useMaterial,
getMetaApi,
META_SERVICE,
useMessage,
useHistory
} from '@opentiny/tiny-engine-meta-register'
import type { NodeAIStatus } from '../../../DesignCanvas/src/api/types'
import { search, fetchAssets } from '../services/agentServices'
import { getCurrent } from '../container'
const { deepClone } = utils
const updateNodeAIStatus = (nodeId: string, aiStatus: Partial<NodeAIStatus>) => {
const { pageState } = useCanvas()
const { publish } = useMessage()
if (!pageState.aiNodesStatus[nodeId]) {
pageState.aiNodesStatus[nodeId] = {
state: 'hidden',
aiContext: null,
lastAIAction: '',
aiHistory: []
}
}
Object.assign(pageState.aiNodesStatus[nodeId], aiStatus)
publish({ topic: 'nodeAIStatusUpdate', data: { nodeId, aiStatus: pageState.aiNodesStatus[nodeId] } })
}
const getNodeAIStatus = (nodeId: string): NodeAIStatus | null => {
const { pageState } = useCanvas()
return pageState.aiNodesStatus[nodeId] || null
}
const addNodeAIActionHistory = (nodeId: string, action: string, content: any) => {
const currentStatus = getNodeAIStatus(nodeId)
if (!currentStatus) {
updateNodeAIStatus(nodeId, {
state: 'hidden',
aiHistory: [{ timestamp: Date.now(), action, content }]
})
return
}
const aiHistory = currentStatus.aiHistory || []
aiHistory.push({ timestamp: Date.now(), action, content })
updateNodeAIStatus(nodeId, {
aiHistory,
lastAIAction: action
})
}
const openNodeAIChat = (nodeId: string, initialContent: string = '') => {
updateNodeAIStatus(nodeId, {
state: 'chat',
collapsed: false,
chatContent: initialContent,
lastAIAction: 'open_chat'
})
addNodeAIActionHistory(nodeId, 'open_chat', {
timestamp: Date.now(),
initialContent
})
}
const closeNodeAIHelper = (nodeId: string) => {
updateNodeAIStatus(nodeId, {
state: 'hidden',
lastAIAction: 'close'
})
addNodeAIActionHistory(nodeId, 'close', {
timestamp: Date.now()
})
}
const confirmNodeAIAction = (nodeId: string) => {
const currentStatus = getNodeAIStatus(nodeId)
if (!currentStatus || currentStatus.state !== 'confirm') {
return
}
updateNodeAIStatus(nodeId, {
state: 'hidden',
collapsed: false,
originalNodeData: deepClone(currentStatus.aiModifiedNodeData),
aiModifiedNodeData: undefined,
lastAIAction: 'confirm'
})
}
const cancelNodeAIAction = (nodeId: string) => {
const currentStatus = getNodeAIStatus(nodeId)
if (!currentStatus) {
return
}
if (currentStatus.originalNodeData && currentStatus.state === 'confirm') {
const { restoreNodeSubtree } = useCanvas()
const { publish } = useMessage()
restoreNodeSubtree(nodeId, deepClone(currentStatus.originalNodeData))
publish({ topic: 'schemaChange', data: { nodeId } })
}
const newState = currentStatus.chatContent ? 'chat' : 'hidden'
updateNodeAIStatus(nodeId, {
state: newState,
collapsed: false,
aiModifiedNodeData: undefined,
lastAIAction: 'cancel'
})
}
* 设置AI修改后的节点数据(AI运行完调用)
* @param nodeId 节点ID
* @param aiModifiedNodeData AI修改后的节点数据
*/
const setNodeAIModifiedData = (nodeId: string, aiModifiedNodeData: any) => {
const currentAIStatus = getNodeAIStatus(nodeId)
if (!currentAIStatus) {
return
}
updateNodeAIStatus(nodeId, {
aiModifiedNodeData: deepClone(aiModifiedNodeData)
})
}
* 采纳AI修改
* 逻辑:设置originalNodeData为AI修改后的schema,设置aiModifiedNodeData为空
* @param nodeId 节点ID
*/
const adoptNodeAIModification = (nodeId: string) => {
const currentAIStatus = getNodeAIStatus(nodeId)
if (!currentAIStatus || currentAIStatus.state !== 'confirm') {
return false
}
updateNodeAIStatus(nodeId, {
state: 'completed',
originalNodeData: deepClone(currentAIStatus.aiModifiedNodeData),
aiModifiedNodeData: undefined
})
return true
}
* 拒绝AI修改
* 逻辑:设置aiModifiedNodeData为空,修改画布节点schema为originalNodeData
* @param nodeId 节点ID
*/
const rejectNodeAIModification = (nodeId: string) => {
const currentAIStatus = getNodeAIStatus(nodeId)
if (!currentAIStatus || currentAIStatus.state !== 'confirm') {
return false
}
if (currentAIStatus.originalNodeData) {
const { restoreNodeSubtree } = useCanvas()
const { publish } = useMessage()
restoreNodeSubtree(nodeId, deepClone(currentAIStatus.originalNodeData))
publish({ topic: 'schemaChange', data: { nodeId } })
}
const newState = currentAIStatus.chatContent ? 'chat' : 'hidden'
updateNodeAIStatus(nodeId, {
state: newState,
aiModifiedNodeData: undefined
})
return true
}
* 重置节点AI采纳状态
* @param nodeId 节点ID
*/
const resetNodeAIAdoptionStatus = (nodeId: string) => {
const currentAIStatus = getNodeAIStatus(nodeId)
if (currentAIStatus) {
updateNodeAIStatus(nodeId, {
originalNodeData: undefined,
aiModifiedNodeData: undefined
})
}
}
* 检查节点是否有待处理的AI修改
* @param nodeId 节点ID
*/
const hasNodePendingAIModification = (nodeId: string): boolean => {
const aiStatus = getNodeAIStatus(nodeId)
return aiStatus?.state === 'confirm'
}
const shouldShowNodeAIChat = (nodeId: string): boolean => {
const status = getNodeAIStatus(nodeId)
return status?.state === 'chat' && !status?.collapsed
}
const shouldShowNodeAIConfirm = (nodeId: string): boolean => {
const status = getNodeAIStatus(nodeId)
return status?.state === 'confirm' && !status?.collapsed
}
const startNodeAILoading = (nodeId: string, loadingMessage: string = 'AI处理中...') => {
updateNodeAIStatus(nodeId, {
state: 'loading',
collapsed: false,
lastAIAction: 'start_loading',
aiContext: {
loadingMessage,
startTime: Date.now()
}
})
addNodeAIActionHistory(nodeId, 'start_loading', {
timestamp: Date.now(),
loadingMessage
})
}
const completeNodeAILoading = (nodeId: string) => {
updateNodeAIStatus(nodeId, {
state: 'confirm',
collapsed: false,
lastAIAction: 'complete_loading'
})
addNodeAIActionHistory(nodeId, 'complete_loading', {
timestamp: Date.now()
})
}
const cancelNodeAILoading = (nodeId: string) => {
const currentStatus = getNodeAIStatus(nodeId)
if (!currentStatus || currentStatus.state !== 'loading') {
return
}
const newState = currentStatus.chatContent ? 'chat' : 'hidden'
updateNodeAIStatus(nodeId, {
state: newState,
collapsed: false,
aiContext: undefined,
lastAIAction: 'cancel_loading'
})
addNodeAIActionHistory(nodeId, 'cancel_loading', {
timestamp: Date.now(),
previousState: currentStatus.state,
loadingDuration: Date.now() - (currentStatus.aiContext?.startTime || Date.now())
})
}
const shouldShowNodeAILoading = (nodeId: string): boolean => {
const status = getNodeAIStatus(nodeId)
return status?.state === 'loading' && !status?.collapsed
}
* 逐个解析 JSON Patch 数组中的顶级 patch 对象
* 当整个数组因括号不匹配等原因无法解析时,通过顶层 `{"op":` 模式分割后分别解析
* 对每个单独的对象尝试 JSON.parse → jsonrepair → 修复多余括号
*/
const parseIndividualPatches = (jsonStr: string): any[] => {
const patches: any[] = []
let str = jsonStr.trim()
if (str.startsWith('[')) str = str.slice(1)
if (str.endsWith(']')) str = str.slice(0, -1)
const segments: string[] = []
let depth = 0
let inString = false
let escape = false
let segStart = -1
for (let i = 0; i < str.length; i++) {
const ch = str[i]
if (escape) {
escape = false
continue
}
if (ch === '\\' && inString) {
escape = true
continue
}
if (ch === '"') {
inString = !inString
continue
}
if (inString) continue
if (ch === '{') {
if (depth === 0) segStart = i
depth++
} else if (ch === '}') {
depth--
if (depth === 0 && segStart >= 0) {
segments.push(str.slice(segStart, i + 1))
segStart = -1
}
}
}
for (const seg of segments) {
let patch = null
try {
patch = JSON.parse(seg)
} catch {
try {
patch = JSON.parse(jsonrepair(seg))
} catch {
let fixed = seg
for (let trim = 0; trim < 5; trim++) {
fixed = fixed.replace(/\}(\}*)$/, '$1')
try {
patch = JSON.parse(fixed)
break
} catch {
}
}
}
}
if (patch && patch.op && patch.path) {
patches.push(patch)
}
}
return patches
}
const findJsonPatchPath = (node, targetId, path = []) => {
if (!node || typeof node !== 'object') return null
if (node.id === targetId) {
return '/' + path.join('/')
}
if (Array.isArray(node.children)) {
for (let i = 0; i < node.children.length; i++) {
const result = findJsonPatchPath(node.children[i], targetId, [...path, 'children', i])
if (result) return result
}
}
for (const key in node) {
if (key !== 'children' && Object.hasOwn(node, key)) {
const value = node[key]
if (value && typeof value === 'object') {
const result = findJsonPatchPath(value, targetId, [...path, key])
if (result) return result
}
}
}
return null
}
* 解析 AI 返回的 JSON Patch 字符串,逐步增强容错:
* 1. 剥离 Markdown 代码块包裹
* 2. 直接 JSON.parse
* 3. 修复未转义换行符后再 parse
* 4. jsonrepair 修复
* 5. 逐个 patch 解析(跳过损坏的单个 patch)
*/
const parseJsonPatches = (content: string): any[] | null => {
let jsonStr = content
const codeBlockMatch = jsonStr.match(/```(?:json|schema)?([\s\S]*?)```/)
if (codeBlockMatch) {
jsonStr = codeBlockMatch[1].trim()
}
let patches: any[] = []
try {
try {
patches = JSON.parse(jsonStr)
} catch {
try {
const fixedStr = jsonStr.replace(
/"((?:[^"\\]|\\.)*)"/g,
(match, inner) => '"' + inner.replace(/\n/g, '\\n').replace(/\r/g, '\\r') + '"'
)
patches = JSON.parse(fixedStr)
} catch {
try {
patches = JSON.parse(jsonrepair(jsonStr))
} catch {
patches = parseIndividualPatches(jsonStr)
}
}
}
} catch (error) {
return null
}
if (!Array.isArray(patches)) {
patches = [patches]
}
return patches
}
* 辅助函数:根据路径段从对象中安全取值
*/
const getValueBySegments = (obj: any, segments: string[]): any => {
return segments.reduce((o, key) => (o !== null ? o[key] : undefined), obj)
}
* 辅助函数:将路径段拼接为 JSON Pointer 格式(以 / 开头)
*/
const toPointer = (segments: string[]): string => '/' + segments.filter(Boolean).join('/')
* 将 JSON Patch 数组应用到页面 schema 上
* 按操作类型分步应用:先 replace/remove,再 add,最后其他
* add 操作会自动处理:目标数组不存在时初始化、索引越界时追加到末尾
* @param patches JSON Patch 数组
* @param pageSchema 当前页面 schema
* @param parentPath 当前节点在 schema 中的路径
* @returns 应用后的新 schema
*/
const applyPatchesToSchema = (patches: any[], pageSchema: object, parentPath: string): object => {
const replacePatches = patches.filter((p) => p.op === 'replace' || p.op === 'remove')
const addPatches = patches.filter((p) => p.op === 'add')
const otherPatches = patches.filter((p) => p.op !== 'replace' && p.op !== 'remove' && p.op !== 'add')
let newSchema = replacePatches.reduce((acc, patch) => {
try {
const fullPatch = {
...patch,
path: parentPath + patch.path
}
return jsonpatch.applyPatch(acc, [fullPatch], false, false).newDocument
} catch (error) {
return acc
}
}, pageSchema)
newSchema = addPatches.reduce((acc, patch) => {
try {
const pathSegments = patch.path.split('/').filter(Boolean)
const lastSegment = pathSegments[pathSegments.length - 1]
if (!lastSegment) {
return acc
}
const parentSegments = pathSegments.slice(0, -1)
const fullParentSegments = (parentPath + '/' + parentSegments.join('/')).split('/').filter(Boolean)
let parentValue = getValueBySegments(acc, fullParentSegments)
let fixedPath = patch.path
if (!Array.isArray(parentValue) && /^\d+$/.test(lastSegment)) {
const arrayPath = toPointer(parentPath.split('/').filter(Boolean).concat(parentSegments))
try {
const patched = jsonpatch.applyPatch(
acc,
[{ op: 'add', path: arrayPath, value: [] }],
false,
false
).newDocument
acc = patched
parentValue = getValueBySegments(acc, fullParentSegments)
} catch {
}
}
if (Array.isArray(parentValue)) {
const index = Number(lastSegment)
if (Number.isNaN(index)) {
fixedPath = toPointer(pathSegments) + '/-'
} else if (index >= parentValue.length) {
fixedPath = toPointer(parentSegments) + '/-'
}
}
const fullPatch = {
...patch,
path: parentPath + fixedPath
}
return jsonpatch.applyPatch(acc, [fullPatch], false, false).newDocument
} catch (error) {
return acc
}
}, newSchema)
newSchema = otherPatches.reduce((acc, patch) => {
try {
const fullPatch = {
...patch,
path: parentPath + patch.path
}
return jsonpatch.applyPatch(acc, [fullPatch], false, false).newDocument
} catch (error) {
return acc
}
}, newSchema)
return newSchema
}
* 应用AI返回的JSON Patch到页面schema,完成画布更新
* 逻辑:设置chatContent、设置aiModifiedNodeData为AI修改后的节点schema、修改画布节点schema为AI的schema
* @param nodeId 当前节点ID
* @param chatResponse AI聊天接口返回的响应对象
* @param chatContent 用户发送的聊天消息
* @returns 应用成功返回 true,失败返回 false
*/
const applyAIPatches = (nodeId: string, chatResponse: any, chatContent?: string): boolean => {
if (!chatResponse?.choices?.[0]?.message?.content) {
return false
}
const { fixMethods, schemaAutoFix } = getMetaApi('engine.service.robot')
const { getPageSchema, getNode, updatePageSchema, setSaved } = useCanvas()
const content = chatResponse.choices[0].message.content
const validJsonPatches = parseJsonPatches(content)
if (!validJsonPatches) {
return false
}
const parentPath = findJsonPatchPath(getPageSchema(), nodeId)
const newSchema = applyPatchesToSchema(validJsonPatches, getPageSchema(), parentPath)
fixMethods(newSchema.methods)
schemaAutoFix(newSchema.children)
updatePageSchema(newSchema)
const modifiedNode = getNode(nodeId)
const modifiedNodeData = modifiedNode ? deepClone(modifiedNode) : validJsonPatches[0]?.value
updateNodeAIStatus(nodeId, {
state: 'confirm',
collapsed: false,
chatContent,
aiModifiedNodeData: modifiedNodeData
})
setSaved(false)
useHistory().addHistory()
completeNodeAILoading(nodeId)
return true
}
* 构建AI聊天请求参数
* @param content 用户输入的消息文本
* @returns 请求参数对象
*/
const buildAIChatRequest = async (content: string) => {
const { getRobotServiceOptions, formatComponents, getAgentSystemPrompt, getSelectedModelInfo } =
getMetaApi('engine.service.robot')
const currentSchema = getCurrent().schema
const modelInfo = getSelectedModelInfo()
let referenceContext = ''
let imageAssets: any[] = []
if (getRobotServiceOptions()?.enableRagContext) {
referenceContext = await search(content)
}
if (getRobotServiceOptions()?.enableResourceContext) {
const appId = getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id
imageAssets = await fetchAssets(appId)
}
const { materialState, getComponentDetail } = useMaterial()
const components = formatComponents(materialState.components, getComponentDetail)
const messages = [
{ role: 'system', content: getAgentSystemPrompt(components, currentSchema, referenceContext, imageAssets) },
{ role: 'user', content: [{ type: 'text', text: content }] }
]
return {
body: {
baseUrl: modelInfo.baseUrl,
model: modelInfo.model,
apiKey: modelInfo.apiKey,
messages
},
headers: {
Authorization: `Bearer ${modelInfo.apiKey}`,
'Content-Type': 'application/json'
}
}
}
export default function () {
return {
updateNodeAIStatus,
getNodeAIStatus,
addNodeAIActionHistory,
openNodeAIChat,
closeNodeAIHelper,
startNodeAILoading,
completeNodeAILoading,
cancelNodeAILoading,
confirmNodeAIAction,
cancelNodeAIAction,
shouldShowNodeAIChat,
shouldShowNodeAILoading,
shouldShowNodeAIConfirm,
setNodeAIModifiedData,
adoptNodeAIModification,
rejectNodeAIModification,
resetNodeAIAdoptionStatus,
hasNodePendingAIModification,
findJsonPatchPath,
applyAIPatches,
buildAIChatRequest
}
}