You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

486 lines
22 KiB

<template>
<div class="flow-editor-page">
<el-page-header @back="$router.back()" :content="isEdit ? '编辑: ' + flowName : '创建新流'" />
<el-alert v-if="initError" type="error" :title="initError" show-icon closable @close="initError = ''" style="margin-top: 12px" />
<el-card style="margin-top: 20px">
<div class="editor-toolbar">
<el-input v-model="flowName" placeholder="流名称" style="width: 200px" />
<el-input v-model="flowDesc" placeholder="描述" style="width: 300px; margin-left: 12px" />
<el-select v-model="flowMode" style="width: 140px; margin-left: 12px" :disabled="isEdit">
<el-option label="对话型 (Chatflow)" value="chatflow" />
<el-option label="工作流型 (Workflow)" value="workflow" />
</el-select>
<el-button type="primary" @click="saveFlow" :loading="saving">保存</el-button>
<el-button @click="testFlow">验证</el-button>
<el-button v-if="isEdit" type="success" @click="publishFlow">上架到企微</el-button>
<el-button v-if="isEdit" type="primary" @click="publishToWeb">上架到网页</el-button>
<el-button v-if="isEdit" @click="showVersionHistory">版本历史</el-button>
</div>
</el-card>
<div v-if="!initError" class="editor-body">
<div class="node-panel">
<div class="panel-title">节点面板</div>
<div
v-for="node in nodeTypes"
:key="node.type"
class="node-item"
draggable="true"
@dragstart="onDragStart($event, node)"
>
<el-icon :size="18"><component :is="node.icon" /></el-icon>
<span>{{ node.label }}</span>
</div>
<el-divider />
<div class="panel-title">操作提示</div>
<div class="panel-hint">
<p>拖拽节点到画布</p>
<p>从绿色圆点拖线(true)</p>
<p>从红色圆点拖线(false)</p>
<p>循环: 青色(循环体)/灰色(完成)</p>
<p>选中连线/节点按 Delete 删除</p>
<p>右键点击连线可删除</p>
<p>点击空白处取消选中</p>
<p>滚轮缩放画布</p>
</div>
</div>
<div class="canvas-wrapper">
<div class="canvas-header">
<span>设计画布</span>
<el-button size="small" @click="clearCanvas">清空</el-button>
</div>
<FlowCanvas
ref="canvasRef"
:nodes="nodes"
:edges="edges"
:node-color="getMiniMapColor"
@node-click="onNodeClick"
@pane-click="onPaneClick"
@drop="onDrop"
@node-delete="removeNode"
@update:nodes="(v: any) => nodes = v"
@update:edges="(v: any) => edges = v"
/>
</div>
<div class="config-panel" v-if="selectedNodeId">
<div class="panel-title">节点配置</div>
<div class="config-actions">
<el-button size="small" type="danger" @click="removeNode(selectedNodeId)">删除节点</el-button>
</div>
<el-form label-width="100px" size="small" style="margin-top:12px">
<el-form-item label="类型">
<el-tag>{{ selectedNode?.data?.typeDesc || selectedNode?.type }}</el-tag>
</el-form-item>
<el-form-item label="名称">
<el-input v-model="selectedNodeData.label" @change="onConfigLabelChange" />
</el-form-item>
<component
v-if="selectedNode?.data?.type"
:is="getConfigComponent(selectedNode.data.type)"
v-model="selectedNodeData.config"
:model-list="modelList"
:mcp-servers="mcpServers"
@change="onConfigChange"
/>
</el-form>
</div>
</div>
<el-dialog v-model="versionDialogVisible" title="版本历史" width="700px">
<el-table :data="versions" v-loading="loadingVersions" max-height="400">
<el-table-column prop="version" label="版本" width="80" />
<el-table-column prop="changelog" label="变更说明" min-width="200">
<template #default="{ row }">
<span v-if="row.changelog">{{ row.changelog }}</span>
<span v-else style="color:#999">-</span>
</template>
</el-table-column>
<el-table-column label="时间" width="170">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="rollbackVersion(row.id)">
回滚到此版本
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="!versions.length && !loadingVersions" style="text-align:center;padding:24px;color:#999">
暂无版本记录
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MarkerType } from '@vue-flow/core'
import { Promotion, ChatDotRound, Tools, Connection, Bell, DataAnalysis, Search, RefreshRight, Document, Link, Operation, Edit, Sunny } from '@element-plus/icons-vue'
import FlowCanvas from './FlowCanvas.vue'
import TriggerConfig from './node-configs/TriggerConfig.vue'
import LlmConfig from './node-configs/LlmConfig.vue'
import ToolConfig from './node-configs/ToolConfig.vue'
import McpConfig from './node-configs/McpConfig.vue'
import NotifyConfig from './node-configs/WecomNotifyConfig.vue'
import ConditionConfig from './node-configs/ConditionConfig.vue'
import RagConfig from './node-configs/RagConfig.vue'
import OutputConfig from './node-configs/OutputConfig.vue'
import LoopConfig from './node-configs/LoopConfig.vue'
import CodeConfig from './node-configs/CodeConfig.vue'
import HttpRequestConfig from './node-configs/HttpRequestConfig.vue'
import QuestionClassifierConfig from './node-configs/QuestionClassifierConfig.vue'
import VariableAssignerConfig from './node-configs/VariableAssignerConfig.vue'
import TemplateTransformConfig from './node-configs/TemplateTransformConfig.vue'
import IterationConfig from './node-configs/IterationConfig.vue'
import QuestionOptimiserConfig from './node-configs/QuestionOptimiserConfig.vue'
import MergeConfig from './node-configs/MergeConfig.vue'
const route = useRoute()
const router = useRouter()
const flowId = computed(() => route.params.id as string)
const isEdit = computed(() => !!flowId.value)
const initError = ref('')
const flowName = ref('新工作流')
const flowDesc = ref('')
const flowStatus = ref('')
const flowMode = ref('chatflow')
const saving = ref(false)
const versionDialogVisible = ref(false)
const versions = ref<any[]>([])
const loadingVersions = ref(false)
const selectedNodeId = ref('')
const selectedNodeData = ref<any>({})
const mcpServers = ref<any[]>([])
const modelList = ref<any[]>([])
const nodes = ref<any[]>([])
const edges = ref<any[]>([])
const canvasRef = ref<any>(null)
let nodeCounter = 0
const nodeTypes = [
{ type: 'trigger', label: '触发节点', icon: Promotion, typeDesc: '流程触发' },
{ type: 'llm', label: 'LLM处理', icon: ChatDotRound, typeDesc: 'AI处理' },
{ type: 'tool', label: '工具调用', icon: Tools, typeDesc: '工具调用' },
{ type: 'mcp', label: 'MCP服务', icon: Connection, typeDesc: '外部MCP' },
{ type: 'notify', label: '通知', icon: Bell, typeDesc: '消息通知' },
{ type: 'condition', label: '条件判断', icon: DataAnalysis, typeDesc: '条件分支' },
{ type: 'rag', label: 'RAG检索', icon: Search, typeDesc: '知识库检索' },
{ type: 'loop', label: '循环', icon: RefreshRight, typeDesc: '循环迭代' },
{ type: 'merge', label: '变量聚合', icon: Connection, typeDesc: '并行汇聚' },
{ type: 'code', label: '代码执行', icon: Document, typeDesc: '代码执行' },
{ type: 'output', label: '输出节点', icon: Promotion, typeDesc: '结果输出' },
{ type: 'http_request', label: 'HTTP请求', icon: Link, typeDesc: '外部API调用' },
{ type: 'question_classifier', label: '问题分类', icon: Operation, typeDesc: '意图分类路由' },
{ type: 'variable_assigner', label: '变量赋值', icon: Edit, typeDesc: '变量操作' },
{ type: 'template_transform', label: '模板转换', icon: Document, typeDesc: '格式转换' },
{ type: 'iteration', label: '迭代处理', icon: RefreshRight, typeDesc: '数组遍历' },
{ type: 'question_optimiser', label: '问题优化', icon: Sunny, typeDesc: '查询优化' },
]
const configComponentMap: Record<string, any> = {
trigger: TriggerConfig,
llm: LlmConfig,
tool: ToolConfig,
mcp: McpConfig,
notify: NotifyConfig,
wecom_notify: NotifyConfig,
condition: ConditionConfig,
rag: RagConfig,
output: OutputConfig,
loop: LoopConfig,
code: CodeConfig,
merge: MergeConfig,
http_request: HttpRequestConfig,
question_classifier: QuestionClassifierConfig,
variable_assigner: VariableAssignerConfig,
template_transform: TemplateTransformConfig,
iteration: IterationConfig,
question_optimiser: QuestionOptimiserConfig,
}
function getConfigComponent(type: string) {
return configComponentMap[type] || null
}
const colorMap: Record<string, string> = {
trigger: '#722ed1',
llm: '#409EFF',
tool: '#67C23A',
mcp: '#E6A23C',
notify: '#F56C6C',
wecom_notify: '#F56C6C',
condition: '#909399',
rag: '#337ecc',
loop: '#13c2c2',
code: '#eb2f96',
output: '#722ed1',
http_request: '#2d8cf0',
question_classifier: '#ff9900',
variable_assigner: '#19be6b',
template_transform: '#9c27b0',
iteration: '#ff5722',
question_optimiser: '#e6a23c',
}
const selectedNode = computed(() => nodes.value.find((n: any) => n.id === selectedNodeId.value) || null)
function getMiniMapColor(node: any) {
return colorMap[node?.data?.type || node?.type] || '#409EFF'
}
function onDragStart(event: DragEvent, node: any) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow', JSON.stringify(node))
event.dataTransfer.effectAllowed = 'move'
}
}
function onDrop(event: DragEvent) {
const dataStr = event.dataTransfer?.getData('application/vueflow')
if (!dataStr) return
const nodeData = JSON.parse(dataStr)
const id = `node_${++nodeCounter}`
nodes.value.push({
id,
type: 'custom',
position: { x: 100 + nodeCounter * 50, y: 100 + nodeCounter * 30 },
data: {
label: nodeData.label,
type: nodeData.type,
typeDesc: nodeData.typeDesc,
color: colorMap[nodeData.type] || '#409EFF',
config: getDefaultConfig(nodeData.type),
},
draggable: true,
connectable: true,
})
}
function getDefaultConfig(type: string) {
const defaults: Record<string, any> = {
trigger: { event_type: 'text_message', channels: ['wecom'], callback_url: '' },
llm: { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '', max_tokens: 2000, context_length: 5, memory_mode: 'short_term', stream: true, tool_call: false },
tool: { tool_type: '', tool_name: '', param_mapping: '{}', tool_params: {}, timeout: 30, retry_count: 0, error_handling: 'throw' },
mcp: { mcp_server: '', tool_name: '', input_params: {}, timeout: 30, response_parser: 'json', error_handling: 'throw' },
notify: { channels: { wecom: true, web: false }, message_template: '', web_template: '', target: '', message_type: 'text', async_send: false, error_handling: 'throw' },
wecom_notify: { channels: { wecom: true, web: false }, message_template: '', web_template: '', target: '', message_type: 'text', async_send: false, error_handling: 'throw' },
condition: { condition_type: 'expression', condition: '', true_label: '是', false_label: '否', default_branch: 'false' },
rag: { knowledge_base: '', top_k: 5, search_mode: 'hybrid', similarity_threshold: 0.7, result_sort: 'similarity', include_metadata: true },
loop: { loop_type: 'fixed', count: 3, iterator_variable: 'item', max_iterations: 10, items: [] },
merge: { merge_type: 'concat', expected_branches: 2 },
code: { language: 'python', code: '', timeout: 30, sandbox: true },
output: { format: 'text', output_template: '', indent: 2, encoding: 'utf-8', truncate: false, max_length: 2000 },
http_request: { method: 'GET', url: '', headers: '{}', body: '', auth_type: 'none', auth_config: {}, timeout: 30, retry_count: 0 },
question_classifier: { categories: [], instruction: '', model: '', temperature: 0.3 },
variable_assigner: { assignments: [] },
template_transform: { template: '', output_type: 'string' },
iteration: { input_array_source: '', max_iterations: 20 },
question_optimiser: { optimization_type: 'rewrite', model: '' },
}
return defaults[type] || {}
}
function onNodeClick({ node }: any) {
selectedNodeId.value = node.id
const d = node.data || {}
selectedNodeData.value = { label: d.label || '', typeDesc: d.typeDesc || '', config: { ...(d.config || {}) } }
}
function onPaneClick() { selectedNodeId.value = '' }
function onConfigLabelChange() {
const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value)
if (idx !== -1) {
const updated = { ...nodes.value[idx] }
updated.data = { ...updated.data, label: selectedNodeData.value.label }
nodes.value = nodes.value.map((n: any, i: number) => i === idx ? updated : n)
}
}
function onConfigChange() {
const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value)
if (idx === -1) return
const found = nodes.value[idx]
const updated = { ...found }
updated.data = { ...found.data, config: { ...selectedNodeData.value.config } }
nodes.value = nodes.value.map((n: any, i: number) => i === idx ? updated : n)
}
function removeNode(id: string) {
nodes.value = nodes.value.filter((n: any) => n.id !== id)
edges.value = edges.value.filter((e: any) => e.source !== id && e.target !== id)
if (selectedNodeId.value === id) selectedNodeId.value = ''
}
function clearCanvas() { nodes.value = []; edges.value = []; nodeCounter = 0; selectedNodeId.value = '' }
function getEdgeStyle(sourceHandle: string | undefined) {
if (sourceHandle === 'false') return { stroke: '#E53935', strokeWidth: 3 }
if (sourceHandle === 'loop_body') return { stroke: '#13c2c2', strokeWidth: 3, strokeDasharray: '6,3' }
if (sourceHandle === 'loop_done') return { stroke: '#909399', strokeWidth: 3 }
return { stroke: '#1976D2', strokeWidth: 3 }
}
async function loadFlow() {
if (!isEdit.value) return
try {
const { flowApi } = await import('@/api')
const res: any = await flowApi.getFlow(flowId.value)
const flow = res?.data || res || {}
flowName.value = flow.name || ''
flowDesc.value = flow.description || ''
flowStatus.value = flow.status || ''
flowMode.value = flow.flow_mode || 'chatflow'
const definition = flow.definition_json || {}
const loadedNodes: any[] = []
const loadedEdges: any[] = []
const defNodes = definition.nodes || []
for (let i = 0; i < defNodes.length; i++) {
const n = defNodes[i]
const nt = nodeTypes.find(t => t.type === n.type)
loadedNodes.push({
id: n.id || `node_${i}`, type: 'custom',
position: n.position || { x: 100 + (i % 4) * 250, y: 60 + Math.floor(i / 4) * 150 },
data: { label: n.label || n.id || `节点${i}`, type: n.type, typeDesc: nt?.typeDesc || n.type, color: colorMap[n.type] || '#409EFF', config: n.config || {} },
draggable: true, connectable: true,
})
}
const defEdges = definition.edges || []
for (const e of defEdges) {
const source = e.source || e.from
const target = e.target || e.to
const cond = e.condition || e.sourceHandle
loadedEdges.push({ id: e.id || `edge_${source}_${target}`, source, target, sourceHandle: cond || 'source', type: 'smoothstep', animated: cond === 'loop_body', markerEnd: MarkerType.ArrowClosed, style: getEdgeStyle(cond) })
}
nodes.value = loadedNodes
edges.value = loadedEdges
nodeCounter = defNodes.length
} catch (e: any) {
console.error('loadFlow error:', e)
}
}
async function loadMcpServers() {
try { const { mcpApi } = await import('@/api'); const res: any = await mcpApi.getServers(); mcpServers.value = Array.isArray(res) ? res : (res?.data || []) } catch {}
}
async function loadModels() {
try { const { modelProviderApi } = await import('@/api'); const res: any = await modelProviderApi.getAllModels(); modelList.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []) } catch {}
}
async function saveFlow() {
if (!flowName.value) { ElMessage.warning('请输入流名称'); return }
saving.value = true
try {
const { flowApi } = await import('@/api')
const snapshot = canvasRef.value?.getSnapshot() || { nodes: [], edges: [] }
const serializedNodes = snapshot.nodes.map((n: any) => ({ id: n.id, type: n.data?.type || n.type, label: n.data?.label || n.id, config: n.data?.config || {}, position: n.position }))
const serializedEdges = snapshot.edges.map((e: any) => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle || 'source' }))
const payload = { name: flowName.value, description: flowDesc.value, nodes: serializedNodes, edges: serializedEdges, trigger: {}, flow_mode: flowMode.value }
if (isEdit.value) { await flowApi.updateFlow(flowId.value, payload); ElMessage.success('保存成功') }
else { const res: any = await flowApi.createFlow(payload); const data = res?.data || res || {}; if (data.id) { router.replace(`/admin/flow/editor/${data.id}`); ElMessage.success('创建成功') } }
} finally { saving.value = false }
}
async function testFlow() {
if (!isEdit.value) { ElMessage.info('请先保存再进行验证'); return }
try { const { flowApi } = await import('@/api'); const res: any = await flowApi.testFlow(flowId.value); const data = res?.data || res || {}; if (data.valid) { ElMessage.success(`验证通过: ${data.node_count}个节点, ${data.edge_count}条边`) } else { ElMessage.warning(`验证问题: ${(data.issues || []).join(', ')}`) } } catch {}
}
async function publishFlow() {
if (!isEdit.value) { ElMessage.warning('请先保存'); return }
try { const { flowApi } = await import('@/api'); await flowApi.publishFlow(flowId.value); ElMessage.success('流已上架到企微'); await loadFlow() } catch {}
}
async function publishToWeb() {
if (!isEdit.value) { ElMessage.warning('请先保存'); return }
try { const { flowApi } = await import('@/api'); await flowApi.publishToWeb(flowId.value); ElMessage.success('流已上架到网页'); await loadFlow() } catch {}
}
async function showVersionHistory() {
versionDialogVisible.value = true
await loadVersions()
}
async function loadVersions() {
loadingVersions.value = true
try {
const { flowApi } = await import('@/api')
const res: any = await flowApi.getVersions(flowId.value)
versions.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
} catch { versions.value = [] }
finally { loadingVersions.value = false }
}
async function rollbackVersion(versionId: string) {
try {
await ElMessageBox.confirm('回滚将覆盖当前定义,确定继续?', '确认回滚', { type: 'warning' })
const { flowApi } = await import('@/api')
await flowApi.rollbackVersion(flowId.value, versionId)
ElMessage.success('已回滚到该版本')
versionDialogVisible.value = false
await loadFlow()
} catch {}
}
function formatTime(ts: string) {
if (!ts) return '-'
try { return new Date(ts).toLocaleString() } catch { return ts }
}
onMounted(async () => {
try {
if (isEdit.value) { await loadFlow() }
await Promise.allSettled([loadMcpServers(), loadModels()])
startAutoSave()
} catch (e: any) {
console.error('FlowEditor init error:', e)
initError.value = '初始化失败: ' + (e?.message || '未知错误')
}
})
let autoSaveTimer: ReturnType<typeof setInterval> | null = null
function startAutoSave() {
if (autoSaveTimer) clearInterval(autoSaveTimer)
autoSaveTimer = setInterval(() => {
if (flowName.value && isEdit.value) {
saveFlow().catch(() => {})
}
}, 30000)
}
onBeforeUnmount(() => {
if (autoSaveTimer) clearInterval(autoSaveTimer)
})
</script>
<style scoped>
.flow-editor-page { height: 100%; display: flex; flex-direction: column; }
.editor-toolbar { display: flex; align-items: center; gap: 8px; }
.editor-body { display: flex; gap: 12px; margin-top: 12px; flex: 1; min-height: 0; }
.node-panel { width: 160px; background: #fff; border-radius: 4px; padding: 12px; border: 1px solid #ebeef5; overflow-y: auto; flex-shrink: 0; }
.panel-title { font-weight: bold; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #ebeef5; }
.node-item { display: flex; align-items: center; gap: 8px; padding: 8px; margin-bottom: 8px; border: 1px solid #e4e7ed; border-radius: 4px; cursor: grab; font-size: 13px; background: #f5f7fa; transition: all 0.2s; user-select: none; }
.node-item:hover { border-color: #409EFF; background: #ecf5ff; }
.panel-hint { font-size: 12px; color: #909399; line-height: 1.8; }
.panel-hint p::before { content: '• '; color: #409EFF; }
.canvas-wrapper { flex: 1; background: #fff; border-radius: 4px; border: 1px solid #ebeef5; display: flex; flex-direction: column; min-width: 0; }
.canvas-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; border-bottom: 1px solid #ebeef5; font-weight: bold; flex-shrink: 0; }
.config-panel { width: 280px; background: #fff; border-radius: 4px; padding: 12px; border: 1px solid #ebeef5; overflow-y: auto; flex-shrink: 0; }
.config-actions { margin-top: 8px; padding-top: 8px; border-top: 1px solid #ebeef5; }
</style>