From dd80461ac6a7ca74a3a20a49b5ffab5af7fbbe79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?MSI-7950X=5C=E5=88=98=E6=B3=BD=E6=98=8E?= Date: Wed, 13 May 2026 10:29:44 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=BB=E5=B8=83=E9=A1=B5=E9=9D=A2=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/modules/rag/knowledge.py | 12 +- frontend/src/api/index.ts | 3 + frontend/src/views/flow/FlowCanvas.vue | 106 ++++++ frontend/src/views/flow/FlowEditor.vue | 437 ++++++------------------- 4 files changed, 212 insertions(+), 346 deletions(-) create mode 100644 frontend/src/views/flow/FlowCanvas.vue diff --git a/backend/modules/rag/knowledge.py b/backend/modules/rag/knowledge.py index 702af1a..7756714 100644 --- a/backend/modules/rag/knowledge.py +++ b/backend/modules/rag/knowledge.py @@ -1,4 +1,5 @@ import os +import asyncio import logging from agentscope.embedding import OpenAITextEmbedding from agentscope.rag import SimpleKnowledge, QdrantStore, TextReader, PDFReader, WordReader, ExcelReader @@ -98,7 +99,13 @@ async def add_text(text: str, source: str = "manual") -> str: async def search(query: str, limit: int = 5, score_threshold: float = 0.3) -> list[dict]: try: kb = get_knowledge_base() - docs = await kb.retrieve(query=query, limit=limit, score_threshold=score_threshold) + if not kb or not hasattr(kb, 'retrieve'): + logger.warning("知识库未初始化或不可用") + return [] + docs = await asyncio.wait_for( + kb.retrieve(query=query, limit=limit, score_threshold=score_threshold), + timeout=10.0 + ) return [ { "id": doc.id, @@ -108,6 +115,9 @@ async def search(query: str, limit: int = 5, score_threshold: float = 0.3) -> li } for doc in docs ] + except asyncio.TimeoutError: + logger.warning(f"知识检索超时 (query={query[:50]})") + return [] except Exception as e: logger.error(f"知识检索失败: {e}") return [] diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 2f8754d..9ebf2f5 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -20,6 +20,9 @@ api.interceptors.response.use( (error) => { if (error.response?.status === 401) { localStorage.removeItem('token') + if (router.currentRoute.value.path !== '/login') { + router.push('/login') + } return Promise.reject(error) } const msg = error.response?.data?.message || error.message || '请求失败' diff --git a/frontend/src/views/flow/FlowCanvas.vue b/frontend/src/views/flow/FlowCanvas.vue new file mode 100644 index 0000000..40020bf --- /dev/null +++ b/frontend/src/views/flow/FlowCanvas.vue @@ -0,0 +1,106 @@ + + + diff --git a/frontend/src/views/flow/FlowEditor.vue b/frontend/src/views/flow/FlowEditor.vue index 5315400..9118c78 100644 --- a/frontend/src/views/flow/FlowEditor.vue +++ b/frontend/src/views/flow/FlowEditor.vue @@ -2,6 +2,8 @@
+ +
@@ -14,7 +16,7 @@
-
+
节点面板
设计画布 清空
- - - - - - - + @node-delete="removeNode" + />
@@ -209,26 +176,16 @@ import { ref, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' -import { VueFlow, useVueFlow, MarkerType } from '@vue-flow/core' -import { Background } from '@vue-flow/background' -import { Controls } from '@vue-flow/controls' -import { MiniMap } from '@vue-flow/minimap' -import '@vue-flow/core/dist/style.css' -import '@vue-flow/core/dist/theme-default.css' -import '@vue-flow/controls/dist/style.css' -import '@vue-flow/minimap/dist/style.css' -import { flowApi, mcpApi, agentApi } from '@/api' -import { - Promotion, ChatDotRound, Tools, Connection, Bell, - DataAnalysis, Search, -} from '@element-plus/icons-vue' -import FlowNode from './FlowNode.vue' +import { Promotion, ChatDotRound, Tools, Connection, Bell, DataAnalysis, Search } from '@element-plus/icons-vue' +import FlowCanvas from './FlowCanvas.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('') @@ -238,6 +195,12 @@ const selectedNodeData = ref({}) const mcpServers = ref([]) const agentList = ref([]) +const nodes = ref([]) +const edges = ref([]) +const canvasRef = ref(null) + +let nodeCounter = 0 + const nodeTypes = [ { type: 'trigger', label: '触发节点', icon: Promotion, typeDesc: '企微触发' }, { type: 'llm', label: 'LLM处理', icon: ChatDotRound, typeDesc: 'AI处理' }, @@ -260,24 +223,18 @@ const colorMap: Record = { output: '#722ed1', } -const vueFlowStore = useVueFlow() -const { nodes, edges, addNodes, addEdges, removeNodes, removeEdges, toObject } = vueFlowStore -const storeAny = vueFlowStore as any -const undo = () => storeAny.undo?.() -const redo = () => storeAny.redo?.() -const canUndo = computed(() => !!(storeAny.canUndo ?? false)) -const canRedo = computed(() => !!(storeAny.canRedo ?? false)) -let nodeCounter = 0 +function undo() { canvasRef.value?.undo() } +function redo() { canvasRef.value?.redo() } +const canUndo = computed(() => canvasRef.value?.canUndo ?? false) +const canRedo = computed(() => canvasRef.value?.canRedo ?? false) -const selectedNode = computed(() => { - return nodes.value.find((n: any) => n.id === selectedNodeId.value) || null -}) +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: (typeof nodeTypes)[0]) { +function onDragStart(event: DragEvent, node: any) { if (event.dataTransfer) { event.dataTransfer.setData('application/vueflow', JSON.stringify(node)) event.dataTransfer.effectAllowed = 'move' @@ -287,74 +244,31 @@ function onDragStart(event: DragEvent, node: (typeof nodeTypes)[0]) { function onDrop(event: DragEvent) { const dataStr = event.dataTransfer?.getData('application/vueflow') if (!dataStr) return - const nodeData = JSON.parse(dataStr) - - let position = { x: 100, y: 100 } - try { - const bounds = (event.currentTarget as HTMLElement)?.getBoundingClientRect() - if (bounds) { - position = { - x: event.clientX - bounds.left - 80, - y: event.clientY - bounds.top - 20, - } - } - } catch { /* fallback */ } - const id = `node_${++nodeCounter}` - const newNode = { + canvasRef.value?.addNodes?.([{ id, type: 'custom', - position, + 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: nodeData.type === 'llm' - ? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '' } - : {}, + config: nodeData.type === 'llm' ? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7, agent_id: '' } : {}, }, draggable: true, connectable: true, - } - addNodes([newNode]) -} - -function onConnect(connection: any) { - const sourceHandle = connection.sourceHandle || 'source' - const edgeId = `edge_${connection.source}_${connection.target}_${sourceHandle}` - - const isConditionFalse = sourceHandle === 'false' - const newEdge = { - id: edgeId, - source: connection.source, - target: connection.target, - sourceHandle: connection.sourceHandle, - targetHandle: connection.targetHandle, - type: 'smoothstep', - animated: true, - markerEnd: MarkerType.ArrowClosed, - style: isConditionFalse - ? { stroke: '#F56C6C', strokeWidth: 2 } - : { stroke: '#409EFF', strokeWidth: 2 }, - } - addEdges([newEdge]) + }]) } function onNodeClick({ node }: any) { selectedNodeId.value = node.id const d = node.data || {} - selectedNodeData.value = { - label: d.label || '', - typeDesc: d.typeDesc || '', - ...(d.config || {}), - } + selectedNodeData.value = { label: d.label || '', typeDesc: d.typeDesc || '', ...(d.config || {}) } } -function onPaneClick() { - selectedNodeId.value = '' -} +function onPaneClick() { selectedNodeId.value = '' } function onConfigLabelChange() { const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value) @@ -379,288 +293,121 @@ function onAgentSelect(val: string) { function onConfigChange() { const idx = nodes.value.findIndex((n: any) => n.id === selectedNodeId.value) if (idx === -1) return - const found = nodes.value[idx] const nodeType = found.data.type const cfg: any = {} - - if (nodeType === 'trigger') { - cfg.event_type = selectedNodeData.value.event_type - } else if (nodeType === 'llm') { - cfg.system_prompt = selectedNodeData.value.system_prompt - cfg.model = selectedNodeData.value.model - cfg.temperature = selectedNodeData.value.temperature - cfg.agent_id = selectedNodeData.value.agent_id - } else if (nodeType === 'tool') { - cfg.tool_name = selectedNodeData.value.tool_name - } else if (nodeType === 'mcp') { - cfg.mcp_server = selectedNodeData.value.mcp_server - cfg.tool_name = selectedNodeData.value.tool_name - } else if (nodeType === 'wecom_notify') { - cfg.message_template = selectedNodeData.value.message_template - cfg.target = selectedNodeData.value.target - } else if (nodeType === 'condition') { - cfg.condition = selectedNodeData.value.condition - } else if (nodeType === 'rag') { - cfg.knowledge_base = selectedNodeData.value.knowledge_base - cfg.top_k = selectedNodeData.value.top_k - } else if (nodeType === 'output') { - cfg.format = selectedNodeData.value.format - } - + if (nodeType === 'trigger') cfg.event_type = selectedNodeData.value.event_type + else if (nodeType === 'llm') { cfg.system_prompt = selectedNodeData.value.system_prompt; cfg.model = selectedNodeData.value.model; cfg.temperature = selectedNodeData.value.temperature; cfg.agent_id = selectedNodeData.value.agent_id } + else if (nodeType === 'tool') cfg.tool_name = selectedNodeData.value.tool_name + else if (nodeType === 'mcp') { cfg.mcp_server = selectedNodeData.value.mcp_server; cfg.tool_name = selectedNodeData.value.tool_name } + else if (nodeType === 'wecom_notify') { cfg.message_template = selectedNodeData.value.message_template; cfg.target = selectedNodeData.value.target } + else if (nodeType === 'condition') cfg.condition = selectedNodeData.value.condition + else if (nodeType === 'rag') { cfg.knowledge_base = selectedNodeData.value.knowledge_base; cfg.top_k = selectedNodeData.value.top_k } + else if (nodeType === 'output') cfg.format = selectedNodeData.value.format const updated = { ...found } updated.data = { ...found.data, config: cfg } nodes.value[idx] = updated } function removeNode(id: string) { - removeNodes([id]) - edges.value = edges.value.filter((e: any) => e.source !== id && e.target !== id) + canvasRef.value?.removeNodes?.([id]) if (selectedNodeId.value === id) selectedNodeId.value = '' } -function clearCanvas() { - nodes.value = [] - edges.value = [] - nodeCounter = 0 - selectedNodeId.value = '' -} - -function onNodesChange() {} -function onEdgesChange() {} - -async function saveFlow() { - if (!flowName.value) { ElMessage.warning('请输入流名称'); return } - saving.value = true - try { - const serializedNodes = nodes.value.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 = edges.value.map((e: any) => ({ - source: e.source, - target: e.target, - sourceHandle: e.sourceHandle || 'source', - condition: e.sourceHandle === 'false' ? 'false' : (e.sourceHandle === 'true' ? 'true' : undefined), - })) - - const payload = { name: flowName.value, description: flowDesc.value, nodes: serializedNodes, edges: serializedEdges, trigger: {} } - - 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 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 } - await flowApi.publishFlow(flowId.value) - ElMessage.success('流已上架到企微') - await loadFlow() -} +function clearCanvas() { nodes.value = []; edges.value = []; nodeCounter = 0; selectedNodeId.value = '' } 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 || '' - 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', + 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, + 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 - const isFalse = cond === 'false' - loadedEdges.push({ - id: e.id || `edge_${source}_${target}`, - source, - target, - sourceHandle: cond || 'source', - type: 'smoothstep', - animated: true, - markerEnd: MarkerType.ArrowClosed, - style: isFalse - ? { stroke: '#F56C6C', strokeWidth: 2 } - : { stroke: '#409EFF', strokeWidth: 2 }, - }) + loadedEdges.push({ id: e.id || `edge_${source}_${target}`, source, target, sourceHandle: cond || 'source', type: 'smoothstep', animated: true, style: cond === 'false' ? { stroke: '#F56C6C', strokeWidth: 2 } : { stroke: '#409EFF', strokeWidth: 2 } }) } - nodes.value = loadedNodes edges.value = loadedEdges nodeCounter = defNodes.length - } catch { /**/ } + } catch (e: any) { + console.error('loadFlow error:', e) + } } async function loadMcpServers() { - try { - const res: any = await mcpApi.getServers() - mcpServers.value = Array.isArray(res) ? res : (res?.data || []) - } catch { /**/ } + try { const { mcpApi } = await import('@/api'); const res: any = await mcpApi.getServers(); mcpServers.value = Array.isArray(res) ? res : (res?.data || []) } catch {} } async function loadAgents() { + try { const { agentApi } = await import('@/api'); const res: any = await agentApi.getList(); agentList.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 res: any = await agentApi.getList() - agentList.value = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []) - } catch { /**/ } + const { flowApi } = await import('@/api') + const serializedNodes = nodes.value.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 = edges.value.map((e: any) => ({ source: e.source, target: e.target, sourceHandle: e.sourceHandle || 'source', condition: e.sourceHandle === 'false' ? 'false' : (e.sourceHandle === 'true' ? 'true' : undefined) })) + const payload = { name: flowName.value, description: flowDesc.value, nodes: serializedNodes, edges: serializedEdges, trigger: {} } + 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 {} } onMounted(async () => { - if (isEdit.value) { - await loadFlow() + try { + if (isEdit.value) { await loadFlow() } + await Promise.allSettled([loadMcpServers(), loadAgents()]) + } catch (e: any) { + console.error('FlowEditor init error:', e) + initError.value = '初始化失败: ' + (e?.message || '未知错误') } - await loadMcpServers() - await loadAgents() }) \ No newline at end of file +.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; } +