Feat:Add AI assistant to canvas node (#1800)

* feat:画布点击节点实现AI对话修改

* feat:添加节点AI助手交互

* feat:添加AI返回JSON处理
This commit is contained in:
lichunn 2026-04-30 18:10:03 +08:00 committed by GitHub
parent 7f3c45168d
commit 59e8548715
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1664 additions and 15 deletions

View File

@ -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<string, any>
nodesStatus: Record<string, NodeStatus>
aiNodesStatus: Record<string, NodeAIStatus> // AI状态独立存储避免与nodesStatus的可见性(false)冲突
loading: boolean
}

View File

@ -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<NodeAIStatus> = {}) => {
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<PageState> = {}) => {
// preserveAINodeStatus: 为true时保留aiNodesStatus并为新增节点补初始化适用于AI/robot等schema热更新场景
const resetCanvasState = async (state: Partial<PageState> = {}, options?: { preserveAINodeStatus?: boolean }) => {
const previousSchema = JSON.parse(JSON.stringify(pageState.pageSchema))
const preserveAINodeStatus = options?.preserveAINodeStatus ?? false
// 保留旧aiNodesStatus快照用于后续diff补初始化
const oldAINodesStatus: Record<string, NodeAIStatus> = 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<PageState> = {}) => {
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<PageState> = {}) => {
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<PageSchema>) => {
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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -0,0 +1,115 @@
<template>
<div class="ai-confirm-dialog">
<div class="ai-confirm-header">
<div class="header-left">
<icon-successful class="icon-successful"></icon-successful>
<span class="header-title">AI操作已完成您可选择采纳或放弃</span>
</div>
</div>
<div class="ai-confirm-actions-row">
<div class="actions-right">
<svg-icon name="refresh" class="refresh-icon" @click="handleRefresh" title="重新生成"></svg-icon>
<tiny-button class="actions-btn" @click="handleCancel" round>放弃</tiny-button>
<tiny-button class="actions-btn" @click="handleConfirm" type="primary" round> 采纳</tiny-button>
</div>
</div>
</div>
</template>
<script>
import { TinyButton } from '@opentiny/vue'
import { IconSuccessful } from '@opentiny/vue-icon'
export default {
components: {
IconSuccessful: IconSuccessful(),
TinyButton
},
emits: ['confirm', 'cancel', 'close', 'refresh'],
setup(props, { emit }) {
const handleConfirm = () => {
emit('confirm')
}
const handleCancel = () => {
emit('cancel')
}
const handleRefresh = () => {
emit('refresh')
}
return {
handleConfirm,
handleCancel,
handleRefresh
}
}
}
</script>
<style lang="less" scoped>
.ai-confirm-dialog {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
padding: 16px 20px;
}
/* 第一行:标题行 */
.ai-confirm-header {
display: flex;
align-items: center;
margin-bottom: 12px;
.header-left {
display: flex;
align-items: center;
}
.icon-successful {
font-size: 20px;
fill: #5cb300;
margin-right: 8px;
}
.header-title {
font-size: 14px;
font-weight: 500;
color: #191919;
margin-right: 16px;
}
.close-icon {
cursor: pointer;
font-size: 16px;
color: #000;
}
}
/* 第二行:操作行 */
.ai-confirm-actions-row {
display: flex;
justify-content: right;
align-items: center;
.actions-right {
display: flex;
gap: 12px;
align-items: center;
.refresh-icon {
cursor: pointer;
color: #000;
font-size: 18px;
}
.actions-btn {
min-width: 68px;
height: 28px;
}
}
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<div class="ai-loading-dialog">
<div class="ai-loading-header">
<div class="header-left">
<img src="../../assets/loading.webp" class="loading" />
<span class="header-title">AI操作中您可随时取消</span>
</div>
</div>
<div class="ai-loading-actions-row">
<div class="actions-right">
<tiny-button class="actions-btn" @click="handleCancel" round>取消</tiny-button>
</div>
</div>
</div>
</template>
<script>
import { TinyButton } from '@opentiny/vue'
export default {
components: {
TinyButton
},
emits: ['cancel'],
setup(props, { emit }) {
const handleCancel = () => {
emit('cancel')
}
return {
handleCancel
}
}
}
</script>
<style lang="less" scoped>
.ai-loading-dialog {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
padding: 16px 20px;
}
.ai-loading-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.header-left {
display: flex;
align-items: center;
}
.loading {
width: 20px;
height: 20px;
margin-right: 8px;
}
.header-title {
font-size: 14px;
font-weight: 500;
color: #191919;
margin-right: 16px;
}
.close-icon {
cursor: pointer;
font-size: 16px;
color: #000;
}
}
.ai-loading-actions-row {
display: flex;
justify-content: right;
align-items: center;
.actions-right {
display: flex;
gap: 12px;
align-items: center;
.actions-btn {
min-width: 40px;
height: 28px;
}
}
}
</style>

View File

@ -0,0 +1,71 @@
<template>
<div class="chat-input">
<TrSender
v-model="content"
mode="multiple"
placeholder="请输入问题或“/”获取提示词"
clearable
show-word-limit
:max-length="5000"
@submit="handleSubmit"
>
<template #prefix>
<svg-icon name="AI"></svg-icon>
</template>
</TrSender>
</div>
</template>
<script>
import { ref } from 'vue'
import { TrSender } from '@opentiny/tiny-robot'
export default {
components: {
TrSender
},
emits: ['complete', 'close'],
setup(props, { emit }) {
const content = ref('')
const handleSubmit = async (value) => {
emit('complete', value)
content.value = ''
}
return {
content,
handleSubmit
}
}
}
</script>
<style lang="less" scoped>
:deep(.tiny-sender) {
.tiny-sender__input-row {
min-height: 64px;
}
.tiny-sender__prefix-slot .svg-icon {
width: 24px;
height: 24px;
}
.tiny-sender__word-limit,
.tiny-sender__input-field-wrapper .tiny-textarea__inner {
font-size: 14px !important;
}
.action-buttons__icon--send,
.action-buttons__button svg {
font-size: 24px !important;
}
.action-buttons {
gap: 6px;
}
.action-buttons__utility {
gap: 0;
}
}
</style>

View File

@ -91,6 +91,39 @@
<div title="删除">
<icon-del class="svg-currentcolor" @click.stop="remove"></icon-del>
</div>
<!-- AI助手按钮默认状态隐藏 -->
<div title="AI助手" class="ai-helper">
<TinyPopover
v-model="showAIPopover"
placement="bottom-start"
width="360"
popper-class="ai-popper"
trigger="manual"
:visible-arrow="false"
>
<CanvasAIChat
v-if="shouldShowAIChat"
class="ai-component"
@complete="handleAIChatComplete"
@close="closeAIHelper"
></CanvasAIChat>
<AILoadingDialog
v-if="shouldShowAILoading"
class="ai-component"
@cancel="handleAILoadingCancel"
></AILoadingDialog>
<AIConfirmDialog
v-if="shouldShowAIConfirm"
class="ai-component"
@confirm="handleAIConfirm"
@cancel="handleAICancel"
@refresh="handleAIRefresh"
></AIConfirmDialog>
<template #reference>
<svg-icon name="add-group" @click.stop="openAIHelper"></svg-icon>
</template>
</TinyPopover>
</div>
</div>
</div>
<div v-show="hoverState.height && hoverState.width" class="canvas-rect hover">
@ -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()
// AIAbortController
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 {
// hiddenchat
openNodeAIChat(currentSchema.id)
}
}
// AIAI
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正在处理您的请求...')
// AIoriginalNodeData
// /
const currentNode = getNode(currentSchema.id)
if (currentNode) {
const currentStatus = getNodeAIStatus(currentSchema.id)
if (currentStatus) {
currentStatus.originalNodeData = deepClone(currentNode)
}
}
// AbortControllertoken
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
}
// AIchatContentaiModifiedNodeDataschemaAIschema
// loadingUI
if (!applyAIPatches(currentSchema.id, response, content)) {
cancelNodeAILoading(currentSchema.id)
}
} catch (error) {
//
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return
}
// loadingUI
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
// schemaoriginalNodeDataaiModifiedNodeData
const handleAIRefresh = async () => {
const currentSchema = getCurrent().schema
if (!currentSchema?.id) {
return
}
const nodeId = currentSchema.id
const currentAIStatus = getNodeAIStatus(nodeId)
if (!currentAIStatus) {
return
}
// schemaoriginalNodeDatanodesMap
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)
// AbortControllertoken
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;
}
</style>

View File

@ -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<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
}
// 添加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
}
// 采纳后直接回到hiddencompleted无对应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修改后的schemaaiModifiedNodeData为空
* @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
* chatContentaiModifiedNodeData为AI修改后的节点schemaschema为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
}
}

View File

@ -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<string> => {
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
}

View File

@ -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",

View File

@ -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
}
}

View File

@ -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()
}

View File

@ -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
}
}

View File

@ -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()