Feat:Add AI assistant to canvas node (#1800)
* feat:画布点击节点实现AI对话修改 * feat:添加节点AI助手交互 * feat:添加AI返回JSON处理
This commit is contained in:
parent
7f3c45168d
commit
59e8548715
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
|
||||
// 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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// 采纳后直接回到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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue