diff --git a/packages/canvas/DesignCanvas/src/api/types.ts b/packages/canvas/DesignCanvas/src/api/types.ts index bb2a826645..4ba9f5be09 100644 --- a/packages/canvas/DesignCanvas/src/api/types.ts +++ b/packages/canvas/DesignCanvas/src/api/types.ts @@ -2,6 +2,28 @@ import type { Node, RootNode } from '../../../types' export type PageSchema = RootNode +export type AIHelperState = 'hidden' | 'chat' | 'loading' | 'confirm' | 'completed' + +export interface NodeAIStatus { + state: AIHelperState + collapsed?: boolean // 面板是否收起(收起时保留原状态,重新打开可恢复) + aiContext?: any + lastAIAction?: string + aiHistory?: Array<{ + timestamp: number + action: string + content: any + }> + chatContent?: string // 聊天内容 + // AI采纳状态相关字段 + originalNodeData?: any // AI修改前的节点数据备份 + aiModifiedNodeData?: any // AI修改后的节点数据 +} + +export interface NodeStatus { + [key: string]: any +} + export interface PageState { currentVm?: unknown currentSchema?: { [x: string]: any; id: string } @@ -17,7 +39,8 @@ export interface PageState { isSaved: boolean isLock: boolean isBlock: boolean - nodesStatus: Record + nodesStatus: Record + aiNodesStatus: Record // AI状态独立存储,避免与nodesStatus的可见性(false)冲突 loading: boolean } diff --git a/packages/canvas/DesignCanvas/src/api/useCanvas.ts b/packages/canvas/DesignCanvas/src/api/useCanvas.ts index 1c64aefc78..30d2c430af 100644 --- a/packages/canvas/DesignCanvas/src/api/useCanvas.ts +++ b/packages/canvas/DesignCanvas/src/api/useCanvas.ts @@ -21,6 +21,7 @@ import type { ChangePropsOperation, DeleteOperation, InsertOperation, + NodeAIStatus, NodeOperation, PageSchema, PageState, @@ -43,6 +44,7 @@ const defaultPageState: PageState = { isLock: false, isBlock: false, nodesStatus: {}, + aiNodesStatus: {}, loading: false } @@ -84,6 +86,39 @@ const rootSchema = ref([ } ]) +// 初始化单个节点的AI状态(使用独立的aiNodesStatus,避免与nodesStatus可见性冲突) +const initializeNodeAIStatus = (node: Node, initialStatus: Partial = {}) => { + pageState.aiNodesStatus[node.id] = { + state: 'hidden', + originalNodeData: deepClone(node), + aiModifiedNodeData: undefined, + aiContext: null, + lastAIAction: '', + aiHistory: [], + ...initialStatus + } +} + +// 初始化所有现有节点的AI状态 +const initializeAllNodesAIStatus = () => { + // 递归遍历 pageSchema 的 children 来初始化所有节点的AI状态 + const traverseNodes = (nodes: any[]) => { + if (!nodes) return + nodes.forEach((node) => { + if (node.id && !pageState.aiNodesStatus[node.id]) { + initializeNodeAIStatus(node) + } + if (Array.isArray(node.children) && node.children.length) { + traverseNodes(node.children) + } + }) + } + + if (pageState.pageSchema?.children) { + traverseNodes(pageState.pageSchema.children) + } +} + const handleTinyGridColumnsSlots = (node: Node) => { const columns = Array.isArray(node.props?.columns) ? node.props.columns : [] for (const columnItem of columns) { @@ -176,13 +211,26 @@ const jsonDiffPatchInstance = jsonDiffPatch.create({ const { publish } = useMessage() // 重置画布数据 -const resetCanvasState = async (state: Partial = {}) => { +// preserveAINodeStatus: 为true时保留aiNodesStatus并为新增节点补初始化(适用于AI/robot等schema热更新场景) +const resetCanvasState = async (state: Partial = {}, options?: { preserveAINodeStatus?: boolean }) => { const previousSchema = JSON.parse(JSON.stringify(pageState.pageSchema)) + const preserveAINodeStatus = options?.preserveAINodeStatus ?? false + + // 保留旧aiNodesStatus快照,用于后续diff补初始化 + const oldAINodesStatus: Record = preserveAINodeStatus ? { ...pageState.aiNodesStatus } : {} Object.assign(pageState, defaultPageState, state) nodesMap.value.clear() + if (preserveAINodeStatus) { + // 保留aiNodesStatus,后续只为新增节点补初始化 + pageState.aiNodesStatus = oldAINodesStatus + } else { + // 切换页面时清空所有节点的AI状态,避免旧页面的AI状态残留 + pageState.aiNodesStatus = {} + } + if (pageState.pageSchema) { if (!pageState.pageSchema.children) { pageState.pageSchema.children = [] @@ -200,14 +248,34 @@ const resetCanvasState = async (state: Partial = {}) => { nodesMap.value.set(0, { node: rootSchema.value, parent: pageState.pageSchema }) generateNodesMap(pageState.pageSchema.children, pageState.pageSchema) + + if (preserveAINodeStatus) { + // 为新增的节点初始化AI状态(已存在的不覆盖) + nodesMap.value.forEach(({ node }) => { + if (node.id && !pageState.aiNodesStatus[node.id]) { + initializeNodeAIStatus(node) + } + }) + } else { + // 初始化所有节点的AI状态 + initializeAllNodesAIStatus() + } } const diffPatch = jsonDiffPatchInstance.diff(previousSchema, pageState.pageSchema) - canvasApi.value?.clearSelect?.() + if (!preserveAINodeStatus) { + canvasApi.value?.clearSelect?.() + } + publish({ topic: 'schemaImport', data: { current: pageState.pageSchema, previous: previousSchema, diffPatch } }) } +// 更新页面schema,保留AI状态(委托resetCanvasState + preserveAINodeStatus) +const updatePageSchema = (newPageSchema: any) => { + resetCanvasState({ ...pageState, pageSchema: newPageSchema }, { preserveAINodeStatus: true }) +} + // 页面重置画布数据 const resetPageCanvasState = (state: Partial = {}) => { state.isBlock = false @@ -383,10 +451,28 @@ const operationTypeMap = { setNode(newNodeData, parentNode) + // 初始化新节点的AI状态 + if (newNodeData.id) { + initializeNodeAIStatus(newNodeData) + } + // 6. 如果新节点有子节点,递归构建 nodeMap if (Array.isArray(newNodeData?.children) && newNodeData.children.length > 0) { const newNode = getNode(newNodeData.id) generateNodesMap(newNodeData.children, newNode) + + // 递归初始化所有子节点的AI状态 + const initChildrenAIStatus = (children: Node[]) => { + children.forEach((child) => { + if (child.id) { + initializeNodeAIStatus(child) + } + if (Array.isArray(child?.children) && child.children.length > 0) { + initChildrenAIStatus(child.children) + } + }) + } + initChildrenAIStatus(newNodeData.children) } // 7. 返回插入结果 @@ -410,16 +496,18 @@ const operationTypeMap = { if (index > -1) { parent.children.splice(index, 1) nodesMap.value.delete(node.id) + delete pageState.aiNodesStatus[node.id] } let children = [...(node.children || [])] - // 递归清理 nodesMap + // 递归清理 nodesMap 和 aiNodesStatus while (children?.length) { const len = children.length children.forEach((item) => { const nodeItem = getNode(item.id) nodesMap.value.delete(item.id) + delete pageState.aiNodesStatus[item.id] if (Array.isArray(nodeItem?.children) && nodeItem?.children.length) { children.push(...nodeItem.children) @@ -597,7 +685,7 @@ const patchLatestSchema = (schema: unknown) => { } } -const importSchema = (data: any) => { +const importSchema = (data: any, options?: { preserveAINodeStatus?: boolean }) => { let importData = data if (typeof data === 'string') { @@ -609,11 +697,7 @@ const importSchema = (data: any) => { } } - // JSON 格式校验 - resetCanvasState({ - ...pageState, - pageSchema: importData - }) + resetCanvasState({ ...pageState, pageSchema: importData }, options) } const exportSchema = () => { @@ -650,6 +734,49 @@ const updateSchema = (data: Partial) => { publish({ topic: 'schemaChange', data: {} }) } +/** + * 恢复节点子树数据并重建nodesMap + * 用于AI回滚场景:恢复originalNodeData后需要同步清理/重建nodesMap + * @param nodeId 要恢复的节点ID + * @param restoredData 恢复后的节点数据(deepClone后的originalNodeData) + */ +const restoreNodeSubtree = (nodeId: string, restoredData: any) => { + // 1. 收集恢复前该节点子树中的所有ID(这些是需要从nodesMap中清理的) + const collectSubtreeIds = (node: any): string[] => { + const ids: string[] = [] + if (node?.id) ids.push(node.id) + if (Array.isArray(node?.children)) { + node.children.forEach((child: any) => ids.push(...collectSubtreeIds(child))) + } + return ids + } + + const currentNode = getNode(nodeId) + const oldIds = currentNode ? collectSubtreeIds(currentNode) : [] + // 获取当前节点的parent信息(在清理前保存) + const parentEntry = nodesMap.value.get(nodeId) + const parentNode = parentEntry?.parent + + // 2. 清理旧子树的nodesMap + oldIds.forEach((id) => nodesMap.value.delete(id)) + + // 3. 用恢复后的数据覆盖当前节点 + if (currentNode) { + Object.keys(currentNode).forEach((key) => delete currentNode[key]) + Object.assign(currentNode, restoredData) + } + + // 4. 重建该节点自身的nodesMap条目 + if (currentNode && parentNode) { + nodesMap.value.set(nodeId, { node: currentNode, parent: parentNode }) + } + + // 5. 重建子节点的nodesMap + if (Array.isArray(restoredData?.children) && restoredData.children.length && currentNode) { + generateNodesMap(restoredData.children, currentNode) + } +} + export default function () { return { pageState, @@ -684,6 +811,8 @@ export default function () { exportSchema, getSchema, getNodePath, - updateSchema + updateSchema, + updatePageSchema, + restoreNodeSubtree } } diff --git a/packages/canvas/container/assets/loading.webp b/packages/canvas/container/assets/loading.webp new file mode 100644 index 0000000000..ed71b43597 Binary files /dev/null and b/packages/canvas/container/assets/loading.webp differ diff --git a/packages/canvas/container/src/components/AIConfirmDialog.vue b/packages/canvas/container/src/components/AIConfirmDialog.vue new file mode 100644 index 0000000000..f3af12e385 --- /dev/null +++ b/packages/canvas/container/src/components/AIConfirmDialog.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/packages/canvas/container/src/components/AILoadingDialog.vue b/packages/canvas/container/src/components/AILoadingDialog.vue new file mode 100644 index 0000000000..192d005451 --- /dev/null +++ b/packages/canvas/container/src/components/AILoadingDialog.vue @@ -0,0 +1,93 @@ + + + + diff --git a/packages/canvas/container/src/components/CanvasAIChat.vue b/packages/canvas/container/src/components/CanvasAIChat.vue new file mode 100644 index 0000000000..75bce3508c --- /dev/null +++ b/packages/canvas/container/src/components/CanvasAIChat.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/canvas/container/src/components/CanvasAction.vue b/packages/canvas/container/src/components/CanvasAction.vue index 9df3bc0cac..f3cd4a50ea 100644 --- a/packages/canvas/container/src/components/CanvasAction.vue +++ b/packages/canvas/container/src/components/CanvasAction.vue @@ -91,6 +91,39 @@
+ +
+ + + + + + +
@@ -136,6 +169,14 @@ import { import { useLayout, useMaterial, useCanvas, useMessage } from '@opentiny/tiny-engine-meta-register' import { Popover } from '@opentiny/vue' import shortCutPopover from './shortCutPopover.vue' +import CanvasAIChat from './CanvasAIChat.vue' +import AIConfirmDialog from './AIConfirmDialog.vue' +import AILoadingDialog from './AILoadingDialog.vue' +import { chat } from '../services/agentServices' +import { utils } from '@opentiny/tiny-engine-utils' +import useAIChat from '../composables/useAIChat' + +const { deepClone } = utils // 工具操作条高度 const OPTION_BAR_HEIGHT = 24 @@ -155,6 +196,9 @@ const STYLE_UNSET = 'unset' export default { components: { + AILoadingDialog, + CanvasAIChat, + AIConfirmDialog, IconDel: IconDel(), IconSetting: IconSetting(), IconChevronLeft: IconChevronLeft(), @@ -198,6 +242,29 @@ export default { }, emits: ['remove', 'selectSlot', 'setting'], setup(props) { + const { pageState: _pageState, getNode } = useCanvas() + + const { + getNodeAIStatus, + openNodeAIChat, + closeNodeAIHelper, + startNodeAILoading, + cancelNodeAILoading, + shouldShowNodeAIChat, + shouldShowNodeAILoading, + shouldShowNodeAIConfirm, + confirmNodeAIAction, + updateNodeAIStatus, + cancelNodeAIAction, + applyAIPatches, + buildAIChatRequest + } = useAIChat() + + // AI请求的AbortController,用于取消正在进行的请求 + let aiChatAbortController = null + // 递增token,用于防止并发请求的响应竞争 + let aiChatRequestToken = 0 + const remove = () => { removeNodeById(getCurrent().schema?.id) } @@ -283,6 +350,89 @@ export default { return config?.configure?.isModal }) + // 是否显示AI聊天界面 + const shouldShowAIChat = computed(() => { + const currentSchema = getCurrent().schema + if (!currentSchema?.id) { + return false + } + + return shouldShowNodeAIChat(currentSchema.id) + }) + + // 是否显示确认弹窗 + const shouldShowAIConfirm = computed(() => { + const currentSchema = getCurrent().schema + if (!currentSchema?.id) { + return false + } + + return shouldShowNodeAIConfirm(currentSchema.id) + }) + + // 是否显示AI加载状态 + const shouldShowAILoading = computed(() => { + const currentSchema = getCurrent().schema + if (!currentSchema?.id) { + return false + } + + return shouldShowNodeAILoading(currentSchema.id) + }) + + const showAIPopover = ref(false) + watch( + () => { + const currentSchema = getCurrent().schema + if (!currentSchema?.id) { + return false + } + const status = getNodeAIStatus(currentSchema?.id) + return status?.state !== 'hidden' && !status?.collapsed + }, + (val) => { + showAIPopover.value = val + } + ) + + // 切换AI助手显示/隐藏 + const openAIHelper = () => { + const currentSchema = getCurrent().schema + + if (!currentSchema?.id) { + return + } + + const currentStatus = getNodeAIStatus(currentSchema.id) + + if (currentStatus && currentStatus.collapsed) { + // 面板已收起,重新展开恢复原状态 + updateNodeAIStatus(currentSchema.id, { collapsed: false }) + } else if (currentStatus && currentStatus.state !== 'hidden') { + // 面板当前可见,收起面板但保留业务状态 + updateNodeAIStatus(currentSchema.id, { collapsed: true }) + } else { + // hidden状态,进入chat + openNodeAIChat(currentSchema.id) + } + } + + // 关闭AI助手(由其他组件调用,如AI聊天界面的关闭按钮) + const closeAIHelper = () => { + const currentSchema = getCurrent().schema + if (!currentSchema?.id) { + return + } + + const currentStatus = getNodeAIStatus(currentSchema.id) + // loading/confirm状态下只收起面板,保留状态以便重新打开恢复 + if (currentStatus && (currentStatus.state === 'loading' || currentStatus.state === 'confirm')) { + updateNodeAIStatus(currentSchema.id, { collapsed: true }) + } else { + closeNodeAIHelper(currentSchema.id) + } + } + const optionRef = ref(null) const fixStyle = ref('') @@ -633,6 +783,166 @@ export default { fixStyle.value = optionStyleValue }) + // AI聊天完成处理 + const handleAIChatComplete = async (content) => { + const currentSchema = getCurrent().schema + if (!currentSchema?.id) { + return + } + + // 先进入加载状态 + startNodeAILoading(currentSchema.id, 'AI正在处理您的请求...') + + // 在AI修改节点前,先刷新originalNodeData为当前最新节点数据 + // 避免取消/重新生成时回滚到过期的快照,丢失用户之前的普通编辑 + const currentNode = getNode(currentSchema.id) + if (currentNode) { + const currentStatus = getNodeAIStatus(currentSchema.id) + if (currentStatus) { + currentStatus.originalNodeData = deepClone(currentNode) + } + } + + // 创建新的AbortController用于取消请求,递增token防止响应竞争 + aiChatAbortController = new AbortController() + const currentToken = ++aiChatRequestToken + + try { + const params = await buildAIChatRequest(content) + const response = await chat(params, aiChatAbortController.signal) + + // 响应到达后校验token:如果有更新的请求已经发出,丢弃本次响应 + if (currentToken !== aiChatRequestToken) { + return + } + + // 响应到达后再次检查:如果用户已经取消,不应用AI补丁 + const status = getNodeAIStatus(currentSchema.id) + if (!status || status.state !== 'loading') { + return + } + + // AI运行完:设置chatContent、aiModifiedNodeData,修改画布schema为AI的schema + // 应用失败则取消loading,避免UI永久转圈 + if (!applyAIPatches(currentSchema.id, response, content)) { + cancelNodeAILoading(currentSchema.id) + } + } catch (error) { + // 请求被取消时不再应用补丁 + if (error.name === 'AbortError' || error.name === 'CanceledError') { + return + } + // 其他错误:取消loading状态,避免UI永久转圈 + cancelNodeAILoading(currentSchema.id) + } + } + + // AI加载取消处理 + const handleAILoadingCancel = () => { + const currentSchema = getCurrent().schema + if (!currentSchema?.id) { + return + } + + // 中止正在进行的AI请求 + if (aiChatAbortController) { + aiChatAbortController.abort() + aiChatAbortController = null + // 递增token,使任何未完成的请求响应失效 + aiChatRequestToken++ + } + + // 取消加载状态 + cancelNodeAILoading(currentSchema.id) + } + + // 刷新AI操作(重新生成) + // 逻辑:修改画布节点schema为originalNodeData,设置aiModifiedNodeData为空,重新发起请求 + const handleAIRefresh = async () => { + const currentSchema = getCurrent().schema + if (!currentSchema?.id) { + return + } + + const nodeId = currentSchema.id + const currentAIStatus = getNodeAIStatus(nodeId) + if (!currentAIStatus) { + return + } + + // 恢复画布节点schema为originalNodeData,同步重建nodesMap + if (currentAIStatus.originalNodeData) { + const { restoreNodeSubtree } = useCanvas() + restoreNodeSubtree(nodeId, deepClone(currentAIStatus.originalNodeData)) + useMessage().publish({ topic: 'schemaChange', data: { nodeId } }) + } + + // 设置aiModifiedNodeData为空 + updateNodeAIStatus(nodeId, { + aiModifiedNodeData: undefined + }) + + // 重新进入加载状态 + startNodeAILoading(nodeId, 'AI正在重新生成...') + + // 使用上次的聊天消息重新发起请求 + const chatContent = currentAIStatus.chatContent + if (!chatContent) { + cancelNodeAILoading(nodeId) + return + } + + try { + const params = await buildAIChatRequest(chatContent) + // 创建新的AbortController用于重新生成的请求,递增token防止响应竞争 + const refreshToken = ++aiChatRequestToken + aiChatAbortController = new AbortController() + const response = await chat(params, aiChatAbortController.signal) + + // 响应到达后校验token:如果有更新的请求已经发出,丢弃本次响应 + if (refreshToken !== aiChatRequestToken) { + return + } + + // 响应到达后检查是否已被取消 + const status = getNodeAIStatus(nodeId) + if (!status || status.state !== 'loading') { + return + } + + // AI运行完操作和 handleAIChatComplete 一样,应用失败则取消loading + if (!applyAIPatches(nodeId, response, chatContent)) { + cancelNodeAILoading(nodeId) + } + } catch (error) { + // 请求被取消时不做额外处理 + if (error.name === 'AbortError' || error.name === 'CanceledError') { + return + } + cancelNodeAILoading(nodeId) + } + } + + // 确认AI操作 + const handleAIConfirm = () => { + const currentSchema = getCurrent().schema + if (!currentSchema?.id) { + return + } + + confirmNodeAIAction(currentSchema.id) + } + + // 取消AI操作 + const handleAICancel = () => { + const currentSchema = getCurrent().schema + if (!currentSchema?.id) { + return + } + + cancelNodeAIAction(currentSchema.id) + } + return { remove, moveUp, @@ -646,11 +956,22 @@ export default { showQuickAction, showPopover, showToParent, + showAIPopover, activeSetting, isModal, onMousedown, labelStyle, - labelRef + labelRef, + openAIHelper, + shouldShowAIChat, + shouldShowAILoading, + shouldShowAIConfirm, + closeAIHelper, + handleAIChatComplete, + handleAILoadingCancel, + handleAIConfirm, + handleAICancel, + handleAIRefresh } } } @@ -942,4 +1263,34 @@ export default { cursor: se-resize; } } + +.ai-helper { + position: relative; + .ai-component, + .ai-component-loading { + position: absolute; + right: 0; + width: 360px; + opacity: 0; + transform: translateY(-10px); + animation: slideIn 0.2s ease-in-out forwards; + } + .ai-component-loading { + width: 270px; + } +} +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(10px); + } +} +.ai-popper.ai-popper.tiny-popper.tiny-popover { + padding: 0; + border-radius: 40px; +} diff --git a/packages/canvas/container/src/composables/useAIChat.ts b/packages/canvas/container/src/composables/useAIChat.ts new file mode 100644 index 0000000000..f3cac5d227 --- /dev/null +++ b/packages/canvas/container/src/composables/useAIChat.ts @@ -0,0 +1,701 @@ +/** + * 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 + +// ==================== AI助手状态管理 ==================== + +const updateNodeAIStatus = (nodeId: string, aiStatus: Partial) => { + 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 +} + +// 添加AI操作历史记录 +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 + }) +} + +// 打开AI助手聊天界面 +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 + }) +} + +// 关闭AI助手 +const closeNodeAIHelper = (nodeId: string) => { + updateNodeAIStatus(nodeId, { + state: 'hidden', + lastAIAction: 'close' + }) + + addNodeAIActionHistory(nodeId, 'close', { + timestamp: Date.now() + }) +} + +// 确认AI助手操作(采纳) +// 逻辑:设置originalNodeData为AI修改后的schema,设置aiModifiedNodeData为空 +const confirmNodeAIAction = (nodeId: string) => { + const currentStatus = getNodeAIStatus(nodeId) + if (!currentStatus || currentStatus.state !== 'confirm') { + return + } + + // 采纳后直接回到hidden,completed无对应UI,避免需要点击两次才能重新打开 + updateNodeAIStatus(nodeId, { + state: 'hidden', + collapsed: false, + originalNodeData: deepClone(currentStatus.aiModifiedNodeData), + aiModifiedNodeData: undefined, + lastAIAction: 'confirm' + }) +} + +// 取消AI助手操作 +// 逻辑:设置aiModifiedNodeData为空,修改画布节点schema为originalNodeData +const cancelNodeAIAction = (nodeId: string) => { + const currentStatus = getNodeAIStatus(nodeId) + if (!currentStatus) { + return + } + + // 恢复画布节点schema为originalNodeData,同步重建nodesMap + 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 + } + + // 恢复画布节点schema为originalNodeData,同步重建nodesMap + 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' +} +// ==================== AI助手状态函数 ==================== + +// 获取当前节点是否应该显示AI聊天界面 +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 +} + +// 开始AI加载状态 +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 + }) +} + +// 完成AI加载,进入确认状态 +const completeNodeAILoading = (nodeId: string) => { + updateNodeAIStatus(nodeId, { + state: 'confirm', + collapsed: false, + lastAIAction: 'complete_loading' + }) + + addNodeAIActionHistory(nodeId, 'complete_loading', { + timestamp: Date.now() + }) +} + +// 取消AI加载 +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()) + }) +} + +// 获取当前节点是否应该显示AI加载状态 +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) + + // 通过顶层 {"op": 模式分割各个 patch 对象 + 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 + } + } + } + + // 对每个 segment 尝试解析,逐步增强容错 + 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 + // 剥离 Markdown 代码块 + const codeBlockMatch = jsonStr.match(/```(?:json|schema)?([\s\S]*?)```/) + if (codeBlockMatch) { + jsonStr = codeBlockMatch[1].trim() + } + + let patches: any[] = [] + try { + // 策略1:直接解析 + try { + patches = JSON.parse(jsonStr) + } catch { + // 策略2:修复 JSON 字符串值中未转义的换行符 + try { + const fixedStr = jsonStr.replace( + /"((?:[^"\\]|\\.)*)"/g, + (match, inner) => '"' + inner.replace(/\n/g, '\\n').replace(/\r/g, '\\r') + '"' + ) + patches = JSON.parse(fixedStr) + } catch { + // 策略3:使用 jsonrepair 修复 + try { + patches = JSON.parse(jsonrepair(jsonStr)) + } catch { + // 策略4:逐个 patch 解析 — AI 常在深层嵌套的 children 中产生多余的括号, + // 导致整个数组解析失败。逐个提取顶级 patch 对象分别解析,跳过损坏的。 + 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') + + // 先应用 replace/remove 操作 + 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) + + // 再应用 add 操作 + // 需要处理的情况: + // 1. 目标数组不存在 → 先初始化空数组,再追加 + // 2. 索引超出数组长度 → 降级为追加到末尾(/-) + // 3. 最后一段不是数字索引且目标是数组 → 追加到末尾(/-) + 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 + + // 父路径对应的值不是数组但路径暗示要往数组中插入(如 /children/0), + // 需要先初始化空数组 + 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) + + // 最后应用其他操作(move, copy, test 等) + 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 更新画布(保留 nodesStatus 不清空) + updatePageSchema(newSchema) + + // 设置 AI 状态:chatContent、aiModifiedNodeData + 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聊天请求构建 ==================== + +/** + * 构建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 { + // AI助手状态管理 + updateNodeAIStatus, + getNodeAIStatus, + addNodeAIActionHistory, + // AI助手状态机函数 + openNodeAIChat, + closeNodeAIHelper, + startNodeAILoading, + completeNodeAILoading, + cancelNodeAILoading, + confirmNodeAIAction, + cancelNodeAIAction, + shouldShowNodeAIChat, + shouldShowNodeAILoading, + shouldShowNodeAIConfirm, + // 节点级AI采纳状态管理 + setNodeAIModifiedData, + adoptNodeAIModification, + rejectNodeAIModification, + resetNodeAIAdoptionStatus, + hasNodePendingAIModification, + findJsonPatchPath, + applyAIPatches, + // AI聊天请求构建 + buildAIChatRequest + } +} diff --git a/packages/canvas/container/src/services/agentServices.ts b/packages/canvas/container/src/services/agentServices.ts new file mode 100644 index 0000000000..3f9361cb1d --- /dev/null +++ b/packages/canvas/container/src/services/agentServices.ts @@ -0,0 +1,73 @@ +/** + * 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 { getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register' + +/** + * AI搜索功能 + * @param content 搜索内容 + * @returns 搜索结果字符串 + */ +export const search = async (content: string): Promise => { + let result = '' + const MAX_SEARCH_LENGTH = 8000 + + try { + const res = await getMetaApi(META_SERVICE.Http).post('app-center/api/ai/search', { content }) + + res.forEach((item: { content: string }) => { + if (result.length + item.content.length > MAX_SEARCH_LENGTH) { + return + } + result += item.content + }) + } catch (error) { + return '' + } + + return result +} + +/** + * 获取资源列表 + * @returns 格式化的资源列表 + */ +export const fetchAssets = async (appId: string) => { + try { + const res = (await getMetaApi(META_SERVICE.Http).get(`material-center/api/resource/find/${appId}`)) || [] + return res + .map((group: any) => group.resources) + .flat() + .filter((item: any) => item.description) + .map((item: any) => ({ + url: item.resourceUrl, + describe: item.description + })) + } catch (error) { + return [] + } +} + +export const chat = async (params, signal?: AbortSignal) => { + const response = await getMetaApi(META_SERVICE.Http).request({ + url: 'app-center/api/ai/chat', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...params.headers + }, + data: params.body, + signal + }) + + return response +} diff --git a/packages/canvas/package.json b/packages/canvas/package.json index 047d6297fb..d3ed66ef19 100644 --- a/packages/canvas/package.json +++ b/packages/canvas/package.json @@ -42,6 +42,11 @@ "@opentiny/tiny-engine-i18n-host": "workspace:*", "@opentiny/tiny-engine-meta-register": "workspace:*", "@opentiny/tiny-engine-utils": "workspace:*", + "@opentiny/tiny-robot": "0.4.0", + "@opentiny/tiny-robot-kit": "0.4.0", + "@opentiny/tiny-robot-svgs": "0.4.0", + "fast-json-patch": "~3.1.1", + "jsonrepair": "3.13.0", "@vue/babel-plugin-jsx": "^1.2.5", "@vue/shared": "^3.3.4", "@vueuse/core": "^9.6.0", diff --git a/packages/plugins/robot/src/composables/core/useConfig.ts b/packages/plugins/robot/src/composables/core/useConfig.ts index 01908a89a0..b81683f5c4 100644 --- a/packages/plugins/robot/src/composables/core/useConfig.ts +++ b/packages/plugins/robot/src/composables/core/useConfig.ts @@ -13,10 +13,21 @@ /* metaService: engine.plugins.robot.useRobot */ import { reactive, readonly } from 'vue' import { DEFAULT_LLM_MODELS } from '../../constants' -import { getRobotServiceOptions } from '../../utils' +import { formatComponents, getAgentSystemPrompt, getJsonFixPrompt } from '../../constants/prompts' +import { + isValidJsonPatchObjectString, + getRobotServiceOptions, + addSystemPrompt, + jsonPatchAutoFix, + isValidFastJsonPatch, + getJsonObjectString, + fixMethods, + schemaAutoFix +} from '../../utils' import { ChatMode } from '../../types/mode.types' import type { ModelConfig, ModelService, RobotSettings, SelectedModelInfo } from '../../types/setting.types' import apiService from '../../services/api' +import { updatePageSchema } from '../core/pageUpdater' const SETTING_STORAGE_KEY = 'tiny-engine-robot-settings' const SETTING_VERSION = 2 // 新版本号 @@ -463,6 +474,20 @@ export default () => { addCustomService, updateService, deleteService, - getServiceById + getServiceById, + + // 公共方法 + formatComponents, + getAgentSystemPrompt, + getJsonFixPrompt, + isValidJsonPatchObjectString, + getRobotServiceOptions, + addSystemPrompt, + jsonPatchAutoFix, + isValidFastJsonPatch, + getJsonObjectString, + fixMethods, + schemaAutoFix, + updatePageSchema } } diff --git a/packages/toolbars/save/src/Main.vue b/packages/toolbars/save/src/Main.vue index c3a16c6bc5..8b95c2e33b 100644 --- a/packages/toolbars/save/src/Main.vue +++ b/packages/toolbars/save/src/Main.vue @@ -70,11 +70,12 @@ import type { Component } from 'vue' import { VueMonaco } from '@opentiny/tiny-engine-common' import { Button, Popover, DialogBox, Checkbox, Select } from '@opentiny/vue' import { iconUpWard, iconDownWard } from '@opentiny/vue-icon' -import { useCanvas, useMessage } from '@opentiny/tiny-engine-meta-register' +import { useCanvas, useMessage, useNotify } from '@opentiny/tiny-engine-meta-register' import { ToolbarBase } from '@opentiny/tiny-engine-common' import { openCommon, saveCommon } from './js/index' import { isLoading, setAutoSaveStatus, getAutoSaveStatus } from './js/index' import { constants } from '@opentiny/tiny-engine-utils' +import useSaveValidation from './js/aiSaveValidation' const { OPEN_DELAY } = constants export const api = { @@ -175,6 +176,18 @@ export default { state.originalCode = '' } const openApi = () => { + if (useSaveValidation().hasAnyPendingAIModification()) { + const pendingNodeIds = useSaveValidation().getAllNodesWithPendingAIModification() + + useNotify({ + type: 'warning', + title: '保存被阻止', + message: `以下节点的AI修改还未处理:${pendingNodeIds.join('、')},请先采纳或取消`, + duration: 5000, + customClass: 'ai-save-warning' + }) + return + } if (!isLoading.value) { openCommon() } diff --git a/packages/toolbars/save/src/js/aiSaveValidation.ts b/packages/toolbars/save/src/js/aiSaveValidation.ts new file mode 100644 index 0000000000..202c348946 --- /dev/null +++ b/packages/toolbars/save/src/js/aiSaveValidation.ts @@ -0,0 +1,36 @@ +/** + * 保存页面时的验证逻辑 + * 检查是否有未完成的AI修改,如果有则提示用户 + */ +import { useCanvas } from '@opentiny/tiny-engine-meta-register' + +/** + * 获取所有有待处理AI修改的节点ID + */ +const getAllNodesWithPendingAIModification = (): string[] => { + const { pageState, getNode } = useCanvas() + const pendingNodes: string[] = [] + + Object.entries(pageState.aiNodesStatus).forEach(([nodeId, status]) => { + // 只统计仍存在于schema中的节点,已删除节点不再阻塞保存 + if (status?.state === 'confirm' && getNode(nodeId)) { + pendingNodes.push(nodeId) + } + }) + + return pendingNodes +} + +/** + * 检查页面是否有任何待处理的AI修改 + */ +const hasAnyPendingAIModification = (): boolean => { + return getAllNodesWithPendingAIModification().length > 0 +} + +export default function () { + return { + getAllNodesWithPendingAIModification, + hasAnyPendingAIModification + } +} diff --git a/packages/toolbars/save/src/js/index.ts b/packages/toolbars/save/src/js/index.ts index 9434e68915..9000592dd7 100644 --- a/packages/toolbars/save/src/js/index.ts +++ b/packages/toolbars/save/src/js/index.ts @@ -26,6 +26,7 @@ import { import { constants } from '@opentiny/tiny-engine-utils' import { handlePageUpdate } from '@opentiny/tiny-engine-common/js/http' import meta from '../../meta' +import useSaveValidation from './aiSaveValidation' const { publish } = useMessage() @@ -153,6 +154,19 @@ export const openCommon = async () => { return } + // 检查是否有未处理的AI修改 + if (useSaveValidation().hasAnyPendingAIModification()) { + const pendingNodeIds = useSaveValidation().getAllNodesWithPendingAIModification() + + useNotify({ + type: 'warning', + title: '保存被阻止', + message: `以下节点的AI修改还未处理:${pendingNodeIds.join('、')},请先采纳或取消`, + duration: 5000 + }) + return + } + state.disabled = true const pageSchema = getSchema()