Files
aiagent/frontend/src/components/WorkflowEditor/WorkflowEditor.vue
2026-01-23 09:49:45 +08:00

8417 lines
278 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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&#10;或: {value} > 10 and {value} < 20&#10;或: ({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 IDAgent将使用其配置的工作流执行
</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_contentbase64编码内容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>