8417 lines
278 KiB
Vue
8417 lines
278 KiB
Vue
<template>
|
||
<div class="workflow-editor">
|
||
<div class="editor-toolbar">
|
||
<el-button
|
||
type="primary"
|
||
@click="handleSave"
|
||
:loading="saving"
|
||
:disabled="!hasChanges && !!lastSavedData"
|
||
>
|
||
<el-icon v-if="!saving"><Check /></el-icon>
|
||
{{ saving ? '保存中...' : hasChanges ? '保存*' : '保存' }}
|
||
</el-button>
|
||
<el-button @click="handleRun">运行</el-button>
|
||
<el-button @click="handleClear">清空</el-button>
|
||
<el-button type="warning" @click="handleTestAnimation" :loading="testingAnimation">
|
||
<el-icon><VideoPlay /></el-icon>
|
||
{{ testingAnimation ? '测试中...' : '测试动画' }}
|
||
</el-button>
|
||
<el-button v-if="copiedNode" @click="handlePasteNodeFromButton" type="success">
|
||
<el-icon><DocumentCopy /></el-icon>
|
||
粘贴节点 (Ctrl+V)
|
||
</el-button>
|
||
<el-divider direction="vertical" />
|
||
<!-- 节点对齐功能 -->
|
||
<el-dropdown @command="handleAlignNodes" trigger="click">
|
||
<el-button>
|
||
<el-icon><Rank /></el-icon>
|
||
对齐
|
||
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||
</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="left">
|
||
<el-icon><Sort style="transform: rotate(90deg)" /></el-icon>
|
||
左对齐
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="right">
|
||
<el-icon><Sort style="transform: rotate(-90deg)" /></el-icon>
|
||
右对齐
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="top">
|
||
<el-icon><Sort /></el-icon>
|
||
上对齐
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="bottom">
|
||
<el-icon><Sort style="transform: rotate(180deg)" /></el-icon>
|
||
下对齐
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="center-h">
|
||
<el-icon><Grid /></el-icon>
|
||
水平居中
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="center-v">
|
||
<el-icon><Grid style="transform: rotate(90deg)" /></el-icon>
|
||
垂直居中
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="distribute-h" divided>
|
||
<el-icon><Operation /></el-icon>
|
||
水平分布
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="distribute-v">
|
||
<el-icon><Operation style="transform: rotate(90deg)" /></el-icon>
|
||
垂直分布
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<el-button @click="handleAutoLayout" title="自动布局 (Ctrl+L)">
|
||
<el-icon><Operation /></el-icon>
|
||
自动布局
|
||
</el-button>
|
||
<el-button @click="handleApplyTemplate" title="应用工作流模板">
|
||
<el-icon><Document /></el-icon>
|
||
应用模板
|
||
</el-button>
|
||
<el-button :type="snapEnabled ? 'primary' : 'default'" @click="snapEnabled = !snapEnabled" title="吸附/网格对齐">
|
||
<el-icon><Aim /></el-icon>
|
||
吸附
|
||
</el-button>
|
||
<el-button :type="showSwimlanes ? 'primary' : 'default'" @click="showSwimlanes = !showSwimlanes" title="显示/隐藏泳道">
|
||
<el-icon><Grid /></el-icon>
|
||
泳道
|
||
</el-button>
|
||
<div class="lane-config" v-if="showSwimlanes">
|
||
<span>泳道数</span>
|
||
<el-input-number v-model="laneCount" :min="1" :max="8" size="small" style="width: 120px; margin-left: 6px;" />
|
||
</div>
|
||
<div class="toolbar-spacer"></div>
|
||
<div class="zoom-controls">
|
||
<el-button size="small" @click="zoomIn" title="放大 (Ctrl +)">
|
||
<el-icon><ZoomIn /></el-icon>
|
||
</el-button>
|
||
<el-button size="small" @click="zoomOut" title="缩小 (Ctrl -)">
|
||
<el-icon><ZoomOut /></el-icon>
|
||
</el-button>
|
||
<el-button size="small" @click="resetZoom" title="重置缩放 (Ctrl 0)">
|
||
<el-icon><FullScreen /></el-icon>
|
||
</el-button>
|
||
<span class="zoom-level">{{ Math.round(currentZoom * 100) }}%</span>
|
||
</div>
|
||
<el-tag v-if="hasChanges" type="warning" size="small" style="margin-left: 10px;">
|
||
<el-icon><Warning /></el-icon>
|
||
未保存
|
||
</el-tag>
|
||
<!-- 协作用户列表 -->
|
||
<div v-if="collaborationEnabled && onlineUsers.length > 0" class="collaboration-users">
|
||
<el-popover placement="bottom" :width="200" trigger="hover">
|
||
<template #reference>
|
||
<el-tag type="info" size="small" style="margin-left: 10px; cursor: pointer;">
|
||
<el-icon><User /></el-icon>
|
||
{{ onlineUsers.length }} 人在线
|
||
</el-tag>
|
||
</template>
|
||
<div class="online-users-list">
|
||
<div v-for="user in onlineUsers" :key="user.user_id" class="user-item">
|
||
<span class="user-color" :style="{ backgroundColor: user.color }"></span>
|
||
<span>{{ user.username }}</span>
|
||
<el-tag v-if="user.user_id === currentUser?.user_id" type="success" size="small">我</el-tag>
|
||
</div>
|
||
</div>
|
||
</el-popover>
|
||
</div>
|
||
<el-tag v-if="collaborationEnabled && !collaborationConnected" type="warning" size="small" style="margin-left: 10px;">
|
||
协作未连接
|
||
</el-tag>
|
||
</div>
|
||
|
||
<div class="editor-container">
|
||
<!-- 左侧节点工具箱 -->
|
||
<div class="node-toolbox">
|
||
<h3>节点类型</h3>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="node-search">
|
||
<el-input
|
||
v-model="nodeSearchKeyword"
|
||
placeholder="搜索节点..."
|
||
clearable
|
||
size="small"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
|
||
<!-- 分类折叠 -->
|
||
<div class="node-filters" v-if="nodeCategories.length > 0">
|
||
<div class="collapse-actions">
|
||
<el-link type="primary" :underline="false" @click="handleExpandAllGroups">展开全部</el-link>
|
||
<el-link type="primary" :underline="false" @click="handleCollapseAllGroups" style="margin-left: 8px;">收起全部</el-link>
|
||
</div>
|
||
<el-collapse v-model="toolboxActiveNames">
|
||
<el-collapse-item
|
||
v-for="group in groupedNodeTypes"
|
||
:key="group.category"
|
||
:name="group.category"
|
||
>
|
||
<template #title>
|
||
<span>{{ group.label }}</span>
|
||
<el-tag size="small" type="info" style="margin-left: 6px;">{{ group.items.length }}</el-tag>
|
||
</template>
|
||
<div class="node-list">
|
||
<div
|
||
v-for="nodeType in group.items"
|
||
:key="nodeType.type"
|
||
class="node-item"
|
||
draggable="true"
|
||
@dragstart="handleDragStart($event, nodeType)"
|
||
:title="nodeType.label"
|
||
>
|
||
<el-icon><component :is="nodeType.icon" /></el-icon>
|
||
<span>{{ nodeType.label }}</span>
|
||
</div>
|
||
<div v-if="group.items.length === 0" class="empty-nodes">
|
||
<el-empty description="该分类下暂无匹配节点" :image-size="60" />
|
||
</div>
|
||
</div>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</div>
|
||
|
||
<!-- 画布节点搜索 -->
|
||
<div class="canvas-node-search" v-if="nodes.length > 0">
|
||
<el-divider />
|
||
<div class="search-header">
|
||
<h4>画布节点</h4>
|
||
<el-button
|
||
link
|
||
type="primary"
|
||
size="small"
|
||
@click="handleFocusAllNodes"
|
||
title="定位所有节点"
|
||
>
|
||
<el-icon><Aim /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
<el-input
|
||
v-model="canvasNodeSearchKeyword"
|
||
placeholder="搜索画布节点..."
|
||
clearable
|
||
size="small"
|
||
style="margin-bottom: 8px;"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
<div class="canvas-node-list">
|
||
<div
|
||
v-for="node in filteredCanvasNodes"
|
||
:key="node.id"
|
||
class="canvas-node-item"
|
||
:class="{ 'is-selected': selectedNode?.id === node.id }"
|
||
@click="handleFocusNode(node.id)"
|
||
:title="`${node.data?.label || node.id} (${node.type})`"
|
||
>
|
||
<el-icon><component :is="getNodeIcon(node.type)" /></el-icon>
|
||
<span class="node-name">{{ node.data?.label || node.id }}</span>
|
||
<el-tag size="small" type="info">{{ node.type }}</el-tag>
|
||
</div>
|
||
<div v-if="filteredCanvasNodes.length === 0" class="empty-canvas-nodes">
|
||
<el-empty description="未找到匹配的节点" :image-size="40" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 中间画布区域 -->
|
||
<div
|
||
class="editor-canvas"
|
||
ref="canvasRef"
|
||
@drop="handleDrop"
|
||
@dragover.prevent="handleDragOver"
|
||
@dragenter.prevent="handleDragEnter"
|
||
>
|
||
<div v-if="showSwimlanes" class="swimlane-overlay" :style="laneOverlayStyle"></div>
|
||
<VueFlow
|
||
:nodes="nodes"
|
||
:edges="edges"
|
||
:node-types="customNodeTypes"
|
||
:connection-line-style="{ stroke: '#409eff', strokeWidth: 2.5, strokeDasharray: '5,5' }"
|
||
:default-edge-options="{
|
||
type: 'bezier',
|
||
animated: true,
|
||
selectable: true,
|
||
deletable: true,
|
||
focusable: true,
|
||
style: { stroke: '#409eff', strokeWidth: 2.5 },
|
||
markerEnd: { type: 'arrowclosed', color: '#409eff', width: 20, height: 20 }
|
||
}"
|
||
:connection-line-type="'bezier'"
|
||
:edges-focusable="true"
|
||
:nodes-focusable="true"
|
||
:delete-key-code="'Delete'"
|
||
:connection-radius="20"
|
||
:snap-to-grid="snapEnabled"
|
||
:snap-grid="[20, 20]"
|
||
:is-valid-connection="isValidConnection"
|
||
:default-viewport="{ zoom: 1, x: 0, y: 0 }"
|
||
:min-zoom="0.1"
|
||
:max-zoom="4"
|
||
:pan-on-drag="true"
|
||
:pan-on-scroll="true"
|
||
:zoom-on-scroll="true"
|
||
:zoom-on-double-click="false"
|
||
:fit-view-on-init="false"
|
||
@node-click="onNodeClick"
|
||
@node-double-click="onNodeDoubleClick"
|
||
@edge-click="onEdgeClick"
|
||
@pane-click="onPaneClick"
|
||
@nodes-change="onNodesChange"
|
||
@edges-change="onEdgesChange"
|
||
@connect="onConnect"
|
||
@connect-start="onConnectStart"
|
||
@connect-end="onConnectEnd"
|
||
@viewport-change="onViewportChange"
|
||
>
|
||
<Background />
|
||
<Controls />
|
||
<MiniMap />
|
||
</VueFlow>
|
||
</div>
|
||
|
||
<!-- 右侧配置面板 -->
|
||
<div class="config-panel" v-if="selectedNode">
|
||
<h3>节点配置</h3>
|
||
|
||
<!-- 配置标签页 -->
|
||
<el-tabs v-model="configActiveTab" type="border-card">
|
||
<!-- 基础配置 -->
|
||
<el-tab-pane label="基础" name="basic">
|
||
<el-form :model="selectedNode.data" label-width="100px">
|
||
<el-form-item label="节点ID">
|
||
<el-input v-model="selectedNode.id" disabled />
|
||
</el-form-item>
|
||
<el-form-item label="节点类型">
|
||
<el-input v-model="selectedNode.type" disabled />
|
||
</el-form-item>
|
||
<el-form-item label="节点名称">
|
||
<el-input v-model="selectedNode.data.label" />
|
||
</el-form-item>
|
||
|
||
<!-- 快速模板 & 变量插入 -->
|
||
<el-divider />
|
||
<div class="quick-actions">
|
||
<div class="quick-item">
|
||
<span class="quick-label">快速模板</span>
|
||
<el-select
|
||
v-model="templateSelection"
|
||
size="small"
|
||
placeholder="选择模板"
|
||
style="width: 180px; margin-left: 8px;"
|
||
>
|
||
<el-option v-if="selectedNode.type === 'http'" label="HTTP GET" value="http_get" />
|
||
<el-option v-if="selectedNode.type === 'http'" label="HTTP POST JSON" value="http_post_json" />
|
||
<el-option v-if="selectedNode.type === 'http'" label="HTTP PUT" value="http_put" />
|
||
<el-option v-if="selectedNode.type === 'http'" label="HTTP DELETE" value="http_delete" />
|
||
<el-option v-if="selectedNode.type === 'llm'" label="LLM 总结" value="llm_summary" />
|
||
<el-option v-if="selectedNode.type === 'llm'" label="LLM 翻译" value="llm_translate" />
|
||
<el-option v-if="selectedNode.type === 'llm'" label="LLM 提取" value="llm_extract" />
|
||
<el-option v-if="selectedNode.type === 'llm'" label="LLM 分类" value="llm_classify" />
|
||
<el-option v-if="selectedNode.type === 'json'" label="JSON 解析" value="json_parse" />
|
||
<el-option v-if="selectedNode.type === 'json'" label="JSON 提取" value="json_extract" />
|
||
<el-option v-if="selectedNode.type === 'text'" label="文本拆分" value="text_split" />
|
||
<el-option v-if="selectedNode.type === 'text'" label="文本格式化" value="text_format" />
|
||
<el-option v-if="selectedNode.type === 'cache'" label="缓存读取" value="cache_get" />
|
||
<el-option v-if="selectedNode.type === 'cache'" label="缓存写入" value="cache_set" />
|
||
<el-option v-if="selectedNode.type === 'vector_db'" label="向量搜索" value="vector_search" />
|
||
<el-option v-if="selectedNode.type === 'vector_db'" label="向量入库" value="vector_upsert" />
|
||
<el-option v-if="selectedNode.type === 'oauth'" label="OAuth Google" value="oauth_google" />
|
||
<el-option v-if="selectedNode.type === 'oauth'" label="OAuth GitHub" value="oauth_github" />
|
||
<el-option v-if="selectedNode.type === 'slack'" label="Slack 发送消息" value="slack_send" />
|
||
<el-option v-if="selectedNode.type === 'dingtalk'" label="钉钉 发送消息" value="dingtalk_send" />
|
||
<el-option v-if="selectedNode.type === 'wechat_work'" label="企业微信 发送消息" value="wechat_send" />
|
||
</el-select>
|
||
<el-button size="small" type="primary" @click="applyTemplate" style="margin-left: 8px;">套用</el-button>
|
||
</div>
|
||
<div class="quick-item">
|
||
<span class="quick-label">变量插入</span>
|
||
<el-select
|
||
v-model="variableInsertField"
|
||
size="small"
|
||
placeholder="选择字段"
|
||
style="width: 150px; margin-left: 8px;"
|
||
>
|
||
<el-option
|
||
v-for="f in availableStringFields"
|
||
:key="f"
|
||
:label="f"
|
||
:value="f"
|
||
/>
|
||
</el-select>
|
||
<el-select
|
||
v-model="variableToInsert"
|
||
size="small"
|
||
placeholder="选择变量"
|
||
style="width: 140px; margin-left: 8px;"
|
||
>
|
||
<el-option
|
||
v-for="v in availableVariables"
|
||
:key="v"
|
||
:label="`{${v}}`"
|
||
:value="v"
|
||
/>
|
||
</el-select>
|
||
<el-button
|
||
size="small"
|
||
:disabled="!variableInsertField || !variableToInsert"
|
||
@click="insertVariable(variableInsertField, variableToInsert)"
|
||
style="margin-left: 8px;"
|
||
>
|
||
插入
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<el-divider />
|
||
|
||
<!-- 根据节点类型显示不同的配置项 -->
|
||
<template v-if="selectedNode.type === 'llm'">
|
||
<el-form-item label="提供商">
|
||
<el-select v-model="selectedNode.data.provider" placeholder="选择提供商">
|
||
<el-option label="OpenAI" value="openai" />
|
||
<el-option label="DeepSeek" value="deepseek" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="提示词">
|
||
<div class="prompt-input-wrapper" style="position: relative;">
|
||
<el-input
|
||
ref="promptTextareaRef"
|
||
v-model="selectedNode.data.prompt"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder="输入提示词,可使用 {input} 或 {{variable}} 引用输入数据或变量"
|
||
@input="handlePromptInput"
|
||
@keydown="handlePromptKeydown"
|
||
@blur="handlePromptBlur"
|
||
/>
|
||
|
||
<!-- 变量自动补全下拉框 -->
|
||
<div
|
||
v-if="showVariableAutocomplete"
|
||
class="variable-autocomplete-dropdown"
|
||
:style="autocompletePosition"
|
||
ref="autocompleteDropdownRef"
|
||
>
|
||
<div
|
||
v-for="(varItem, index) in filteredAutocompleteVariables"
|
||
:key="varItem.name"
|
||
class="autocomplete-item"
|
||
:class="{ 'is-active': index === autocompleteSelectedIndex }"
|
||
@click="selectAutocompleteVariable(varItem.name)"
|
||
@mouseenter="autocompleteSelectedIndex = index"
|
||
>
|
||
<div class="var-item-name">
|
||
<span class="var-name">{{ varItem.name }}</span>
|
||
<el-tag size="small" :type="getVarTypeTag(varItem.type)" style="margin-left: 8px;">
|
||
{{ varItem.type }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="var-item-desc" v-if="varItem.description">
|
||
{{ varItem.description }}
|
||
</div>
|
||
</div>
|
||
<div v-if="filteredAutocompleteVariables.length === 0" class="autocomplete-empty">
|
||
未找到匹配的变量
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 增强的变量提示面板 -->
|
||
<el-collapse v-model="variablePanelActive" style="margin-top: 10px;">
|
||
<el-collapse-item title="可用变量" name="variables">
|
||
<div class="variable-suggestions">
|
||
<!-- 基础变量 -->
|
||
<div class="var-group" v-if="basicVariables.length > 0">
|
||
<div class="group-title" style="font-size: 12px; font-weight: 500; color: #606266; margin-bottom: 8px;">基础变量</div>
|
||
<div class="var-items" style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
<el-tag
|
||
v-for="varName in basicVariables"
|
||
:key="varName"
|
||
class="var-tag"
|
||
size="small"
|
||
type="info"
|
||
style="cursor: pointer;"
|
||
@click="insertVariable('prompt', varName)"
|
||
>
|
||
{{ varName }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 上游变量 -->
|
||
<div class="var-group" v-if="upstreamVariablesList.length > 0" style="margin-top: 12px;">
|
||
<div class="group-title" style="font-size: 12px; font-weight: 500; color: #606266; margin-bottom: 8px;">上游节点变量</div>
|
||
<div class="var-items" style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
<el-tag
|
||
v-for="varItem in upstreamVariablesList"
|
||
:key="varItem.name"
|
||
class="var-tag"
|
||
size="small"
|
||
:type="varItem.type === 'object' ? 'warning' : varItem.type === 'array' ? 'success' : 'info'"
|
||
style="cursor: pointer;"
|
||
@click="insertVariable('prompt', varItem.name)"
|
||
>
|
||
{{ varItem.name }}
|
||
<el-tooltip v-if="varItem.description" :content="varItem.description" placement="top">
|
||
<el-icon style="margin-left: 4px;"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 记忆变量 -->
|
||
<div class="var-group" v-if="memoryVariablesList.length > 0" style="margin-top: 12px;">
|
||
<div class="group-title" style="font-size: 12px; font-weight: 500; color: #606266; margin-bottom: 8px;">记忆变量</div>
|
||
<div class="var-items" style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
<el-tag
|
||
v-for="varItem in memoryVariablesList"
|
||
:key="varItem.name"
|
||
class="var-tag"
|
||
size="small"
|
||
type="success"
|
||
style="cursor: pointer;"
|
||
@click="insertVariable('prompt', varItem.name)"
|
||
>
|
||
{{ varItem.name }}
|
||
<el-tooltip v-if="varItem.description" :content="varItem.description" placement="top">
|
||
<el-icon style="margin-left: 4px;"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</el-form-item>
|
||
<el-form-item label="模型">
|
||
<el-select v-model="selectedNode.data.model" placeholder="选择模型">
|
||
<template v-if="!selectedNode.data.provider || selectedNode.data.provider === 'openai'">
|
||
<el-option label="GPT-4" value="gpt-4" />
|
||
<el-option label="GPT-3.5 Turbo" value="gpt-3.5-turbo" />
|
||
<el-option label="GPT-4 Turbo" value="gpt-4-turbo-preview" />
|
||
</template>
|
||
<template v-else-if="selectedNode.data.provider === 'deepseek'">
|
||
<el-option label="DeepSeek Chat" value="deepseek-chat" />
|
||
<el-option label="DeepSeek Coder" value="deepseek-coder" />
|
||
</template>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="温度">
|
||
<el-slider
|
||
v-model="selectedNode.data.temperature"
|
||
:min="0"
|
||
:max="2"
|
||
:step="0.1"
|
||
show-input
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="最大Token数">
|
||
<el-input-number
|
||
v-model="selectedNode.data.max_tokens"
|
||
:min="1"
|
||
:max="4096"
|
||
placeholder="可选,留空使用默认值"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-if="selectedNode.type === 'template'">
|
||
<el-form-item label="选择模板">
|
||
<el-select
|
||
v-model="selectedNode.data.template_id"
|
||
placeholder="选择模板或手动输入提示词"
|
||
filterable
|
||
clearable
|
||
@change="handleTemplateChange"
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="template in nodeTemplates"
|
||
:key="template.id"
|
||
:label="template.name"
|
||
:value="template.id"
|
||
>
|
||
<div>
|
||
<div>{{ template.name }}</div>
|
||
<div style="font-size: 12px; color: #909399">{{ template.description || '无描述' }}</div>
|
||
</div>
|
||
</el-option>
|
||
</el-select>
|
||
<div style="margin-top: 5px">
|
||
<el-button
|
||
type="primary"
|
||
link
|
||
size="small"
|
||
@click="handleManageTemplates"
|
||
>
|
||
管理模板
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="提供商">
|
||
<el-select v-model="selectedNode.data.provider" placeholder="选择提供商">
|
||
<el-option label="OpenAI" value="openai" />
|
||
<el-option label="DeepSeek" value="deepseek" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="提示词">
|
||
<el-input
|
||
v-model="selectedNode.data.prompt"
|
||
type="textarea"
|
||
:rows="6"
|
||
placeholder="输入提示词,可使用 {input} 或 {{variable}} 引用输入数据或变量"
|
||
/>
|
||
<div style="margin-top: 5px; color: #909399; font-size: 12px">
|
||
提示:使用 {{变量名}} 格式定义变量,例如 {{useCase}}、{{templateType}}
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="模型">
|
||
<el-select v-model="selectedNode.data.model" placeholder="选择模型">
|
||
<template v-if="!selectedNode.data.provider || selectedNode.data.provider === 'openai'">
|
||
<el-option label="GPT-4" value="gpt-4" />
|
||
<el-option label="GPT-3.5 Turbo" value="gpt-3.5-turbo" />
|
||
<el-option label="GPT-4 Turbo" value="gpt-4-turbo-preview" />
|
||
</template>
|
||
<template v-else-if="selectedNode.data.provider === 'deepseek'">
|
||
<el-option label="DeepSeek Chat" value="deepseek-chat" />
|
||
<el-option label="DeepSeek Coder" value="deepseek-coder" />
|
||
</template>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="温度">
|
||
<el-slider
|
||
v-model="selectedNode.data.temperature"
|
||
:min="0"
|
||
:max="2"
|
||
:step="0.1"
|
||
show-input
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="最大Token数">
|
||
<el-input-number
|
||
v-model="selectedNode.data.max_tokens"
|
||
:min="1"
|
||
:max="4096"
|
||
placeholder="可选,留空使用默认值"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-if="selectedNode.type === 'condition'">
|
||
<el-form-item label="条件表达式">
|
||
<el-input
|
||
v-model="selectedNode.data.condition"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder="例如: {value} > 10 或: {value} > 10 and {value} < 20 或: ({status} == 'active' or {status} == 'pending') and {count} > 0"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="表达式说明">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
<p><strong>支持的运算符:</strong> ==, !=, >, >=, <, <=, in, not in, contains, not contains</p>
|
||
<p><strong>逻辑运算符:</strong> and, or, not</p>
|
||
<p><strong>示例:</strong></p>
|
||
<ul style="margin: 5px 0; padding-left: 20px;">
|
||
<li>{value} > 10</li>
|
||
<li>{status} == 'active'</li>
|
||
<li>{value} > 10 and {value} < 20</li>
|
||
<li>({status} == 'a' or {status} == 'b') and {count} > 0</li>
|
||
</ul>
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 数据转换节点配置 -->
|
||
<template v-if="selectedNode.type === 'transform' || selectedNode.type === 'data'">
|
||
<el-form-item label="转换模式">
|
||
<el-select v-model="selectedNode.data.mode" placeholder="选择转换模式">
|
||
<el-option label="字段映射" value="mapping" />
|
||
<el-option label="数据过滤" value="filter" />
|
||
<el-option label="数据计算" value="compute" />
|
||
<el-option label="全部" value="all" />
|
||
</el-select>
|
||
</el-form-item>
|
||
|
||
<template v-if="!selectedNode.data.mode || selectedNode.data.mode === 'mapping' || selectedNode.data.mode === 'all'">
|
||
<el-form-item label="字段映射">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 10px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
格式: {"目标字段": "源字段"}<br/>
|
||
示例: {"new_name": "old_name", "user_id": "id"}
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
<el-input
|
||
v-model="selectedNode.data.mapping"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder='{"target_field": "source_field"}'
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-if="selectedNode.data.mode === 'filter' || selectedNode.data.mode === 'all'">
|
||
<el-form-item label="过滤规则">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 10px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
JSON数组格式,每个规则包含: field, operator, value<br/>
|
||
示例: [{"field": "status", "operator": "==", "value": "active"}]
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
<el-input
|
||
v-model="selectedNode.data.filter_rules"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder='[{"field": "status", "operator": "==", "value": "active"}]'
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<template v-if="selectedNode.data.mode === 'compute' || selectedNode.data.mode === 'all'">
|
||
<el-form-item label="计算规则">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 10px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
格式: {"结果字段": "表达式"}<br/>
|
||
示例: {"total": "{price} * {quantity}", "sum": "{a} + {b}"}
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
<el-input
|
||
v-model="selectedNode.data.compute_rules"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder='{"result": "{a} + {b}"}'
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
</template>
|
||
|
||
<!-- 循环节点配置 -->
|
||
<template v-if="selectedNode.type === 'loop' || selectedNode.type === 'foreach'">
|
||
<el-form-item label="数组路径">
|
||
<el-input
|
||
v-model="selectedNode.data.items_path"
|
||
placeholder="例如: items 或 data.list"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
输入数据中数组的路径,支持嵌套路径如 "data.items" 或 "items[0].list"
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="循环变量名">
|
||
<el-input
|
||
v-model="selectedNode.data.item_variable"
|
||
placeholder="例如: item"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
循环体中可通过此变量名访问当前循环项,还会自动添加 {变量名}_index 和 {变量名}_total
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="错误处理">
|
||
<el-select v-model="selectedNode.data.error_handling" placeholder="选择错误处理方式">
|
||
<el-option label="继续执行" value="continue" />
|
||
<el-option label="停止循环" value="stop" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- Agent节点配置 -->
|
||
<template v-if="selectedNode.type === 'agent'">
|
||
<el-form-item label="Agent ID">
|
||
<el-input
|
||
v-model="selectedNode.data.agent_id"
|
||
placeholder="输入Agent ID"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
输入要执行的Agent ID,Agent将使用其配置的工作流执行
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- HTTP请求节点配置 -->
|
||
<template v-if="selectedNode.type === 'http' || selectedNode.type === 'request'">
|
||
<el-form-item label="请求URL">
|
||
<el-input
|
||
v-model="selectedNode.data.url"
|
||
placeholder="https://api.example.com/endpoint"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
支持变量替换,使用 {key} 或 ${key} 引用输入数据
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="请求方法">
|
||
<el-select v-model="selectedNode.data.method" placeholder="选择请求方法">
|
||
<el-option label="GET" value="GET" />
|
||
<el-option label="POST" value="POST" />
|
||
<el-option label="PUT" value="PUT" />
|
||
<el-option label="DELETE" value="DELETE" />
|
||
<el-option label="PATCH" value="PATCH" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="请求头">
|
||
<el-input
|
||
v-model="selectedNode.data.headers"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder='{"Content-Type": "application/json", "Authorization": "Bearer {token}"}'
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="URL参数">
|
||
<el-input
|
||
v-model="selectedNode.data.params"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder='{"page": 1, "limit": 10}'
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="请求体">
|
||
<el-input
|
||
v-model="selectedNode.data.body"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder='{"name": "{name}", "value": "{value}"}'
|
||
/>
|
||
</el-form-item>
|
||
<!-- 超时时间已移至高级配置 -->
|
||
</template>
|
||
|
||
<!-- 数据库操作节点配置 -->
|
||
<template v-if="selectedNode.type === 'database' || selectedNode.type === 'db'">
|
||
<el-form-item label="数据源ID">
|
||
<el-input
|
||
v-model="selectedNode.data.data_source_id"
|
||
placeholder="输入数据源ID"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="操作类型">
|
||
<el-select v-model="selectedNode.data.operation" placeholder="选择操作类型">
|
||
<el-option label="查询" value="query" />
|
||
<el-option label="插入" value="insert" />
|
||
<el-option label="更新" value="update" />
|
||
<el-option label="删除" value="delete" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'query'" label="SQL语句">
|
||
<el-input
|
||
v-model="selectedNode.data.sql"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder="SELECT * FROM users WHERE id = {id}"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
支持变量替换,使用 {key} 或 ${key} 引用输入数据
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'insert' || selectedNode.data.operation === 'update'" label="表名">
|
||
<el-input
|
||
v-model="selectedNode.data.table"
|
||
placeholder="users"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'insert' || selectedNode.data.operation === 'update'" label="数据">
|
||
<el-input
|
||
v-model="selectedNode.data.data"
|
||
type="textarea"
|
||
:rows="3"
|
||
placeholder='{"name": "{name}", "email": "{email}"}'
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'update' || selectedNode.data.operation === 'delete'" label="WHERE条件">
|
||
<el-input
|
||
v-model="selectedNode.data.where"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder='{"id": "{id}"}'
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- Webhook节点配置 -->
|
||
<template v-if="selectedNode.type === 'webhook'">
|
||
<el-form-item label="Webhook URL">
|
||
<el-input
|
||
v-model="selectedNode.data.url"
|
||
placeholder="https://example.com/webhook"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
支持变量替换,使用 {key} 或 ${key} 引用输入数据
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="请求方法">
|
||
<el-select v-model="selectedNode.data.method" placeholder="选择请求方法">
|
||
<el-option label="POST" value="POST" />
|
||
<el-option label="PUT" value="PUT" />
|
||
<el-option label="PATCH" value="PATCH" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="请求头">
|
||
<el-input
|
||
v-model="selectedNode.data.headers"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder='{"Content-Type": "application/json", "Authorization": "Bearer {token}"}'
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
JSON格式,支持变量替换
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="请求体">
|
||
<el-input
|
||
v-model="selectedNode.data.body"
|
||
type="textarea"
|
||
:rows="6"
|
||
placeholder='{"key": "value", "data": "{input_data}"}'
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
JSON格式,支持变量替换。如果为空,将使用输入数据作为请求体
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<!-- 超时时间已移至高级配置 -->
|
||
</template>
|
||
|
||
<!-- 邮件节点配置 -->
|
||
<template v-if="selectedNode.type === 'email' || selectedNode.type === 'mail'">
|
||
<el-form-item label="SMTP服务器">
|
||
<el-input
|
||
v-model="selectedNode.data.smtp_host"
|
||
placeholder="smtp.example.com"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
支持变量替换,使用 {key} 或 ${key} 引用输入数据
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="SMTP端口">
|
||
<el-input-number
|
||
v-model="selectedNode.data.smtp_port"
|
||
:min="1"
|
||
:max="65535"
|
||
placeholder="587"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="SMTP用户名">
|
||
<el-input
|
||
v-model="selectedNode.data.smtp_user"
|
||
placeholder="your-email@example.com"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="SMTP密码">
|
||
<el-input
|
||
v-model="selectedNode.data.smtp_password"
|
||
type="password"
|
||
placeholder="请输入SMTP密码"
|
||
show-password
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="使用TLS">
|
||
<el-switch v-model="selectedNode.data.use_tls" />
|
||
</el-form-item>
|
||
<el-form-item label="发件人邮箱">
|
||
<el-input
|
||
v-model="selectedNode.data.from_email"
|
||
placeholder="sender@example.com"
|
||
/>
|
||
<el-alert
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
必填项,支持变量替换
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="收件人邮箱">
|
||
<el-input
|
||
v-model="selectedNode.data.to_email"
|
||
placeholder="recipient@example.com"
|
||
/>
|
||
<el-alert
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
必填项,多个邮箱用逗号分隔,支持变量替换
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="抄送邮箱(可选)">
|
||
<el-input
|
||
v-model="selectedNode.data.cc_email"
|
||
placeholder="cc@example.com"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
多个邮箱用逗号分隔,支持变量替换
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="密送邮箱(可选)">
|
||
<el-input
|
||
v-model="selectedNode.data.bcc_email"
|
||
placeholder="bcc@example.com"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
多个邮箱用逗号分隔,支持变量替换
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="邮件主题">
|
||
<el-input
|
||
v-model="selectedNode.data.subject"
|
||
placeholder="邮件主题"
|
||
/>
|
||
<el-alert
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
必填项,支持变量替换
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="邮件正文类型">
|
||
<el-select v-model="selectedNode.data.body_type" placeholder="选择正文类型">
|
||
<el-option label="纯文本" value="text" />
|
||
<el-option label="HTML" value="html" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="邮件正文">
|
||
<el-input
|
||
v-model="selectedNode.data.body"
|
||
type="textarea"
|
||
:rows="8"
|
||
placeholder="邮件正文内容,支持变量替换"
|
||
/>
|
||
<el-alert
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
必填项,支持变量替换。HTML类型支持HTML标签
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="附件(可选)">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 10px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
JSON数组格式,每个附件包含: file_path(文件路径)或 file_content(base64编码内容)、file_name(文件名)<br/>
|
||
示例: [{"file_path": "/path/to/file.pdf", "file_name": "document.pdf"}]
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
<el-input
|
||
v-model="selectedNode.data.attachments"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder='[{"file_path": "/path/to/file.pdf", "file_name": "document.pdf"}]'
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 消息队列节点配置 -->
|
||
<template v-if="selectedNode.type === 'message_queue' || selectedNode.type === 'mq' || selectedNode.type === 'rabbitmq' || selectedNode.type === 'kafka'">
|
||
<el-form-item label="队列类型">
|
||
<el-select v-model="selectedNode.data.queue_type" placeholder="选择队列类型">
|
||
<el-option label="RabbitMQ" value="rabbitmq" />
|
||
<el-option label="Kafka" value="kafka" />
|
||
</el-select>
|
||
</el-form-item>
|
||
|
||
<!-- RabbitMQ配置 -->
|
||
<template v-if="selectedNode.data.queue_type === 'rabbitmq'">
|
||
<el-form-item label="主机地址">
|
||
<el-input
|
||
v-model="selectedNode.data.host"
|
||
placeholder="localhost"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
支持变量替换,使用 {key} 或 ${key} 引用输入数据
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="端口">
|
||
<el-input-number
|
||
v-model="selectedNode.data.port"
|
||
:min="1"
|
||
:max="65535"
|
||
placeholder="5672"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="用户名">
|
||
<el-input
|
||
v-model="selectedNode.data.username"
|
||
placeholder="guest"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="密码">
|
||
<el-input
|
||
v-model="selectedNode.data.password"
|
||
type="password"
|
||
placeholder="guest"
|
||
show-password
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="Exchange(可选)">
|
||
<el-input
|
||
v-model="selectedNode.data.exchange"
|
||
placeholder="exchange名称"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
如果配置了exchange,将使用exchange和routing_key发送消息
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="Routing Key">
|
||
<el-input
|
||
v-model="selectedNode.data.routing_key"
|
||
placeholder="routing_key"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
支持变量替换。如果未配置exchange,将作为queue_name使用
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="队列名称">
|
||
<el-input
|
||
v-model="selectedNode.data.queue_name"
|
||
placeholder="queue名称"
|
||
/>
|
||
<el-alert
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
必填项(如果未配置exchange),支持变量替换
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- Kafka配置 -->
|
||
<template v-if="selectedNode.data.queue_type === 'kafka'">
|
||
<el-form-item label="Bootstrap Servers">
|
||
<el-input
|
||
v-model="selectedNode.data.bootstrap_servers"
|
||
placeholder="localhost:9092"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
多个服务器用逗号分隔,例如: localhost:9092,localhost:9093<br/>
|
||
支持变量替换
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="Topic">
|
||
<el-input
|
||
v-model="selectedNode.data.topic"
|
||
placeholder="topic名称"
|
||
/>
|
||
<el-alert
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
必填项,支持变量替换
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 消息内容配置(通用) -->
|
||
<el-form-item label="消息内容">
|
||
<el-input
|
||
v-model="selectedNode.data.message"
|
||
type="textarea"
|
||
:rows="6"
|
||
placeholder='{"key": "value"} 或 JSON字符串,支持变量替换。如果为空,将使用输入数据作为消息'
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
JSON格式,支持变量替换。如果为空,将使用输入数据作为消息内容
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 结束节点配置 -->
|
||
<template v-if="selectedNode.type === 'end' || selectedNode.type === 'output'">
|
||
<el-form-item label="输出格式">
|
||
<el-select v-model="selectedNode.data.output_format" placeholder="选择输出格式">
|
||
<el-option label="纯文本(适合对话)" value="text" />
|
||
<el-option label="JSON格式" value="json" />
|
||
</el-select>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
<strong>纯文本</strong>:自动提取文本内容,适合对话场景<br/>
|
||
<strong>JSON格式</strong>:保留完整数据结构,适合API调用
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item label="描述">
|
||
<el-input
|
||
v-model="selectedNode.data.description"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="节点描述(可选)"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 定时任务节点配置 -->
|
||
<template v-if="isScheduleNodeSelected">
|
||
<el-form-item label="延迟类型">
|
||
<el-select v-model="selectedNode.data.delay_type" placeholder="选择延迟类型">
|
||
<el-option label="固定延迟" value="fixed" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="延迟值">
|
||
<el-input-number
|
||
v-model="selectedNode.data.delay_value"
|
||
:min="0"
|
||
:precision="0"
|
||
placeholder="请输入延迟值"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="时间单位">
|
||
<el-select v-model="selectedNode.data.delay_unit" placeholder="选择时间单位">
|
||
<el-option label="秒" value="seconds" />
|
||
<el-option label="分钟" value="minutes" />
|
||
<el-option label="小时" value="hours" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-alert :title="delayAlertTitle" type="info" :closable="false" style="margin-top: 10px" />
|
||
|
||
</template>
|
||
<!-- 文件操作节点配置 -->
|
||
<template v-if="selectedNode.type === 'file' || selectedNode.type === 'file_operation'">
|
||
<el-form-item label="操作类型">
|
||
<el-select v-model="selectedNode.data.operation" placeholder="选择操作类型">
|
||
<el-option label="读取" value="read" />
|
||
<el-option label="写入" value="write" />
|
||
<el-option label="上传" value="upload" />
|
||
<el-option label="下载" value="download" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'read' || selectedNode.data.operation === 'write' || selectedNode.data.operation === 'download'" label="文件路径">
|
||
<el-input
|
||
v-model="selectedNode.data.file_path"
|
||
placeholder="/path/to/file.txt 或 {path} 使用变量"
|
||
/>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 5px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
支持变量替换,使用 {key} 或 ${key} 引用输入数据
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'write'" label="文件内容">
|
||
<el-input
|
||
v-model="selectedNode.data.content"
|
||
type="textarea"
|
||
:rows="6"
|
||
placeholder="文件内容,支持变量替换"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'read' || selectedNode.data.operation === 'write'" label="编码">
|
||
<el-select v-model="selectedNode.data.encoding" placeholder="选择编码">
|
||
<el-option label="UTF-8" value="utf-8" />
|
||
<el-option label="GBK" value="gbk" />
|
||
<el-option label="ASCII" value="ascii" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'upload'" label="上传类型">
|
||
<el-select v-model="selectedNode.data.upload_type" placeholder="选择上传类型">
|
||
<el-option label="Base64" value="base64" />
|
||
<el-option label="URL" value="url" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'upload' && selectedNode.data.upload_type === 'url'" label="URL">
|
||
<el-input
|
||
v-model="selectedNode.data.url"
|
||
placeholder="https://example.com/file.pdf"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'upload'" label="目标路径">
|
||
<el-input
|
||
v-model="selectedNode.data.target_path"
|
||
placeholder="/path/to/save/file.ext"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item v-if="selectedNode.data.operation === 'download'" label="下载格式">
|
||
<el-select v-model="selectedNode.data.download_format" placeholder="选择下载格式">
|
||
<el-option label="Base64编码" value="base64" />
|
||
<el-option label="文件路径" value="path" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<el-form-item>
|
||
<div class="config-actions">
|
||
<el-button type="primary" @click="handleSaveNode">保存配置</el-button>
|
||
<el-button @click="handleCopyNode">复制节点</el-button>
|
||
<el-button type="danger" @click="handleDeleteNode">删除节点</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-tab-pane>
|
||
|
||
<!-- 数据流转可视化 -->
|
||
<el-tab-pane label="数据流" name="dataflow">
|
||
<!-- 上游数据源 -->
|
||
<el-card shadow="never" class="dataflow-card" style="margin-bottom: 15px;">
|
||
<template #header>
|
||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>📥 输入数据源</span>
|
||
<el-tag size="small" type="info">{{ upstreamEdges.length }} 个上游节点</el-tag>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="upstreamEdges.length === 0" class="empty-state">
|
||
<el-empty description="暂无上游节点" :image-size="80" />
|
||
</div>
|
||
|
||
<!-- 上游节点列表 -->
|
||
<div v-for="edge in upstreamEdges" :key="edge.id" class="upstream-item" style="margin-bottom: 15px; padding: 12px; border: 1px solid #e4e7ed; border-radius: 4px;">
|
||
<div class="node-info" style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
|
||
<el-icon><ArrowLeft /></el-icon>
|
||
<span class="node-name" style="font-weight: 500;">{{ getNodeLabel(edge.source) }}</span>
|
||
<el-tag size="small" :type="getNodeTypeColor(edge.source)">
|
||
{{ getNodeType(edge.source) }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<!-- 可用变量列表 -->
|
||
<div class="variables-list" v-if="getUpstreamVariables(edge.source).length > 0">
|
||
<div class="var-group-title" style="font-size: 12px; color: #909399; margin-bottom: 8px;">可用变量:</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
<el-tag
|
||
v-for="varItem in getUpstreamVariables(edge.source)"
|
||
:key="varItem.name"
|
||
class="variable-item"
|
||
size="small"
|
||
:type="varItem.type === 'object' ? 'warning' : varItem.type === 'array' ? 'success' : 'info'"
|
||
style="cursor: pointer;"
|
||
@click="insertVariableFromDataflow(varItem.name)"
|
||
>
|
||
{{ varItem.name }}
|
||
<el-tooltip v-if="varItem.description" :content="varItem.description" placement="top">
|
||
<el-icon style="margin-left: 4px;"><InfoFilled /></el-icon>
|
||
</el-tooltip>
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 数据预览(如果有执行记录) -->
|
||
<el-collapse v-if="hasUpstreamExecutionData(edge.source)" style="margin-top: 10px;">
|
||
<el-collapse-item title="数据预览" name="preview">
|
||
<div style="margin-top: 8px;">
|
||
<el-select
|
||
:model-value="upstreamExecutionDataMap[edge.source]?.executionId"
|
||
@update:model-value="(value) => updateUpstreamExecutionId(edge.source, value)"
|
||
placeholder="选择执行记录"
|
||
size="small"
|
||
style="width: 100%; margin-bottom: 8px;"
|
||
@change="loadUpstreamNodeData(edge.source)"
|
||
clearable
|
||
>
|
||
<el-option
|
||
v-for="exec in recentExecutions"
|
||
:key="exec.id"
|
||
:label="`${formatExecutionTime(exec.created_at)} - ${exec.status}`"
|
||
:value="exec.id"
|
||
/>
|
||
</el-select>
|
||
|
||
<div v-if="upstreamExecutionDataMap[edge.source]?.data">
|
||
<el-card shadow="never" style="margin-bottom: 8px;">
|
||
<template #header>
|
||
<span style="font-size: 12px;">📤 输出数据</span>
|
||
</template>
|
||
<el-scrollbar height="150px">
|
||
<pre style="margin: 0; padding: 8px; font-size: 11px; line-height: 1.4; background: #f5f7fa; border-radius: 4px;">{{ formatJSON(upstreamExecutionDataMap[edge.source].data.output) }}</pre>
|
||
</el-scrollbar>
|
||
</el-card>
|
||
<div style="font-size: 11px; color: #909399; text-align: right;">
|
||
执行时间: {{ upstreamExecutionDataMap[edge.source].data.duration || '未知' }}ms
|
||
</div>
|
||
</div>
|
||
<div v-else-if="upstreamExecutionDataMap[edge.source]?.loading" style="text-align: center; padding: 20px;">
|
||
<el-icon class="is-loading"><Loading /></el-icon>
|
||
<span style="margin-left: 8px; color: #909399;">加载中...</span>
|
||
</div>
|
||
</div>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 当前节点输出 -->
|
||
<el-card shadow="never" class="dataflow-card">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span>📤 输出数据</span>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 输出字段说明 -->
|
||
<div class="output-fields" v-if="getOutputFields(selectedNode?.type || '').length > 0">
|
||
<div class="var-group-title" style="font-size: 12px; color: #909399; margin-bottom: 8px;">输出字段:</div>
|
||
<div
|
||
v-for="field in getOutputFields(selectedNode?.type || '')"
|
||
:key="field.name"
|
||
class="output-field"
|
||
style="padding: 8px; margin-bottom: 8px; background: #f5f7fa; border-radius: 4px;"
|
||
>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<span class="field-name" style="font-weight: 500; color: #409eff;">{{ field.name }}</span>
|
||
<el-tag size="small" type="info">{{ field.type }}</el-tag>
|
||
</div>
|
||
<div class="field-desc" style="font-size: 12px; color: #909399; margin-top: 4px;">{{ field.description }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-state">
|
||
<el-empty description="无输出字段说明" :image-size="60" />
|
||
</div>
|
||
|
||
<!-- 下游节点 -->
|
||
<div v-if="downstreamNodes.length > 0" class="downstream-section" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #e4e7ed;">
|
||
<div class="section-title" style="font-size: 12px; color: #909399; margin-bottom: 8px;">下游节点:</div>
|
||
<div
|
||
v-for="node in downstreamNodes"
|
||
:key="node.id"
|
||
class="downstream-item"
|
||
style="display: flex; align-items: center; gap: 8px; padding: 6px; margin-bottom: 4px;"
|
||
>
|
||
<el-icon><ArrowRight /></el-icon>
|
||
<span>{{ node.data.label }}</span>
|
||
<el-tag size="small" type="info">{{ node.type }}</el-tag>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</el-tab-pane>
|
||
|
||
<!-- 记忆信息展示(仅 Cache 节点) -->
|
||
<el-tab-pane label="记忆信息" name="memory" v-if="selectedNode?.type === 'cache'">
|
||
<el-card shadow="never" style="margin-bottom: 15px;">
|
||
<template #header>
|
||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>💾 记忆内容</span>
|
||
<el-button size="small" @click="refreshMemory" :loading="memoryLoading">
|
||
<el-icon><Refresh /></el-icon>
|
||
刷新
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 记忆键 -->
|
||
<el-form-item label="记忆键">
|
||
<el-input
|
||
:model-value="memoryKey"
|
||
placeholder="user_memory_{user_id}"
|
||
readonly
|
||
/>
|
||
<el-tag size="small" style="margin-left: 8px;" :type="memoryStatus === '存在' ? 'success' : 'info'">
|
||
{{ memoryStatus }}
|
||
</el-tag>
|
||
</el-form-item>
|
||
|
||
<!-- 对话历史 -->
|
||
<el-form-item label="对话历史">
|
||
<el-tag
|
||
v-if="memoryData?.conversation_history"
|
||
type="info"
|
||
>
|
||
{{ memoryData.conversation_history.length }} 条记录
|
||
</el-tag>
|
||
<el-tag v-else type="info">无记录</el-tag>
|
||
<el-button
|
||
v-if="memoryData?.conversation_history && memoryData.conversation_history.length > 0"
|
||
size="small"
|
||
text
|
||
@click="showConversationHistory = true"
|
||
style="margin-left: 8px;"
|
||
>
|
||
查看详情
|
||
</el-button>
|
||
</el-form-item>
|
||
|
||
<!-- 用户画像 -->
|
||
<el-form-item label="用户画像">
|
||
<el-tag
|
||
v-if="memoryData?.user_profile"
|
||
type="success"
|
||
>
|
||
{{ Object.keys(memoryData.user_profile).length }} 个字段
|
||
</el-tag>
|
||
<el-tag v-else type="info">无数据</el-tag>
|
||
<el-button
|
||
v-if="memoryData?.user_profile && Object.keys(memoryData.user_profile).length > 0"
|
||
size="small"
|
||
text
|
||
@click="showUserProfile = true"
|
||
style="margin-left: 8px;"
|
||
>
|
||
查看详情
|
||
</el-button>
|
||
</el-form-item>
|
||
|
||
<!-- TTL 信息 -->
|
||
<el-form-item label="过期时间" v-if="selectedNode.data.ttl">
|
||
<el-tag type="warning">
|
||
{{ ttlInfo }}
|
||
</el-tag>
|
||
</el-form-item>
|
||
</el-card>
|
||
|
||
<!-- 记忆操作 -->
|
||
<el-card shadow="never">
|
||
<template #header>
|
||
<span>🔧 记忆操作</span>
|
||
</template>
|
||
|
||
<el-button-group>
|
||
<el-button size="small" @click="testMemoryQuery">测试查询</el-button>
|
||
<el-button size="small" @click="clearMemory">清空记忆</el-button>
|
||
<el-button size="small" type="danger" @click="deleteMemory">删除记忆</el-button>
|
||
</el-button-group>
|
||
</el-card>
|
||
|
||
<!-- 对话历史详情对话框 -->
|
||
<el-dialog v-model="showConversationHistory" title="对话历史" width="70%">
|
||
<el-scrollbar height="400px">
|
||
<div v-for="(msg, index) in memoryData?.conversation_history" :key="index" style="margin-bottom: 15px; padding: 12px; border: 1px solid #e4e7ed; border-radius: 4px;">
|
||
<div style="font-weight: 500; margin-bottom: 8px;">
|
||
<el-tag size="small" :type="msg.role === 'user' ? 'primary' : 'success'">{{ msg.role === 'user' ? '用户' : '助手' }}</el-tag>
|
||
<span style="margin-left: 8px; color: #909399; font-size: 12px;">{{ msg.timestamp || '未知时间' }}</span>
|
||
</div>
|
||
<div style="white-space: pre-wrap;">{{ msg.content }}</div>
|
||
</div>
|
||
</el-scrollbar>
|
||
</el-dialog>
|
||
|
||
<!-- 用户画像详情对话框 -->
|
||
<el-dialog v-model="showUserProfile" title="用户画像" width="60%">
|
||
<el-descriptions :column="2" border>
|
||
<el-descriptions-item
|
||
v-for="(value, key) in memoryData?.user_profile"
|
||
:key="key"
|
||
:label="key"
|
||
>
|
||
{{ typeof value === 'object' ? JSON.stringify(value, null, 2) : value }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-dialog>
|
||
</el-tab-pane>
|
||
|
||
<!-- 执行数据预览 -->
|
||
<el-tab-pane label="数据预览" name="preview">
|
||
<el-card shadow="never" style="margin-bottom: 15px;">
|
||
<template #header>
|
||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>📊 执行数据</span>
|
||
<el-select
|
||
v-model="selectedExecutionId"
|
||
placeholder="选择执行记录"
|
||
size="small"
|
||
style="width: 200px;"
|
||
@change="loadNodeExecutionData"
|
||
clearable
|
||
>
|
||
<el-option
|
||
v-for="exec in recentExecutions"
|
||
:key="exec.id"
|
||
:label="`${formatExecutionTime(exec.created_at)} - ${exec.status}`"
|
||
:value="exec.id"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="!selectedExecutionId" class="empty-state">
|
||
<el-empty description="请选择执行记录查看数据" :image-size="80" />
|
||
</div>
|
||
|
||
<div v-else>
|
||
<!-- 输入数据 -->
|
||
<el-card shadow="never" style="margin-bottom: 15px;">
|
||
<template #header>
|
||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>📥 输入数据</span>
|
||
<el-button
|
||
text
|
||
size="small"
|
||
@click="copyToClipboard(executionData?.input)"
|
||
title="复制"
|
||
>
|
||
<el-icon><DocumentCopy /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
<el-scrollbar height="200px" v-if="executionData?.input">
|
||
<pre class="data-viewer" style="margin: 0; padding: 12px; font-size: 12px; line-height: 1.5;">{{ formatJSON(executionData.input) }}</pre>
|
||
</el-scrollbar>
|
||
<div v-else class="empty-state">
|
||
<el-empty description="无输入数据" :image-size="60" />
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 输出数据 -->
|
||
<el-card shadow="never">
|
||
<template #header>
|
||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>📤 输出数据</span>
|
||
<el-button
|
||
text
|
||
size="small"
|
||
@click="copyToClipboard(executionData?.output)"
|
||
title="复制"
|
||
>
|
||
<el-icon><DocumentCopy /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
<el-scrollbar height="200px" v-if="executionData?.output">
|
||
<pre class="data-viewer" style="margin: 0; padding: 12px; font-size: 12px; line-height: 1.5;">{{ formatJSON(executionData.output) }}</pre>
|
||
</el-scrollbar>
|
||
<div v-else class="empty-state">
|
||
<el-empty description="无输出数据" :image-size="60" />
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 执行信息 -->
|
||
<el-descriptions :column="2" border style="margin-top: 15px;" v-if="executionData">
|
||
<el-descriptions-item label="执行时间">
|
||
{{ executionData.duration ? `${executionData.duration}ms` : '未知' }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="开始时间">
|
||
{{ executionData.start_time ? formatExecutionTime(executionData.start_time) : '未知' }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="完成时间">
|
||
{{ executionData.complete_time ? formatExecutionTime(executionData.complete_time) : '未知' }}
|
||
</el-descriptions-item>
|
||
<!-- 缓存命中情况(Cache节点或输出中包含cache_hit) -->
|
||
<el-descriptions-item
|
||
v-if="selectedNode?.type === 'cache' || executionData?.output?.cache_hit !== undefined"
|
||
label="缓存命中"
|
||
>
|
||
<el-tag :type="executionData?.output?.cache_hit ? 'success' : 'info'" size="small">
|
||
{{ executionData?.output?.cache_hit ? '✅ 命中' : '❌ 未命中' }}
|
||
</el-tag>
|
||
<span v-if="executionData?.output?.cache_hit" style="margin-left: 8px; font-size: 12px; color: #909399;">
|
||
(从缓存读取)
|
||
</span>
|
||
<span v-else style="margin-left: 8px; font-size: 12px; color: #909399;">
|
||
(执行计算)
|
||
</span>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<!-- Cache节点额外信息 -->
|
||
<el-card v-if="selectedNode?.type === 'cache' && executionData?.output" shadow="never" style="margin-top: 15px;">
|
||
<template #header>
|
||
<span style="font-size: 13px;">💾 缓存信息</span>
|
||
</template>
|
||
<el-descriptions :column="1" border size="small">
|
||
<el-descriptions-item label="缓存键">
|
||
<code style="font-size: 11px;">{{ executionData.output.key || '未知' }}</code>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="缓存状态">
|
||
<el-tag :type="executionData.output.cache_hit ? 'success' : 'warning'" size="small">
|
||
{{ executionData.output.cache_hit ? '已缓存' : '未缓存' }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item v-if="executionData.output.memory" label="记忆数据">
|
||
<el-tag type="info" size="small">
|
||
{{ Object.keys(executionData.output.memory || {}).length }} 个字段
|
||
</el-tag>
|
||
<el-button
|
||
size="small"
|
||
text
|
||
@click="showCacheMemoryDetail = true; cacheMemoryDetail = executionData.output.memory"
|
||
style="margin-left: 8px;"
|
||
>
|
||
查看详情
|
||
</el-button>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-card>
|
||
|
||
<!-- 缓存记忆详情对话框 -->
|
||
<el-dialog v-model="showCacheMemoryDetail" title="缓存记忆详情" width="70%">
|
||
<el-scrollbar height="400px">
|
||
<pre style="margin: 0; padding: 12px; background: #f5f7fa; border-radius: 4px; font-size: 12px;">{{ formatJSON(cacheMemoryDetail) }}</pre>
|
||
</el-scrollbar>
|
||
</el-dialog>
|
||
</div>
|
||
</el-card>
|
||
</el-tab-pane>
|
||
|
||
<!-- 高级配置 -->
|
||
<el-tab-pane label="高级" name="advanced">
|
||
<el-form :model="selectedNode.data" label-width="100px">
|
||
<!-- 通用高级配置 -->
|
||
<el-form-item label="超时时间(秒)" v-if="hasTimeoutConfig">
|
||
<el-input-number v-model="selectedNode.data.timeout" :min="1" :max="300" />
|
||
<span style="margin-left: 8px; color: #909399; font-size: 12px;">默认30秒</span>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="重试次数" v-if="hasRetryConfig">
|
||
<el-input-number v-model="selectedNode.data.retry_count" :min="0" :max="10" />
|
||
<span style="margin-left: 8px; color: #909399; font-size: 12px;">失败时重试次数</span>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="重试延迟(ms)" v-if="hasRetryConfig && selectedNode.data.retry_count > 0">
|
||
<el-input-number v-model="selectedNode.data.retry_delay" :min="100" :max="10000" :step="100" />
|
||
</el-form-item>
|
||
|
||
<!-- HTTP节点高级配置 -->
|
||
<template v-if="selectedNode.type === 'http'">
|
||
<el-form-item label="SSL验证">
|
||
<el-switch v-model="selectedNode.data.verify_ssl" />
|
||
</el-form-item>
|
||
<el-form-item label="跟随重定向">
|
||
<el-switch v-model="selectedNode.data.follow_redirects" />
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- LLM节点高级配置 -->
|
||
<template v-if="selectedNode.type === 'llm' || selectedNode.type === 'template'">
|
||
<el-form-item label="流式输出">
|
||
<el-switch v-model="selectedNode.data.stream" />
|
||
</el-form-item>
|
||
<el-form-item label="Top P">
|
||
<el-input-number v-model="selectedNode.data.top_p" :min="0" :max="1" :step="0.1" :precision="2" />
|
||
</el-form-item>
|
||
<el-form-item label="频率惩罚">
|
||
<el-input-number v-model="selectedNode.data.frequency_penalty" :min="-2" :max="2" :step="0.1" :precision="1" />
|
||
</el-form-item>
|
||
<el-form-item label="存在惩罚">
|
||
<el-input-number v-model="selectedNode.data.presence_penalty" :min="-2" :max="2" :step="0.1" :precision="1" />
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 缓存节点高级配置 -->
|
||
<template v-if="selectedNode.type === 'cache'">
|
||
<el-form-item label="缓存后端">
|
||
<el-select v-model="selectedNode.data.backend">
|
||
<el-option label="内存" value="memory" />
|
||
<el-option label="Redis" value="redis" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="Redis连接" v-if="selectedNode.data.backend === 'redis'">
|
||
<el-input v-model="selectedNode.data.redis_url" placeholder="redis://localhost:6379/0" />
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 错误处理节点高级配置 -->
|
||
<template v-if="selectedNode.type === 'error_handler'">
|
||
<el-form-item label="错误处理工作流">
|
||
<el-input v-model="selectedNode.data.error_handler_workflow" placeholder="错误处理工作流ID(可选)" />
|
||
</el-form-item>
|
||
<el-form-item label="错误通知">
|
||
<el-switch v-model="selectedNode.data.error_notify" />
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 批处理节点高级配置 -->
|
||
<template v-if="selectedNode.type === 'batch'">
|
||
<el-form-item label="等待完成">
|
||
<el-switch v-model="selectedNode.data.wait_for_completion" />
|
||
</el-form-item>
|
||
<el-form-item label="并发数" v-if="selectedNode.data.wait_for_completion">
|
||
<el-input-number v-model="selectedNode.data.concurrency" :min="1" :max="10" />
|
||
</el-form-item>
|
||
</template>
|
||
|
||
<!-- 向量数据库节点高级配置 -->
|
||
<template v-if="selectedNode.type === 'vector_db'">
|
||
<el-form-item label="相似度阈值">
|
||
<el-input-number v-model="selectedNode.data.similarity_threshold" :min="0" :max="1" :step="0.1" :precision="2" />
|
||
</el-form-item>
|
||
<el-form-item label="距离度量">
|
||
<el-select v-model="selectedNode.data.distance_metric">
|
||
<el-option label="余弦相似度" value="cosine" />
|
||
<el-option label="欧氏距离" value="euclidean" />
|
||
<el-option label="点积" value="dot" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</template>
|
||
</el-form>
|
||
</el-tab-pane>
|
||
|
||
<!-- 工具配置(仅LLM节点) -->
|
||
<el-tab-pane label="工具" name="tools" v-if="selectedNode.type === 'llm' || selectedNode.type === 'template'">
|
||
<el-form :model="selectedNode.data" label-width="120px">
|
||
<el-form-item>
|
||
<template #label>
|
||
<span>启用工具调用</span>
|
||
<el-tooltip content="启用后,LLM可以根据需要自动调用预定义的工具" placement="right">
|
||
<el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
|
||
</el-tooltip>
|
||
</template>
|
||
<el-switch
|
||
v-model="selectedNode.data.enable_tools"
|
||
@change="handleToolsToggle"
|
||
/>
|
||
<span style="margin-left: 8px; color: #909399; font-size: 12px;">
|
||
允许LLM调用工具来获取信息或执行操作
|
||
</span>
|
||
</el-form-item>
|
||
|
||
<div v-if="selectedNode.data.enable_tools" style="margin-top: 20px;">
|
||
<el-form-item label="选择工具">
|
||
<el-select
|
||
v-model="selectedNode.data.tools"
|
||
multiple
|
||
placeholder="选择可用工具"
|
||
style="width: 100%"
|
||
filterable
|
||
@change="handleToolsChange"
|
||
>
|
||
<el-option-group
|
||
v-for="group in toolGroups"
|
||
:key="group.label"
|
||
:label="group.label"
|
||
>
|
||
<el-option
|
||
v-for="tool in group.tools"
|
||
:key="tool.name"
|
||
:label="tool.name"
|
||
:value="tool.name"
|
||
>
|
||
<div style="display: flex; flex-direction: column;">
|
||
<span style="font-weight: 500;">{{ tool.name }}</span>
|
||
<span style="font-size: 12px; color: #909399; margin-top: 2px;">
|
||
{{ tool.description }}
|
||
</span>
|
||
</div>
|
||
</el-option>
|
||
</el-option-group>
|
||
</el-select>
|
||
<div style="margin-top: 8px; color: #909399; font-size: 12px;">
|
||
已选择 {{ (selectedNode.data.tools || []).length }} 个工具
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<!-- 显示选中工具的详细信息 -->
|
||
<div v-if="selectedNode.data.tools && selectedNode.data.tools.length > 0" style="margin-top: 20px;">
|
||
<el-divider content-position="left">工具详情</el-divider>
|
||
<div v-for="toolName in selectedNode.data.tools" :key="toolName" style="margin-bottom: 15px;">
|
||
<el-card shadow="hover" style="border: 1px solid #e4e7ed;">
|
||
<template #header>
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span style="font-weight: 500;">{{ toolName }}</span>
|
||
<el-button
|
||
type="danger"
|
||
size="small"
|
||
text
|
||
@click="removeTool(toolName)"
|
||
>
|
||
移除
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
<div v-if="getToolSchema(toolName)">
|
||
<div style="margin-bottom: 8px;">
|
||
<strong>描述:</strong>
|
||
<span style="color: #606266;">{{ getToolSchema(toolName).function?.description || '无描述' }}</span>
|
||
</div>
|
||
<div v-if="getToolSchema(toolName).function?.parameters">
|
||
<strong>参数:</strong>
|
||
<el-collapse style="margin-top: 8px;">
|
||
<el-collapse-item>
|
||
<template #title>
|
||
<span style="font-size: 12px;">查看参数定义</span>
|
||
</template>
|
||
<pre style="margin: 0; padding: 8px; background: #f5f7fa; border-radius: 4px; font-size: 11px; overflow-x: auto;">{{ JSON.stringify(getToolSchema(toolName).function.parameters, null, 2) }}</pre>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</div>
|
||
</div>
|
||
<div v-else style="color: #909399; font-size: 12px;">
|
||
工具信息加载中...
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-if="!selectedNode.data.tools || selectedNode.data.tools.length === 0"
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 15px;"
|
||
>
|
||
<template #title>
|
||
<span style="font-size: 12px;">请至少选择一个工具</span>
|
||
</template>
|
||
</el-alert>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-else
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 15px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
<p style="margin: 0 0 8px 0;">工具调用功能允许LLM自动调用预定义的工具来:</p>
|
||
<ul style="margin: 0; padding-left: 20px;">
|
||
<li>发送HTTP请求获取数据</li>
|
||
<li>读取文件内容</li>
|
||
<li>执行其他自定义操作</li>
|
||
</ul>
|
||
<p style="margin: 8px 0 0 0;">启用后,LLM会根据用户需求自动选择合适的工具。</p>
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</el-form>
|
||
</el-tab-pane>
|
||
|
||
<!-- 配置助手 -->
|
||
<el-tab-pane label="配置助手" name="assistant">
|
||
<el-card shadow="never" style="margin-bottom: 15px;">
|
||
<template #header>
|
||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>🎯 快速配置</span>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 配置模式选择 -->
|
||
<el-radio-group v-model="configAssistantMode" style="width: 100%; margin-bottom: 15px;">
|
||
<el-radio-button label="simple">简单模式</el-radio-button>
|
||
<el-radio-button label="template">模板模式</el-radio-button>
|
||
<el-radio-button label="wizard">向导模式</el-radio-button>
|
||
</el-radio-group>
|
||
|
||
<!-- 简单模式 -->
|
||
<div v-if="configAssistantMode === 'simple'" class="config-wizard">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 15px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
选择常用场景,快速配置节点
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
|
||
<div class="scenario-list">
|
||
<div
|
||
v-for="scenario in getScenarios(selectedNode?.type || '')"
|
||
:key="scenario.id"
|
||
class="scenario-item"
|
||
@click="selectScenario(scenario)"
|
||
>
|
||
<div class="scenario-icon">
|
||
<el-icon :size="24"><component :is="getScenarioIcon(scenario.icon)" /></el-icon>
|
||
</div>
|
||
<div class="scenario-info">
|
||
<div class="scenario-name">{{ scenario.name }}</div>
|
||
<div class="scenario-desc">{{ scenario.description }}</div>
|
||
</div>
|
||
<el-button size="small" type="primary" text>应用</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 模板模式 -->
|
||
<div v-else-if="configAssistantMode === 'template'" class="template-selector">
|
||
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
|
||
<el-input
|
||
v-model="configTemplateSearchKeyword"
|
||
placeholder="搜索模板..."
|
||
size="small"
|
||
clearable
|
||
style="flex: 1;"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
<el-select
|
||
v-model="templateCategoryFilter"
|
||
placeholder="分类"
|
||
size="small"
|
||
style="width: 120px;"
|
||
clearable
|
||
>
|
||
<el-option label="全部" value="" />
|
||
<el-option label="常用" value="common" />
|
||
<el-option label="AI" value="ai" />
|
||
<el-option label="数据处理" value="data" />
|
||
<el-option label="网络" value="network" />
|
||
</el-select>
|
||
</div>
|
||
|
||
<el-scrollbar height="400px">
|
||
<div class="template-list">
|
||
<div
|
||
v-for="template in filteredConfigTemplates"
|
||
:key="template.id"
|
||
class="template-item"
|
||
:class="{ 'is-favorite': template.isFavorite }"
|
||
>
|
||
<div class="template-header">
|
||
<div class="template-title">
|
||
<span class="template-name">{{ template.name }}</span>
|
||
<el-tag size="small" type="info" style="margin-left: 8px;">{{ template.category }}</el-tag>
|
||
</div>
|
||
<div class="template-actions">
|
||
<el-button
|
||
size="small"
|
||
text
|
||
@click="toggleTemplateFavorite(template.id)"
|
||
>
|
||
<el-icon>
|
||
<Star v-if="template.isFavorite" style="color: #f7ba2a;" />
|
||
<Star v-else />
|
||
</el-icon>
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="template-desc">{{ template.description }}</div>
|
||
<div class="template-preview">
|
||
<pre>{{ getTemplatePreview(template) }}</pre>
|
||
</div>
|
||
<div class="template-footer">
|
||
<el-button size="small" type="primary" @click="applyConfigTemplate(template)">
|
||
应用模板
|
||
</el-button>
|
||
<el-button size="small" @click="viewTemplateDetail(template)">
|
||
查看详情
|
||
</el-button>
|
||
<el-button size="small" @click="exportTemplate(template)">
|
||
导出
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-scrollbar>
|
||
</div>
|
||
|
||
<!-- 向导模式 -->
|
||
<div v-else-if="configAssistantMode === 'wizard'" class="wizard-mode">
|
||
<el-steps :active="wizardStep" simple style="margin-bottom: 20px;">
|
||
<el-step title="选择场景" />
|
||
<el-step title="配置参数" />
|
||
<el-step title="完成" />
|
||
</el-steps>
|
||
|
||
<!-- 场景选择 -->
|
||
<div v-if="wizardStep === 0" class="wizard-content">
|
||
<div class="scenario-list">
|
||
<div
|
||
v-for="scenario in getScenarios(selectedNode?.type || '')"
|
||
:key="scenario.id"
|
||
class="scenario-item"
|
||
@click="selectWizardScenario(scenario)"
|
||
>
|
||
<div class="scenario-icon">
|
||
<el-icon :size="24"><component :is="getScenarioIcon(scenario.icon)" /></el-icon>
|
||
</div>
|
||
<div class="scenario-info">
|
||
<div class="scenario-name">{{ scenario.name }}</div>
|
||
<div class="scenario-desc">{{ scenario.description }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 参数配置 -->
|
||
<div v-if="wizardStep === 1" class="wizard-content">
|
||
<el-form :model="wizardConfig" label-width="120px">
|
||
<el-form-item
|
||
v-for="field in selectedWizardScenario?.fields || []"
|
||
:key="field.name"
|
||
:label="field.label"
|
||
>
|
||
<el-input
|
||
v-if="field.type === 'text'"
|
||
v-model="wizardConfig[field.name]"
|
||
:placeholder="field.placeholder"
|
||
/>
|
||
<el-input
|
||
v-else-if="field.type === 'textarea'"
|
||
v-model="wizardConfig[field.name]"
|
||
type="textarea"
|
||
:rows="3"
|
||
:placeholder="field.placeholder"
|
||
/>
|
||
<el-select
|
||
v-else-if="field.type === 'select'"
|
||
v-model="wizardConfig[field.name]"
|
||
:placeholder="field.placeholder"
|
||
>
|
||
<el-option
|
||
v-for="opt in field.options"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
<el-input-number
|
||
v-else-if="field.type === 'number'"
|
||
v-model="wizardConfig[field.name]"
|
||
:min="field.min"
|
||
:max="field.max"
|
||
:step="field.step"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<div style="margin-top: 20px; text-align: right;">
|
||
<el-button @click="wizardStep = 0">上一步</el-button>
|
||
<el-button type="primary" @click="applyWizardConfig">完成</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 完成 -->
|
||
<div v-if="wizardStep === 2" class="wizard-content">
|
||
<el-result
|
||
icon="success"
|
||
title="配置完成"
|
||
sub-title="节点配置已应用,您可以在基础标签页中查看和调整"
|
||
>
|
||
<template #extra>
|
||
<el-button type="primary" @click="wizardStep = 0">重新配置</el-button>
|
||
</template>
|
||
</el-result>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 模板管理工具栏 -->
|
||
<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #e4e7ed;">
|
||
<el-button-group>
|
||
<el-button size="small" @click="templateImportVisible = true">
|
||
<el-icon><Upload /></el-icon>
|
||
导入模板
|
||
</el-button>
|
||
<el-button size="small" @click="saveCurrentAsTemplate">
|
||
<el-icon><DocumentAdd /></el-icon>
|
||
保存为模板
|
||
</el-button>
|
||
</el-button-group>
|
||
</div>
|
||
|
||
<!-- 模板详情对话框 -->
|
||
<el-dialog v-model="templateDetailVisible" title="模板详情" width="60%">
|
||
<div v-if="selectedTemplate">
|
||
<h3>{{ selectedTemplate.name }}</h3>
|
||
<p style="color: #909399; margin-bottom: 15px;">{{ selectedTemplate.description }}</p>
|
||
<el-divider />
|
||
<h4>配置预览:</h4>
|
||
<el-scrollbar height="300px">
|
||
<pre style="margin: 0; padding: 12px; background: #f5f7fa; border-radius: 4px;">{{ formatJSON(selectedTemplate.config) }}</pre>
|
||
</el-scrollbar>
|
||
<div style="margin-top: 15px; text-align: right;">
|
||
<el-button @click="templateDetailVisible = false">关闭</el-button>
|
||
<el-button @click="exportTemplate(selectedTemplate)">导出模板</el-button>
|
||
<el-button type="primary" @click="applyConfigTemplate(selectedTemplate)">应用模板</el-button>
|
||
</div>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<!-- 模板导入对话框 -->
|
||
<el-dialog v-model="templateImportVisible" title="导入模板" width="50%">
|
||
<el-upload
|
||
class="template-upload"
|
||
drag
|
||
:auto-upload="false"
|
||
:on-change="handleTemplateImport"
|
||
:file-list="[]"
|
||
accept=".json"
|
||
>
|
||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||
<div class="el-upload__text">
|
||
将模板文件拖到此处,或<em>点击上传</em>
|
||
</div>
|
||
<template #tip>
|
||
<div class="el-upload__tip">
|
||
只能上传JSON格式的模板文件
|
||
</div>
|
||
</template>
|
||
</el-upload>
|
||
|
||
<el-divider>或直接粘贴JSON</el-divider>
|
||
|
||
<el-input
|
||
v-model="templateImportJson"
|
||
type="textarea"
|
||
:rows="10"
|
||
placeholder="粘贴模板JSON内容..."
|
||
/>
|
||
|
||
<div style="margin-top: 15px; text-align: right;">
|
||
<el-button @click="templateImportVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="importTemplateFromJson">导入</el-button>
|
||
</div>
|
||
</el-dialog>
|
||
</el-tab-pane>
|
||
|
||
<!-- 节点测试 -->
|
||
<el-tab-pane label="测试" name="test">
|
||
<div class="node-test-section">
|
||
<el-form label-width="100px">
|
||
<!-- 测试用例管理 -->
|
||
<el-form-item label="测试用例">
|
||
<div class="test-case-bar">
|
||
<el-select
|
||
v-model="selectedTestCaseId"
|
||
placeholder="选择用例"
|
||
size="small"
|
||
style="width: 180px;"
|
||
>
|
||
<el-option
|
||
v-for="c in currentNodeTestCases"
|
||
:key="c.id"
|
||
:label="c.name"
|
||
:value="c.id"
|
||
/>
|
||
</el-select>
|
||
<el-button-group style="margin-left: 8px;">
|
||
<el-button size="small" @click="saveCurrentTestCase">
|
||
<el-icon><DocumentCopy /></el-icon>
|
||
保存
|
||
</el-button>
|
||
<el-button size="small" @click="applySelectedTestCase" :disabled="!selectedTestCaseId">
|
||
<el-icon><Download /></el-icon>
|
||
应用
|
||
</el-button>
|
||
<el-button size="small" type="danger" @click="deleteSelectedTestCase" :disabled="!selectedTestCaseId">
|
||
<el-icon><Delete /></el-icon>
|
||
删除
|
||
</el-button>
|
||
</el-button-group>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<!-- 测试输入区域 -->
|
||
<el-form-item label="测试输入">
|
||
<div class="test-input-toolbar">
|
||
<el-button-group size="small">
|
||
<el-button @click="fillFromUpstream" :disabled="!hasUpstreamNodes">
|
||
<el-icon><Download /></el-icon>
|
||
从上游填充
|
||
</el-button>
|
||
<el-button @click="formatTestInput">
|
||
<el-icon><Sort /></el-icon>
|
||
格式化
|
||
</el-button>
|
||
<el-button @click="clearTestInput">
|
||
<el-icon><Delete /></el-icon>
|
||
清空
|
||
</el-button>
|
||
</el-button-group>
|
||
<el-select
|
||
v-model="testInputTemplate"
|
||
size="small"
|
||
placeholder="快速模板"
|
||
style="width: 150px; margin-left: 8px;"
|
||
@change="applyTestInputTemplate"
|
||
>
|
||
<el-option label="空对象" value="empty" />
|
||
<el-option label="基础输入" value="basic" />
|
||
<el-option label="完整示例" value="full" />
|
||
<el-option v-if="selectedNode?.type === 'llm'" label="LLM输入" value="llm" />
|
||
<el-option v-if="selectedNode?.type === 'http'" label="HTTP输入" value="http" />
|
||
<el-option v-if="selectedNode?.type === 'json'" label="JSON输入" value="json" />
|
||
</el-select>
|
||
</div>
|
||
<el-input
|
||
v-model="nodeTestInput"
|
||
type="textarea"
|
||
:rows="6"
|
||
placeholder='请输入JSON格式的测试输入数据,例如:{"input": "测试输入", "query": "测试查询"}'
|
||
:class="{ 'input-error': testInputError }"
|
||
/>
|
||
<div class="test-input-hint">
|
||
<el-icon><InfoFilled /></el-icon>
|
||
<span>输入数据将作为节点的输入参数</span>
|
||
<span v-if="testInputError" class="error-text">{{ testInputError }}</span>
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<!-- 测试操作按钮 -->
|
||
<el-form-item>
|
||
<el-button
|
||
type="success"
|
||
@click="handleTestNode"
|
||
:loading="testingNode"
|
||
:disabled="!selectedNode || testInputError"
|
||
style="width: 100%"
|
||
>
|
||
<el-icon><VideoPlay /></el-icon>
|
||
{{ testingNode ? '测试中...' : '运行测试' }}
|
||
</el-button>
|
||
</el-form-item>
|
||
|
||
<!-- 测试结果区域 -->
|
||
<el-form-item v-if="nodeTestResult" label="测试输出">
|
||
<div class="test-result-header">
|
||
<div class="test-status-group">
|
||
<span class="test-status" :class="nodeTestResult.status">
|
||
<el-icon v-if="nodeTestResult.status === 'success'"><CircleCheck /></el-icon>
|
||
<el-icon v-else><CircleClose /></el-icon>
|
||
{{ nodeTestResult.status === 'success' ? '测试成功' : '测试失败' }}
|
||
</span>
|
||
<span class="test-time" v-if="nodeTestResult.execution_time">
|
||
<el-icon><Timer /></el-icon>
|
||
{{ nodeTestResult.execution_time }}ms
|
||
</span>
|
||
</div>
|
||
<div class="test-result-actions" v-if="nodeTestResult.status === 'success'">
|
||
<el-button size="small" text @click="copyTestResult">
|
||
<el-icon><DocumentCopy /></el-icon>
|
||
复制
|
||
</el-button>
|
||
<el-button size="small" text @click="downloadTestResult">
|
||
<el-icon><Download /></el-icon>
|
||
导出
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- JSON格式化展示 -->
|
||
<div class="test-result-content">
|
||
<el-scrollbar height="300px">
|
||
<pre class="json-viewer" v-if="nodeTestResult.status === 'success'">{{ formattedTestOutput }}</pre>
|
||
<div v-else class="test-error-detail">
|
||
<el-alert
|
||
type="error"
|
||
:title="nodeTestResult.error_message || '测试失败'"
|
||
:closable="false"
|
||
show-icon
|
||
>
|
||
<template #default>
|
||
<div v-if="nodeTestResult.error_trace" class="error-trace">
|
||
<details>
|
||
<summary>错误详情</summary>
|
||
<pre>{{ nodeTestResult.error_trace }}</pre>
|
||
</details>
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
</div>
|
||
</el-scrollbar>
|
||
</div>
|
||
|
||
<!-- 测试统计信息 -->
|
||
<div v-if="nodeTestResult.status === 'success' && nodeTestResult.output" class="test-stats">
|
||
<el-tag size="small" type="info">
|
||
输出类型: {{ getOutputType(nodeTestResult.output) }}
|
||
</el-tag>
|
||
<el-tag size="small" type="info" v-if="typeof nodeTestResult.output === 'object'">
|
||
字段数: {{ Object.keys(nodeTestResult.output).length }}
|
||
</el-tag>
|
||
<el-tag size="small" type="info" v-if="typeof nodeTestResult.output === 'string'">
|
||
长度: {{ nodeTestResult.output.length }} 字符
|
||
</el-tag>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 运行对话框 -->
|
||
<el-dialog
|
||
v-model="runDialogVisible"
|
||
title="运行工作流"
|
||
width="600px"
|
||
:close-on-click-modal="false"
|
||
>
|
||
<el-form :model="runForm" label-width="100px">
|
||
<el-form-item label="执行参数">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 10px;"
|
||
>
|
||
<template #title>
|
||
<div style="font-size: 12px;">
|
||
输入JSON格式的执行参数,这些参数将作为工作流的初始输入数据。<br/>
|
||
例如: {"input": "你好", "value": 10, "status": "active"}
|
||
</div>
|
||
</template>
|
||
</el-alert>
|
||
<el-input
|
||
v-model="runForm.inputData"
|
||
type="textarea"
|
||
:rows="8"
|
||
placeholder='{"input": "你好", "value": 10}'
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="runDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleConfirmRun" :loading="running">
|
||
开始运行
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 工作流模板应用对话框 -->
|
||
<el-dialog
|
||
v-model="templateDialogVisible"
|
||
title="应用工作流模板"
|
||
width="800px"
|
||
:close-on-click-modal="false"
|
||
>
|
||
<div v-loading="loadingWorkflowTemplates">
|
||
<el-input
|
||
v-model="templateSearchKeyword"
|
||
placeholder="搜索模板..."
|
||
clearable
|
||
style="margin-bottom: 15px;"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Search /></el-icon>
|
||
</template>
|
||
</el-input>
|
||
|
||
<div class="template-list">
|
||
<div
|
||
v-for="template in filteredTemplates"
|
||
:key="template.id"
|
||
class="template-item"
|
||
@click="handleSelectTemplate(template)"
|
||
>
|
||
<div class="template-header">
|
||
<h4>{{ template.name }}</h4>
|
||
<el-tag v-if="template.is_featured" type="warning" size="small">精选</el-tag>
|
||
</div>
|
||
<div class="template-description">
|
||
{{ template.description || '无描述' }}
|
||
</div>
|
||
<div class="template-meta">
|
||
<span>节点数: {{ getTemplateNodeCount(template) }}</span>
|
||
<span>使用次数: {{ template.use_count || 0 }}</span>
|
||
<span v-if="template.rating_avg">评分: {{ template.rating_avg.toFixed(1) }} ⭐</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="filteredTemplates.length === 0" class="empty-templates">
|
||
<el-empty description="暂无可用模板" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<el-button @click="templateDialogVisible = false">取消</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 节点执行详情面板 -->
|
||
<NodeExecutionDetail
|
||
v-model:visible="nodeDetailVisible"
|
||
:node-id="selectedNode?.id"
|
||
:node-label="selectedNode?.data?.label || selectedNode?.id"
|
||
:node-type="selectedNode?.type"
|
||
:execution-id="currentExecutionId"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, markRaw, watch, nextTick } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||
import { Background } from '@vue-flow/background'
|
||
import { Controls } from '@vue-flow/controls'
|
||
import { MiniMap } from '@vue-flow/minimap'
|
||
import type { Node, Edge, NodeClickEvent, EdgeClickEvent, Connection, Viewport } from '@vue-flow/core'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Check, Warning, ZoomIn, ZoomOut, FullScreen, DocumentCopy, User, VideoPlay, InfoFilled, WarningFilled, Rank, ArrowDown, Sort, Grid, Operation, Document, Search, Timer, Box, Edit, Picture, Download, Delete, CircleCheck, CircleClose, Aim, ArrowLeft, ArrowRight, Refresh, Loading, Star, ChatDotRound, Connection as ConnectionIcon, Setting, DataAnalysis, Link, Upload, UploadFilled, DocumentAdd, QuestionFilled } from '@element-plus/icons-vue'
|
||
import { useWorkflowStore } from '@/stores/workflow'
|
||
import api from '@/api'
|
||
import type { WorkflowNode, WorkflowEdge } from '@/types'
|
||
import { StartNode, LLMNode, ConditionNode, EndNode, DefaultNode } from './NodeTypes'
|
||
import { useCollaboration } from '@/composables/useCollaboration'
|
||
import NodeExecutionDetail from './NodeExecutionDetail.vue'
|
||
|
||
const props = defineProps<{
|
||
workflowId?: string
|
||
agentId?: string
|
||
initialNodes?: any[]
|
||
initialEdges?: any[]
|
||
executionStatus?: any // 执行状态,包含当前执行的节点信息
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
save: [workflow: { nodes: WorkflowNode[]; edges: WorkflowEdge[] }]
|
||
'node-test': [data: { node: any; result: any; input: any }]
|
||
}>()
|
||
|
||
const router = useRouter()
|
||
const workflowStore = useWorkflowStore()
|
||
const canvasRef = ref<HTMLElement>()
|
||
const selectedNode = ref<Node | null>(null)
|
||
const selectedEdge = ref<Edge | null>(null)
|
||
const draggedNodeType = ref<any>(null)
|
||
const testingNode = ref(false)
|
||
const nodeDetailVisible = ref(false)
|
||
const currentExecutionId = ref<string | null>(null)
|
||
const snapEnabled = ref(true)
|
||
const showSwimlanes = ref(false)
|
||
const laneCount = ref(3)
|
||
|
||
// 节点复制相关
|
||
const copiedNode = ref<Node | null>(null)
|
||
|
||
// 节点测试相关
|
||
const nodeTestInput = ref('{}')
|
||
const nodeTestOutput = ref('')
|
||
const nodeTestResult = ref<any>(null)
|
||
const testInputTemplate = ref<string>('')
|
||
const testInputError = ref<string>('')
|
||
const selectedTestCaseId = ref<string>('')
|
||
const nodeTestCases = ref<Record<string, { id: string; name: string; data: string }[]>>({})
|
||
const testCaseStorageKey = computed(() => `workflow_node_test_cases_${props.workflowId || 'default'}`)
|
||
const currentNodeTestCases = computed(() => {
|
||
const id = selectedNode.value?.id
|
||
if (!id) return []
|
||
return nodeTestCases.value[id] || []
|
||
})
|
||
|
||
// 格式化测试输出
|
||
const formattedTestOutput = computed(() => {
|
||
if (!nodeTestResult.value || nodeTestResult.value.status !== 'success') return ''
|
||
try {
|
||
const output = nodeTestResult.value.output
|
||
if (typeof output === 'string') {
|
||
// 尝试解析为JSON
|
||
try {
|
||
const parsed = JSON.parse(output)
|
||
return JSON.stringify(parsed, null, 2)
|
||
} catch {
|
||
return output
|
||
}
|
||
} else if (typeof output === 'object' && output !== null) {
|
||
return JSON.stringify(output, null, 2)
|
||
} else {
|
||
return String(output)
|
||
}
|
||
} catch {
|
||
return String(nodeTestResult.value.output)
|
||
}
|
||
})
|
||
|
||
// 检查是否有上游节点
|
||
const hasUpstreamNodes = computed(() => {
|
||
if (!selectedNode.value) return false
|
||
return edges.value.some(e => e.target === selectedNode.value?.id)
|
||
})
|
||
|
||
// 节点模板相关
|
||
const nodeTemplates = ref<any[]>([])
|
||
const loadingTemplates = ref(false)
|
||
|
||
// 加载节点模板列表
|
||
const loadNodeTemplates = async () => {
|
||
loadingTemplates.value = true
|
||
try {
|
||
const response = await api.get('/api/v1/node-templates', {
|
||
params: {
|
||
limit: 100 // 加载前100个模板
|
||
}
|
||
})
|
||
nodeTemplates.value = response.data
|
||
} catch (error: any) {
|
||
console.error('加载节点模板失败:', error)
|
||
// 不显示错误消息,因为这是后台加载
|
||
} finally {
|
||
loadingTemplates.value = false
|
||
}
|
||
}
|
||
|
||
// 处理模板选择
|
||
const handleTemplateChange = async (templateId: string) => {
|
||
if (!templateId || !selectedNode.value) return
|
||
|
||
try {
|
||
// 获取模板详情
|
||
const response = await api.get(`/api/v1/node-templates/${templateId}`)
|
||
const template = response.data
|
||
|
||
// 应用模板配置到节点
|
||
if (selectedNode.value.data) {
|
||
selectedNode.value.data.prompt = template.prompt
|
||
selectedNode.value.data.provider = template.provider
|
||
selectedNode.value.data.model = template.model
|
||
selectedNode.value.data.temperature = parseFloat(template.temperature) || 0.7
|
||
selectedNode.value.data.max_tokens = template.max_tokens || 1500
|
||
selectedNode.value.data.template_id = templateId
|
||
|
||
// 如果有变量定义,可以在这里处理
|
||
if (template.variables && template.variables.length > 0) {
|
||
// 可以保存变量定义供后续使用
|
||
selectedNode.value.data.template_variables = template.variables
|
||
}
|
||
}
|
||
|
||
// 增加模板使用次数
|
||
try {
|
||
await api.post(`/api/v1/node-templates/${templateId}/use`)
|
||
} catch (e) {
|
||
// 忽略使用次数更新失败
|
||
}
|
||
|
||
ElMessage.success(`已应用模板: ${template.name}`)
|
||
} catch (error: any) {
|
||
ElMessage.error(error.response?.data?.detail || '加载模板失败')
|
||
}
|
||
}
|
||
|
||
// 管理模板
|
||
const handleManageTemplates = () => {
|
||
router.push({ name: 'node-templates' })
|
||
}
|
||
|
||
// 工作流模板相关
|
||
const templateDialogVisible = ref(false)
|
||
const workflowTemplates = ref<any[]>([])
|
||
const templateSearchKeyword = ref('')
|
||
const loadingWorkflowTemplates = ref(false)
|
||
|
||
// 加载工作流模板列表
|
||
const loadWorkflowTemplates = async () => {
|
||
loadingWorkflowTemplates.value = true
|
||
try {
|
||
// 优先从模板市场获取
|
||
try {
|
||
const response = await api.get('/api/v1/template-market', {
|
||
params: {
|
||
limit: 50,
|
||
sort_by: 'use_count',
|
||
sort_order: 'desc'
|
||
}
|
||
})
|
||
workflowTemplates.value = response.data || []
|
||
} catch (e) {
|
||
// 如果模板市场失败,尝试从工作流模板API获取
|
||
const response = await api.get('/api/v1/workflows/templates')
|
||
workflowTemplates.value = response.data || []
|
||
}
|
||
} catch (error: any) {
|
||
console.error('加载工作流模板失败:', error)
|
||
ElMessage.error('加载模板列表失败')
|
||
workflowTemplates.value = []
|
||
} finally {
|
||
loadingWorkflowTemplates.value = false
|
||
}
|
||
}
|
||
|
||
// 过滤模板
|
||
const filteredTemplates = computed(() => {
|
||
if (!templateSearchKeyword.value) {
|
||
return workflowTemplates.value
|
||
}
|
||
const keyword = templateSearchKeyword.value.toLowerCase()
|
||
return workflowTemplates.value.filter(template =>
|
||
template.name?.toLowerCase().includes(keyword) ||
|
||
template.description?.toLowerCase().includes(keyword) ||
|
||
template.category?.toLowerCase().includes(keyword)
|
||
)
|
||
})
|
||
|
||
// 获取模板节点数
|
||
const getTemplateNodeCount = (template: any) => {
|
||
if (template.nodes && Array.isArray(template.nodes)) {
|
||
return template.nodes.length
|
||
}
|
||
if (template.workflow_config?.nodes) {
|
||
return template.workflow_config.nodes.length
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// 打开模板对话框
|
||
const handleApplyTemplate = async () => {
|
||
templateDialogVisible.value = true
|
||
if (workflowTemplates.value.length === 0) {
|
||
await loadWorkflowTemplates()
|
||
}
|
||
}
|
||
|
||
// 选择并应用模板
|
||
const handleSelectTemplate = async (template: any) => {
|
||
try {
|
||
// 获取模板详情
|
||
let templateData: any
|
||
try {
|
||
const response = await api.get(`/api/v1/template-market/${template.id}`)
|
||
templateData = response.data
|
||
} catch (e) {
|
||
// 如果模板市场API失败,尝试从工作流模板API获取
|
||
const response = await api.get(`/api/v1/workflows/templates/${template.id}`)
|
||
templateData = response.data
|
||
}
|
||
|
||
if (!templateData) {
|
||
ElMessage.error('获取模板详情失败')
|
||
return
|
||
}
|
||
|
||
// 获取模板的节点和边
|
||
let templateNodes = templateData.nodes || templateData.workflow_config?.nodes || []
|
||
let templateEdges = templateData.edges || templateData.workflow_config?.edges || []
|
||
|
||
if (!templateNodes || templateNodes.length === 0) {
|
||
ElMessage.warning('模板中没有节点')
|
||
return
|
||
}
|
||
|
||
// 生成节点ID映射(避免ID冲突)
|
||
const nodeIdMapping: Record<string, string> = {}
|
||
const timestamp = Date.now()
|
||
|
||
templateNodes.forEach((node: any, index: number) => {
|
||
const oldId = node.id
|
||
const newId = `${node.type || 'node'}_${timestamp}_${index}`
|
||
nodeIdMapping[oldId] = newId
|
||
node.id = newId
|
||
})
|
||
|
||
// 更新边的源节点和目标节点ID
|
||
templateEdges.forEach((edge: any) => {
|
||
if (edge.source && nodeIdMapping[edge.source]) {
|
||
edge.source = nodeIdMapping[edge.source]
|
||
}
|
||
if (edge.target && nodeIdMapping[edge.target]) {
|
||
edge.target = nodeIdMapping[edge.target]
|
||
}
|
||
// 生成新的边ID
|
||
edge.id = `edge_${edge.source}_${edge.target}_${timestamp}`
|
||
})
|
||
|
||
// 计算偏移量,将模板节点添加到画布右侧
|
||
const existingNodes = nodes.value
|
||
let offsetX = 100
|
||
let offsetY = 100
|
||
|
||
if (existingNodes.length > 0) {
|
||
// 找到现有节点的最大X坐标
|
||
const maxX = Math.max(...existingNodes.map(n => n.position.x))
|
||
offsetX = maxX + 400 // 在现有节点右侧400px处开始
|
||
|
||
// 找到现有节点的最小Y坐标
|
||
const minY = Math.min(...existingNodes.map(n => n.position.y))
|
||
offsetY = minY
|
||
}
|
||
|
||
// 调整节点位置
|
||
templateNodes.forEach((node: any) => {
|
||
node.position = {
|
||
x: (node.position?.x || 0) + offsetX,
|
||
y: (node.position?.y || 0) + offsetY
|
||
}
|
||
})
|
||
|
||
// 转换为Vue Flow节点格式
|
||
const vueFlowNodes = templateNodes.map((node: any) => ({
|
||
id: node.id,
|
||
type: node.type || 'default',
|
||
position: node.position || { x: 0, y: 0 },
|
||
data: node.data || { label: node.label || node.type }
|
||
}))
|
||
|
||
// 转换为Vue Flow边格式
|
||
const vueFlowEdges = templateEdges.map((edge: any) => ({
|
||
id: edge.id,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
sourceHandle: edge.sourceHandle || 'right',
|
||
targetHandle: edge.targetHandle || 'left',
|
||
type: 'bezier',
|
||
animated: true,
|
||
selectable: true,
|
||
deletable: true,
|
||
focusable: true,
|
||
style: {
|
||
stroke: '#409eff',
|
||
strokeWidth: 2.5,
|
||
strokeDasharray: '0'
|
||
},
|
||
markerEnd: {
|
||
type: 'arrowclosed',
|
||
color: '#409eff',
|
||
width: 20,
|
||
height: 20
|
||
}
|
||
}))
|
||
|
||
// 添加到画布
|
||
addNodes(vueFlowNodes)
|
||
await nextTick()
|
||
addEdges(vueFlowEdges)
|
||
|
||
// 关闭对话框
|
||
templateDialogVisible.value = false
|
||
ElMessage.success(`已应用模板: ${template.name} (${templateNodes.length}个节点)`)
|
||
|
||
// 标记有变更
|
||
hasChanges.value = true
|
||
|
||
// 可选:自动布局新添加的节点
|
||
await nextTick()
|
||
setTimeout(() => {
|
||
handleAutoLayout()
|
||
}, 300)
|
||
|
||
} catch (error: any) {
|
||
console.error('应用模板失败:', error)
|
||
ElMessage.error(error.response?.data?.detail || '应用模板失败')
|
||
}
|
||
}
|
||
|
||
// 执行状态相关的节点高亮
|
||
const executedNodeIds = ref<Set<string>>(new Set())
|
||
const runningNodeId = ref<string | null>(null)
|
||
const failedNodeIds = ref<Set<string>>(new Set())
|
||
const testingAnimation = ref(false)
|
||
const testAnimationTimer = ref<number | null>(null)
|
||
|
||
// 运行对话框
|
||
const runDialogVisible = ref(false)
|
||
const running = ref(false)
|
||
const runForm = ref({
|
||
inputData: '{}'
|
||
})
|
||
|
||
// 使用useVueFlow获取节点和边的响应式引用
|
||
const vueFlowInstance = useVueFlow()
|
||
const {
|
||
nodes,
|
||
edges,
|
||
addNodes,
|
||
addEdges,
|
||
removeNodes,
|
||
removeEdges,
|
||
updateNode,
|
||
updateEdge,
|
||
screenToFlowCoordinate,
|
||
zoomIn: vueFlowZoomIn,
|
||
zoomOut: vueFlowZoomOut,
|
||
setViewport,
|
||
getViewport,
|
||
fitView
|
||
} = vueFlowInstance
|
||
|
||
// 保存状态
|
||
const saving = ref(false)
|
||
const hasChanges = ref(false)
|
||
const lastSavedData = ref<string>('')
|
||
const savingInterval = ref<number | null>(null)
|
||
|
||
// 撤销/重做状态
|
||
type WorkflowSnapshot = {
|
||
nodes: Node[]
|
||
edges: Edge[]
|
||
selectedNodeId: string | null
|
||
selectedEdgeId: string | null
|
||
viewport: Viewport | null
|
||
}
|
||
const history = ref<WorkflowSnapshot[]>([])
|
||
const redoStack = ref<WorkflowSnapshot[]>([])
|
||
const isRestoringHistory = ref(false)
|
||
const historyInitialized = ref(false)
|
||
const HISTORY_LIMIT = 50
|
||
|
||
const transientNodeDataKeys = ['executionStatus', 'executionClass', 'errorMessage', 'errorType']
|
||
const sanitizeNodeData = (data: Record<string, any> = {}) => {
|
||
const cloned = JSON.parse(JSON.stringify(data || {}))
|
||
transientNodeDataKeys.forEach(key => {
|
||
if (key in cloned) {
|
||
delete cloned[key]
|
||
}
|
||
})
|
||
return cloned
|
||
}
|
||
|
||
const snapshotEquals = (a: WorkflowSnapshot, b: WorkflowSnapshot) => {
|
||
const coreA = JSON.stringify({ nodes: a.nodes, edges: a.edges })
|
||
const coreB = JSON.stringify({ nodes: b.nodes, edges: b.edges })
|
||
return coreA === coreB && a.selectedNodeId === b.selectedNodeId && a.selectedEdgeId === b.selectedEdgeId
|
||
}
|
||
|
||
const createSnapshot = (): WorkflowSnapshot => {
|
||
const viewport = typeof getViewport === 'function' ? getViewport() : null
|
||
const nodesSnapshot = nodes.value.map(node => {
|
||
const cloned = JSON.parse(JSON.stringify(node))
|
||
cloned.data = sanitizeNodeData(node.data)
|
||
return cloned
|
||
})
|
||
const edgesSnapshot = edges.value.map(edge => JSON.parse(JSON.stringify(edge)))
|
||
return {
|
||
nodes: nodesSnapshot,
|
||
edges: edgesSnapshot,
|
||
selectedNodeId: selectedNode.value?.id || null,
|
||
selectedEdgeId: selectedEdge.value?.id || null,
|
||
viewport: viewport ? { ...viewport } : null
|
||
}
|
||
}
|
||
|
||
const resetHistory = () => {
|
||
history.value = [createSnapshot()]
|
||
redoStack.value = []
|
||
historyInitialized.value = true
|
||
}
|
||
|
||
const pushHistory = (reason = 'update', { markDirty = true, force = false } = {}) => {
|
||
if (isRestoringHistory.value) return
|
||
if (!historyInitialized.value && !force) return
|
||
const snapshot = createSnapshot()
|
||
const last = history.value[history.value.length - 1]
|
||
if (last && snapshotEquals(last, snapshot)) {
|
||
return
|
||
}
|
||
history.value.push(snapshot)
|
||
if (history.value.length > HISTORY_LIMIT) {
|
||
history.value.shift()
|
||
}
|
||
if (markDirty) {
|
||
hasChanges.value = true
|
||
}
|
||
redoStack.value = []
|
||
console.debug('[WorkflowEditor] history push:', reason, 'size:', history.value.length)
|
||
}
|
||
|
||
const restoreSnapshot = async (snapshot: WorkflowSnapshot) => {
|
||
isRestoringHistory.value = true
|
||
nodes.value = snapshot.nodes.map(n => ({ ...n, data: sanitizeNodeData(n.data) }))
|
||
edges.value = snapshot.edges.map(e => ({ ...e }))
|
||
selectedNode.value = snapshot.selectedNodeId ? nodes.value.find(n => n.id === snapshot.selectedNodeId) || null : null
|
||
selectedEdge.value = snapshot.selectedEdgeId ? edges.value.find(e => e.id === snapshot.selectedEdgeId) || null : null
|
||
if (snapshot.viewport && typeof setViewport === 'function') {
|
||
setViewport({ ...snapshot.viewport }, { duration: 0 })
|
||
}
|
||
await nextTick()
|
||
checkChanges()
|
||
isRestoringHistory.value = false
|
||
}
|
||
|
||
const undo = async () => {
|
||
if (!historyInitialized.value || history.value.length <= 1) {
|
||
ElMessage.info('没有可撤销的操作')
|
||
return
|
||
}
|
||
const current = history.value.pop()
|
||
const previous = history.value[history.value.length - 1]
|
||
if (!current || !previous) {
|
||
return
|
||
}
|
||
redoStack.value.push(current)
|
||
await restoreSnapshot(previous)
|
||
}
|
||
|
||
const redo = async () => {
|
||
if (!historyInitialized.value || redoStack.value.length === 0) {
|
||
ElMessage.info('没有可重做的操作')
|
||
return
|
||
}
|
||
const snapshot = redoStack.value.pop()
|
||
if (!snapshot) return
|
||
history.value.push(snapshot)
|
||
if (history.value.length > HISTORY_LIMIT) {
|
||
history.value.shift()
|
||
}
|
||
await restoreSnapshot(snapshot)
|
||
}
|
||
|
||
// 缩放状态
|
||
const currentZoom = ref(1)
|
||
|
||
// 协作功能
|
||
const collaborationEnabled = ref(false)
|
||
let collaboration: ReturnType<typeof useCollaboration> | null = null
|
||
if (props.workflowId) {
|
||
collaboration = useCollaboration(props.workflowId)
|
||
collaborationEnabled.value = true
|
||
}
|
||
|
||
const collaborationConnected = ref(false)
|
||
const onlineUsers = ref<any[]>([])
|
||
const currentUser = ref<any>(null)
|
||
const isProcessingRemoteOperation = ref(false) // 防止远程操作触发本地操作
|
||
|
||
// 自定义节点类型映射(使用markRaw避免响应式转换)
|
||
const customNodeTypes = {
|
||
start: markRaw(StartNode),
|
||
input: markRaw(DefaultNode), // 输入节点使用默认节点(有输入和输出)
|
||
llm: markRaw(LLMNode),
|
||
template: markRaw(LLMNode), // template也使用LLM节点
|
||
condition: markRaw(ConditionNode),
|
||
switch: markRaw(DefaultNode), // Switch节点使用默认节点(支持多个输出handle)
|
||
merge: markRaw(DefaultNode), // Merge节点使用默认节点
|
||
wait: markRaw(DefaultNode), // Wait节点使用默认节点
|
||
transform: markRaw(DefaultNode), // 转换节点使用默认节点
|
||
json: markRaw(DefaultNode), // JSON处理节点使用默认节点
|
||
text: markRaw(DefaultNode), // 文本处理节点使用默认节点
|
||
cache: markRaw(DefaultNode), // 缓存节点使用默认节点
|
||
vector_db: markRaw(DefaultNode), // 向量数据库节点使用默认节点
|
||
log: markRaw(DefaultNode), // 日志节点使用默认节点
|
||
error_handler: markRaw(DefaultNode), // 错误处理节点使用默认节点
|
||
csv: markRaw(DefaultNode), // CSV处理节点使用默认节点
|
||
object_storage: markRaw(DefaultNode), // 对象存储节点使用默认节点
|
||
slack: markRaw(DefaultNode), // Slack节点使用默认节点
|
||
dingtalk: markRaw(DefaultNode), // 钉钉节点使用默认节点
|
||
dingding: markRaw(DefaultNode), // 钉钉节点别名
|
||
wechat_work: markRaw(DefaultNode), // 企业微信节点使用默认节点
|
||
wecom: markRaw(DefaultNode), // 企业微信节点别名
|
||
sms: markRaw(DefaultNode), // 短信节点使用默认节点
|
||
pdf: markRaw(DefaultNode), // PDF处理节点使用默认节点
|
||
image: markRaw(DefaultNode), // 图像处理节点使用默认节点
|
||
excel: markRaw(DefaultNode), // Excel处理节点使用默认节点
|
||
subworkflow: markRaw(DefaultNode), // 子工作流节点使用默认节点
|
||
code: markRaw(DefaultNode), // 代码执行节点使用默认节点
|
||
oauth: markRaw(DefaultNode), // OAuth节点使用默认节点
|
||
validator: markRaw(DefaultNode), // 数据验证节点使用默认节点
|
||
batch: markRaw(DefaultNode), // 批处理节点使用默认节点
|
||
loop: markRaw(DefaultNode), // 循环节点使用默认节点
|
||
foreach: markRaw(DefaultNode), // foreach也使用默认节点
|
||
agent: markRaw(DefaultNode), // Agent节点使用默认节点
|
||
schedule: markRaw(DefaultNode), // 定时任务节点使用默认节点
|
||
delay: markRaw(DefaultNode), // 延迟节点使用默认节点
|
||
timer: markRaw(DefaultNode), // 定时器节点使用默认节点
|
||
webhook: markRaw(DefaultNode), // Webhook节点使用默认节点
|
||
email: markRaw(DefaultNode), // 邮件节点使用默认节点
|
||
mail: markRaw(DefaultNode), // 邮件节点别名
|
||
message_queue: markRaw(DefaultNode), // 消息队列节点使用默认节点
|
||
mq: markRaw(DefaultNode), // 消息队列节点别名
|
||
rabbitmq: markRaw(DefaultNode), // RabbitMQ节点别名
|
||
kafka: markRaw(DefaultNode), // Kafka节点别名
|
||
output: markRaw(DefaultNode), // 输出节点使用默认节点
|
||
end: markRaw(EndNode),
|
||
default: markRaw(DefaultNode)
|
||
}
|
||
|
||
// 节点类型定义
|
||
const nodeTypes = [
|
||
{ type: 'start', label: '开始', icon: 'Play', category: '基础' },
|
||
{ type: 'input', label: '输入', icon: 'Upload', category: '基础' },
|
||
{ type: 'llm', label: 'LLM', icon: 'ChatDotRound', category: 'AI' },
|
||
{ type: 'template', label: '模板', icon: 'Document', category: 'AI' },
|
||
{ type: 'condition', label: '条件', icon: 'Switch', category: '逻辑' },
|
||
{ type: 'switch', label: 'Switch', icon: 'Operation', category: '逻辑' },
|
||
{ type: 'merge', label: 'Merge', icon: 'Connection', category: '逻辑' },
|
||
{ type: 'wait', label: '等待', icon: 'Timer', category: '逻辑' },
|
||
{ type: 'transform', label: '转换', icon: 'Refresh', category: '数据' },
|
||
{ type: 'json', label: 'JSON处理', icon: 'Document', category: '数据' },
|
||
{ type: 'text', label: '文本处理', icon: 'Edit', category: '数据' },
|
||
{ type: 'cache', label: '缓存', icon: 'Box', category: '数据' },
|
||
{ type: 'vector_db', label: '向量数据库', icon: 'Connection', category: 'AI' },
|
||
{ type: 'log', label: '日志', icon: 'Document', category: '系统' },
|
||
{ type: 'error_handler', label: '错误处理', icon: 'Warning', category: '逻辑' },
|
||
{ type: 'csv', label: 'CSV处理', icon: 'Document', category: '数据' },
|
||
{ type: 'object_storage', label: '对象存储', icon: 'Box', category: '数据' },
|
||
{ type: 'pdf', label: 'PDF处理', icon: 'Document', category: '数据' },
|
||
{ type: 'image', label: '图像处理', icon: 'Picture', category: '数据' },
|
||
{ type: 'excel', label: 'Excel处理', icon: 'Document', category: '数据' },
|
||
{ type: 'subworkflow', label: '子工作流', icon: 'Operation', category: '逻辑' },
|
||
{ type: 'code', label: '代码执行', icon: 'Edit', category: '逻辑' },
|
||
{ type: 'oauth', label: 'OAuth', icon: 'Document', category: '网络' },
|
||
{ type: 'validator', label: '数据验证', icon: 'Document', category: '数据' },
|
||
{ type: 'batch', label: '批处理', icon: 'Grid', category: '逻辑' },
|
||
{ type: 'loop', label: '循环', icon: 'RefreshRight', category: '逻辑' },
|
||
{ type: 'agent', label: 'Agent', icon: 'User', category: 'AI' },
|
||
{ type: 'http', label: 'HTTP请求', icon: 'Connection', category: '网络' },
|
||
{ type: 'database', label: '数据库', icon: 'Connection', category: '数据' },
|
||
{ type: 'file', label: '文件操作', icon: 'Document', category: '数据' },
|
||
{ type: 'schedule', label: '定时任务', icon: 'Clock', category: '系统' },
|
||
{ type: 'webhook', label: 'Webhook', icon: 'Link', category: '网络' },
|
||
{ type: 'email', label: '邮件', icon: 'Message', category: '通信' },
|
||
{ type: 'slack', label: 'Slack', icon: 'Message', category: '通信' },
|
||
{ type: 'dingtalk', label: '钉钉', icon: 'Message', category: '通信' },
|
||
{ type: 'wechat_work', label: '企业微信', icon: 'Message', category: '通信' },
|
||
{ type: 'sms', label: '短信', icon: 'Message', category: '通信' },
|
||
{ type: 'message_queue', label: '消息队列', icon: 'Connection', category: '通信' },
|
||
{ type: 'output', label: '输出', icon: 'Download', category: '基础' },
|
||
{ type: 'end', label: '结束', icon: 'CircleCheck', category: '基础' }
|
||
]
|
||
|
||
// 节点搜索和筛选相关
|
||
const nodeSearchKeyword = ref('')
|
||
const selectedCategory = ref('')
|
||
const canvasNodeSearchKeyword = ref('')
|
||
|
||
// 节点分类
|
||
const nodeCategories = computed(() => {
|
||
const categories = new Set(nodeTypes.map(nt => nt.category))
|
||
return Array.from(categories).map(cat => ({
|
||
value: cat,
|
||
label: cat
|
||
}))
|
||
})
|
||
|
||
// 折叠控制
|
||
const toolboxActiveNames = ref<string[]>([])
|
||
watch(nodeCategories, (cats) => {
|
||
toolboxActiveNames.value = cats.map(c => c.value)
|
||
}, { immediate: true })
|
||
|
||
// 分组节点(含搜索与可选分类过滤)
|
||
const groupedNodeTypes = computed(() => {
|
||
const keyword = nodeSearchKeyword.value?.toLowerCase() || ''
|
||
const categoryFilter = selectedCategory.value || ''
|
||
|
||
return nodeCategories.value
|
||
.filter(cat => !categoryFilter || cat.value === categoryFilter)
|
||
.map(cat => {
|
||
let items = nodeTypes.filter(nt => nt.category === cat.value)
|
||
if (keyword) {
|
||
items = items.filter(nt =>
|
||
nt.label.toLowerCase().includes(keyword) ||
|
||
nt.type.toLowerCase().includes(keyword) ||
|
||
(nt.category && nt.category.toLowerCase().includes(keyword))
|
||
)
|
||
}
|
||
return { category: cat.value, label: cat.label, items }
|
||
})
|
||
})
|
||
|
||
// 过滤画布节点
|
||
const filteredCanvasNodes = computed(() => {
|
||
if (!canvasNodeSearchKeyword.value) {
|
||
return nodes.value
|
||
}
|
||
|
||
const keyword = canvasNodeSearchKeyword.value.toLowerCase()
|
||
return nodes.value.filter(node =>
|
||
(node.data?.label || '').toLowerCase().includes(keyword) ||
|
||
node.id.toLowerCase().includes(keyword) ||
|
||
node.type.toLowerCase().includes(keyword)
|
||
)
|
||
})
|
||
|
||
// 图标映射对象
|
||
const iconMap: Record<string, any> = {
|
||
Check, Warning, ZoomIn, ZoomOut, FullScreen, DocumentCopy, User, VideoPlay,
|
||
InfoFilled, WarningFilled, Rank, ArrowDown, Sort, Grid, Operation, Document,
|
||
Search, Timer, Box, Edit, Picture, Download, Delete, CircleCheck, CircleClose,
|
||
Aim, ArrowLeft, ArrowRight, Refresh, Loading, Star, ChatDotRound,
|
||
Connection: ConnectionIcon, // 使用别名避免与 Vue Flow 的 Connection 类型冲突
|
||
Setting, DataAnalysis, Link, Upload, UploadFilled, DocumentAdd
|
||
}
|
||
|
||
// 获取节点图标
|
||
const getNodeIcon = (nodeType: string) => {
|
||
const nodeTypeDef = nodeTypes.find(nt => nt.type === nodeType)
|
||
const iconName = nodeTypeDef?.icon || 'Document'
|
||
return iconMap[iconName] || Document
|
||
}
|
||
|
||
// 获取场景图标
|
||
const getScenarioIcon = (iconName: string) => {
|
||
// 将 'Connection' 映射到 ConnectionIcon
|
||
if (iconName === 'Connection') {
|
||
return ConnectionIcon
|
||
}
|
||
return iconMap[iconName] || Document
|
||
}
|
||
|
||
// 折叠控制
|
||
const handleExpandAllGroups = () => {
|
||
toolboxActiveNames.value = nodeCategories.value.map(c => c.value)
|
||
}
|
||
const handleCollapseAllGroups = () => {
|
||
toolboxActiveNames.value = []
|
||
}
|
||
|
||
// 配置标签页
|
||
const configActiveTab = ref<string>('basic')
|
||
|
||
// 工具相关
|
||
const availableTools = ref<Array<{name: string, description: string, category?: string, function_schema?: any}>>([])
|
||
const toolSchemas = ref<Record<string, any>>({})
|
||
const toolGroups = computed(() => {
|
||
const builtin: Array<{name: string, description: string, function_schema?: any}> = []
|
||
const custom: Array<{name: string, description: string, category?: string, function_schema?: any}> = []
|
||
|
||
availableTools.value.forEach(tool => {
|
||
if (tool.category === 'builtin') {
|
||
builtin.push(tool)
|
||
} else {
|
||
custom.push(tool)
|
||
}
|
||
})
|
||
|
||
const groups: Array<{label: string, tools: Array<any>}> = []
|
||
if (builtin.length > 0) {
|
||
groups.push({ label: '内置工具', tools: builtin })
|
||
}
|
||
if (custom.length > 0) {
|
||
groups.push({ label: '自定义工具', tools: custom })
|
||
}
|
||
return groups
|
||
})
|
||
|
||
// 加载工具列表
|
||
const loadTools = async () => {
|
||
try {
|
||
// 加载公开工具(不需要认证)
|
||
let publicTools: any[] = []
|
||
try {
|
||
const response = await api.get('/api/v1/tools')
|
||
publicTools = response.data || []
|
||
} catch (error: any) {
|
||
// 如果获取公开工具失败,只记录警告,不影响内置工具加载
|
||
console.warn('加载公开工具失败:', error)
|
||
}
|
||
|
||
// 加载内置工具(不需要认证)
|
||
let builtinTools: any[] = []
|
||
try {
|
||
const builtinResponse = await api.get('/api/v1/tools/builtin')
|
||
builtinTools = builtinResponse.data || []
|
||
} catch (error: any) {
|
||
console.error('加载内置工具失败:', error)
|
||
ElMessage.error('加载内置工具失败: ' + (error.response?.data?.detail || error.message || '请检查网络连接'))
|
||
return
|
||
}
|
||
|
||
// 合并工具列表
|
||
availableTools.value = [
|
||
...builtinTools.map((schema: any) => ({
|
||
name: schema.function?.name || schema.name,
|
||
description: schema.function?.description || schema.description || '',
|
||
category: 'builtin',
|
||
function_schema: schema
|
||
})),
|
||
...publicTools.map((tool: any) => ({
|
||
name: tool.name,
|
||
description: tool.description,
|
||
category: tool.category,
|
||
function_schema: tool.function_schema
|
||
}))
|
||
]
|
||
|
||
// 构建工具schema映射
|
||
availableTools.value.forEach(tool => {
|
||
if (tool.function_schema) {
|
||
toolSchemas.value[tool.name] = tool.function_schema
|
||
}
|
||
})
|
||
|
||
console.log(`工具列表加载成功: ${availableTools.value.length} 个工具`)
|
||
} catch (error: any) {
|
||
console.error('加载工具列表失败:', error)
|
||
ElMessage.error('加载工具列表失败: ' + (error.response?.data?.detail || error.message || '未知错误'))
|
||
}
|
||
}
|
||
|
||
// 获取工具schema
|
||
const getToolSchema = (toolName: string) => {
|
||
return toolSchemas.value[toolName]
|
||
}
|
||
|
||
// 处理工具开关切换
|
||
const handleToolsToggle = (enabled: boolean) => {
|
||
if (enabled && (!selectedNode.value.data.tools || selectedNode.value.data.tools.length === 0)) {
|
||
// 如果启用但没有选择工具,初始化空数组
|
||
if (!selectedNode.value.data.tools) {
|
||
selectedNode.value.data.tools = []
|
||
}
|
||
// 加载工具列表(如果还没加载)
|
||
if (availableTools.value.length === 0) {
|
||
loadTools()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理工具选择变化
|
||
const handleToolsChange = (tools: string[]) => {
|
||
// 可以在这里添加额外的逻辑
|
||
console.log('工具选择变化:', tools)
|
||
}
|
||
|
||
// 移除工具
|
||
const removeTool = (toolName: string) => {
|
||
if (selectedNode.value.data.tools) {
|
||
const index = selectedNode.value.data.tools.indexOf(toolName)
|
||
if (index > -1) {
|
||
selectedNode.value.data.tools.splice(index, 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 可用变量与字符串字段(用于插入占位符)
|
||
const availableVariables = computed(() => {
|
||
// 从上游节点输出动态推断可用变量
|
||
if (!selectedNode.value) return ['input', 'text', 'route', 'USER_INPUT']
|
||
|
||
const vars = new Set<string>(['input', 'text', 'route', 'USER_INPUT', 'query', 'message', 'content'])
|
||
|
||
// 查找上游节点
|
||
const upstreamEdges = edges.value.filter(e => e.target === selectedNode.value?.id)
|
||
upstreamEdges.forEach(edge => {
|
||
const upstreamNode = nodes.value.find(n => n.id === edge.source)
|
||
if (upstreamNode) {
|
||
// 根据上游节点类型推断可能的输出字段
|
||
const nodeType = upstreamNode.type
|
||
if (nodeType === 'llm' || nodeType === 'template') {
|
||
vars.add('output')
|
||
vars.add('response')
|
||
} else if (nodeType === 'http') {
|
||
vars.add('response')
|
||
vars.add('data')
|
||
vars.add('body')
|
||
} else if (nodeType === 'json') {
|
||
vars.add('data')
|
||
vars.add('result')
|
||
} else if (nodeType === 'transform') {
|
||
vars.add('result')
|
||
// 从mapping中提取目标字段
|
||
const mapping = upstreamNode.data?.mapping || {}
|
||
Object.keys(mapping).forEach(key => vars.add(key))
|
||
} else if (nodeType === 'code') {
|
||
vars.add('result')
|
||
vars.add('output')
|
||
} else if (nodeType === 'vector_db') {
|
||
vars.add('results')
|
||
vars.add('similarity')
|
||
} else if (nodeType === 'cache') {
|
||
vars.add('value')
|
||
vars.add('cached')
|
||
}
|
||
|
||
// 从上游节点的data中提取可能的输出字段
|
||
const upstreamData = upstreamNode.data || {}
|
||
Object.keys(upstreamData).forEach(key => {
|
||
if (typeof upstreamData[key] === 'string' && key !== 'label') {
|
||
vars.add(key)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
return Array.from(vars).sort()
|
||
})
|
||
|
||
const laneOverlayStyle = computed(() => {
|
||
if (!showSwimlanes.value) return {}
|
||
const count = Math.max(1, laneCount.value || 1)
|
||
return {
|
||
backgroundImage: 'linear-gradient(90deg, rgba(64,158,255,0.06) 0, rgba(64,158,255,0.06) 50%, transparent 50%, transparent 100%)',
|
||
backgroundSize: `${100 / count}% 100%`,
|
||
}
|
||
})
|
||
|
||
const variableInsertField = ref<string>('')
|
||
const variableToInsert = ref<string>('')
|
||
const availableStringFields = computed(() => {
|
||
const data = selectedNode.value?.data || {}
|
||
return Object.keys(data).filter(k => typeof (data as any)[k] === 'string')
|
||
})
|
||
|
||
// 判断节点是否有超时配置
|
||
const hasTimeoutConfig = computed(() => {
|
||
const nodeType = selectedNode.value?.type
|
||
return ['http', 'webhook', 'database', 'file', 'object_storage', 'slack', 'dingtalk', 'wechat_work', 'sms'].includes(nodeType || '')
|
||
})
|
||
|
||
// 判断节点是否有重试配置
|
||
const hasRetryConfig = computed(() => {
|
||
const nodeType = selectedNode.value?.type
|
||
return ['http', 'webhook', 'database', 'file', 'object_storage', 'llm', 'template'].includes(nodeType || '')
|
||
})
|
||
|
||
const insertVariable = (field: string, variable: string) => {
|
||
if (!selectedNode.value || !field || !variable) return
|
||
const val = selectedNode.value.data?.[field]
|
||
const newVal = (val || '') + `{${variable}}`
|
||
selectedNode.value.data[field] = newVal
|
||
}
|
||
|
||
// 从数据流面板插入变量
|
||
const insertVariableFromDataflow = (variable: string) => {
|
||
// 找到第一个可编辑的字符串字段
|
||
const stringFields = availableStringFields.value
|
||
if (stringFields.length > 0) {
|
||
insertVariable(stringFields[0], variable)
|
||
}
|
||
}
|
||
|
||
// 处理提示词输入
|
||
const handlePromptInput = () => {
|
||
if (!selectedNode.value || !promptTextareaRef.value) return
|
||
|
||
const textarea = promptTextareaRef.value.$el?.querySelector('textarea')
|
||
if (!textarea) return
|
||
|
||
const value = selectedNode.value.data.prompt || ''
|
||
const cursorPos = textarea.selectionStart
|
||
|
||
// 检查是否输入了 {{
|
||
const beforeCursor = value.substring(0, cursorPos)
|
||
const lastOpen = beforeCursor.lastIndexOf('{{')
|
||
const lastClose = beforeCursor.lastIndexOf('}}')
|
||
|
||
// 如果找到了 {{ 且没有对应的 }}
|
||
if (lastOpen !== -1 && (lastClose === -1 || lastClose < lastOpen)) {
|
||
// 提取搜索文本({{ 之后的内容)
|
||
const searchText = beforeCursor.substring(lastOpen + 2)
|
||
|
||
// 检查是否已经输入了 }},如果输入了就不显示自动补全
|
||
if (!searchText.includes('}}')) {
|
||
autocompleteSearchText.value = searchText
|
||
autocompleteTriggerPosition.value = lastOpen
|
||
showVariableAutocomplete.value = true
|
||
autocompleteSelectedIndex.value = 0
|
||
|
||
// 计算下拉框位置
|
||
nextTick(() => {
|
||
updateAutocompletePosition(textarea, cursorPos)
|
||
})
|
||
} else {
|
||
showVariableAutocomplete.value = false
|
||
}
|
||
} else {
|
||
showVariableAutocomplete.value = false
|
||
}
|
||
}
|
||
|
||
// 更新自动补全下拉框位置
|
||
const updateAutocompletePosition = (textarea: HTMLTextAreaElement, cursorPos: number) => {
|
||
if (!textarea || !autocompleteDropdownRef.value) return
|
||
|
||
// 计算光标在文本中的位置(行和列)
|
||
const text = textarea.value.substring(0, cursorPos)
|
||
const lines = text.split('\n')
|
||
const lineIndex = lines.length - 1
|
||
const colIndex = lines[lines.length - 1].length
|
||
|
||
// 获取 textarea 的位置和样式
|
||
const rect = textarea.getBoundingClientRect()
|
||
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 20
|
||
const paddingTop = parseInt(getComputedStyle(textarea).paddingTop) || 0
|
||
const paddingLeft = parseInt(getComputedStyle(textarea).paddingLeft) || 0
|
||
|
||
// 计算光标位置
|
||
const top = rect.top + paddingTop + (lineIndex * lineHeight) + lineHeight + 5
|
||
const left = rect.left + paddingLeft + (colIndex * 8) // 假设每个字符宽度约8px
|
||
|
||
autocompletePosition.value = {
|
||
top: `${top}px`,
|
||
left: `${left}px`
|
||
}
|
||
}
|
||
|
||
// 处理提示词键盘事件
|
||
const handlePromptKeydown = (event: KeyboardEvent) => {
|
||
if (!showVariableAutocomplete.value) return
|
||
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault()
|
||
autocompleteSelectedIndex.value = Math.min(
|
||
autocompleteSelectedIndex.value + 1,
|
||
filteredAutocompleteVariables.value.length - 1
|
||
)
|
||
scrollToSelectedItem()
|
||
} else if (event.key === 'ArrowUp') {
|
||
event.preventDefault()
|
||
autocompleteSelectedIndex.value = Math.max(autocompleteSelectedIndex.value - 1, 0)
|
||
scrollToSelectedItem()
|
||
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
||
event.preventDefault()
|
||
if (filteredAutocompleteVariables.value.length > 0) {
|
||
selectAutocompleteVariable(
|
||
filteredAutocompleteVariables.value[autocompleteSelectedIndex.value].name
|
||
)
|
||
}
|
||
} else if (event.key === 'Escape') {
|
||
event.preventDefault()
|
||
showVariableAutocomplete.value = false
|
||
}
|
||
}
|
||
|
||
// 滚动到选中的项
|
||
const scrollToSelectedItem = () => {
|
||
nextTick(() => {
|
||
if (!autocompleteDropdownRef.value) return
|
||
const items = autocompleteDropdownRef.value.querySelectorAll('.autocomplete-item')
|
||
const selectedItem = items[autocompleteSelectedIndex.value] as HTMLElement
|
||
if (selectedItem) {
|
||
selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||
}
|
||
})
|
||
}
|
||
|
||
// 选择自动补全变量
|
||
const selectAutocompleteVariable = (variableName: string) => {
|
||
if (!selectedNode.value || !promptTextareaRef.value) return
|
||
|
||
const textarea = promptTextareaRef.value.$el?.querySelector('textarea')
|
||
if (!textarea) return
|
||
|
||
const value = selectedNode.value.data.prompt || ''
|
||
const cursorPos = textarea.selectionStart
|
||
|
||
// 替换 {{ 到光标位置的内容
|
||
const beforeCursor = value.substring(0, cursorPos)
|
||
const afterCursor = value.substring(cursorPos)
|
||
const lastOpen = beforeCursor.lastIndexOf('{{')
|
||
|
||
if (lastOpen !== -1) {
|
||
const newValue =
|
||
value.substring(0, lastOpen) +
|
||
`{{${variableName}}}` +
|
||
afterCursor
|
||
|
||
selectedNode.value.data.prompt = newValue
|
||
|
||
// 设置光标位置到 }} 之后
|
||
nextTick(() => {
|
||
const newCursorPos = lastOpen + `{{${variableName}}}`.length
|
||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||
textarea.focus()
|
||
})
|
||
}
|
||
|
||
showVariableAutocomplete.value = false
|
||
}
|
||
|
||
// 处理提示词失去焦点
|
||
const handlePromptBlur = (event: FocusEvent) => {
|
||
// 延迟隐藏,以便点击下拉框时不会立即关闭
|
||
setTimeout(() => {
|
||
if (!autocompleteDropdownRef.value?.contains(event.relatedTarget as Node)) {
|
||
showVariableAutocomplete.value = false
|
||
}
|
||
}, 200)
|
||
}
|
||
|
||
// 获取变量类型标签
|
||
const getVarTypeTag = (type: string) => {
|
||
const typeMap: Record<string, string> = {
|
||
'string': 'info',
|
||
'number': 'warning',
|
||
'boolean': 'success',
|
||
'object': 'warning',
|
||
'array': 'success',
|
||
'any': 'info'
|
||
}
|
||
return typeMap[type] || 'info'
|
||
}
|
||
|
||
// 变量面板展开状态
|
||
const variablePanelActive = ref<string[]>([])
|
||
|
||
// 变量自动补全相关
|
||
const promptTextareaRef = ref<any>(null)
|
||
const autocompleteDropdownRef = ref<HTMLElement | null>(null)
|
||
const showVariableAutocomplete = ref(false)
|
||
const autocompletePosition = ref({ top: '0px', left: '0px' })
|
||
const autocompleteSelectedIndex = ref(0)
|
||
const autocompleteSearchText = ref('')
|
||
const autocompleteTriggerPosition = ref(0) // 触发位置(光标位置)
|
||
|
||
// 所有可用变量(用于自动补全)
|
||
const allAutocompleteVariables = computed(() => {
|
||
const vars: Array<{ name: string; type: string; description: string; group: string }> = []
|
||
|
||
// 基础变量
|
||
basicVariables.value.forEach(name => {
|
||
vars.push({
|
||
name,
|
||
type: 'string',
|
||
description: '基础变量',
|
||
group: '基础变量'
|
||
})
|
||
})
|
||
|
||
// 上游变量
|
||
upstreamVariablesList.value.forEach(item => {
|
||
vars.push({
|
||
...item,
|
||
group: '上游节点变量'
|
||
})
|
||
})
|
||
|
||
// 记忆变量
|
||
memoryVariablesList.value.forEach(item => {
|
||
vars.push({
|
||
...item,
|
||
group: '记忆变量'
|
||
})
|
||
})
|
||
|
||
return vars
|
||
})
|
||
|
||
// 过滤后的自动补全变量
|
||
const filteredAutocompleteVariables = computed(() => {
|
||
if (!autocompleteSearchText.value) {
|
||
return allAutocompleteVariables.value.slice(0, 10) // 限制显示数量
|
||
}
|
||
|
||
const search = autocompleteSearchText.value.toLowerCase()
|
||
return allAutocompleteVariables.value.filter(v =>
|
||
v.name.toLowerCase().includes(search)
|
||
).slice(0, 10)
|
||
})
|
||
|
||
// 基础变量
|
||
const basicVariables = computed(() => {
|
||
return ['input', 'text', 'route', 'USER_INPUT', 'query', 'message', 'content']
|
||
})
|
||
|
||
// 上游变量列表(带类型和描述)
|
||
const upstreamVariablesList = computed(() => {
|
||
if (!selectedNode.value) return []
|
||
const vars: Array<{ name: string; type: string; description: string }> = []
|
||
|
||
const upstreamEdgesList = edges.value.filter(e => e.target === selectedNode.value?.id)
|
||
upstreamEdgesList.forEach(edge => {
|
||
const upstreamNode = nodes.value.find(n => n.id === edge.source)
|
||
if (upstreamNode) {
|
||
const nodeVars = getUpstreamVariables(edge.source)
|
||
vars.push(...nodeVars)
|
||
}
|
||
})
|
||
|
||
// 去重
|
||
const uniqueVars = new Map()
|
||
vars.forEach(v => {
|
||
if (!uniqueVars.has(v.name)) {
|
||
uniqueVars.set(v.name, v)
|
||
}
|
||
})
|
||
|
||
return Array.from(uniqueVars.values())
|
||
})
|
||
|
||
// 记忆变量列表
|
||
const memoryVariablesList = computed(() => {
|
||
if (!selectedNode.value) return []
|
||
const vars: Array<{ name: string; type: string; description: string }> = []
|
||
|
||
// 检查上游是否有 cache 节点
|
||
const upstreamEdgesList = edges.value.filter(e => e.target === selectedNode.value?.id)
|
||
upstreamEdgesList.forEach(edge => {
|
||
const upstreamNode = nodes.value.find(n => n.id === edge.source)
|
||
if (upstreamNode?.type === 'cache') {
|
||
vars.push(
|
||
{ name: 'memory', type: 'object', description: '完整的记忆数据对象' },
|
||
{ name: 'memory.conversation_history', type: 'array', description: '对话历史记录' },
|
||
{ name: 'memory.user_profile', type: 'object', description: '用户画像信息' },
|
||
{ name: 'memory.context', type: 'object', description: '上下文信息' }
|
||
)
|
||
}
|
||
})
|
||
|
||
return vars
|
||
})
|
||
|
||
// 上游边
|
||
const upstreamEdges = computed(() => {
|
||
if (!selectedNode.value) return []
|
||
return edges.value.filter(e => e.target === selectedNode.value?.id)
|
||
})
|
||
|
||
// 下游节点
|
||
const downstreamNodes = computed(() => {
|
||
if (!selectedNode.value) return []
|
||
const downstreamEdges = edges.value.filter(e => e.source === selectedNode.value?.id)
|
||
return downstreamEdges.map(edge => {
|
||
return nodes.value.find(n => n.id === edge.target)
|
||
}).filter(Boolean) as WorkflowNode[]
|
||
})
|
||
|
||
// 获取节点标签
|
||
const getNodeLabel = (nodeId: string) => {
|
||
const node = nodes.value.find(n => n.id === nodeId)
|
||
return node?.data?.label || nodeId
|
||
}
|
||
|
||
// 获取节点类型
|
||
const getNodeType = (nodeId: string) => {
|
||
const node = nodes.value.find(n => n.id === nodeId)
|
||
return node?.type || 'unknown'
|
||
}
|
||
|
||
// 获取节点类型颜色
|
||
const getNodeTypeColor = (nodeId: string) => {
|
||
const nodeType = getNodeType(nodeId)
|
||
const colorMap: Record<string, string> = {
|
||
'llm': 'primary',
|
||
'cache': 'success',
|
||
'transform': 'warning',
|
||
'http': 'danger',
|
||
'switch': 'info',
|
||
'merge': 'info',
|
||
'start': 'success',
|
||
'end': 'danger'
|
||
}
|
||
return colorMap[nodeType] || 'info'
|
||
}
|
||
|
||
// 获取上游节点的变量
|
||
const getUpstreamVariables = (nodeId: string): Array<{ name: string; type: string; description: string }> => {
|
||
const node = nodes.value.find(n => n.id === nodeId)
|
||
if (!node) return []
|
||
|
||
const variables: Array<{ name: string; type: string; description: string }> = []
|
||
|
||
switch (node.type) {
|
||
case 'llm':
|
||
case 'template':
|
||
variables.push(
|
||
{ name: 'output', type: 'string', description: 'LLM生成的回复内容' },
|
||
{ name: 'response', type: 'string', description: '完整的响应内容' }
|
||
)
|
||
break
|
||
case 'http':
|
||
variables.push(
|
||
{ name: 'response', type: 'object', description: 'HTTP响应对象' },
|
||
{ name: 'data', type: 'any', description: '响应数据' },
|
||
{ name: 'body', type: 'string', description: '响应体内容' },
|
||
{ name: 'status', type: 'number', description: 'HTTP状态码' }
|
||
)
|
||
break
|
||
case 'json':
|
||
variables.push(
|
||
{ name: 'data', type: 'any', description: '解析后的JSON数据' },
|
||
{ name: 'result', type: 'any', description: '提取的结果' }
|
||
)
|
||
break
|
||
case 'transform':
|
||
variables.push({ name: 'result', type: 'any', description: '转换后的结果' })
|
||
// 从mapping中提取目标字段
|
||
const mapping = node.data?.mapping || {}
|
||
Object.keys(mapping).forEach(key => {
|
||
variables.push({ name: key, type: 'any', description: `映射字段: ${key}` })
|
||
})
|
||
break
|
||
case 'code':
|
||
variables.push(
|
||
{ name: 'result', type: 'any', description: '代码执行结果' },
|
||
{ name: 'output', type: 'any', description: '代码输出' }
|
||
)
|
||
break
|
||
case 'vector_db':
|
||
variables.push(
|
||
{ name: 'results', type: 'array', description: '搜索结果列表' },
|
||
{ name: 'similarity', type: 'number', description: '相似度分数' }
|
||
)
|
||
break
|
||
case 'cache':
|
||
variables.push(
|
||
{ name: 'value', type: 'any', description: '缓存的值' },
|
||
{ name: 'cached', type: 'boolean', description: '是否命中缓存' },
|
||
{ name: 'memory', type: 'object', description: '记忆数据对象' },
|
||
{ name: 'conversation_history', type: 'array', description: '对话历史' },
|
||
{ name: 'user_profile', type: 'object', description: '用户画像' }
|
||
)
|
||
break
|
||
}
|
||
|
||
return variables
|
||
}
|
||
|
||
// 获取节点输出字段
|
||
const getOutputFields = (nodeType: string): Array<{ name: string; type: string; description: string }> => {
|
||
const outputFieldsMap: Record<string, Array<{ name: string; type: string; description: string }>> = {
|
||
'llm': [
|
||
{ name: 'output', type: 'string', description: 'LLM生成的文本回复' },
|
||
{ name: 'response', type: 'object', description: '完整的LLM响应对象' }
|
||
],
|
||
'cache': [
|
||
{ name: 'value', type: 'any', description: '缓存的值' },
|
||
{ name: 'cached', type: 'boolean', description: '是否命中缓存' }
|
||
],
|
||
'transform': [
|
||
{ name: 'result', type: 'any', description: '转换后的数据' }
|
||
],
|
||
'http': [
|
||
{ name: 'response', type: 'object', description: 'HTTP响应对象' },
|
||
{ name: 'data', type: 'any', description: '响应数据' },
|
||
{ name: 'status', type: 'number', description: 'HTTP状态码' }
|
||
],
|
||
'switch': [
|
||
{ name: 'route', type: 'string', description: '路由到的分支名称' }
|
||
],
|
||
'merge': [
|
||
{ name: 'result', type: 'object', description: '合并后的数据' }
|
||
]
|
||
}
|
||
|
||
return outputFieldsMap[nodeType] || []
|
||
}
|
||
|
||
// 记忆相关数据和方法
|
||
const memoryData = ref<any>(null)
|
||
const memoryLoading = ref(false)
|
||
const showConversationHistory = ref(false)
|
||
const showUserProfile = ref(false)
|
||
|
||
// 记忆键
|
||
const memoryKey = computed(() => {
|
||
if (!selectedNode.value || selectedNode.value.type !== 'cache') return ''
|
||
return selectedNode.value.data?.key || ''
|
||
})
|
||
|
||
// 记忆状态
|
||
const memoryStatus = computed(() => {
|
||
if (!memoryData.value) return '不存在'
|
||
return '存在'
|
||
})
|
||
|
||
// TTL 信息
|
||
const ttlInfo = computed(() => {
|
||
if (!selectedNode.value || !selectedNode.value.data?.ttl) return '未设置'
|
||
const ttl = selectedNode.value.data.ttl
|
||
if (ttl >= 86400) {
|
||
return `${Math.floor(ttl / 86400)} 天`
|
||
} else if (ttl >= 3600) {
|
||
return `${Math.floor(ttl / 3600)} 小时`
|
||
} else if (ttl >= 60) {
|
||
return `${Math.floor(ttl / 60)} 分钟`
|
||
} else {
|
||
return `${ttl} 秒`
|
||
}
|
||
})
|
||
|
||
// 刷新记忆
|
||
const refreshMemory = async () => {
|
||
if (!selectedNode.value || selectedNode.value.type !== 'cache') return
|
||
|
||
const key = memoryKey.value
|
||
if (!key) {
|
||
ElMessage.warning('记忆键未设置')
|
||
return
|
||
}
|
||
|
||
memoryLoading.value = true
|
||
try {
|
||
// 调用API获取记忆数据
|
||
const response = await api.get(`/api/v1/execution-logs/cache/${encodeURIComponent(key)}`)
|
||
if (response.data && response.data.value) {
|
||
memoryData.value = response.data.value
|
||
ElMessage.success('记忆数据已刷新')
|
||
} else {
|
||
memoryData.value = null
|
||
ElMessage.info('记忆数据不存在')
|
||
}
|
||
} catch (error: any) {
|
||
if (error.response?.status === 404) {
|
||
memoryData.value = null
|
||
ElMessage.info('记忆数据不存在')
|
||
} else {
|
||
ElMessage.error('获取记忆数据失败: ' + (error.response?.data?.detail || error.message || '未知错误'))
|
||
}
|
||
} finally {
|
||
memoryLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 测试记忆查询
|
||
const testMemoryQuery = () => {
|
||
ElMessage.info('测试查询功能开发中...')
|
||
}
|
||
|
||
// 清空记忆
|
||
const clearMemory = () => {
|
||
ElMessageBox.confirm('确定要清空记忆吗?此操作不可恢复。', '确认清空', {
|
||
type: 'warning'
|
||
}).then(async () => {
|
||
const key = memoryKey.value
|
||
if (!key) {
|
||
ElMessage.warning('记忆键未设置')
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 这里可以调用API清空记忆,或者直接删除
|
||
await deleteMemory()
|
||
ElMessage.success('记忆已清空')
|
||
} catch (error: any) {
|
||
ElMessage.error('清空记忆失败: ' + (error.response?.data?.detail || error.message || '未知错误'))
|
||
}
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// 删除记忆
|
||
const deleteMemory = async () => {
|
||
const key = memoryKey.value
|
||
if (!key) {
|
||
ElMessage.warning('记忆键未设置')
|
||
return
|
||
}
|
||
|
||
ElMessageBox.confirm('确定要删除记忆吗?此操作不可恢复。', '确认删除', {
|
||
type: 'warning'
|
||
}).then(async () => {
|
||
try {
|
||
await api.delete(`/api/v1/execution-logs/cache/${encodeURIComponent(key)}`)
|
||
ElMessage.success('记忆已删除')
|
||
memoryData.value = null
|
||
} catch (error: any) {
|
||
ElMessage.error('删除记忆失败: ' + (error.response?.data?.detail || error.message || '未知错误'))
|
||
}
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// 执行数据预览相关
|
||
const selectedExecutionId = ref<string>('')
|
||
const executionData = ref<any>(null)
|
||
const recentExecutions = ref<any[]>([])
|
||
|
||
// 缓存记忆详情
|
||
const showCacheMemoryDetail = ref(false)
|
||
const cacheMemoryDetail = ref<any>(null)
|
||
|
||
// 配置助手相关
|
||
const configAssistantMode = ref<'simple' | 'template' | 'wizard'>('simple')
|
||
const wizardStep = ref(0)
|
||
const selectedWizardScenario = ref<any>(null)
|
||
const wizardConfig = ref<Record<string, any>>({})
|
||
const configTemplateSearchKeyword = ref('')
|
||
const templateCategoryFilter = ref('')
|
||
const templateDetailVisible = ref(false)
|
||
const selectedTemplate = ref<any>(null)
|
||
|
||
// 获取场景列表
|
||
const getScenarios = (nodeType: string) => {
|
||
const scenarios: Array<{
|
||
id: string
|
||
name: string
|
||
description: string
|
||
icon: string
|
||
fields?: Array<{
|
||
name: string
|
||
label: string
|
||
type: string
|
||
placeholder?: string
|
||
options?: Array<{ label: string; value: any }>
|
||
min?: number
|
||
max?: number
|
||
step?: number
|
||
}>
|
||
config?: Record<string, any>
|
||
}> = []
|
||
|
||
switch (nodeType) {
|
||
case 'llm':
|
||
scenarios.push(
|
||
{
|
||
id: 'llm_summary',
|
||
name: '文本总结',
|
||
description: '总结长文本内容',
|
||
icon: 'Document',
|
||
fields: [
|
||
{ name: 'prompt', label: '提示词', type: 'textarea', placeholder: '请总结以下内容:{{input}}' },
|
||
{ name: 'max_tokens', label: '最大Token数', type: 'number', min: 100, max: 4000, step: 100 }
|
||
],
|
||
config: {
|
||
provider: 'deepseek',
|
||
model: 'deepseek-chat',
|
||
prompt: '请总结以下内容,100字以内:{{input}}',
|
||
temperature: 0.5,
|
||
max_tokens: 500
|
||
}
|
||
},
|
||
{
|
||
id: 'llm_translate',
|
||
name: '文本翻译',
|
||
description: '翻译文本内容',
|
||
icon: 'Connection',
|
||
fields: [
|
||
{ name: 'target_lang', label: '目标语言', type: 'select', options: [
|
||
{ label: '英语', value: 'English' },
|
||
{ label: '中文', value: 'Chinese' },
|
||
{ label: '日语', value: 'Japanese' }
|
||
]}
|
||
],
|
||
config: {
|
||
provider: 'deepseek',
|
||
model: 'deepseek-chat',
|
||
prompt: '请把下列内容翻译成{{target_lang}}:{{input}}',
|
||
temperature: 0.3,
|
||
max_tokens: 1000
|
||
}
|
||
},
|
||
{
|
||
id: 'llm_extract',
|
||
name: '信息提取',
|
||
description: '从文本中提取结构化信息',
|
||
icon: 'DataAnalysis',
|
||
config: {
|
||
provider: 'deepseek',
|
||
model: 'deepseek-chat',
|
||
prompt: '请从以下文本中提取关键信息,以JSON格式返回:{{input}}',
|
||
temperature: 0.2,
|
||
max_tokens: 1000
|
||
}
|
||
},
|
||
{
|
||
id: 'llm_classify',
|
||
name: '文本分类',
|
||
description: '对文本进行分类',
|
||
icon: 'Sort',
|
||
config: {
|
||
provider: 'deepseek',
|
||
model: 'deepseek-chat',
|
||
prompt: '请对以下文本进行分类:{{input}}',
|
||
temperature: 0.1,
|
||
max_tokens: 200
|
||
}
|
||
}
|
||
)
|
||
break
|
||
case 'http':
|
||
scenarios.push(
|
||
{
|
||
id: 'http_api_call',
|
||
name: 'API调用',
|
||
description: '调用外部API',
|
||
icon: 'Link',
|
||
fields: [
|
||
{ name: 'url', label: 'API地址', type: 'text', placeholder: 'https://api.example.com/data' },
|
||
{ name: 'method', label: '请求方法', type: 'select', options: [
|
||
{ label: 'GET', value: 'GET' },
|
||
{ label: 'POST', value: 'POST' },
|
||
{ label: 'PUT', value: 'PUT' },
|
||
{ label: 'DELETE', value: 'DELETE' }
|
||
]}
|
||
],
|
||
config: {
|
||
method: 'GET',
|
||
url: 'https://api.example.com/data',
|
||
headers: '{"Content-Type": "application/json"}',
|
||
timeout: 30
|
||
}
|
||
}
|
||
)
|
||
break
|
||
case 'cache':
|
||
scenarios.push(
|
||
{
|
||
id: 'cache_memory',
|
||
name: '记忆存储',
|
||
description: '存储用户记忆数据',
|
||
icon: 'Box',
|
||
fields: [
|
||
{ name: 'key', label: '记忆键', type: 'text', placeholder: 'user_memory_{user_id}' },
|
||
{ name: 'ttl', label: '过期时间(秒)', type: 'number', min: 60, max: 86400, step: 60 }
|
||
],
|
||
config: {
|
||
operation: 'set',
|
||
key: 'user_memory_{user_id}',
|
||
ttl: 3600,
|
||
backend: 'redis'
|
||
}
|
||
},
|
||
{
|
||
id: 'cache_query',
|
||
name: '记忆查询',
|
||
description: '查询用户记忆数据',
|
||
icon: 'Search',
|
||
config: {
|
||
operation: 'get',
|
||
key: 'user_memory_{user_id}',
|
||
backend: 'redis'
|
||
}
|
||
}
|
||
)
|
||
break
|
||
}
|
||
|
||
return scenarios
|
||
}
|
||
|
||
// 选择场景(简单模式)
|
||
const selectScenario = (scenario: any) => {
|
||
if (scenario.config && selectedNode.value) {
|
||
Object.assign(selectedNode.value.data, scenario.config)
|
||
ElMessage.success(`已应用场景:${scenario.name}`)
|
||
}
|
||
}
|
||
|
||
// 选择向导场景
|
||
const selectWizardScenario = (scenario: any) => {
|
||
selectedWizardScenario.value = scenario
|
||
wizardConfig.value = {}
|
||
wizardStep.value = 1
|
||
}
|
||
|
||
// 应用向导配置
|
||
const applyWizardConfig = () => {
|
||
if (!selectedNode.value || !selectedWizardScenario.value) return
|
||
|
||
// 合并基础配置和向导配置
|
||
const finalConfig = {
|
||
...selectedWizardScenario.value.config,
|
||
...wizardConfig.value
|
||
}
|
||
|
||
Object.assign(selectedNode.value.data, finalConfig)
|
||
wizardStep.value = 2
|
||
ElMessage.success('配置已应用')
|
||
}
|
||
|
||
// 配置模板列表
|
||
const configTemplates = ref<Array<{
|
||
id: string
|
||
name: string
|
||
description: string
|
||
category: string
|
||
nodeType: string
|
||
config: Record<string, any>
|
||
isFavorite?: boolean
|
||
}>>([
|
||
{
|
||
id: 'llm_summary_template',
|
||
name: 'LLM文本总结模板',
|
||
description: '用于总结长文本的LLM配置模板',
|
||
category: 'ai',
|
||
nodeType: 'llm',
|
||
config: {
|
||
provider: 'deepseek',
|
||
model: 'deepseek-chat',
|
||
prompt: '请总结以下内容,100字以内:{{input}}',
|
||
temperature: 0.5,
|
||
max_tokens: 500
|
||
}
|
||
},
|
||
{
|
||
id: 'llm_translate_template',
|
||
name: 'LLM翻译模板',
|
||
description: '用于翻译文本的LLM配置模板',
|
||
category: 'ai',
|
||
nodeType: 'llm',
|
||
config: {
|
||
provider: 'deepseek',
|
||
model: 'deepseek-chat',
|
||
prompt: '请把下列内容翻译成英文:{{input}}',
|
||
temperature: 0.3,
|
||
max_tokens: 1000
|
||
}
|
||
},
|
||
{
|
||
id: 'http_get_template',
|
||
name: 'HTTP GET请求模板',
|
||
description: '用于GET请求的HTTP节点配置',
|
||
category: 'network',
|
||
nodeType: 'http',
|
||
config: {
|
||
method: 'GET',
|
||
url: 'https://api.example.com/data',
|
||
headers: '{}',
|
||
timeout: 30
|
||
}
|
||
},
|
||
{
|
||
id: 'cache_memory_template',
|
||
name: '缓存记忆模板',
|
||
description: '用于存储用户记忆的缓存配置',
|
||
category: 'data',
|
||
nodeType: 'cache',
|
||
config: {
|
||
operation: 'set',
|
||
key: 'user_memory_{user_id}',
|
||
ttl: 3600,
|
||
backend: 'redis'
|
||
}
|
||
}
|
||
])
|
||
|
||
// 过滤配置模板
|
||
const filteredConfigTemplates = computed(() => {
|
||
let result = configTemplates.value.filter(t =>
|
||
t.nodeType === selectedNode.value?.type
|
||
)
|
||
|
||
if (configTemplateSearchKeyword.value) {
|
||
const keyword = configTemplateSearchKeyword.value.toLowerCase()
|
||
result = result.filter(t =>
|
||
t.name.toLowerCase().includes(keyword) ||
|
||
t.description.toLowerCase().includes(keyword)
|
||
)
|
||
}
|
||
|
||
if (templateCategoryFilter.value) {
|
||
result = result.filter(t => t.category === templateCategoryFilter.value)
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
// 获取模板预览
|
||
const getTemplatePreview = (template: any) => {
|
||
return JSON.stringify(template.config, null, 2)
|
||
}
|
||
|
||
// 应用配置模板
|
||
const applyConfigTemplate = (template: any) => {
|
||
if (!selectedNode.value) return
|
||
|
||
Object.assign(selectedNode.value.data, template.config)
|
||
templateDetailVisible.value = false
|
||
ElMessage.success(`已应用模板:${template.name}`)
|
||
}
|
||
|
||
// 查看模板详情
|
||
const viewTemplateDetail = (template: any) => {
|
||
selectedTemplate.value = template
|
||
templateDetailVisible.value = true
|
||
}
|
||
|
||
// 切换模板收藏
|
||
const toggleTemplateFavorite = (templateId: string) => {
|
||
const template = configTemplates.value.find(t => t.id === templateId)
|
||
if (template) {
|
||
template.isFavorite = !template.isFavorite
|
||
// 保存到localStorage
|
||
saveTemplatesToStorage()
|
||
ElMessage.success(template.isFavorite ? '已收藏' : '已取消收藏')
|
||
}
|
||
}
|
||
|
||
// 导出模板
|
||
const exportTemplate = (template: any) => {
|
||
try {
|
||
const dataStr = JSON.stringify(template, null, 2)
|
||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||
const url = URL.createObjectURL(dataBlob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `${template.name.replace(/\s+/g, '_')}.json`
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
ElMessage.success('模板已导出')
|
||
} catch (error) {
|
||
ElMessage.error('导出失败')
|
||
}
|
||
}
|
||
|
||
// 模板导入相关
|
||
const templateImportVisible = ref(false)
|
||
const templateImportJson = ref('')
|
||
|
||
// 处理模板导入
|
||
const handleTemplateImport = (file: any) => {
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
try {
|
||
const template = JSON.parse(e.target?.result as string)
|
||
importTemplate(template)
|
||
} catch (error) {
|
||
ElMessage.error('模板文件格式错误')
|
||
}
|
||
}
|
||
reader.readAsText(file.raw)
|
||
}
|
||
|
||
// 从JSON导入模板
|
||
const importTemplateFromJson = () => {
|
||
if (!templateImportJson.value.trim()) {
|
||
ElMessage.warning('请输入模板JSON内容')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const template = JSON.parse(templateImportJson.value)
|
||
importTemplate(template)
|
||
templateImportJson.value = ''
|
||
} catch (error) {
|
||
ElMessage.error('JSON格式错误')
|
||
}
|
||
}
|
||
|
||
// 导入模板
|
||
const importTemplate = (template: any) => {
|
||
// 验证模板格式
|
||
if (!template.name || !template.config || !template.nodeType) {
|
||
ElMessage.error('模板格式不完整')
|
||
return
|
||
}
|
||
|
||
// 检查是否已存在
|
||
const exists = configTemplates.value.find(t => t.id === template.id)
|
||
if (exists) {
|
||
ElMessageBox.confirm('模板已存在,是否覆盖?', '确认', {
|
||
type: 'warning'
|
||
}).then(() => {
|
||
Object.assign(exists, template)
|
||
saveTemplatesToStorage()
|
||
ElMessage.success('模板已更新')
|
||
templateImportVisible.value = false
|
||
}).catch(() => {})
|
||
} else {
|
||
// 生成新ID
|
||
template.id = template.id || `template_${Date.now()}`
|
||
configTemplates.value.push(template)
|
||
saveTemplatesToStorage()
|
||
ElMessage.success('模板已导入')
|
||
templateImportVisible.value = false
|
||
}
|
||
}
|
||
|
||
// 保存当前配置为模板
|
||
const saveCurrentAsTemplate = async () => {
|
||
if (!selectedNode.value) {
|
||
ElMessage.warning('请先选择节点')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const { value: name } = await ElMessageBox.prompt('请输入模板名称', '保存模板', {
|
||
inputValue: `${selectedNode.value.type}_template_${Date.now()}`,
|
||
inputValidator: (value) => {
|
||
if (!value || value.trim() === '') {
|
||
return '模板名称不能为空'
|
||
}
|
||
return true
|
||
}
|
||
})
|
||
|
||
const { value: description } = await ElMessageBox.prompt('请输入模板描述', '保存模板', {
|
||
inputValue: `${selectedNode.value.data?.label || selectedNode.value.type} 节点配置模板`
|
||
})
|
||
|
||
const newTemplate = {
|
||
id: `template_${Date.now()}`,
|
||
name: name.trim(),
|
||
description: description.trim() || '',
|
||
category: 'custom',
|
||
nodeType: selectedNode.value.type,
|
||
config: { ...selectedNode.value.data },
|
||
isFavorite: false
|
||
}
|
||
|
||
configTemplates.value.push(newTemplate)
|
||
saveTemplatesToStorage()
|
||
ElMessage.success('模板已保存')
|
||
} catch (error) {
|
||
// 用户取消
|
||
}
|
||
}
|
||
|
||
// 保存模板到localStorage
|
||
const saveTemplatesToStorage = () => {
|
||
try {
|
||
localStorage.setItem('node_config_templates', JSON.stringify(configTemplates.value))
|
||
} catch (error) {
|
||
console.error('保存模板失败:', error)
|
||
}
|
||
}
|
||
|
||
// 从localStorage加载模板
|
||
const loadTemplatesFromStorage = () => {
|
||
try {
|
||
const stored = localStorage.getItem('node_config_templates')
|
||
if (stored) {
|
||
const storedTemplates = JSON.parse(stored)
|
||
// 合并存储的模板和默认模板
|
||
storedTemplates.forEach((stored: any) => {
|
||
const exists = configTemplates.value.find(t => t.id === stored.id)
|
||
if (!exists) {
|
||
configTemplates.value.push(stored)
|
||
} else {
|
||
// 更新收藏状态
|
||
exists.isFavorite = stored.isFavorite
|
||
}
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.error('加载模板失败:', error)
|
||
}
|
||
}
|
||
|
||
|
||
// 上游节点执行数据映射
|
||
const upstreamExecutionDataMap = ref<Record<string, {
|
||
executionId?: string
|
||
data?: any
|
||
loading?: boolean
|
||
}>>({})
|
||
|
||
// 检查是否有上游节点的执行数据
|
||
const hasUpstreamExecutionData = (nodeId: string) => {
|
||
return recentExecutions.value.length > 0
|
||
}
|
||
|
||
// 更新上游节点执行ID
|
||
const updateUpstreamExecutionId = (nodeId: string, executionId: string | undefined) => {
|
||
if (!upstreamExecutionDataMap.value[nodeId]) {
|
||
upstreamExecutionDataMap.value[nodeId] = {}
|
||
}
|
||
upstreamExecutionDataMap.value[nodeId].executionId = executionId
|
||
if (executionId) {
|
||
loadUpstreamNodeData(nodeId)
|
||
} else {
|
||
upstreamExecutionDataMap.value[nodeId].data = null
|
||
}
|
||
}
|
||
|
||
// 加载上游节点数据
|
||
const loadUpstreamNodeData = async (nodeId: string) => {
|
||
const executionId = upstreamExecutionDataMap.value[nodeId]?.executionId
|
||
if (!executionId) {
|
||
if (!upstreamExecutionDataMap.value[nodeId]) {
|
||
upstreamExecutionDataMap.value[nodeId] = {}
|
||
}
|
||
upstreamExecutionDataMap.value[nodeId].data = null
|
||
return
|
||
}
|
||
|
||
if (!upstreamExecutionDataMap.value[nodeId]) {
|
||
upstreamExecutionDataMap.value[nodeId] = {}
|
||
}
|
||
|
||
upstreamExecutionDataMap.value[nodeId] = {
|
||
...upstreamExecutionDataMap.value[nodeId],
|
||
loading: true
|
||
}
|
||
|
||
try {
|
||
const response = await api.get(
|
||
`/api/v1/execution-logs/executions/${executionId}/nodes/${nodeId}/data`
|
||
)
|
||
upstreamExecutionDataMap.value[nodeId] = {
|
||
executionId,
|
||
data: response.data,
|
||
loading: false
|
||
}
|
||
} catch (error: any) {
|
||
console.error('加载上游节点数据失败:', error)
|
||
upstreamExecutionDataMap.value[nodeId] = {
|
||
executionId,
|
||
data: null,
|
||
loading: false
|
||
}
|
||
}
|
||
}
|
||
|
||
// 加载最近的执行记录
|
||
const loadRecentExecutions = async () => {
|
||
if (!selectedNode.value) return
|
||
|
||
try {
|
||
// 获取当前工作流或智能体的ID
|
||
const workflowId = props.workflowId
|
||
const agentId = props.agentId
|
||
|
||
if (!workflowId && !agentId) return
|
||
|
||
const response = await api.get('/api/v1/executions', {
|
||
params: {
|
||
workflow_id: workflowId || undefined,
|
||
agent_id: agentId || undefined,
|
||
limit: 10,
|
||
skip: 0
|
||
}
|
||
})
|
||
|
||
recentExecutions.value = response.data || []
|
||
} catch (error: any) {
|
||
console.error('加载执行记录失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载节点执行数据
|
||
const loadNodeExecutionData = async () => {
|
||
if (!selectedExecutionId.value || !selectedNode.value) {
|
||
executionData.value = null
|
||
return
|
||
}
|
||
|
||
try {
|
||
const response = await api.get(
|
||
`/api/v1/execution-logs/executions/${selectedExecutionId.value}/nodes/${selectedNode.value.id}/data`
|
||
)
|
||
executionData.value = response.data
|
||
} catch (error: any) {
|
||
console.error('加载节点执行数据失败:', error)
|
||
ElMessage.warning('无法加载执行数据: ' + (error.response?.data?.detail || error.message || '未知错误'))
|
||
executionData.value = null
|
||
}
|
||
}
|
||
|
||
// 格式化执行时间
|
||
const formatExecutionTime = (time: string | Date) => {
|
||
if (!time) return '未知'
|
||
const date = typeof time === 'string' ? new Date(time) : time
|
||
return date.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
})
|
||
}
|
||
|
||
// 格式化JSON
|
||
const formatJSON = (data: any) => {
|
||
if (!data) return ''
|
||
try {
|
||
return JSON.stringify(data, null, 2)
|
||
} catch {
|
||
return String(data)
|
||
}
|
||
}
|
||
|
||
// 复制到剪贴板
|
||
const copyToClipboard = async (data: any) => {
|
||
if (!data) {
|
||
ElMessage.warning('没有可复制的内容')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2)
|
||
await navigator.clipboard.writeText(text)
|
||
ElMessage.success('已复制到剪贴板')
|
||
} catch (error) {
|
||
ElMessage.error('复制失败')
|
||
}
|
||
}
|
||
|
||
// 监听节点选择变化,加载执行记录
|
||
watch(selectedNode, (newNode) => {
|
||
if (newNode) {
|
||
loadRecentExecutions()
|
||
} else {
|
||
recentExecutions.value = []
|
||
selectedExecutionId.value = ''
|
||
executionData.value = null
|
||
}
|
||
}, { immediate: true })
|
||
|
||
// 快速模板
|
||
const templateSelection = ref<string>('')
|
||
const applyTemplate = () => {
|
||
if (!selectedNode.value) return
|
||
const t = selectedNode.value.type
|
||
if (!templateSelection.value) return
|
||
// HTTP模板
|
||
if (t === 'http') {
|
||
if (templateSelection.value === 'http_get') {
|
||
selectedNode.value.data.method = 'GET'
|
||
selectedNode.value.data.url = 'https://httpbin.org/get'
|
||
selectedNode.value.data.headers = '{}'
|
||
selectedNode.value.data.timeout = 10
|
||
} else if (templateSelection.value === 'http_post_json') {
|
||
selectedNode.value.data.method = 'POST'
|
||
selectedNode.value.data.url = 'https://httpbin.org/post'
|
||
selectedNode.value.data.headers = '{ "Content-Type": "application/json" }'
|
||
selectedNode.value.data.body = '{ "text": "{text}" }'
|
||
selectedNode.value.data.timeout = 10
|
||
}
|
||
}
|
||
// LLM模板
|
||
if (t === 'llm') {
|
||
if (templateSelection.value === 'llm_summary') {
|
||
selectedNode.value.data.provider = selectedNode.value.data.provider || 'deepseek'
|
||
selectedNode.value.data.model = selectedNode.value.data.model || 'deepseek-chat'
|
||
selectedNode.value.data.prompt = '请总结以下内容,100字以内:{text}'
|
||
selectedNode.value.data.temperature = 0.5
|
||
} else if (templateSelection.value === 'llm_translate') {
|
||
selectedNode.value.data.prompt = '请把下列内容翻译成英文:{text}'
|
||
selectedNode.value.data.temperature = 0.3
|
||
}
|
||
}
|
||
// OAuth模板
|
||
if (t === 'oauth') {
|
||
if (templateSelection.value === 'oauth_google') {
|
||
selectedNode.value.data.provider = 'google'
|
||
selectedNode.value.data.scopes = ['openid', 'profile', 'email']
|
||
} else if (templateSelection.value === 'oauth_github') {
|
||
selectedNode.value.data.provider = 'github'
|
||
selectedNode.value.data.scopes = ['read:user', 'user:email']
|
||
}
|
||
}
|
||
// JSON处理模板
|
||
if (t === 'json') {
|
||
if (templateSelection.value === 'json_parse') {
|
||
selectedNode.value.data.operation = 'parse'
|
||
selectedNode.value.data.path = ''
|
||
} else if (templateSelection.value === 'json_extract') {
|
||
selectedNode.value.data.operation = 'extract'
|
||
selectedNode.value.data.path = '$.data.items[*]'
|
||
}
|
||
}
|
||
// 文本处理模板
|
||
if (t === 'text') {
|
||
if (templateSelection.value === 'text_split') {
|
||
selectedNode.value.data.operation = 'split'
|
||
selectedNode.value.data.delimiter = '\n'
|
||
} else if (templateSelection.value === 'text_format') {
|
||
selectedNode.value.data.operation = 'format'
|
||
selectedNode.value.data.template = 'Hello {name}, your value is {value}'
|
||
}
|
||
}
|
||
// 缓存模板
|
||
if (t === 'cache') {
|
||
if (templateSelection.value === 'cache_get') {
|
||
selectedNode.value.data.operation = 'get'
|
||
selectedNode.value.data.key = 'cache_{key}'
|
||
selectedNode.value.data.ttl = 3600
|
||
} else if (templateSelection.value === 'cache_set') {
|
||
selectedNode.value.data.operation = 'set'
|
||
selectedNode.value.data.key = 'cache_{key}'
|
||
selectedNode.value.data.ttl = 3600
|
||
}
|
||
}
|
||
// 向量数据库模板
|
||
if (t === 'vector_db') {
|
||
if (templateSelection.value === 'vector_search') {
|
||
selectedNode.value.data.operation = 'search'
|
||
selectedNode.value.data.collection = 'documents'
|
||
selectedNode.value.data.top_k = 5
|
||
} else if (templateSelection.value === 'vector_upsert') {
|
||
selectedNode.value.data.operation = 'upsert'
|
||
selectedNode.value.data.collection = 'documents'
|
||
}
|
||
}
|
||
// HTTP扩展模板
|
||
if (t === 'http') {
|
||
if (templateSelection.value === 'http_put') {
|
||
selectedNode.value.data.method = 'PUT'
|
||
selectedNode.value.data.url = 'https://httpbin.org/put'
|
||
selectedNode.value.data.headers = '{ "Content-Type": "application/json" }'
|
||
selectedNode.value.data.body = '{ "id": "{id}", "data": "{data}" }'
|
||
selectedNode.value.data.timeout = 10
|
||
} else if (templateSelection.value === 'http_delete') {
|
||
selectedNode.value.data.method = 'DELETE'
|
||
selectedNode.value.data.url = 'https://httpbin.org/delete'
|
||
selectedNode.value.data.headers = '{}'
|
||
selectedNode.value.data.timeout = 10
|
||
}
|
||
}
|
||
// LLM扩展模板
|
||
if (t === 'llm') {
|
||
if (templateSelection.value === 'llm_extract') {
|
||
selectedNode.value.data.provider = selectedNode.value.data.provider || 'deepseek'
|
||
selectedNode.value.data.model = selectedNode.value.data.model || 'deepseek-chat'
|
||
selectedNode.value.data.prompt = '请从以下文本中提取关键信息(JSON格式):{text}'
|
||
selectedNode.value.data.temperature = 0.3
|
||
} else if (templateSelection.value === 'llm_classify') {
|
||
selectedNode.value.data.provider = selectedNode.value.data.provider || 'deepseek'
|
||
selectedNode.value.data.model = selectedNode.value.data.model || 'deepseek-chat'
|
||
selectedNode.value.data.prompt = '请将以下内容分类为:正面/中性/负面。内容:{text}'
|
||
selectedNode.value.data.temperature = 0.2
|
||
}
|
||
}
|
||
// 通信节点模板
|
||
if (t === 'slack' && templateSelection.value === 'slack_send') {
|
||
selectedNode.value.data.operation = 'send_message'
|
||
selectedNode.value.data.channel = '#general'
|
||
selectedNode.value.data.message = '通知:{message}'
|
||
}
|
||
if (t === 'dingtalk' && templateSelection.value === 'dingtalk_send') {
|
||
selectedNode.value.data.operation = 'send_message'
|
||
selectedNode.value.data.message = '通知:{message}'
|
||
}
|
||
if (t === 'wechat_work' && templateSelection.value === 'wechat_send') {
|
||
selectedNode.value.data.operation = 'send_message'
|
||
selectedNode.value.data.message = '通知:{message}'
|
||
}
|
||
|
||
templateSelection.value = ''
|
||
ElMessage.success('模板已应用')
|
||
}
|
||
|
||
// 定位到节点
|
||
const handleFocusNode = (nodeId: string) => {
|
||
const node = nodes.value.find(n => n.id === nodeId)
|
||
if (!node) return
|
||
|
||
// 选中节点
|
||
selectedNode.value = node
|
||
|
||
// 定位到节点(居中显示)
|
||
const viewport = getViewport()
|
||
if (viewport) {
|
||
const nodeX = node.position.x
|
||
const nodeY = node.position.y
|
||
|
||
// 计算视口中心位置
|
||
const canvasElement = document.querySelector('.editor-canvas')
|
||
if (canvasElement) {
|
||
const rect = canvasElement.getBoundingClientRect()
|
||
const centerX = rect.width / 2
|
||
const centerY = rect.height / 2
|
||
|
||
// 设置视口,使节点居中
|
||
setViewport({
|
||
x: centerX - nodeX * viewport.zoom,
|
||
y: centerY - nodeY * viewport.zoom,
|
||
zoom: viewport.zoom
|
||
}, { duration: 300 })
|
||
}
|
||
}
|
||
|
||
ElMessage.success(`已定位到节点: ${node.data?.label || node.id}`)
|
||
}
|
||
|
||
// 定位所有节点(适应视图)
|
||
const handleFocusAllNodes = () => {
|
||
if (nodes.value.length === 0) {
|
||
ElMessage.warning('画布中没有节点')
|
||
return
|
||
}
|
||
|
||
// 使用Vue Flow的fitView功能
|
||
if (fitView) {
|
||
fitView({ padding: 0.2, duration: 300 })
|
||
ElMessage.success('已定位所有节点')
|
||
} else {
|
||
// 手动计算并设置视口
|
||
const minX = Math.min(...nodes.value.map(n => n.position.x))
|
||
const maxX = Math.max(...nodes.value.map(n => n.position.x))
|
||
const minY = Math.min(...nodes.value.map(n => n.position.y))
|
||
const maxY = Math.max(...nodes.value.map(n => n.position.y))
|
||
|
||
const centerX = (minX + maxX) / 2
|
||
const centerY = (minY + maxY) / 2
|
||
const width = maxX - minX
|
||
const height = maxY - minY
|
||
|
||
const canvasElement = document.querySelector('.editor-canvas')
|
||
if (canvasElement) {
|
||
const rect = canvasElement.getBoundingClientRect()
|
||
const canvasWidth = rect.width
|
||
const canvasHeight = rect.height
|
||
|
||
// 计算合适的缩放比例
|
||
const scaleX = canvasWidth / (width + 200)
|
||
const scaleY = canvasHeight / (height + 200)
|
||
const zoom = Math.min(scaleX, scaleY, 1.2)
|
||
|
||
setViewport({
|
||
x: canvasWidth / 2 - centerX * zoom,
|
||
y: canvasHeight / 2 - centerY * zoom,
|
||
zoom
|
||
}, { duration: 300 })
|
||
|
||
ElMessage.success('已定位所有节点')
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// 拖拽开始
|
||
const handleDragStart = (event: DragEvent, nodeType: any) => {
|
||
draggedNodeType.value = nodeType
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.effectAllowed = 'copy'
|
||
// 存储节点类型信息到dataTransfer
|
||
event.dataTransfer.setData('text/plain', nodeType.type)
|
||
}
|
||
console.log('开始拖拽节点:', nodeType.type)
|
||
}
|
||
|
||
// 拖拽进入
|
||
const handleDragEnter = (event: DragEvent) => {
|
||
event.preventDefault()
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.dropEffect = 'copy'
|
||
}
|
||
}
|
||
|
||
// 拖拽悬停
|
||
const handleDragOver = (event: DragEvent) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.dropEffect = 'copy'
|
||
}
|
||
}
|
||
|
||
// 拖拽放置
|
||
const handleDrop = (event: DragEvent) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
|
||
console.log('Drop事件触发', { draggedNodeType: draggedNodeType.value, canvasRef: !!canvasRef.value })
|
||
|
||
if (!draggedNodeType.value) {
|
||
// 尝试从dataTransfer获取
|
||
const nodeTypeStr = event.dataTransfer?.getData('text/plain')
|
||
if (nodeTypeStr) {
|
||
const foundType = nodeTypes.find(nt => nt.type === nodeTypeStr)
|
||
if (foundType) {
|
||
draggedNodeType.value = foundType
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!draggedNodeType.value || !canvasRef.value) {
|
||
console.warn('无法创建节点:缺少节点类型或画布引用')
|
||
draggedNodeType.value = null
|
||
return
|
||
}
|
||
|
||
// 获取画布相对于视口的位置
|
||
const rect = canvasRef.value.getBoundingClientRect()
|
||
const clientX = event.clientX
|
||
const clientY = event.clientY
|
||
|
||
// 计算相对于画布的坐标
|
||
const relativeX = clientX - rect.left
|
||
const relativeY = clientY - rect.top
|
||
|
||
// 尝试使用Vue Flow的坐标转换
|
||
let position = { x: relativeX, y: relativeY }
|
||
try {
|
||
// 获取Vue Flow实例
|
||
const flowInstance = vueFlowInstance
|
||
if (flowInstance && typeof flowInstance.screenToFlowCoordinate === 'function') {
|
||
const flowPos = flowInstance.screenToFlowCoordinate({
|
||
x: clientX,
|
||
y: clientY
|
||
})
|
||
position = flowPos
|
||
console.log('使用Vue Flow坐标转换:', position)
|
||
} else {
|
||
// 如果没有坐标转换函数,使用相对坐标
|
||
// 减去一些偏移量以补偿Vue Flow的视口
|
||
position = { x: relativeX - 100, y: relativeY - 100 }
|
||
console.log('使用相对坐标:', position)
|
||
}
|
||
} catch (e) {
|
||
console.error('坐标转换失败:', e)
|
||
position = { x: relativeX - 100, y: relativeY - 100 }
|
||
}
|
||
|
||
// 创建新节点
|
||
const nodeType = draggedNodeType.value.type || 'default'
|
||
const isLLMNode = nodeType === 'llm' || nodeType === 'agent' || nodeType === 'template'
|
||
const isTemplateNode = nodeType === 'template'
|
||
const isScheduleNode = nodeType === 'schedule' || nodeType === 'delay' || nodeType === 'timer'
|
||
const isWebhookNode = nodeType === 'webhook'
|
||
const isEmailNode = nodeType === 'email' || nodeType === 'mail'
|
||
const isMQNode = nodeType === 'message_queue' || nodeType === 'mq' || nodeType === 'rabbitmq' || nodeType === 'kafka'
|
||
|
||
const newNode: Node = {
|
||
id: `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||
type: nodeType,
|
||
position: position,
|
||
data: {
|
||
label: draggedNodeType.value.label,
|
||
prompt: '',
|
||
model: 'gpt-3.5-turbo',
|
||
condition: '',
|
||
// LLM节点默认配置
|
||
...(isLLMNode && !isTemplateNode ? {
|
||
provider: 'deepseek',
|
||
model: 'deepseek-chat',
|
||
prompt: '请处理用户请求。',
|
||
temperature: 0.5,
|
||
max_tokens: 1500,
|
||
enable_tools: false,
|
||
tools: []
|
||
} : {}),
|
||
// 模板节点默认配置
|
||
...(isTemplateNode ? {
|
||
provider: 'deepseek',
|
||
model: 'deepseek-chat',
|
||
prompt: '',
|
||
temperature: 0.7,
|
||
max_tokens: 1500,
|
||
template_id: null,
|
||
enable_tools: false,
|
||
tools: []
|
||
} : {}),
|
||
// 定时任务节点默认配置
|
||
...(isScheduleNode ? {
|
||
delay_type: 'fixed',
|
||
delay_value: 5,
|
||
delay_unit: 'seconds'
|
||
} : {}),
|
||
// Webhook节点默认配置
|
||
...(isWebhookNode ? {
|
||
method: 'POST',
|
||
url: '',
|
||
headers: '{}',
|
||
body: '',
|
||
timeout: 30
|
||
} : {}),
|
||
// 邮件节点默认配置
|
||
...(isEmailNode ? {
|
||
smtp_host: 'smtp.example.com',
|
||
smtp_port: 587,
|
||
smtp_user: '',
|
||
smtp_password: '',
|
||
use_tls: true,
|
||
from_email: '',
|
||
to_email: '',
|
||
cc_email: '',
|
||
bcc_email: '',
|
||
subject: '',
|
||
body: '',
|
||
body_type: 'text',
|
||
attachments: []
|
||
} : {}),
|
||
// Switch节点默认配置
|
||
...(nodeType === 'switch' ? {
|
||
field: 'status',
|
||
cases: {},
|
||
default: 'default'
|
||
} : {}),
|
||
// Merge节点默认配置
|
||
...(nodeType === 'merge' ? {
|
||
mode: 'merge_all',
|
||
strategy: 'array'
|
||
} : {}),
|
||
// Wait节点默认配置
|
||
...(nodeType === 'wait' ? {
|
||
wait_type: 'condition',
|
||
condition: '',
|
||
timeout: 300,
|
||
poll_interval: 5
|
||
} : {}),
|
||
// JSON处理节点默认配置
|
||
...(nodeType === 'json' ? {
|
||
operation: 'parse',
|
||
path: '',
|
||
schema: {}
|
||
} : {}),
|
||
// 文本处理节点默认配置
|
||
...(nodeType === 'text' ? {
|
||
operation: 'split',
|
||
delimiter: '\n',
|
||
regex: '',
|
||
template: ''
|
||
} : {}),
|
||
// 缓存节点默认配置
|
||
...(nodeType === 'cache' ? {
|
||
operation: 'get',
|
||
key: '',
|
||
ttl: 3600,
|
||
backend: 'memory'
|
||
} : {}),
|
||
// 向量数据库节点默认配置
|
||
...(nodeType === 'vector_db' ? {
|
||
operation: 'search',
|
||
collection: 'default',
|
||
query_vector: '',
|
||
top_k: 5
|
||
} : {}),
|
||
// 日志节点默认配置
|
||
...(nodeType === 'log' ? {
|
||
level: 'info',
|
||
message: '节点执行',
|
||
include_data: true
|
||
} : {}),
|
||
// 错误处理节点默认配置
|
||
...(nodeType === 'error_handler' ? {
|
||
retry_count: 3,
|
||
retry_delay: 1000,
|
||
on_error: 'notify',
|
||
error_handler_workflow: ''
|
||
} : {}),
|
||
// CSV处理节点默认配置
|
||
...(nodeType === 'csv' ? {
|
||
operation: 'parse',
|
||
delimiter: ',',
|
||
headers: true,
|
||
encoding: 'utf-8'
|
||
} : {}),
|
||
// 对象存储节点默认配置
|
||
...(nodeType === 'object_storage' ? {
|
||
provider: 's3',
|
||
operation: 'upload',
|
||
bucket: '',
|
||
key: '',
|
||
file: ''
|
||
} : {}),
|
||
// Slack节点默认配置
|
||
...(nodeType === 'slack' ? {
|
||
operation: 'send_message',
|
||
token: '',
|
||
channel: '',
|
||
message: '',
|
||
attachments: []
|
||
} : {}),
|
||
// 钉钉节点默认配置
|
||
...(nodeType === 'dingtalk' || nodeType === 'dingding' ? {
|
||
operation: 'send_message',
|
||
webhook_url: '',
|
||
access_token: '',
|
||
chat_id: '',
|
||
message: ''
|
||
} : {}),
|
||
// 企业微信节点默认配置
|
||
...(nodeType === 'wechat_work' || nodeType === 'wecom' ? {
|
||
operation: 'send_message',
|
||
corp_id: '',
|
||
corp_secret: '',
|
||
agent_id: '',
|
||
chat_id: '',
|
||
message: ''
|
||
} : {}),
|
||
// 短信节点默认配置
|
||
...(nodeType === 'sms' ? {
|
||
provider: 'aliyun',
|
||
operation: 'send',
|
||
phone: '',
|
||
template: '',
|
||
sign: '',
|
||
access_key: '',
|
||
access_secret: ''
|
||
} : {}),
|
||
// PDF处理节点默认配置
|
||
...(nodeType === 'pdf' ? {
|
||
operation: 'extract_text',
|
||
pages: '',
|
||
template: ''
|
||
} : {}),
|
||
// 图像处理节点默认配置
|
||
...(nodeType === 'image' ? {
|
||
operation: 'resize',
|
||
width: 800,
|
||
height: 600,
|
||
format: 'png'
|
||
} : {}),
|
||
// Excel处理节点默认配置
|
||
...(nodeType === 'excel' ? {
|
||
operation: 'read',
|
||
sheet: 'Sheet1',
|
||
range: '',
|
||
format: 'xlsx'
|
||
} : {}),
|
||
// 子工作流节点默认配置
|
||
...(nodeType === 'subworkflow' ? {
|
||
workflow_id: '',
|
||
input_mapping: {}
|
||
} : {}),
|
||
// 代码执行节点默认配置
|
||
...(nodeType === 'code' ? {
|
||
language: 'python',
|
||
code: "result = input_data",
|
||
timeout: 30
|
||
} : {}),
|
||
// OAuth节点默认配置
|
||
...(nodeType === 'oauth' ? {
|
||
provider: 'google',
|
||
client_id: '',
|
||
client_secret: '',
|
||
scopes: []
|
||
} : {}),
|
||
// 数据验证节点默认配置
|
||
...(nodeType === 'validator' ? {
|
||
schema: {},
|
||
on_error: 'reject'
|
||
} : {}),
|
||
// 批处理节点默认配置
|
||
...(nodeType === 'batch' ? {
|
||
batch_size: 100,
|
||
mode: 'split',
|
||
wait_for_completion: true
|
||
} : {}),
|
||
// 结束节点默认配置
|
||
...(nodeType === 'end' || nodeType === 'output' ? {
|
||
output_format: 'text' // 默认纯文本格式,适合对话场景
|
||
} : {}),
|
||
// 消息队列节点默认配置
|
||
...(isMQNode ? {
|
||
queue_type: 'rabbitmq',
|
||
host: 'localhost',
|
||
port: 5672,
|
||
username: 'guest',
|
||
password: 'guest',
|
||
exchange: '',
|
||
routing_key: '',
|
||
queue_name: '',
|
||
topic: '',
|
||
bootstrap_servers: 'localhost:9092',
|
||
message: ''
|
||
} : {})
|
||
}
|
||
}
|
||
|
||
console.log('添加节点:', newNode)
|
||
addNodes([newNode])
|
||
pushHistory('add node')
|
||
hasChanges.value = true
|
||
draggedNodeType.value = null
|
||
}
|
||
|
||
// 节点点击
|
||
const onNodeClick = (event: NodeClickEvent) => {
|
||
selectedNode.value = event.node
|
||
selectedEdge.value = null // 清除选中的边
|
||
// 清除所有边的选中状态
|
||
edges.value.forEach(e => {
|
||
if (e.selected) {
|
||
e.selected = false
|
||
e.style = { ...e.style, stroke: '#409eff' } // 恢复默认颜色
|
||
}
|
||
})
|
||
|
||
// 重置节点测试数据
|
||
nodeTestInput.value = getDefaultTestInput(event.node.type)
|
||
nodeTestOutput.value = ''
|
||
nodeTestResult.value = null
|
||
selectedTestCaseId.value = ''
|
||
}
|
||
|
||
// 节点双击 - 显示执行详情
|
||
const onNodeDoubleClick = (event: NodeClickEvent) => {
|
||
// 阻止双击时的默认行为(如缩放)
|
||
event.event?.preventDefault?.()
|
||
|
||
if (currentExecutionId.value) {
|
||
// 确保选中节点
|
||
selectedNode.value = event.node
|
||
nodeDetailVisible.value = true
|
||
} else {
|
||
ElMessage.info('暂无执行记录,请先执行工作流')
|
||
}
|
||
}
|
||
|
||
// 判断是否为定时任务节点类型(计算属性)
|
||
const isScheduleNodeSelected = computed(() => {
|
||
return selectedNode.value && (selectedNode.value.type === 'schedule' || selectedNode.value.type === 'delay' || selectedNode.value.type === 'timer')
|
||
})
|
||
|
||
// 获取延迟提示文本(计算属性)
|
||
const delayAlertTitle = computed(() => {
|
||
if (!selectedNode.value || !selectedNode.value.data) {
|
||
return ''
|
||
}
|
||
const delayValue = selectedNode.value.data.delay_value || 0
|
||
const delayUnit = selectedNode.value.data.delay_unit || 'seconds'
|
||
let unit = '秒'
|
||
if (delayUnit === 'minutes') {
|
||
unit = '分钟'
|
||
} else if (delayUnit === 'hours') {
|
||
unit = '小时'
|
||
}
|
||
return '将在执行时延迟 ' + String(delayValue) + ' ' + unit
|
||
})
|
||
|
||
// 获取默认测试输入(根据节点类型)
|
||
const getDefaultTestInput = (nodeType: string): string => {
|
||
const defaults: Record<string, any> = {
|
||
'start': {},
|
||
'input': { text: '测试文本', input: '测试输入' },
|
||
'llm': { input: '请总结以下内容', text: '这是一段需要处理的文本内容...' },
|
||
'template': { input: '请处理用户请求', context: '相关上下文' },
|
||
'condition': { value: 10, threshold: 5, status: 'active' },
|
||
'switch': { status: 'success', route: 'default', value: 'test' },
|
||
'merge': { branch1: { data: 'data1' }, branch2: { data: 'data2' } },
|
||
'wait': { condition: 'ready', status: 'pending' },
|
||
'transform': { data: { name: '测试', value: 100 }, items: [{ id: 1, name: 'item1' }] },
|
||
'json': { json: '{"data": {"items": [{"id": 1}]}}', path: '$.data.items[*]' },
|
||
'text': { text: '这是需要处理的文本\n包含多行内容', delimiter: '\n' },
|
||
'cache': { key: 'test_key', value: 'test_value' },
|
||
'vector_db': { query: '搜索查询', query_vector: '[0.1, 0.2, 0.3]' },
|
||
'http': { url: 'https://api.example.com/data', method: 'GET', params: { page: 1 } },
|
||
'database': { query: 'SELECT * FROM table', params: {} },
|
||
'file': { file_path: '/path/to/file.txt', content: '文件内容' },
|
||
'webhook': { method: 'POST', body: { event: 'test' } },
|
||
'email': { to: 'test@example.com', subject: '测试邮件', body: '邮件内容' },
|
||
'slack': { channel: '#general', message: '测试消息' },
|
||
'dingtalk': { message: '测试消息', chat_id: 'chat123' },
|
||
'wechat_work': { message: '测试消息', chat_id: 'chat123' },
|
||
'sms': { phone: '13800138000', template: '验证码:{code}', code: '123456' },
|
||
'log': { message: '测试日志', level: 'info' },
|
||
'error_handler': { error: { message: '测试错误', code: 'TEST_ERROR' } },
|
||
'csv': { csv_data: 'name,age\nJohn,30\nJane,25', delimiter: ',' },
|
||
'object_storage': { bucket: 'my-bucket', key: 'files/test.txt', file: 'base64data' },
|
||
'pdf': { pdf_data: 'base64encodedpdf', pages: '1-10' },
|
||
'image': { image_data: 'base64encodedimage', width: 800, height: 600 },
|
||
'excel': { excel_data: 'base64encodedexcel', sheet: 'Sheet1' },
|
||
'subworkflow': { workflow_id: 'workflow123', input_mapping: { param1: 'value1' } },
|
||
'code': { input_data: { value: 10 }, code: 'return input_data["value"] * 2' },
|
||
'oauth': { provider: 'google', scopes: ['openid', 'profile'] },
|
||
'validator': { data: { name: 'test', age: 25 }, schema: {} },
|
||
'batch': { items: [{ id: 1 }, { id: 2 }, { id: 3 }], batch_size: 2 },
|
||
'end': { result: '测试结果', output: '最终输出' },
|
||
'output': { message: '测试消息', data: { test: 'value' } }
|
||
}
|
||
|
||
const defaultData = defaults[nodeType] || { input: '测试输入', data: { test: 'value' } }
|
||
return JSON.stringify(defaultData, null, 2)
|
||
}
|
||
|
||
// 从上游节点填充测试数据
|
||
const fillFromUpstream = () => {
|
||
if (!selectedNode.value) return
|
||
|
||
const upstreamEdges = edges.value.filter(e => e.target === selectedNode.value?.id)
|
||
if (upstreamEdges.length === 0) {
|
||
ElMessage.warning('当前节点没有上游节点')
|
||
return
|
||
}
|
||
|
||
// 获取第一个上游节点的示例输出
|
||
const upstreamNode = nodes.value.find(n => n.id === upstreamEdges[0].source)
|
||
if (!upstreamNode) return
|
||
|
||
// 根据上游节点类型生成示例数据
|
||
const exampleData = generateExampleDataForNode(upstreamNode)
|
||
nodeTestInput.value = JSON.stringify(exampleData, null, 2)
|
||
testInputError.value = ''
|
||
ElMessage.success('已从上游节点填充测试数据')
|
||
}
|
||
|
||
// 根据节点类型生成示例数据
|
||
const generateExampleDataForNode = (node: Node): any => {
|
||
const nodeType = node.type
|
||
const nodeData = node.data || {}
|
||
|
||
switch (nodeType) {
|
||
case 'llm':
|
||
case 'template':
|
||
return {
|
||
output: '这是LLM生成的回复内容',
|
||
response: '这是完整的响应',
|
||
tokens: 100
|
||
}
|
||
case 'http':
|
||
return {
|
||
response: {
|
||
status: 200,
|
||
data: { result: 'success', items: [{ id: 1, name: '测试' }] },
|
||
headers: {}
|
||
},
|
||
body: { result: 'success' }
|
||
}
|
||
case 'json':
|
||
return {
|
||
data: { items: [{ id: 1, value: 'test' }], total: 1 },
|
||
result: { extracted: 'value' }
|
||
}
|
||
case 'transform':
|
||
return {
|
||
result: nodeData.mapping ? Object.keys(nodeData.mapping).reduce((acc, key) => {
|
||
acc[key] = `示例值_${key}`
|
||
return acc
|
||
}, {} as any) : { transformed: 'data' }
|
||
}
|
||
case 'code':
|
||
return {
|
||
output: { result: '代码执行结果' },
|
||
result: '处理后的数据'
|
||
}
|
||
case 'vector_db':
|
||
return {
|
||
results: [
|
||
{ id: '1', text: '相关文档1', similarity: 0.95 },
|
||
{ id: '2', text: '相关文档2', similarity: 0.88 }
|
||
],
|
||
similarity: 0.95
|
||
}
|
||
case 'cache':
|
||
return {
|
||
value: '缓存的值',
|
||
cached: true
|
||
}
|
||
case 'switch':
|
||
return {
|
||
status: 'success',
|
||
route: 'default'
|
||
}
|
||
case 'merge':
|
||
return {
|
||
merged: [{ branch1: 'data1' }, { branch2: 'data2' }]
|
||
}
|
||
default:
|
||
return {
|
||
output: '节点输出',
|
||
data: { test: 'value' }
|
||
}
|
||
}
|
||
}
|
||
|
||
// 格式化测试输入
|
||
const formatTestInput = () => {
|
||
try {
|
||
const parsed = JSON.parse(nodeTestInput.value || '{}')
|
||
nodeTestInput.value = JSON.stringify(parsed, null, 2)
|
||
testInputError.value = ''
|
||
ElMessage.success('格式化成功')
|
||
} catch (error: any) {
|
||
testInputError.value = 'JSON格式错误: ' + error.message
|
||
ElMessage.error('JSON格式错误,无法格式化')
|
||
}
|
||
}
|
||
|
||
// 清空测试输入
|
||
const clearTestInput = () => {
|
||
nodeTestInput.value = '{}'
|
||
testInputError.value = ''
|
||
}
|
||
|
||
// 应用测试输入模板
|
||
const applyTestInputTemplate = (template: string) => {
|
||
if (!selectedNode.value) return
|
||
|
||
let templateData: any = {}
|
||
|
||
switch (template) {
|
||
case 'empty':
|
||
templateData = {}
|
||
break
|
||
case 'basic':
|
||
templateData = { input: '测试输入' }
|
||
break
|
||
case 'full':
|
||
templateData = {
|
||
input: '测试输入',
|
||
query: '测试查询',
|
||
data: { test: 'value', number: 123 },
|
||
options: { enabled: true }
|
||
}
|
||
break
|
||
case 'llm':
|
||
templateData = {
|
||
input: '请总结以下内容',
|
||
text: '这是一段需要总结的长文本内容...',
|
||
context: '相关上下文信息'
|
||
}
|
||
break
|
||
case 'http':
|
||
templateData = {
|
||
url: 'https://api.example.com/data',
|
||
method: 'GET',
|
||
params: { page: 1, limit: 10 }
|
||
}
|
||
break
|
||
case 'json':
|
||
templateData = {
|
||
json: '{"data": {"items": [{"id": 1, "name": "测试"}]}}',
|
||
path: '$.data.items[*]'
|
||
}
|
||
break
|
||
}
|
||
|
||
nodeTestInput.value = JSON.stringify(templateData, null, 2)
|
||
testInputTemplate.value = ''
|
||
testInputError.value = ''
|
||
}
|
||
|
||
// 复制测试结果
|
||
const copyTestResult = () => {
|
||
if (!nodeTestResult.value || nodeTestResult.value.status !== 'success') return
|
||
|
||
const text = formattedTestOutput.value || String(nodeTestResult.value.output)
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
ElMessage.success('已复制到剪贴板')
|
||
}).catch(() => {
|
||
ElMessage.error('复制失败')
|
||
})
|
||
}
|
||
|
||
// 导出测试结果
|
||
const downloadTestResult = () => {
|
||
if (!nodeTestResult.value || nodeTestResult.value.status !== 'success') return
|
||
|
||
const data = {
|
||
node_id: selectedNode.value?.id,
|
||
node_type: selectedNode.value?.type,
|
||
node_name: selectedNode.value?.data?.label,
|
||
test_input: JSON.parse(nodeTestInput.value || '{}'),
|
||
test_output: nodeTestResult.value.output,
|
||
execution_time: nodeTestResult.value.execution_time,
|
||
timestamp: new Date().toISOString()
|
||
}
|
||
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `test_result_${selectedNode.value?.id}_${Date.now()}.json`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
ElMessage.success('测试结果已导出')
|
||
}
|
||
|
||
// 获取输出类型
|
||
const getOutputType = (output: any): string => {
|
||
if (output === null) return 'null'
|
||
if (Array.isArray(output)) return 'array'
|
||
if (typeof output === 'object') return 'object'
|
||
return typeof output
|
||
}
|
||
|
||
// 验证测试输入
|
||
watch(nodeTestInput, (value) => {
|
||
if (!value || value.trim() === '') {
|
||
testInputError.value = ''
|
||
return
|
||
}
|
||
|
||
try {
|
||
JSON.parse(value)
|
||
testInputError.value = ''
|
||
} catch (error: any) {
|
||
testInputError.value = 'JSON格式错误: ' + error.message
|
||
}
|
||
})
|
||
|
||
// 测试用例存储
|
||
const persistTestCases = () => {
|
||
localStorage.setItem(testCaseStorageKey.value, JSON.stringify(nodeTestCases.value))
|
||
}
|
||
|
||
const loadTestCases = () => {
|
||
try {
|
||
const stored = localStorage.getItem(testCaseStorageKey.value)
|
||
if (stored) {
|
||
nodeTestCases.value = JSON.parse(stored)
|
||
}
|
||
} catch (error) {
|
||
console.warn('加载测试用例失败', error)
|
||
}
|
||
}
|
||
|
||
const saveCurrentTestCase = async () => {
|
||
if (!selectedNode.value) {
|
||
ElMessage.warning('请先选择节点')
|
||
return
|
||
}
|
||
try {
|
||
JSON.parse(nodeTestInput.value || '{}')
|
||
} catch (error: any) {
|
||
ElMessage.error('输入数据不是有效的JSON,无法保存用例')
|
||
return
|
||
}
|
||
try {
|
||
const { value } = await ElMessageBox.prompt('请输入测试用例名称', '保存测试用例', {
|
||
inputValue: selectedNode.value.data?.label || '测试用例'
|
||
})
|
||
const nodeId = selectedNode.value.id
|
||
const cases = nodeTestCases.value[nodeId] || []
|
||
const newCase = {
|
||
id: `case_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||
name: value,
|
||
data: nodeTestInput.value
|
||
}
|
||
nodeTestCases.value[nodeId] = [...cases, newCase]
|
||
selectedTestCaseId.value = newCase.id
|
||
persistTestCases()
|
||
ElMessage.success('测试用例已保存')
|
||
} catch (error) {
|
||
// 用户取消不提示
|
||
}
|
||
}
|
||
|
||
const applySelectedTestCase = () => {
|
||
if (!selectedNode.value || !selectedTestCaseId.value) return
|
||
const nodeId = selectedNode.value.id
|
||
const cases = nodeTestCases.value[nodeId] || []
|
||
const c = cases.find(i => i.id === selectedTestCaseId.value)
|
||
if (c) {
|
||
nodeTestInput.value = c.data
|
||
testInputError.value = ''
|
||
ElMessage.success('已应用测试用例')
|
||
}
|
||
}
|
||
|
||
const deleteSelectedTestCase = () => {
|
||
if (!selectedNode.value || !selectedTestCaseId.value) return
|
||
const nodeId = selectedNode.value.id
|
||
const cases = nodeTestCases.value[nodeId] || []
|
||
const next = cases.filter(i => i.id !== selectedTestCaseId.value)
|
||
nodeTestCases.value[nodeId] = next
|
||
selectedTestCaseId.value = ''
|
||
persistTestCases()
|
||
ElMessage.success('已删除测试用例')
|
||
}
|
||
|
||
// 边点击
|
||
const onEdgeClick = (event: EdgeClickEvent) => {
|
||
console.log('Edge clicked:', event.edge)
|
||
|
||
// 选中边,取消选中节点
|
||
selectedEdge.value = event.edge
|
||
selectedNode.value = null
|
||
|
||
// 设置边的选中状态
|
||
const edge = edges.value.find(e => e.id === event.edge.id)
|
||
if (edge) {
|
||
edge.selected = true
|
||
console.log('Edge selected:', edge.id, 'selected:', edge.selected)
|
||
}
|
||
|
||
// 清除其他边的选中状态
|
||
edges.value.forEach(e => {
|
||
if (e.id !== event.edge.id) {
|
||
e.selected = false
|
||
}
|
||
})
|
||
}
|
||
|
||
// 画布点击
|
||
const onPaneClick = () => {
|
||
selectedNode.value = null
|
||
selectedEdge.value = null
|
||
}
|
||
|
||
// 节点变化
|
||
const onNodesChange = (changes: any[]) => {
|
||
// 处理节点变化
|
||
console.log('Nodes changed:', changes)
|
||
if (isRestoringHistory.value) {
|
||
return
|
||
}
|
||
const hasStructuralChange = changes.some(change => change.type !== 'select' && (!('dragging' in change) || change.dragging === false))
|
||
if (hasStructuralChange && !isProcessingRemoteOperation.value) {
|
||
pushHistory('nodes change')
|
||
hasChanges.value = true
|
||
}
|
||
|
||
// 如果正在处理远程操作,不发送协作消息
|
||
if (isProcessingRemoteOperation.value) {
|
||
return
|
||
}
|
||
|
||
// 发送协作操作
|
||
if (collaboration && collaborationConnected.value) {
|
||
for (const change of changes) {
|
||
if (change.type === 'add' && change.item) {
|
||
// 节点添加
|
||
collaboration.sendOperation({
|
||
type: 'node_add',
|
||
data: {
|
||
node: change.item
|
||
}
|
||
})
|
||
} else if (change.type === 'remove' && change.id) {
|
||
// 节点删除
|
||
collaboration.sendOperation({
|
||
type: 'node_delete',
|
||
data: {
|
||
node_id: change.id
|
||
}
|
||
})
|
||
} else if (change.type === 'position' && change.id && change.position) {
|
||
// 节点移动
|
||
collaboration.sendOperation({
|
||
type: 'node_move',
|
||
data: {
|
||
node_id: change.id,
|
||
position: change.position
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 边变化
|
||
const onEdgesChange = (changes: any[]) => {
|
||
// 处理边变化
|
||
console.log('Edges changed:', changes)
|
||
if (isRestoringHistory.value) {
|
||
return
|
||
}
|
||
const hasStructuralChange = changes.some(change => change.type === 'add' || change.type === 'remove')
|
||
if (hasStructuralChange && !isProcessingRemoteOperation.value) {
|
||
pushHistory('edges change')
|
||
hasChanges.value = true
|
||
}
|
||
|
||
// 处理边的选中状态变化
|
||
for (const change of changes) {
|
||
if (change.type === 'select') {
|
||
if (change.selected) {
|
||
// 边被选中
|
||
const edge = edges.value.find(e => e.id === change.id)
|
||
if (edge) {
|
||
selectedEdge.value = edge
|
||
selectedNode.value = null // 清除节点选中
|
||
}
|
||
} else {
|
||
// 边取消选中
|
||
if (selectedEdge.value?.id === change.id) {
|
||
selectedEdge.value = null
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果正在处理远程操作,不发送协作消息
|
||
if (isProcessingRemoteOperation.value) {
|
||
return
|
||
}
|
||
|
||
// 发送协作操作
|
||
if (collaboration && collaborationConnected.value) {
|
||
for (const change of changes) {
|
||
if (change.type === 'add' && change.item) {
|
||
// 边添加
|
||
collaboration.sendOperation({
|
||
type: 'edge_add',
|
||
data: {
|
||
edge: change.item
|
||
}
|
||
})
|
||
} else if (change.type === 'remove' && change.id) {
|
||
// 边删除
|
||
collaboration.sendOperation({
|
||
type: 'edge_delete',
|
||
data: {
|
||
edge_id: change.id
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 连接验证函数 - 允许所有方向的连接(包括左右)
|
||
const isValidConnection = (connection: Connection) => {
|
||
console.log('验证连接:', connection)
|
||
|
||
// 基本验证:必须有源和目标
|
||
if (!connection.source || !connection.target) {
|
||
return false
|
||
}
|
||
|
||
// 不能连接到自身
|
||
if (connection.source === connection.target) {
|
||
return false
|
||
}
|
||
|
||
// 检查是否已经存在相同的连接
|
||
const existingEdge = edges.value.find(
|
||
e => e.source === connection.source &&
|
||
e.target === connection.target &&
|
||
(e.sourceHandle === connection.sourceHandle || (!e.sourceHandle && !connection.sourceHandle)) &&
|
||
(e.targetHandle === connection.targetHandle || (!e.targetHandle && !connection.targetHandle))
|
||
)
|
||
|
||
if (existingEdge) {
|
||
return false
|
||
}
|
||
|
||
// 允许所有方向的连接(上下左右都可以)
|
||
// 不限制连接点的位置,允许任意方向连接
|
||
return true
|
||
}
|
||
|
||
// 连接开始
|
||
const onConnectStart = (event: any) => {
|
||
console.log('开始连接:', event)
|
||
}
|
||
|
||
// 连接结束
|
||
const onConnectEnd = (event: any) => {
|
||
console.log('结束连接:', event)
|
||
}
|
||
|
||
// 连接节点
|
||
const onConnect = (connection: Connection) => {
|
||
console.log('连接节点:', connection)
|
||
if (connection.source && connection.target) {
|
||
// 检查是否连接到自身
|
||
if (connection.source === connection.target) {
|
||
ElMessage.warning('不能连接到自身')
|
||
return
|
||
}
|
||
|
||
// 检查是否已经存在相同的连接
|
||
const existingEdge = edges.value.find(
|
||
e => e.source === connection.source &&
|
||
e.target === connection.target &&
|
||
(e.sourceHandle === connection.sourceHandle || (!e.sourceHandle && !connection.sourceHandle)) &&
|
||
(e.targetHandle === connection.targetHandle || (!e.targetHandle && !connection.targetHandle))
|
||
)
|
||
|
||
if (existingEdge) {
|
||
ElMessage.warning('连接已存在')
|
||
return
|
||
}
|
||
|
||
// 清除选中的边
|
||
selectedEdge.value = null
|
||
|
||
const newEdge: Edge = {
|
||
id: `edge_${connection.source}_${connection.target}_${Date.now()}`,
|
||
source: connection.source,
|
||
target: connection.target,
|
||
sourceHandle: connection.sourceHandle || undefined,
|
||
targetHandle: connection.targetHandle || undefined,
|
||
type: 'bezier', // 使用贝塞尔曲线(平滑曲线)
|
||
animated: true,
|
||
selectable: true,
|
||
deletable: true,
|
||
focusable: true,
|
||
style: {
|
||
stroke: '#409eff',
|
||
strokeWidth: 2.5,
|
||
strokeDasharray: '0'
|
||
},
|
||
markerEnd: {
|
||
type: 'arrowclosed',
|
||
color: '#409eff',
|
||
width: 20,
|
||
height: 20
|
||
}
|
||
}
|
||
addEdges([newEdge])
|
||
pushHistory('connect nodes')
|
||
hasChanges.value = true
|
||
ElMessage.success('节点已连接')
|
||
|
||
// 发送协作操作
|
||
if (collaboration && collaborationConnected.value && !isProcessingRemoteOperation.value) {
|
||
collaboration.sendOperation({
|
||
type: 'edge_add',
|
||
data: {
|
||
edge: newEdge
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 保存节点配置
|
||
const handleSaveNode = async () => {
|
||
if (selectedNode.value) {
|
||
updateNode(selectedNode.value.id, selectedNode.value)
|
||
|
||
// 保存节点配置后,自动保存工作流,确保配置持久化
|
||
try {
|
||
const workflow = checkChanges()
|
||
if (hasChanges.value) {
|
||
emit('save', workflow)
|
||
lastSavedData.value = JSON.stringify(workflow)
|
||
hasChanges.value = false
|
||
}
|
||
ElMessage.success('节点配置已保存并同步到工作流')
|
||
} catch (error: any) {
|
||
ElMessage.warning('节点配置已更新,但工作流保存失败,请手动保存')
|
||
}
|
||
|
||
// 发送协作操作
|
||
if (collaboration && collaborationConnected.value && !isProcessingRemoteOperation.value) {
|
||
collaboration.sendOperation({
|
||
type: 'node_update',
|
||
data: {
|
||
node_id: selectedNode.value.id,
|
||
data: selectedNode.value.data
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 测试节点
|
||
const handleTestNode = async () => {
|
||
if (!selectedNode.value) {
|
||
ElMessage.warning('请先选择一个节点')
|
||
return
|
||
}
|
||
|
||
// 解析输入数据
|
||
let inputData: any = {}
|
||
try {
|
||
inputData = JSON.parse(nodeTestInput.value || '{}')
|
||
} catch (error) {
|
||
ElMessage.error('输入数据格式错误,请输入有效的JSON格式')
|
||
return
|
||
}
|
||
|
||
testingNode.value = true
|
||
nodeTestOutput.value = ''
|
||
nodeTestResult.value = null
|
||
|
||
try {
|
||
// 准备节点数据
|
||
const nodeData = {
|
||
id: selectedNode.value.id,
|
||
type: selectedNode.value.type,
|
||
data: selectedNode.value.data
|
||
}
|
||
|
||
// 调用节点测试API
|
||
const response = await api.post('/api/v1/nodes/test', {
|
||
node: nodeData,
|
||
input_data: inputData
|
||
})
|
||
|
||
const result = response.data
|
||
nodeTestResult.value = result
|
||
|
||
// 格式化输出数据
|
||
if (result.status === 'success') {
|
||
if (result.output === null || result.output === undefined) {
|
||
nodeTestOutput.value = 'null'
|
||
} else {
|
||
// 如果输出是字符串,直接显示;如果是对象,格式化为JSON
|
||
if (typeof result.output === 'string') {
|
||
nodeTestOutput.value = result.output
|
||
} else {
|
||
nodeTestOutput.value = JSON.stringify(result.output, null, 2)
|
||
}
|
||
}
|
||
} else if (result.status === 'error') {
|
||
nodeTestOutput.value = `错误: ${result.error_message || '未知错误'}`
|
||
}
|
||
|
||
// 触发节点测试事件,让父组件在预览面板中显示结果
|
||
emit('node-test', {
|
||
node: selectedNode.value,
|
||
result: result,
|
||
input: inputData
|
||
})
|
||
|
||
if (result.status === 'success') {
|
||
ElMessage.success(`节点测试成功 (${result.execution_time}ms)`)
|
||
} else {
|
||
ElMessage.error(`节点测试失败: ${result.error_message || '未知错误'}`)
|
||
}
|
||
|
||
} catch (error: any) {
|
||
console.error('测试节点失败:', error)
|
||
const errorMessage = error.response?.data?.detail || error.message || '测试失败'
|
||
ElMessage.error(errorMessage)
|
||
|
||
// 显示错误信息
|
||
nodeTestResult.value = {
|
||
status: 'error',
|
||
error_message: errorMessage
|
||
}
|
||
nodeTestOutput.value = `错误: ${errorMessage}`
|
||
|
||
// 即使失败也触发事件,显示错误信息
|
||
emit('node-test', {
|
||
node: selectedNode.value,
|
||
result: {
|
||
status: 'error',
|
||
error_message: errorMessage
|
||
},
|
||
input: inputData
|
||
})
|
||
} finally {
|
||
testingNode.value = false
|
||
}
|
||
}
|
||
|
||
// 处理远程协作操作
|
||
const handleRemoteOperation = (operation: any) => {
|
||
if (!operation || !operation.data) return
|
||
|
||
isProcessingRemoteOperation.value = true
|
||
|
||
try {
|
||
switch (operation.type) {
|
||
case 'node_add':
|
||
if (operation.data.node) {
|
||
addNodes([operation.data.node])
|
||
}
|
||
break
|
||
|
||
case 'node_delete':
|
||
if (operation.data.node_id) {
|
||
const nodeToRemove = nodes.value.find(n => n.id === operation.data.node_id)
|
||
if (nodeToRemove) {
|
||
removeNodes([nodeToRemove])
|
||
}
|
||
}
|
||
break
|
||
|
||
case 'node_move':
|
||
if (operation.data.node_id && operation.data.position) {
|
||
const nodeToMove = nodes.value.find(n => n.id === operation.data.node_id)
|
||
if (nodeToMove) {
|
||
nodeToMove.position = operation.data.position
|
||
}
|
||
}
|
||
break
|
||
|
||
case 'node_update':
|
||
if (operation.data.node_id && operation.data.data) {
|
||
const nodeToUpdate = nodes.value.find(n => n.id === operation.data.node_id)
|
||
if (nodeToUpdate) {
|
||
nodeToUpdate.data = { ...nodeToUpdate.data, ...operation.data.data }
|
||
}
|
||
}
|
||
break
|
||
|
||
case 'edge_add':
|
||
if (operation.data.edge) {
|
||
addEdges([operation.data.edge])
|
||
}
|
||
break
|
||
|
||
case 'edge_delete':
|
||
if (operation.data.edge_id) {
|
||
const edgeToRemove = edges.value.find(e => e.id === operation.data.edge_id)
|
||
if (edgeToRemove) {
|
||
removeEdges([edgeToRemove])
|
||
}
|
||
}
|
||
break
|
||
}
|
||
} finally {
|
||
// 延迟重置标志,避免立即触发本地操作
|
||
setTimeout(() => {
|
||
isProcessingRemoteOperation.value = false
|
||
}, 100)
|
||
}
|
||
}
|
||
|
||
// 复制节点
|
||
const handleCopyNode = () => {
|
||
if (selectedNode.value) {
|
||
// 深拷贝节点
|
||
const nodeCopy = JSON.parse(JSON.stringify(selectedNode.value))
|
||
copiedNode.value = nodeCopy
|
||
ElMessage.success('节点已复制,按Ctrl+V粘贴或点击粘贴按钮')
|
||
}
|
||
}
|
||
|
||
// 粘贴节点(从按钮)
|
||
const handlePasteNodeFromButton = () => {
|
||
if (copiedNode.value) {
|
||
// 计算画布中心位置
|
||
const centerX = 400
|
||
const centerY = 300
|
||
pasteNodeAtPosition(centerX, centerY)
|
||
}
|
||
}
|
||
|
||
// 粘贴节点到指定位置
|
||
const pasteNodeAtPosition = (x: number, y: number) => {
|
||
if (!copiedNode.value) return
|
||
|
||
// 生成新的节点ID
|
||
const newId = `${copiedNode.value.type}-${Date.now()}`
|
||
const newNode: Node = {
|
||
...copiedNode.value,
|
||
id: newId,
|
||
position: {
|
||
x: x,
|
||
y: y
|
||
},
|
||
data: {
|
||
...copiedNode.value.data,
|
||
label: `${copiedNode.value.data?.label || copiedNode.value.type} (副本)`
|
||
}
|
||
}
|
||
|
||
addNodes([newNode])
|
||
selectedNode.value = newNode
|
||
copiedNode.value = null
|
||
ElMessage.success('节点已粘贴')
|
||
}
|
||
|
||
// 缩放控制
|
||
const zoomIn = () => {
|
||
vueFlowZoomIn()
|
||
}
|
||
|
||
const zoomOut = () => {
|
||
vueFlowZoomOut()
|
||
}
|
||
|
||
const resetZoom = () => {
|
||
setViewport({ x: 0, y: 0, zoom: 1 })
|
||
}
|
||
|
||
// 视口变化监听
|
||
const onViewportChange = (viewport: Viewport) => {
|
||
currentZoom.value = viewport.zoom
|
||
}
|
||
|
||
// 键盘快捷键支持
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
// 如果焦点在输入框等元素上,不处理
|
||
const target = event.target as HTMLElement
|
||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||
return
|
||
}
|
||
|
||
const isCtrlOrMeta = event.ctrlKey || event.metaKey
|
||
const key = typeof event.key === 'string' ? event.key.toLowerCase() : ''
|
||
|
||
// Ctrl+Z 撤销 / Ctrl+Shift+Z 或 Ctrl+Y 重做
|
||
if (isCtrlOrMeta && key === 'z') {
|
||
event.preventDefault()
|
||
if (event.shiftKey) {
|
||
redo()
|
||
} else {
|
||
undo()
|
||
}
|
||
return
|
||
}
|
||
if (isCtrlOrMeta && key === 'y') {
|
||
event.preventDefault()
|
||
redo()
|
||
return
|
||
}
|
||
|
||
// Ctrl+C 复制
|
||
if (isCtrlOrMeta && key === 'c' && selectedNode.value) {
|
||
handleCopyNode()
|
||
event.preventDefault()
|
||
}
|
||
// Ctrl+V 粘贴
|
||
if (isCtrlOrMeta && key === 'v' && copiedNode.value) {
|
||
handlePasteNodeFromButton()
|
||
event.preventDefault()
|
||
}
|
||
// Delete 或 Backspace 删除节点或边
|
||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||
console.log('Delete key pressed, selectedEdge:', selectedEdge.value, 'selectedNode:', selectedNode.value)
|
||
|
||
// 优先删除选中的边
|
||
if (selectedEdge.value) {
|
||
console.log('Deleting edge:', selectedEdge.value.id)
|
||
handleDeleteEdge()
|
||
event.preventDefault()
|
||
return
|
||
}
|
||
|
||
// 然后删除选中的节点
|
||
if (selectedNode.value) {
|
||
console.log('Deleting node:', selectedNode.value.id)
|
||
handleDeleteNode()
|
||
event.preventDefault()
|
||
return
|
||
}
|
||
|
||
// 如果没有选中的边或节点,尝试删除 Vue Flow 中选中的元素
|
||
const selectedEdges = edges.value.filter(e => e.selected)
|
||
if (selectedEdges.length > 0) {
|
||
console.log('Deleting selected edges:', selectedEdges.map(e => e.id))
|
||
removeEdges(selectedEdges)
|
||
selectedEdge.value = null
|
||
ElMessage.success('连接已删除')
|
||
event.preventDefault()
|
||
return
|
||
}
|
||
}
|
||
// Ctrl+S 保存
|
||
if (isCtrlOrMeta && key === 's') {
|
||
event.preventDefault()
|
||
handleSave()
|
||
}
|
||
// Ctrl+0 重置缩放
|
||
if (isCtrlOrMeta && key === '0') {
|
||
event.preventDefault()
|
||
resetZoom()
|
||
}
|
||
// Ctrl+Plus 放大
|
||
if (isCtrlOrMeta && (key === '+' || key === '=')) {
|
||
event.preventDefault()
|
||
zoomIn()
|
||
}
|
||
// Ctrl+Minus 缩小
|
||
if (isCtrlOrMeta && key === '-') {
|
||
event.preventDefault()
|
||
zoomOut()
|
||
}
|
||
// Ctrl+L 自动布局
|
||
if (isCtrlOrMeta && key === 'l') {
|
||
event.preventDefault()
|
||
handleAutoLayout()
|
||
}
|
||
}
|
||
|
||
// 删除节点(优化版)
|
||
const handleDeleteNode = () => {
|
||
if (selectedNode.value) {
|
||
const nodeId = selectedNode.value.id
|
||
const nodeType = selectedNode.value.type
|
||
|
||
// 检查是否是开始或结束节点
|
||
if (nodeType === 'start' || nodeType === 'end') {
|
||
ElMessage.warning('不能删除开始或结束节点')
|
||
return
|
||
}
|
||
|
||
// 删除与该节点相关的所有边
|
||
const relatedEdges = edges.value.filter(
|
||
edge => edge.source === nodeId || edge.target === nodeId
|
||
)
|
||
if (relatedEdges.length > 0) {
|
||
removeEdges(relatedEdges)
|
||
}
|
||
|
||
// 删除节点
|
||
removeNodes([selectedNode.value])
|
||
selectedNode.value = null
|
||
ElMessage.success('节点已删除')
|
||
|
||
// 发送协作操作
|
||
if (collaboration && collaborationConnected.value && !isProcessingRemoteOperation.value) {
|
||
collaboration.sendOperation({
|
||
type: 'node_delete',
|
||
data: {
|
||
node_id: nodeId
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 删除边
|
||
const handleDeleteEdge = () => {
|
||
if (selectedEdge.value) {
|
||
const edgeId = selectedEdge.value.id
|
||
const edgeToRemove = edges.value.find(e => e.id === edgeId)
|
||
|
||
if (edgeToRemove) {
|
||
// 删除边
|
||
removeEdges([edgeToRemove])
|
||
selectedEdge.value = null
|
||
ElMessage.success('连接已删除')
|
||
|
||
// 发送协作操作
|
||
if (collaboration && collaborationConnected.value && !isProcessingRemoteOperation.value) {
|
||
collaboration.sendOperation({
|
||
type: 'edge_delete',
|
||
data: {
|
||
edge_id: edgeId
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查是否有变更
|
||
const checkChanges = () => {
|
||
const workflow = {
|
||
nodes: nodes.value.map(node => ({
|
||
id: node.id,
|
||
type: node.type || 'default',
|
||
position: node.position,
|
||
data: node.data
|
||
})),
|
||
edges: edges.value.map(edge => ({
|
||
id: edge.id,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
sourceHandle: edge.sourceHandle,
|
||
targetHandle: edge.targetHandle
|
||
}))
|
||
}
|
||
const currentData = JSON.stringify(workflow)
|
||
hasChanges.value = currentData !== lastSavedData.value
|
||
return workflow
|
||
}
|
||
|
||
// 保存工作流
|
||
const handleSave = async () => {
|
||
if (saving.value) return
|
||
|
||
const workflow = checkChanges()
|
||
|
||
if (!hasChanges.value && lastSavedData.value) {
|
||
ElMessage.info('没有需要保存的更改')
|
||
return
|
||
}
|
||
|
||
saving.value = true
|
||
try {
|
||
await new Promise(resolve => setTimeout(resolve, 300)) // 模拟保存延迟
|
||
|
||
emit('save', workflow)
|
||
lastSavedData.value = JSON.stringify(workflow)
|
||
hasChanges.value = false
|
||
ElMessage.success('工作流已保存')
|
||
} catch (error: any) {
|
||
ElMessage.error(error.response?.data?.detail || '保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
// 自动保存(可选)
|
||
const enableAutoSave = () => {
|
||
if (savingInterval.value) return
|
||
|
||
savingInterval.value = window.setInterval(() => {
|
||
if (hasChanges.value && !saving.value) {
|
||
// 静默保存,不显示提示
|
||
const workflow = checkChanges()
|
||
if (hasChanges.value) {
|
||
emit('save', workflow)
|
||
lastSavedData.value = JSON.stringify(workflow)
|
||
hasChanges.value = false
|
||
console.log('自动保存完成')
|
||
}
|
||
}
|
||
}, 30000) // 每30秒自动保存一次
|
||
}
|
||
|
||
const disableAutoSave = () => {
|
||
if (savingInterval.value) {
|
||
clearInterval(savingInterval.value)
|
||
savingInterval.value = null
|
||
}
|
||
}
|
||
|
||
// 运行工作流
|
||
const handleRun = async () => {
|
||
// 检查是否有工作流ID
|
||
if (!props.workflowId) {
|
||
ElMessage.warning('请先保存工作流后再运行')
|
||
return
|
||
}
|
||
|
||
// 检查工作流是否有节点
|
||
if (nodes.value.length === 0) {
|
||
ElMessage.warning('工作流不能为空,请先添加节点')
|
||
return
|
||
}
|
||
|
||
// 打开运行对话框
|
||
runForm.value.inputData = '{}'
|
||
runDialogVisible.value = true
|
||
}
|
||
|
||
// 确认运行
|
||
const handleConfirmRun = async () => {
|
||
if (!props.workflowId) {
|
||
ElMessage.error('工作流ID不存在')
|
||
return
|
||
}
|
||
|
||
// 解析输入数据
|
||
let inputData: any = {}
|
||
try {
|
||
inputData = JSON.parse(runForm.value.inputData || '{}')
|
||
} catch (error) {
|
||
ElMessage.error('输入参数格式错误,请输入有效的JSON格式')
|
||
return
|
||
}
|
||
|
||
running.value = true
|
||
try {
|
||
// 调用执行API
|
||
const response = await api.post(`/api/v1/workflows/${props.workflowId}/execute`, inputData)
|
||
const execution = response.data
|
||
|
||
ElMessage.success('工作流执行已启动')
|
||
runDialogVisible.value = false
|
||
|
||
// 跳转到执行详情页
|
||
router.push(`/executions/${execution.id}`)
|
||
} catch (error: any) {
|
||
console.error('执行工作流失败:', error)
|
||
const errorMessage = error.response?.data?.message || error.message || '执行工作流失败'
|
||
ElMessage.error(errorMessage)
|
||
} finally {
|
||
running.value = false
|
||
}
|
||
}
|
||
|
||
// 清空画布
|
||
const handleClear = () => {
|
||
removeNodes(nodes.value)
|
||
removeEdges(edges.value)
|
||
selectedNode.value = null
|
||
selectedEdge.value = null
|
||
ElMessage.success('画布已清空')
|
||
}
|
||
|
||
// 节点对齐功能
|
||
const handleAlignNodes = (command: string) => {
|
||
// 获取选中的节点(支持多选)
|
||
const selectedNodes = nodes.value.filter(node => node.selected)
|
||
|
||
if (selectedNodes.length < 2) {
|
||
ElMessage.warning('请至少选择2个节点进行对齐')
|
||
return
|
||
}
|
||
|
||
// 计算对齐基准值
|
||
let baseValue: number
|
||
// 默认节点尺寸(根据节点类型可能有不同)
|
||
const defaultNodeWidth = 150
|
||
const defaultNodeHeight = 50
|
||
|
||
const positions = selectedNodes.map(node => {
|
||
// 尝试从DOM获取实际尺寸,如果获取不到则使用默认值
|
||
let width = defaultNodeWidth
|
||
let height = defaultNodeHeight
|
||
|
||
// 可以通过vueFlowInstance获取节点尺寸
|
||
try {
|
||
const nodeElement = document.querySelector(`[data-id="${node.id}"]`)
|
||
if (nodeElement) {
|
||
const rect = nodeElement.getBoundingClientRect()
|
||
width = rect.width || defaultNodeWidth
|
||
height = rect.height || defaultNodeHeight
|
||
}
|
||
} catch (e) {
|
||
// 如果获取失败,使用默认值
|
||
}
|
||
|
||
return {
|
||
id: node.id,
|
||
x: node.position.x,
|
||
y: node.position.y,
|
||
width,
|
||
height
|
||
}
|
||
})
|
||
|
||
switch (command) {
|
||
case 'left':
|
||
// 左对齐:以最左边的节点为基准
|
||
baseValue = Math.min(...positions.map(p => p.x))
|
||
positions.forEach(pos => {
|
||
updateNode(pos.id, { position: { x: baseValue, y: pos.y } })
|
||
})
|
||
ElMessage.success('节点已左对齐')
|
||
break
|
||
|
||
case 'right':
|
||
// 右对齐:以最右边的节点为基准
|
||
baseValue = Math.max(...positions.map(p => p.x + p.width))
|
||
positions.forEach(pos => {
|
||
updateNode(pos.id, { position: { x: baseValue - pos.width, y: pos.y } })
|
||
})
|
||
ElMessage.success('节点已右对齐')
|
||
break
|
||
|
||
case 'top':
|
||
// 上对齐:以最上边的节点为基准
|
||
baseValue = Math.min(...positions.map(p => p.y))
|
||
positions.forEach(pos => {
|
||
updateNode(pos.id, { position: { x: pos.x, y: baseValue } })
|
||
})
|
||
ElMessage.success('节点已上对齐')
|
||
break
|
||
|
||
case 'bottom':
|
||
// 下对齐:以最下边的节点为基准
|
||
baseValue = Math.max(...positions.map(p => p.y + p.height))
|
||
positions.forEach(pos => {
|
||
updateNode(pos.id, { position: { x: pos.x, y: baseValue - pos.height } })
|
||
})
|
||
ElMessage.success('节点已下对齐')
|
||
break
|
||
|
||
case 'center-h':
|
||
// 水平居中:以所有节点的中心点为基准
|
||
const minX = Math.min(...positions.map(p => p.x))
|
||
const maxX = Math.max(...positions.map(p => p.x + p.width))
|
||
const centerX = (minX + maxX) / 2
|
||
positions.forEach(pos => {
|
||
updateNode(pos.id, { position: { x: centerX - pos.width / 2, y: pos.y } })
|
||
})
|
||
ElMessage.success('节点已水平居中')
|
||
break
|
||
|
||
case 'center-v':
|
||
// 垂直居中:以所有节点的中心点为基准
|
||
const minY = Math.min(...positions.map(p => p.y))
|
||
const maxY = Math.max(...positions.map(p => p.y + p.height))
|
||
const centerY = (minY + maxY) / 2
|
||
positions.forEach(pos => {
|
||
updateNode(pos.id, { position: { x: pos.x, y: centerY - pos.height / 2 } })
|
||
})
|
||
ElMessage.success('节点已垂直居中')
|
||
break
|
||
|
||
case 'distribute-h':
|
||
// 水平分布:均匀分布节点
|
||
const sortedByX = [...positions].sort((a, b) => a.x - b.x)
|
||
const totalWidth = sortedByX[sortedByX.length - 1].x + sortedByX[sortedByX.length - 1].width - sortedByX[0].x
|
||
const spacing = totalWidth / (sortedByX.length - 1)
|
||
let currentX = sortedByX[0].x
|
||
sortedByX.forEach((pos, index) => {
|
||
if (index > 0) {
|
||
currentX = sortedByX[index - 1].x + sortedByX[index - 1].width + spacing - pos.width
|
||
}
|
||
updateNode(pos.id, { position: { x: currentX, y: pos.y } })
|
||
})
|
||
ElMessage.success('节点已水平分布')
|
||
break
|
||
|
||
case 'distribute-v':
|
||
// 垂直分布:均匀分布节点
|
||
const sortedByY = [...positions].sort((a, b) => a.y - b.y)
|
||
const totalHeight = sortedByY[sortedByY.length - 1].y + sortedByY[sortedByY.length - 1].height - sortedByY[0].y
|
||
const vSpacing = totalHeight / (sortedByY.length - 1)
|
||
let currentY = sortedByY[0].y
|
||
sortedByY.forEach((pos, index) => {
|
||
if (index > 0) {
|
||
currentY = sortedByY[index - 1].y + sortedByY[index - 1].height + vSpacing - pos.height
|
||
}
|
||
updateNode(pos.id, { position: { x: pos.x, y: currentY } })
|
||
})
|
||
ElMessage.success('节点已垂直分布')
|
||
break
|
||
}
|
||
|
||
// 标记有变更
|
||
hasChanges.value = true
|
||
}
|
||
|
||
// 自动布局功能(基于DAG的层次布局算法)
|
||
const handleAutoLayout = async () => {
|
||
if (nodes.value.length === 0) {
|
||
ElMessage.warning('画布中没有节点')
|
||
return
|
||
}
|
||
|
||
// 找到开始节点
|
||
const startNode = nodes.value.find(n => n.type === 'start')
|
||
if (!startNode) {
|
||
ElMessage.warning('未找到开始节点,无法进行自动布局')
|
||
return
|
||
}
|
||
|
||
// 构建邻接表(有向图)
|
||
const graph: Record<string, string[]> = {}
|
||
const inDegree: Record<string, number> = {}
|
||
|
||
// 初始化
|
||
nodes.value.forEach(node => {
|
||
graph[node.id] = []
|
||
inDegree[node.id] = 0
|
||
})
|
||
|
||
// 构建图
|
||
edges.value.forEach(edge => {
|
||
if (graph[edge.source] && !graph[edge.source].includes(edge.target)) {
|
||
graph[edge.source].push(edge.target)
|
||
inDegree[edge.target] = (inDegree[edge.target] || 0) + 1
|
||
}
|
||
})
|
||
|
||
// 拓扑排序,将节点分层
|
||
const layers: string[][] = []
|
||
const visited = new Set<string>()
|
||
const queue: string[] = []
|
||
|
||
// 找到所有入度为0的节点(开始节点)
|
||
Object.keys(inDegree).forEach(nodeId => {
|
||
if (inDegree[nodeId] === 0) {
|
||
queue.push(nodeId)
|
||
}
|
||
})
|
||
|
||
// 如果没有入度为0的节点,使用开始节点
|
||
if (queue.length === 0 && startNode) {
|
||
queue.push(startNode.id)
|
||
}
|
||
|
||
// 分层遍历
|
||
while (queue.length > 0) {
|
||
const layer: string[] = []
|
||
const layerSize = queue.length
|
||
|
||
for (let i = 0; i < layerSize; i++) {
|
||
const nodeId = queue.shift()!
|
||
if (visited.has(nodeId)) continue
|
||
|
||
visited.add(nodeId)
|
||
layer.push(nodeId)
|
||
|
||
// 处理该节点的所有出边
|
||
const neighbors = graph[nodeId] || []
|
||
neighbors.forEach(neighborId => {
|
||
inDegree[neighborId] = (inDegree[neighborId] || 0) - 1
|
||
if (inDegree[neighborId] === 0 && !visited.has(neighborId)) {
|
||
queue.push(neighborId)
|
||
}
|
||
})
|
||
}
|
||
|
||
if (layer.length > 0) {
|
||
layers.push(layer)
|
||
}
|
||
}
|
||
|
||
// 处理未访问的节点(可能是孤立节点)
|
||
nodes.value.forEach(node => {
|
||
if (!visited.has(node.id)) {
|
||
if (layers.length === 0) {
|
||
layers.push([node.id])
|
||
} else {
|
||
layers[layers.length - 1].push(node.id)
|
||
}
|
||
}
|
||
})
|
||
|
||
// 布局参数
|
||
const nodeWidth = 200
|
||
const nodeHeight = 80
|
||
const horizontalSpacing = 280 // 水平间距(增大间距,避免节点重叠)
|
||
const verticalSpacing = 180 // 垂直间距(增大间距,使布局更清晰)
|
||
const startX = 100
|
||
const startY = 100
|
||
|
||
// 计算每层的布局(水平居中)
|
||
layers.forEach((layer, layerIndex) => {
|
||
const layerY = startY + layerIndex * verticalSpacing
|
||
const layerWidth = (layer.length - 1) * horizontalSpacing
|
||
const layerStartX = startX - layerWidth / 2
|
||
|
||
layer.forEach((nodeId, nodeIndex) => {
|
||
const nodeX = layerStartX + nodeIndex * horizontalSpacing
|
||
updateNode(nodeId, {
|
||
position: { x: nodeX, y: layerY }
|
||
})
|
||
})
|
||
})
|
||
|
||
// 自动调整视口,使所有节点可见
|
||
await nextTick()
|
||
setTimeout(() => {
|
||
try {
|
||
const allNodes = nodes.value
|
||
if (allNodes.length > 0) {
|
||
const minX = Math.min(...allNodes.map(n => n.position.x))
|
||
const maxX = Math.max(...allNodes.map(n => n.position.x + nodeWidth))
|
||
const minY = Math.min(...allNodes.map(n => n.position.y))
|
||
const maxY = Math.max(...allNodes.map(n => n.position.y + nodeHeight))
|
||
|
||
const centerX = (minX + maxX) / 2
|
||
const centerY = (minY + maxY) / 2
|
||
const width = maxX - minX
|
||
const height = maxY - minY
|
||
|
||
// 计算合适的缩放比例
|
||
const viewport = getViewport()
|
||
if (viewport) {
|
||
const scale = Math.min(
|
||
(viewport.zoom * 800) / width,
|
||
(viewport.zoom * 600) / height,
|
||
1.2 // 最大缩放不超过1.2
|
||
)
|
||
|
||
setViewport({
|
||
x: -centerX * scale + 400,
|
||
y: -centerY * scale + 300,
|
||
zoom: scale
|
||
}, { duration: 300 })
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('自动调整视口失败:', e)
|
||
}
|
||
}, 100)
|
||
|
||
ElMessage.success(`自动布局完成,共 ${layers.length} 层,${nodes.value.length} 个节点`)
|
||
hasChanges.value = true
|
||
}
|
||
|
||
// 测试动画 - 依次执行所有节点,展示完整的工作流动画效果
|
||
const handleTestAnimation = () => {
|
||
// 清除之前的测试状态
|
||
if (testAnimationTimer.value) {
|
||
clearTimeout(testAnimationTimer.value)
|
||
testAnimationTimer.value = null
|
||
}
|
||
|
||
// 找到第一个开始节点
|
||
const startNode = nodes.value.find(n => n.type === 'start' || n.data?.type === 'start' || n.id.startsWith('start'))
|
||
|
||
if (!startNode) {
|
||
ElMessage.warning('未找到开始节点,请先添加一个开始节点')
|
||
return
|
||
}
|
||
|
||
console.log('[rjb] 🧪 开始测试动画,节点ID:', startNode.id)
|
||
|
||
// 清除所有节点的执行状态
|
||
nodes.value.forEach(node => {
|
||
const nodeClass = (node.class || '').replace(/\b(executing|executed|failed)\b/g, '').trim()
|
||
if (nodeClass !== node.class) {
|
||
updateNode(node.id, {
|
||
class: nodeClass,
|
||
data: { ...node.data, executionStatus: undefined, executionClass: undefined }
|
||
})
|
||
}
|
||
})
|
||
|
||
// 清除所有边的执行状态
|
||
edges.value.forEach(edge => {
|
||
const edgeClass = (edge.class || '').replace(/\b(edge-executing|edge-executed)\b/g, '').trim()
|
||
if (edgeClass !== edge.class) {
|
||
if (updateEdge) {
|
||
updateEdge(edge.id, {
|
||
class: edgeClass,
|
||
style: {
|
||
...edge.style,
|
||
stroke: '#409eff',
|
||
strokeWidth: 2.5
|
||
},
|
||
animated: false
|
||
})
|
||
} else {
|
||
edge.class = edgeClass
|
||
edge.animated = false
|
||
}
|
||
}
|
||
})
|
||
|
||
// 使用广度优先搜索(BFS)找到所有节点的执行顺序
|
||
const getExecutionOrder = (startNodeId: string): string[] => {
|
||
const visited = new Set<string>()
|
||
const order: string[] = []
|
||
const queue: string[] = [startNodeId]
|
||
|
||
visited.add(startNodeId)
|
||
|
||
while (queue.length > 0) {
|
||
const currentNodeId = queue.shift()!
|
||
order.push(currentNodeId)
|
||
|
||
// 找到所有从当前节点出发的边
|
||
const outgoingEdges = edges.value.filter(e => e.source === currentNodeId)
|
||
for (const edge of outgoingEdges) {
|
||
if (!visited.has(edge.target)) {
|
||
visited.add(edge.target)
|
||
queue.push(edge.target)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果还有未访问的节点(可能是孤立的),也加入顺序
|
||
nodes.value.forEach(node => {
|
||
if (!visited.has(node.id)) {
|
||
order.push(node.id)
|
||
}
|
||
})
|
||
|
||
return order
|
||
}
|
||
|
||
const executionOrder = getExecutionOrder(startNode.id)
|
||
console.log('[rjb] 🧪 节点执行顺序:', executionOrder)
|
||
|
||
if (executionOrder.length === 0) {
|
||
ElMessage.warning('未找到可执行的节点')
|
||
return
|
||
}
|
||
|
||
testingAnimation.value = true
|
||
|
||
// 依次执行每个节点
|
||
const executeNodeAnimation = (nodeIndex: number) => {
|
||
if (nodeIndex >= executionOrder.length) {
|
||
// 所有节点执行完成
|
||
testingAnimation.value = false
|
||
testAnimationTimer.value = null
|
||
ElMessage.success(`动画测试完成!共执行了 ${executionOrder.length} 个节点`)
|
||
return
|
||
}
|
||
|
||
const nodeId = executionOrder[nodeIndex]
|
||
const node = nodes.value.find(n => n.id === nodeId)
|
||
|
||
if (!node) {
|
||
// 如果节点不存在,跳过
|
||
executeNodeAnimation(nodeIndex + 1)
|
||
return
|
||
}
|
||
|
||
console.log(`[rjb] 🧪 [${nodeIndex + 1}/${executionOrder.length}] 开始执行节点:`, nodeId)
|
||
|
||
// 第一步:标记为执行中
|
||
const nodeClass = ((node.class || '').replace(/\b(executing|executed|failed)\b/g, '').trim() + ' executing').trim()
|
||
const nodeData = { ...node.data, executionStatus: 'executing', executionClass: 'executing' }
|
||
updateNode(node.id, {
|
||
class: nodeClass,
|
||
data: nodeData
|
||
})
|
||
console.log('[rjb] 🧪 ✅ 标记节点为执行中:', nodeId)
|
||
|
||
// 高亮连接到当前节点的边(从上游节点来的边)
|
||
edges.value.forEach(edge => {
|
||
if (edge.target === nodeId) {
|
||
const edgeClass = ((edge.class || '').replace(/\b(edge-executing|edge-executed)\b/g, '').trim() + ' edge-executing').trim()
|
||
try {
|
||
if (updateEdge) {
|
||
updateEdge(edge.id, {
|
||
class: edgeClass,
|
||
style: {
|
||
...edge.style,
|
||
stroke: '#409eff',
|
||
strokeWidth: 3.5,
|
||
strokeDasharray: '8,4'
|
||
},
|
||
animated: true
|
||
})
|
||
} else {
|
||
edge.class = edgeClass
|
||
edge.style = {
|
||
...edge.style,
|
||
stroke: '#409eff',
|
||
strokeWidth: 3.5,
|
||
strokeDasharray: '8,4'
|
||
}
|
||
edge.animated = true
|
||
}
|
||
} catch (e) {
|
||
console.warn('[rjb] Failed to update edge:', e)
|
||
}
|
||
}
|
||
})
|
||
|
||
// 第二步:1.5秒后标记为已完成,然后执行下一个节点
|
||
testAnimationTimer.value = window.setTimeout(() => {
|
||
const executedNodeClass = ((node.class || '').replace(/\b(executing|executed|failed)\b/g, '').trim() + ' executed').trim()
|
||
const executedNodeData = { ...node.data, executionStatus: 'executed', executionClass: 'executed' }
|
||
updateNode(node.id, {
|
||
class: executedNodeClass,
|
||
data: executedNodeData
|
||
})
|
||
console.log('[rjb] 🧪 ✅ 标记节点为已完成:', nodeId)
|
||
|
||
// 更新连接到当前节点的边为已完成状态
|
||
edges.value.forEach(edge => {
|
||
if (edge.target === nodeId) {
|
||
const edgeClass = ((edge.class || '').replace(/\b(edge-executing|edge-executed)\b/g, '').trim() + ' edge-executed').trim()
|
||
try {
|
||
if (updateEdge) {
|
||
updateEdge(edge.id, {
|
||
class: edgeClass,
|
||
style: {
|
||
...edge.style,
|
||
stroke: '#67c23a',
|
||
strokeWidth: 3,
|
||
strokeDasharray: '0'
|
||
},
|
||
animated: false
|
||
})
|
||
} else {
|
||
edge.class = edgeClass
|
||
edge.style = {
|
||
...edge.style,
|
||
stroke: '#67c23a',
|
||
strokeWidth: 3,
|
||
strokeDasharray: '0'
|
||
}
|
||
edge.animated = false
|
||
}
|
||
} catch (e) {
|
||
console.warn('[rjb] Failed to update edge:', e)
|
||
}
|
||
}
|
||
})
|
||
|
||
// 继续执行下一个节点
|
||
executeNodeAnimation(nodeIndex + 1)
|
||
}, 1500) // 每个节点执行1.5秒
|
||
}
|
||
|
||
// 开始执行第一个节点
|
||
executeNodeAnimation(0)
|
||
}
|
||
|
||
// 监听节点和边的变化,检查是否有变更
|
||
watch([nodes, edges], () => {
|
||
if (lastSavedData.value) {
|
||
checkChanges()
|
||
}
|
||
}, { deep: true })
|
||
|
||
// 监听初始数据变化(用于异步加载)
|
||
watch(
|
||
() => props.initialNodes,
|
||
async (newNodes) => {
|
||
// 如果数据从空变为有数据,且当前画布为空,则加载
|
||
if (newNodes && newNodes.length > 0 && nodes.value.length === 0) {
|
||
console.log('[WorkflowEditor] 检测到初始节点数据变化,加载节点:', newNodes.length)
|
||
const vueFlowNodes = newNodes.map((node: any) => ({
|
||
id: node.id,
|
||
type: node.type,
|
||
position: node.position || { x: 0, y: 0 },
|
||
data: node.data || { label: node.label || node.type }
|
||
}))
|
||
addNodes(vueFlowNodes)
|
||
await nextTick()
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(
|
||
() => props.initialEdges,
|
||
async (newEdges) => {
|
||
if (newEdges && newEdges.length > 0 && edges.value.length === 0) {
|
||
console.log('[WorkflowEditor] 检测到初始连接数据变化,加载连接:', newEdges.length)
|
||
const vueFlowEdges = newEdges.map((edge: any) => ({
|
||
id: edge.id,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
sourceHandle: edge.sourceHandle,
|
||
targetHandle: edge.targetHandle,
|
||
selectable: true,
|
||
deletable: true,
|
||
focusable: true,
|
||
type: edge.type || 'bezier',
|
||
animated: true,
|
||
style: {
|
||
stroke: '#409eff',
|
||
strokeWidth: 2.5,
|
||
strokeDasharray: '0'
|
||
},
|
||
markerEnd: {
|
||
type: 'arrowclosed',
|
||
color: '#409eff',
|
||
width: 20,
|
||
height: 20
|
||
}
|
||
}))
|
||
addEdges(vueFlowEdges)
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
// 监听执行状态变化,更新节点样式
|
||
watch(() => props.executionStatus, (newStatus, oldStatus) => {
|
||
console.log('[rjb] WorkflowEditor received executionStatus:', JSON.stringify(newStatus, null, 2))
|
||
console.log('[rjb] Current nodes:', nodes.value.map(n => ({ id: n.id, type: n.type, class: n.class })))
|
||
console.log('[rjb] All node IDs:', nodes.value.map(n => n.id))
|
||
|
||
// 提取execution_id
|
||
if (newStatus && newStatus.execution_id) {
|
||
currentExecutionId.value = newStatus.execution_id
|
||
}
|
||
|
||
// 使用 nextTick 确保 DOM 更新
|
||
nextTick(() => {
|
||
// 清除所有节点的执行状态
|
||
nodes.value.forEach(node => {
|
||
if (node.data) {
|
||
delete node.data.executionStatus
|
||
delete node.data.executionClass
|
||
}
|
||
// 同时清除 class
|
||
let nodeClass = (node.class || '').replace(/\b(executing|executed|failed)\b/g, '').trim()
|
||
if (nodeClass !== node.class) {
|
||
updateNode(node.id, { class: nodeClass, data: { ...node.data } })
|
||
}
|
||
})
|
||
|
||
// 如果没有执行状态,直接返回
|
||
if (!newStatus) {
|
||
console.log('[rjb] No execution status, clearing all node states')
|
||
return
|
||
}
|
||
|
||
const { current_node, executed_nodes, failed_nodes } = newStatus
|
||
console.log('[rjb] Execution status - current:', current_node, 'executed:', executed_nodes, 'failed:', failed_nodes)
|
||
console.log('[rjb] Execution status full:', JSON.stringify(newStatus, null, 2))
|
||
|
||
// 清除所有边的执行状态
|
||
edges.value.forEach(edge => {
|
||
if (edge.data) {
|
||
delete edge.data.executionStatus
|
||
}
|
||
if (edge.class) {
|
||
edge.class = edge.class.replace(/\b(edge-executing|edge-executed)\b/g, '').trim()
|
||
}
|
||
})
|
||
|
||
// 更新正在执行的节点
|
||
if (current_node && current_node.node_id) {
|
||
const node = nodes.value.find(n => n.id === current_node.node_id)
|
||
if (node) {
|
||
const nodeClass = ((node.class || '').replace(/\b(executing|executed|failed)\b/g, '').trim() + ' executing').trim()
|
||
const nodeData = { ...node.data, executionStatus: 'executing', executionClass: 'executing' }
|
||
updateNode(node.id, {
|
||
class: nodeClass,
|
||
data: nodeData
|
||
})
|
||
console.log('[rjb] ✅ Marking node as executing:', current_node.node_id, 'class:', nodeClass, 'nodeData:', nodeData)
|
||
|
||
// 高亮连接到正在执行节点的边
|
||
edges.value.forEach(edge => {
|
||
if (edge.target === current_node.node_id) {
|
||
const edgeClass = ((edge.class || '').replace(/\b(edge-executing|edge-executed)\b/g, '').trim() + ' edge-executing').trim()
|
||
try {
|
||
if (updateEdge) {
|
||
updateEdge(edge.id, {
|
||
class: edgeClass,
|
||
style: {
|
||
...edge.style,
|
||
stroke: '#409eff',
|
||
strokeWidth: 3.5,
|
||
strokeDasharray: '8,4'
|
||
},
|
||
animated: true
|
||
})
|
||
} else {
|
||
// 如果updateEdge不存在,直接修改(兼容旧版本)
|
||
edge.class = edgeClass
|
||
edge.style = {
|
||
...edge.style,
|
||
stroke: '#409eff',
|
||
strokeWidth: 3.5,
|
||
strokeDasharray: '8,4'
|
||
}
|
||
edge.animated = true
|
||
}
|
||
} catch (e) {
|
||
console.warn('[rjb] Failed to update edge:', e)
|
||
// 直接修改作为后备方案
|
||
edge.class = edgeClass
|
||
edge.style = {
|
||
...edge.style,
|
||
stroke: '#409eff',
|
||
strokeWidth: 3.5,
|
||
strokeDasharray: '8,4'
|
||
}
|
||
edge.animated = true
|
||
}
|
||
}
|
||
})
|
||
} else {
|
||
console.warn('[rjb] ❌ Node not found:', current_node.node_id, 'available nodes:', nodes.value.map(n => n.id))
|
||
}
|
||
}
|
||
|
||
// 更新已完成的节点
|
||
if (executed_nodes && Array.isArray(executed_nodes)) {
|
||
executed_nodes.forEach((executedNode: any) => {
|
||
if (executedNode.node_id) {
|
||
const node = nodes.value.find(n => n.id === executedNode.node_id)
|
||
if (node) {
|
||
const nodeClass = ((node.class || '').replace(/\b(executing|executed|failed)\b/g, '').trim() + ' executed').trim()
|
||
const nodeData = { ...node.data, executionStatus: 'executed', executionClass: 'executed' }
|
||
updateNode(node.id, {
|
||
class: nodeClass,
|
||
data: nodeData
|
||
})
|
||
console.log('[rjb] ✅ Marking node as executed:', executedNode.node_id, 'class:', nodeClass)
|
||
|
||
// 高亮连接到已执行节点的边
|
||
edges.value.forEach(edge => {
|
||
if (edge.target === executedNode.node_id) {
|
||
const edgeClass = ((edge.class || '').replace(/\b(edge-executing|edge-executed)\b/g, '').trim() + ' edge-executed').trim()
|
||
try {
|
||
updateEdge(edge.id, {
|
||
class: edgeClass,
|
||
style: {
|
||
...edge.style,
|
||
stroke: '#67c23a',
|
||
strokeWidth: 3,
|
||
strokeDasharray: '0'
|
||
},
|
||
animated: false
|
||
})
|
||
} catch (e) {
|
||
console.warn('[rjb] Failed to update edge:', e)
|
||
// 如果updateEdge不存在,直接修改(兼容旧版本)
|
||
edge.class = edgeClass
|
||
edge.style = {
|
||
...edge.style,
|
||
stroke: '#67c23a',
|
||
strokeWidth: 3,
|
||
strokeDasharray: '0'
|
||
}
|
||
edge.animated = false
|
||
}
|
||
}
|
||
})
|
||
} else {
|
||
console.warn('[rjb] ❌ Executed node not found:', executedNode.node_id)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 更新失败的节点(包含错误信息)
|
||
if (failed_nodes && Array.isArray(failed_nodes)) {
|
||
failed_nodes.forEach((failedNode: any) => {
|
||
if (failedNode.node_id) {
|
||
const node = nodes.value.find(n => n.id === failedNode.node_id)
|
||
if (node) {
|
||
const nodeClass = ((node.class || '').replace(/\b(executing|executed|failed)\b/g, '').trim() + ' failed').trim()
|
||
const nodeData = {
|
||
...node.data,
|
||
executionStatus: 'failed',
|
||
executionClass: 'failed',
|
||
errorMessage: failedNode.error_message || '执行失败',
|
||
errorType: failedNode.error_type
|
||
}
|
||
updateNode(node.id, {
|
||
class: nodeClass,
|
||
data: nodeData
|
||
})
|
||
console.log('[rjb] ✅ Marking node as failed:', failedNode.node_id, 'error:', failedNode.error_message)
|
||
} else {
|
||
console.warn('[rjb] ❌ Failed node not found:', failedNode.node_id)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}, { deep: true, immediate: true })
|
||
|
||
// 加载工作流数据
|
||
onMounted(async () => {
|
||
// 注册键盘事件监听
|
||
window.addEventListener('keydown', handleKeyDown)
|
||
|
||
// 启用自动保存
|
||
enableAutoSave()
|
||
|
||
// 加载节点模板列表
|
||
loadNodeTemplates()
|
||
// 加载测试用例
|
||
loadTestCases()
|
||
// 加载配置模板
|
||
loadTemplatesFromStorage()
|
||
// 加载工具列表
|
||
loadTools()
|
||
|
||
// 初始化视口
|
||
const viewport = getViewport()
|
||
if (viewport) {
|
||
currentZoom.value = viewport.zoom
|
||
}
|
||
|
||
// 加载初始数据的函数
|
||
const loadInitialData = async () => {
|
||
// 如果已经有节点,不重复加载
|
||
if (nodes.value.length > 0) {
|
||
console.log('[WorkflowEditor] 节点已存在,跳过加载')
|
||
return
|
||
}
|
||
|
||
// 加载节点
|
||
if (props.initialNodes && props.initialNodes.length > 0) {
|
||
console.log('[WorkflowEditor] 加载初始节点:', props.initialNodes.length, '个节点', props.initialNodes)
|
||
const vueFlowNodes = props.initialNodes.map((node: any) => ({
|
||
id: node.id,
|
||
type: node.type,
|
||
position: node.position || { x: 0, y: 0 },
|
||
data: node.data || { label: node.label || node.type }
|
||
}))
|
||
addNodes(vueFlowNodes)
|
||
console.log('[WorkflowEditor] 已添加节点到画布:', vueFlowNodes.length)
|
||
}
|
||
|
||
// 等待节点添加完成后再添加连接
|
||
await nextTick()
|
||
|
||
// 加载连接
|
||
if (props.initialEdges && props.initialEdges.length > 0) {
|
||
console.log('[WorkflowEditor] 加载初始连接:', props.initialEdges.length, '个连接', props.initialEdges)
|
||
const vueFlowEdges = props.initialEdges.map((edge: any) => ({
|
||
id: edge.id,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
sourceHandle: edge.sourceHandle,
|
||
targetHandle: edge.targetHandle,
|
||
selectable: true,
|
||
deletable: true,
|
||
focusable: true,
|
||
type: edge.type || 'bezier',
|
||
animated: true,
|
||
style: {
|
||
stroke: '#409eff',
|
||
strokeWidth: 2.5,
|
||
strokeDasharray: '0'
|
||
},
|
||
markerEnd: {
|
||
type: 'arrowclosed',
|
||
color: '#409eff',
|
||
width: 20,
|
||
height: 20
|
||
}
|
||
}))
|
||
addEdges(vueFlowEdges)
|
||
console.log('[WorkflowEditor] 已添加连接到画布:', vueFlowEdges.length)
|
||
}
|
||
|
||
// 保存初始状态
|
||
await nextTick()
|
||
if (props.initialNodes && props.initialNodes.length > 0) {
|
||
const workflowData = {
|
||
nodes: nodes.value,
|
||
edges: edges.value
|
||
}
|
||
lastSavedData.value = JSON.stringify(workflowData)
|
||
hasChanges.value = false
|
||
}
|
||
}
|
||
|
||
// 立即尝试加载
|
||
await loadInitialData()
|
||
|
||
// 如果提供了 workflowId,从后端加载工作流数据
|
||
if (props.workflowId) {
|
||
try {
|
||
const workflow = await workflowStore.fetchWorkflow(props.workflowId)
|
||
if (workflow) {
|
||
// 转换工作流数据为Vue Flow格式
|
||
const vueFlowNodes = workflow.nodes.map((node: any) => ({
|
||
id: node.id,
|
||
type: node.type,
|
||
position: node.position,
|
||
data: node.data
|
||
}))
|
||
const vueFlowEdges = workflow.edges.map((edge: any) => ({
|
||
id: edge.id,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
sourceHandle: edge.sourceHandle,
|
||
targetHandle: edge.targetHandle,
|
||
type: edge.type || 'bezier',
|
||
animated: true,
|
||
selectable: true,
|
||
deletable: true,
|
||
focusable: true,
|
||
style: {
|
||
stroke: '#409eff',
|
||
strokeWidth: 2.5,
|
||
strokeDasharray: '0'
|
||
},
|
||
markerEnd: {
|
||
type: 'arrowclosed',
|
||
color: '#409eff',
|
||
width: 20,
|
||
height: 20
|
||
}
|
||
}))
|
||
|
||
addNodes(vueFlowNodes)
|
||
addEdges(vueFlowEdges)
|
||
|
||
// 保存初始状态
|
||
await nextTick()
|
||
const workflowData = {
|
||
nodes: vueFlowNodes,
|
||
edges: vueFlowEdges
|
||
}
|
||
lastSavedData.value = JSON.stringify(workflowData)
|
||
hasChanges.value = false
|
||
}
|
||
} catch (error) {
|
||
console.error('加载工作流失败:', error)
|
||
}
|
||
|
||
// 建立协作连接
|
||
if (collaboration) {
|
||
try {
|
||
collaboration.connect()
|
||
|
||
// 监听协作状态
|
||
watch(() => collaboration?.connected, (connected) => {
|
||
collaborationConnected.value = connected || false
|
||
}, { immediate: true })
|
||
|
||
// 监听在线用户
|
||
watch(() => collaboration?.onlineUsers, (users) => {
|
||
onlineUsers.value = users || []
|
||
}, { immediate: true, deep: true })
|
||
|
||
// 监听当前用户
|
||
watch(() => collaboration?.currentUser, (user) => {
|
||
currentUser.value = user || null
|
||
}, { immediate: true })
|
||
|
||
// 监听远程操作
|
||
collaboration.onOperation((operation) => {
|
||
// 只处理其他用户的操作
|
||
if (operation.user_id !== currentUser.value?.user_id) {
|
||
handleRemoteOperation(operation)
|
||
}
|
||
})
|
||
} catch (error) {
|
||
console.error('建立协作连接失败:', error)
|
||
ElMessage.warning('协作功能不可用')
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
// 清理键盘事件监听
|
||
window.removeEventListener('keydown', handleKeyDown)
|
||
// 禁用自动保存
|
||
disableAutoSave()
|
||
|
||
// 清理测试动画定时器
|
||
if (testAnimationTimer.value) {
|
||
clearTimeout(testAnimationTimer.value)
|
||
testAnimationTimer.value = null
|
||
}
|
||
|
||
// 断开协作连接
|
||
if (collaboration) {
|
||
collaboration.disconnect()
|
||
}
|
||
|
||
// 如果有未保存的更改,提示用户
|
||
if (hasChanges.value) {
|
||
ElMessageBox.confirm(
|
||
'工作流有未保存的更改,确定要离开吗?',
|
||
'提示',
|
||
{
|
||
confirmButtonText: '离开',
|
||
cancelButtonText: '取消',
|
||
type: 'warning'
|
||
}
|
||
).catch(() => {
|
||
// 用户取消,不做任何操作
|
||
})
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.workflow-editor {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.editor-toolbar {
|
||
padding: 8px 12px;
|
||
background: #f5f5f5;
|
||
border-bottom: 1px solid #ddd;
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.editor-container {
|
||
flex: 1;
|
||
display: flex;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
.node-toolbox {
|
||
width: 220px;
|
||
min-width: 220px;
|
||
background: #fff;
|
||
border-right: 1px solid #ddd;
|
||
padding: 10px;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.node-toolbox h3 {
|
||
margin: 0 0 10px 0;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
.node-search {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.node-filters {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.node-filters :deep(.el-radio-group) {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
}
|
||
|
||
.node-filters :deep(.el-radio-button__inner) {
|
||
padding: 4px 8px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.node-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
.node-item {
|
||
padding: 8px 10px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
cursor: move;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
transition: all 0.2s;
|
||
font-size: 13px;
|
||
user-select: none;
|
||
-webkit-user-drag: element;
|
||
}
|
||
|
||
.node-item:hover {
|
||
background: #f0f0f0;
|
||
border-color: #409eff;
|
||
transform: translateX(2px);
|
||
}
|
||
|
||
.node-item:active {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.empty-nodes {
|
||
padding: 20px 0;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 画布节点搜索区域 */
|
||
.canvas-node-search {
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid #e4e7ed;
|
||
}
|
||
|
||
.canvas-node-search .search-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.canvas-node-search h4 {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.canvas-node-list {
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.canvas-node-item {
|
||
padding: 6px 8px;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
transition: all 0.2s;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.canvas-node-item:hover {
|
||
background: #f5f7fa;
|
||
border-color: #409eff;
|
||
}
|
||
|
||
.canvas-node-item.is-selected {
|
||
background: #ecf5ff;
|
||
border-color: #409eff;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.canvas-node-item .node-name {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.empty-canvas-nodes {
|
||
padding: 10px 0;
|
||
text-align: center;
|
||
}
|
||
|
||
.editor-canvas {
|
||
flex: 1;
|
||
position: relative;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.editor-canvas :deep(.vue-flow) {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.editor-canvas :deep(.vue-flow__viewport) {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.editor-canvas :deep(.vue-flow__container) {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.config-panel {
|
||
width: 420px;
|
||
min-width: 380px;
|
||
background: #fff;
|
||
border-left: 1px solid #ddd;
|
||
padding: 15px 20px 15px 15px; /* 右侧预留滚动条空间,避免按钮被遮挡 */
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
flex-shrink: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.config-panel h3 {
|
||
margin: 0 0 15px 0;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.node-test-section {
|
||
margin-top: 20px;
|
||
padding-top: 20px;
|
||
border-top: 1px solid #e4e7ed;
|
||
}
|
||
|
||
.test-input-hint {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
.test-result-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
padding: 8px;
|
||
background: #f5f7fa;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.test-status {
|
||
font-weight: 500;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.test-status.success {
|
||
color: #67c23a;
|
||
}
|
||
|
||
.test-status.error {
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.test-time {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
/* 数据流相关样式 */
|
||
.dataflow-card {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.upstream-item {
|
||
margin-bottom: 15px;
|
||
padding: 12px;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 4px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.upstream-item:hover {
|
||
border-color: #409eff;
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
.variable-suggestions {
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.var-group {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.var-group:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.group-title {
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: #606266;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.var-items {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.var-tag {
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.var-tag:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.output-field {
|
||
padding: 8px;
|
||
margin-bottom: 8px;
|
||
background: #f5f7fa;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.downstream-section {
|
||
margin-top: 15px;
|
||
padding-top: 15px;
|
||
border-top: 1px solid #e4e7ed;
|
||
}
|
||
|
||
.downstream-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.empty-state {
|
||
padding: 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 变量自动补全样式 */
|
||
.prompt-input-wrapper {
|
||
position: relative;
|
||
}
|
||
|
||
.variable-autocomplete-dropdown {
|
||
position: fixed;
|
||
z-index: 3000;
|
||
background: #fff;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 4px;
|
||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
min-width: 300px;
|
||
max-width: 500px;
|
||
}
|
||
|
||
.autocomplete-item {
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid #f5f7fa;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.autocomplete-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.autocomplete-item:hover,
|
||
.autocomplete-item.is-active {
|
||
background-color: #f5f7fa;
|
||
}
|
||
|
||
.var-item-name {
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #303133;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.var-item-desc {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.autocomplete-empty {
|
||
padding: 12px;
|
||
text-align: center;
|
||
color: #909399;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 配置助手样式 */
|
||
.config-wizard {
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.scenario-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.scenario-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.scenario-item:hover {
|
||
border-color: #409eff;
|
||
background: #f5f7fa;
|
||
}
|
||
|
||
.scenario-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 40px;
|
||
height: 40px;
|
||
background: #ecf5ff;
|
||
border-radius: 4px;
|
||
color: #409eff;
|
||
}
|
||
|
||
.scenario-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.scenario-name {
|
||
font-weight: 500;
|
||
color: #303133;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.scenario-desc {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
.template-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.template-item {
|
||
padding: 12px;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 4px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.template-item.is-favorite {
|
||
border-color: #f7ba2a;
|
||
background: #fef9e6;
|
||
}
|
||
|
||
.template-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.template-title {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.template-name {
|
||
font-weight: 500;
|
||
color: #303133;
|
||
}
|
||
|
||
.template-desc {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.template-preview {
|
||
margin: 8px 0;
|
||
padding: 8px;
|
||
background: #f5f7fa;
|
||
border-radius: 4px;
|
||
max-height: 100px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.template-preview pre {
|
||
margin: 0;
|
||
font-size: 11px;
|
||
line-height: 1.4;
|
||
color: #606266;
|
||
}
|
||
|
||
.template-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.wizard-content {
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.wizard-content .scenario-list {
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.test-error {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
padding: 8px;
|
||
background: #fef0f0;
|
||
border: 1px solid #fde2e2;
|
||
border-radius: 4px;
|
||
color: #f56c6c;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* 节点执行状态动画 */
|
||
@keyframes pulse {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
box-shadow: 0 0 15px rgba(64, 158, 255, 0.8);
|
||
}
|
||
50% {
|
||
opacity: 0.8;
|
||
box-shadow: 0 0 25px rgba(64, 158, 255, 1);
|
||
}
|
||
}
|
||
|
||
.custom-node {
|
||
padding: 8px 16px;
|
||
border-radius: 6px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
min-width: 100px;
|
||
border: 2px solid transparent;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
transition: all 0.3s ease-in-out;
|
||
}
|
||
|
||
|
||
.start-node {
|
||
background: #67c23a;
|
||
color: white;
|
||
}
|
||
|
||
.llm-node {
|
||
background: #409eff;
|
||
color: white;
|
||
}
|
||
|
||
.condition-node {
|
||
background: #e6a23c;
|
||
color: white;
|
||
}
|
||
|
||
.end-node {
|
||
background: #f56c6c;
|
||
color: white;
|
||
}
|
||
|
||
.collaboration-users {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.online-users-list {
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.user-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 6px 0;
|
||
gap: 8px;
|
||
}
|
||
|
||
.user-color {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* 节点执行状态样式 - 全局样式,确保能应用到 Vue Flow 节点 */
|
||
.custom-node.executing {
|
||
border: 3px solid #409eff !important;
|
||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.5), 0 0 20px rgba(64, 158, 255, 0.8) !important;
|
||
animation: pulse-blue 1.5s infinite !important;
|
||
transform: scale(1.05) !important;
|
||
z-index: 3000 !important; /* Element Plus 模态框 z-index 通常是 2000-2100,我们设置更高 */
|
||
position: relative !important;
|
||
}
|
||
|
||
.custom-node.executed,
|
||
.vue-flow__node.executed .custom-node,
|
||
.vue-flow__node .custom-node.executed {
|
||
border: 3px solid #67c23a !important;
|
||
box-shadow: 0 0 0 3px rgba(103, 194, 58, 0.5), 0 2px 8px rgba(103, 194, 58, 0.3) !important;
|
||
z-index: 2999 !important; /* 比执行中的节点稍低,但仍在模态框之上 */
|
||
position: relative !important;
|
||
}
|
||
|
||
.custom-node.failed,
|
||
.vue-flow__node.failed .custom-node,
|
||
.vue-flow__node .custom-node.failed {
|
||
border: 3px solid #f56c6c !important;
|
||
box-shadow: 0 0 0 3px rgba(245, 108, 108, 0.5), 0 2px 8px rgba(245, 108, 108, 0.3) !important;
|
||
z-index: 2999 !important; /* 比执行中的节点稍低,但仍在模态框之上 */
|
||
position: relative !important;
|
||
}
|
||
|
||
/* 确保 Vue Flow 节点容器也能应用样式 */
|
||
.vue-flow__node .custom-node.executing,
|
||
.vue-flow__node .custom-node.executed,
|
||
.vue-flow__node .custom-node.failed {
|
||
border: 3px solid !important;
|
||
box-shadow: 0 0 0 3px !important;
|
||
}
|
||
|
||
.vue-flow__node .custom-node.executing {
|
||
border-color: #409eff !important;
|
||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.5), 0 0 20px rgba(64, 158, 255, 0.8) !important;
|
||
animation: pulse-blue 1.5s infinite, node-pulse 2s ease-in-out infinite !important;
|
||
transform: scale(1.05) !important;
|
||
z-index: 3000 !important;
|
||
position: relative !important;
|
||
}
|
||
|
||
.vue-flow__node .custom-node.executed {
|
||
border-color: #67c23a !important;
|
||
box-shadow: 0 0 0 3px rgba(103, 194, 58, 0.5), 0 2px 8px rgba(103, 194, 58, 0.3) !important;
|
||
z-index: 2999 !important;
|
||
position: relative !important;
|
||
}
|
||
|
||
.vue-flow__node .custom-node.failed {
|
||
border-color: #f56c6c !important;
|
||
box-shadow: 0 0 0 3px rgba(245, 108, 108, 0.5), 0 2px 8px rgba(245, 108, 108, 0.3) !important;
|
||
z-index: 2999 !important;
|
||
position: relative !important;
|
||
}
|
||
|
||
@keyframes pulse-blue {
|
||
0% {
|
||
box-shadow: 0 0 0 0 rgba(64, 158, 255, 0.5), 0 0 20px rgba(64, 158, 255, 0.8);
|
||
}
|
||
70% {
|
||
box-shadow: 0 0 0 10px rgba(64, 158, 255, 0), 0 0 30px rgba(64, 158, 255, 1);
|
||
}
|
||
100% {
|
||
box-shadow: 0 0 0 0 rgba(64, 158, 255, 0), 0 0 20px rgba(64, 158, 255, 0.8);
|
||
}
|
||
}
|
||
|
||
/* 旋转动画 - 用于加载指示器 */
|
||
@keyframes spin {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
/* 增强执行状态的动画效果 */
|
||
.custom-node.executing {
|
||
animation: pulse-blue 1.5s infinite, node-pulse 2s ease-in-out infinite !important;
|
||
}
|
||
|
||
@keyframes node-pulse {
|
||
0%, 100% {
|
||
transform: scale(1.05);
|
||
}
|
||
50% {
|
||
transform: scale(1.08);
|
||
}
|
||
}
|
||
|
||
/* 执行成功时的闪烁效果 */
|
||
.custom-node.executed {
|
||
animation: success-flash 0.5s ease-out !important;
|
||
}
|
||
|
||
@keyframes success-flash {
|
||
0% {
|
||
box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.5), 0 0 20px rgba(103, 194, 58, 0.8);
|
||
}
|
||
50% {
|
||
box-shadow: 0 0 0 8px rgba(103, 194, 58, 0.3), 0 0 30px rgba(103, 194, 58, 1);
|
||
}
|
||
100% {
|
||
box-shadow: 0 0 0 3px rgba(103, 194, 58, 0.5), 0 2px 8px rgba(103, 194, 58, 0.3);
|
||
}
|
||
}
|
||
|
||
/* 执行失败时的闪烁效果 */
|
||
.custom-node.failed {
|
||
animation: error-shake 0.5s ease-out !important;
|
||
}
|
||
|
||
@keyframes error-shake {
|
||
0%, 100% {
|
||
transform: translateX(0);
|
||
}
|
||
10%, 30%, 50%, 70%, 90% {
|
||
transform: translateX(-3px);
|
||
}
|
||
20%, 40%, 60%, 80% {
|
||
transform: translateX(3px);
|
||
}
|
||
}
|
||
|
||
/* 边的执行状态样式 */
|
||
.vue-flow__edge.edge-executing .vue-flow__edge-path {
|
||
stroke: #409eff !important;
|
||
stroke-width: 3.5 !important;
|
||
stroke-dasharray: 8,4 !important;
|
||
animation: edge-flow 1.5s linear infinite !important;
|
||
}
|
||
|
||
.vue-flow__edge.edge-executed .vue-flow__edge-path {
|
||
stroke: #67c23a !important;
|
||
stroke-width: 3 !important;
|
||
}
|
||
|
||
@keyframes edge-flow {
|
||
0% {
|
||
stroke-dashoffset: 0;
|
||
}
|
||
100% {
|
||
stroke-dashoffset: 12;
|
||
}
|
||
}
|
||
|
||
/* 全局样式 - 优化边的选中效果(类似 Dify) */
|
||
.vue-flow__edge.selected .vue-flow__edge-path {
|
||
stroke: #67c23a !important;
|
||
stroke-width: 3.5 !important;
|
||
}
|
||
|
||
.vue-flow__edge.selected .vue-flow__edge-text {
|
||
fill: #67c23a !important;
|
||
}
|
||
|
||
.vue-flow__edge:hover .vue-flow__edge-path {
|
||
stroke-width: 3 !important;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* 连接点样式优化(类似 Dify) */
|
||
.vue-flow__handle {
|
||
width: 10px !important;
|
||
height: 10px !important;
|
||
border: 2px solid #fff !important;
|
||
background: #409eff !important;
|
||
transition: all 0.2s ease !important;
|
||
}
|
||
|
||
.vue-flow__handle:hover {
|
||
width: 14px !important;
|
||
height: 14px !important;
|
||
background: #67c23a !important;
|
||
box-shadow: 0 0 0 4px rgba(64, 158, 255, 0.2) !important;
|
||
}
|
||
|
||
.vue-flow__handle-connecting {
|
||
background: #67c23a !important;
|
||
box-shadow: 0 0 0 4px rgba(103, 194, 58, 0.3) !important;
|
||
}
|
||
|
||
.vue-flow__handle-valid {
|
||
background: #67c23a !important;
|
||
}
|
||
|
||
.vue-flow__connection-line {
|
||
stroke: #409eff !important;
|
||
stroke-width: 2.5 !important;
|
||
stroke-dasharray: 5,5 !important;
|
||
}
|
||
|
||
/* 工作流模板对话框样式 */
|
||
.template-list {
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.template-item {
|
||
padding: 15px;
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
background: #fff;
|
||
}
|
||
|
||
.template-item:hover {
|
||
border-color: #409eff;
|
||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.template-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.template-header h4 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.template-description {
|
||
color: #606266;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.template-meta {
|
||
display: flex;
|
||
gap: 15px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
.template-meta span {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.empty-templates {
|
||
padding: 40px 0;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 节点测试区域样式 */
|
||
.test-input-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.test-input-hint {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
.test-input-hint .error-text {
|
||
color: #f56c6c;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.input-error textarea {
|
||
border-color: #f56c6c !important;
|
||
}
|
||
|
||
.test-result-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
padding: 8px 12px;
|
||
background: #f5f7fa;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.test-status-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.test-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.test-status.success {
|
||
color: #67c23a;
|
||
}
|
||
|
||
.test-status.error {
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.test-time {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
.test-result-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.test-result-content {
|
||
border: 1px solid #e4e7ed;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.json-viewer {
|
||
margin: 0;
|
||
padding: 12px;
|
||
background: #fafafa;
|
||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: #303133;
|
||
overflow-x: auto;
|
||
white-space: pre;
|
||
}
|
||
|
||
.test-error-detail {
|
||
padding: 12px;
|
||
}
|
||
|
||
.error-trace {
|
||
margin-top: 12px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.error-trace details {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.error-trace summary {
|
||
margin-bottom: 8px;
|
||
color: #606266;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.error-trace pre {
|
||
margin: 8px 0 0 0;
|
||
padding: 8px;
|
||
background: #f5f7fa;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
color: #f56c6c;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.test-stats {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
/* 配置按钮布局 */
|
||
.config-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
width: 100%;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.config-actions .el-button {
|
||
flex: 1 1 0;
|
||
min-width: 100px;
|
||
}
|
||
|
||
/* 快速模板 & 变量插入 */
|
||
.quick-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
margin-bottom: 16px;
|
||
background: #f8f9fb;
|
||
border: 1px solid #ebeef5;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.quick-item {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.quick-label {
|
||
font-weight: 600;
|
||
color: #303133;
|
||
}
|
||
|
||
.test-case-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.swimlane-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.lane-config {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-left: 8px;
|
||
}
|
||
</style>
|