From 610ae27cf908eb0a4943045ed833f13f100f5b2f Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 27 Apr 2026 10:48:03 +0800 Subject: [PATCH 01/24] fix(web): switch space --- web/src/components/SiderMenu/index.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/web/src/components/SiderMenu/index.tsx b/web/src/components/SiderMenu/index.tsx index e1d7e596..c0698389 100644 --- a/web/src/components/SiderMenu/index.tsx +++ b/web/src/components/SiderMenu/index.tsx @@ -399,7 +399,7 @@ const Menu: FC<{ className="rb:overflow-y-auto rb:flex-1!" /> {/* Return to space button for superusers */} - {user?.is_superuser && source === 'space' && + {source === 'space' && {collapsed ? null : t('common.switchSpace')} - -
- {collapsed ? null : t('common.returnToSpace')} -
+ {user?.is_superuser && + +
+ {collapsed ? null : t('common.returnToSpace')} +
+ }
} {source === 'manage' && subscription && !collapsed && From d5d81f0c4f9d58c5365233a84c56f92f1f73a4d6 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 27 Apr 2026 13:47:49 +0800 Subject: [PATCH 02/24] fix(web): node execution status reset --- .../views/Workflow/hooks/useWorkflowGraph.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index a22ee6c0..d98de9e8 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-24 17:21:09 + * @Last Modified time: 2026-04-27 13:47:02 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6'; import type { HistoryCommand as Command } from '@antv/x6/lib/plugin/history/type'; @@ -1488,20 +1488,16 @@ export const useWorkflowGraph = ({ if (!graphRef.current) return; const nodes = graphRef.current.getNodes(); - const lastWithSub = [...chatHistory].reverse().find(item => item.subContent?.length); - // Reset all node execution status first + // Reset all node execution status on every chatHistory change nodes.forEach(node => { const data = node.getData(); - if (typeof data.executionStatus === 'string') { - node.setData({ ...data, executionStatus: undefined }); - } + node.setData({ ...data, executionStatus: '' }); }); - if (!lastWithSub?.subContent) return; - // Build a nodeId -> status map first - const statusMap: Record = {}; - lastWithSub.subContent.forEach(sub => { + + const lastAssistant = [...chatHistory].reverse().find(item => item.role === 'assistant'); + if (!lastAssistant?.subContent?.length) return; + lastAssistant.subContent.forEach(sub => { if (typeof sub.status === 'string') { - statusMap[sub.node_id] = sub.status; const node = nodes.find(n => n.getData()?.id === sub.node_id); if (node) { node.setData({ ...node.getData(), executionStatus: sub.status }); From dd7f9f6ceead0a823c81616d98f16f90996037b6 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 27 Apr 2026 14:08:02 +0800 Subject: [PATCH 03/24] fix(web): output type node only has left port --- web/src/views/Workflow/constant.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index 877758fc..6acc01df 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:06:18 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-21 18:23:31 + * @Last Modified time: 2026-04-27 14:07:14 */ import type { ReactShapeConfig } from '@antv/x6-react-shape'; import type { GroupMetadata, PortMetadata } from '@antv/x6/lib/model/port'; @@ -948,6 +948,15 @@ export const graphNodeLibrary: Record = { width: nodeWidth, height: 120, shape: 'notes-node', + }, + output: { + width: nodeWidth, + height: 76, + shape: 'normal-node', + ports: { + groups: { left: defaultPortGroup }, + items: [defaultPortItems[0]], + }, } } From 8baa466b3108c28889f6531d9ca66111861307fe Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 27 Apr 2026 15:00:49 +0800 Subject: [PATCH 04/24] fix(web): loop & iteration history --- .../Workflow/components/CanvasToolbar.tsx | 1 - .../views/Workflow/components/Chat/Chat.tsx | 2 -- .../Workflow/components/Nodes/AddNode.tsx | 3 +++ .../components/Nodes/ConditionNode.tsx | 2 +- .../Workflow/components/PortClickHandler.tsx | 2 ++ .../views/Workflow/hooks/useWorkflowGraph.ts | 20 ++++++++++++------- web/src/views/Workflow/utils.ts | 1 + 7 files changed, 20 insertions(+), 11 deletions(-) diff --git a/web/src/views/Workflow/components/CanvasToolbar.tsx b/web/src/views/Workflow/components/CanvasToolbar.tsx index 1bbb51f2..8225b65f 100644 --- a/web/src/views/Workflow/components/CanvasToolbar.tsx +++ b/web/src/views/Workflow/components/CanvasToolbar.tsx @@ -57,7 +57,6 @@ const CanvasToolbar: FC = ({ } }} labelRender={(props) => { - console.log('props', props) return `${props.value}%` }} className="rb:w-20 rb:h-4!" diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 863825ba..025c2e0b 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -66,8 +66,6 @@ const Chat = forwardRef([]) const [message, setMessage] = useState(undefined) - console.log('abortRef', abortRef, chatList) - /** * Opens the chat drawer and loads workflow variables from the start node */ diff --git a/web/src/views/Workflow/components/Nodes/AddNode.tsx b/web/src/views/Workflow/components/Nodes/AddNode.tsx index 3bdb96c0..15f4aa1e 100644 --- a/web/src/views/Workflow/components/Nodes/AddNode.tsx +++ b/web/src/views/Workflow/components/Nodes/AddNode.tsx @@ -18,6 +18,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { // Handle node selection from popover and create new node replacing the add-node placeholder const handleNodeSelect = (selectedNodeType: any) => { + graph.startBatch('add-node'); const parentBBox = node.getBBox(); const cycleId = data.cycle; const horizontalSpacing = 0; @@ -76,6 +77,8 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { } }); + graph.stopBatch('add-node'); + setTimeout(() => { addedEdges.forEach(e => { const src = graph.getCellById(e.getSourceCellId()); diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index b431ddd4..74a15c78 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -99,7 +99,7 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { {data.type === 'if-else' && {data.config?.cases?.defaultValue.map((item: any, index: number) => ( -
0 ? '' : 'rb:mb-1'}> +
0 ? "space-between" : 'end'} className="rb:mb-1! rb:leading-4"> {item.expressions.length > 0 && CASE{index + 1}} {index === 0 ? 'IF' : `ELIF`} diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx index cb3e16c4..68aef867 100644 --- a/web/src/views/Workflow/components/PortClickHandler.tsx +++ b/web/src/views/Workflow/components/PortClickHandler.tsx @@ -46,6 +46,7 @@ const PortClickHandler: React.FC = ({ graph }) => { // Handle node selection from popover menu and create new node with edge connection const handleNodeSelect = (selectedNodeType: any) => { if (!sourceNode || !graph) return; + graph.startBatch('add-node'); const sourceNodeData = sourceNode.getData(); const sourceNodeType = sourceNodeData?.type; @@ -308,6 +309,7 @@ const PortClickHandler: React.FC = ({ graph }) => { if (tgt?.isNode()) tgt.toFront(); }); } + graph.stopBatch('add-node'); }, 50); // Clean up temporary element diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index a22ee6c0..060bc75c 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -488,6 +488,9 @@ export const useWorkflowGraph = ({ graphRef.current.cleanHistory() } }, 200) + } else { + graphRef.current.enableHistory() + graphRef.current.cleanHistory() } } /** @@ -526,14 +529,12 @@ export const useWorkflowGraph = ({ enabled: false, beforeAddCommand(_event, args: any) { const event = args?.key ? `cell:change:${args.key}` : _event; - if (event.startsWith('cell:change:') && - event !== 'cell:change:position' && - event !== 'cell:change:source' && - event !== 'cell:change:target') return false; + const allowed = ['cell:added', 'cell:removed', 'cell:change:position', 'cell:change:source', 'cell:change:target']; + if (!allowed.includes(event)) return false; }, }), ); - graphRef.current.on('history:change', ({ cmds }: { cmds: Command[] }) => { + graphRef.current.on('history:change', () => { setCanUndo(graphRef.current?.canUndo() ?? false) setCanRedo(graphRef.current?.canRedo() ?? false) }) @@ -753,8 +754,6 @@ export const useWorkflowGraph = ({ // Find corresponding parent node const parentNode = nodes?.find(n => n.id === nodeData.cycle); if (parentNode) { - // Use removeChild method to delete child node - parentNode.removeChild(nodeToDelete); parentNodesToUpdate.push(parentNode); } // Add child node to deletion list @@ -782,6 +781,12 @@ export const useWorkflowGraph = ({ // Delete all collected nodes and edges if (cells.length > 0) { + graphRef.current?.startBatch('delete'); + // Remove parent-child relationships before removeCells + parentNodesToUpdate.forEach(parentNode => { + cells.filter(c => c.isNode() && (c as Node).getData()?.cycle === parentNode.getData()?.id) + .forEach(child => parentNode.removeChild(child)); + }); graphRef.current?.removeCells(cells); // If parent is iteration/loop and only cycle-start remains, add add-node connected to it @@ -818,6 +823,7 @@ export const useWorkflowGraph = ({ }); } }); + graphRef.current?.stopBatch('delete'); } return false; }; diff --git a/web/src/views/Workflow/utils.ts b/web/src/views/Workflow/utils.ts index 74dfca2c..a4035517 100644 --- a/web/src/views/Workflow/utils.ts +++ b/web/src/views/Workflow/utils.ts @@ -17,6 +17,7 @@ export const isSubExprSet = (sub: any) => { * Uses the same per-expression height logic as getConditionNodeCasePortY. */ export const calcConditionNodeTotalHeight = (cases: any[]) => { + if (!cases?.length) return conditionNodeHeight; const casesHeight = cases.reduce((acc: number, c: any) => { const exprs = c?.expressions ?? []; const n = exprs.length; From 750d4ca8416683458a8b27dca37a417f9d2081d6 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 27 Apr 2026 16:04:02 +0800 Subject: [PATCH 05/24] fix(web): custom tool schema api add case Co-authored-by: Copilot --- web/src/views/ToolManagement/components/CustomToolModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/views/ToolManagement/components/CustomToolModal.tsx b/web/src/views/ToolManagement/components/CustomToolModal.tsx index cbcddb7c..08e18679 100644 --- a/web/src/views/ToolManagement/components/CustomToolModal.tsx +++ b/web/src/views/ToolManagement/components/CustomToolModal.tsx @@ -101,6 +101,7 @@ const CustomToolModal = forwardRef(({ }); }; const formatSchema = (value: string) => { + if (!value || value.trim() === '') return setParseSchemaData({} as ParseSchemaData) parseSchema({ schema_content: value }) .then(res => { From f369a63c8d62bdefca957675411e628103ae8c6b Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 27 Apr 2026 16:31:10 +0800 Subject: [PATCH 06/24] fix(web): loop & iteration child node history --- .../Workflow/components/Nodes/LoopNode.tsx | 123 +----------- .../views/Workflow/hooks/useWorkflowGraph.ts | 176 ++++++++++++++---- 2 files changed, 138 insertions(+), 161 deletions(-) diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx index cffb62dd..ac81a667 100644 --- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx +++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx @@ -1,134 +1,15 @@ -import { useEffect } from 'react'; -import { useTranslation } from 'react-i18next' import clsx from 'clsx'; import type { ReactShapeConfig } from '@antv/x6-react-shape'; import { Flex } from 'antd'; import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next' -import { graphNodeLibrary, edgeAttrs } from '../../constant'; import NodeTools from './NodeTools' -const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { +const LoopNode: ReactShapeConfig['component'] = ({ node }) => { const data = node.getData() || {}; const { t } = useTranslation() - useEffect(() => { - // 使用setTimeout确保在所有节点都添加完成后再创建连线 - const timer = setTimeout(() => { - initNodes() - checkAndAddAddNode() - }, 50) - - return () => clearTimeout(timer) - }, [graph]) - - const checkAndAddAddNode = () => { - if (!graph) return; - - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === data.id); - const cycleStartNodes = childNodes.filter((n: any) => n.getData()?.type === 'cycle-start'); - - // 如果只有一个cycle-start节点且没有其他类型的子节点,则添加add-node - if (cycleStartNodes.length === 1 && childNodes.length === 1) { - const cycleStartNode = cycleStartNodes[0]; - const cycleStartBBox = cycleStartNode.getBBox(); - - const addNode = graph.addNode({ - ...graphNodeLibrary.addStart, - x: cycleStartBBox.x + 84, - y: cycleStartBBox.y + 4, - data: { - type: 'add-node', - label: t('workflow.addNode'), - icon: '+', - parentId: node.id, - cycle: data.id, - }, - }); - - node.addChild(addNode); - - // 连接cycle-start和add-node - const sourcePorts = cycleStartNode.getPorts(); - const targetPorts = addNode.getPorts(); - const sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; - const targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left'; - - // 然后创建连线 - graph.addEdge({ - source: { cell: cycleStartNode.id, port: sourcePort }, - target: { cell: addNode.id, port: targetPort }, - ...edgeAttrs, - }); - - cycleStartNode.toFront() - addNode.toFront() - } - } - - const initNodes = () => { - // 检查是否存在cycle为当前节点ID的子节点,若存在则不调用initNodes,避免重复创建 - const existingCycleNodes = graph.getNodes().filter((n: any) => - n.getData()?.cycle === data.id - ); - if (existingCycleNodes.length > 0) return; - // 添加默认子节点 - const parentBBox = node.getBBox(); - const centerX = parentBBox.x + 24; - const centerY = parentBBox.y + 70; - - const cycleStartNodeId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` - const cycleStartNode = graph.addNode({ - ...graphNodeLibrary.cycleStart, - x: centerX, - y: centerY, - id: cycleStartNodeId, - data: { - id: cycleStartNodeId, - type: 'cycle-start', - parentId: node.id, - isDefault: true, // 标记为默认节点,不可删除 - cycle: data.id, - }, - }); - const addNode = graph.addNode({ - ...graphNodeLibrary.addStart, - x: centerX + 84, - y: centerY + 4, - data: { - type: 'add-node', - label: t('workflow.addNode'), - icon: '+', - parentId: node.id, - cycle: data.id, - }, - }); - node.addChild(cycleStartNode) - node.addChild(addNode) - const sourcePorts = cycleStartNode.getPorts() - const targetPorts = addNode.getPorts() - let sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; - - const edgeConfig = { - source: { - cell: cycleStartNode.id, - port: sourcePort - }, - target: { - cell: addNode.id, - port: targetPorts.find((port: any) => port.group === 'left')?.id || 'left' - }, - ...edgeAttrs - } - graph.addEdge(edgeConfig) - - setTimeout(() => { - - cycleStartNode.toFront() - addNode.toFront() - }, 0) - } - return (
{ + graph.getNodes().forEach(parentNode => { + const parentType = parentNode.getData()?.type + if (parentType !== 'loop' && parentType !== 'iteration') return + const children = graph.getNodes().filter( + n => n.getData()?.cycle === parentNode.getData()?.id && n.getData()?.type !== 'add-node' + ) + if (!children.length) return + const padding = 24 + const headerHeight = 50 + const childBounds = children.map(c => c.getBBox()) + const minX = Math.min(...childBounds.map(b => b.x)) + const minY = Math.min(...childBounds.map(b => b.y)) + const maxX = Math.max(...childBounds.map(b => b.x + b.width)) + const maxY = Math.max(...childBounds.map(b => b.y + b.height)) + const parentBBox = parentNode.getBBox() + const newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2) + const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight) + parentNode.prop('size', { width: newWidth, height: newHeight }) + parentNode.getPorts().forEach(port => { + if (port.group === 'right' && port.args) { + parentNode.portProp(port.id!, 'args/x', newWidth) + } + }) + }) + } + + const syncChildRelationships = () => { + if (!graphRef.current) return + const graph = graphRef.current + // Re-establish parent-child relationships based on cycle data + graph.getNodes().forEach(node => { + const cycleId = node.getData()?.cycle + if (!cycleId) return + const parentNode = graph.getCellById(cycleId) as Node | null + if (!parentNode) return + if (!parentNode.getChildren()?.some(c => c.id === node.id)) { + parentNode.addChild(node) + } + }) + // Remove stale parent-child links (parent exists but child's cycle no longer points to it) + graph.getNodes().forEach(node => { + const children = node.getChildren() + if (!children?.length) return + children.forEach(child => { + const childCycleId = (child as Node).getData?.()?.cycle + if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) { + node.removeChild(child) + } + }) + }) + // Recalculate group node size based on current children + resizeGroupNodes(graph) + // Bring child edges and nodes to front + graph.getEdges().forEach(edge => { + const src = graph.getCellById(edge.getSourceCellId()) + const tgt = graph.getCellById(edge.getTargetCellId()) + if (src?.getData()?.cycle || tgt?.getData()?.cycle) { + edge.toFront() + } + }) + graph.getNodes().forEach(node => { + if (node.getData()?.cycle) node.toFront() + }) + } /** * Setup X6 graph plugins (MiniMap, Snapline, Clipboard, Keyboard) */ @@ -538,6 +603,9 @@ export const useWorkflowGraph = ({ setCanUndo(graphRef.current?.canUndo() ?? false) setCanRedo(graphRef.current?.canRedo() ?? false) }) + + graphRef.current.on('history:undo', syncChildRelationships) + graphRef.current.on('history:redo', syncChildRelationships) }; // 显示/隐藏连接桩 // const showPorts = (show: boolean) => { @@ -781,48 +849,50 @@ export const useWorkflowGraph = ({ // Delete all collected nodes and edges if (cells.length > 0) { + // Pre-calculate which parents need an add-node restored (before removal changes the graph) + const parentsNeedingAddNode = parentNodesToUpdate + .filter(parentNode => { + const parentShape = parentNode.shape; + if (parentShape !== 'loop-node' && parentShape !== 'iteration-node') return false; + const parentData = parentNode.getData(); + const allChildren = graphRef.current!.getNodes().filter(n => n.getData()?.cycle === parentData.id); + const cycleStartNodes = allChildren.filter(n => n.getData()?.type === 'cycle-start'); + // After deletion, only cycle-start will remain + const nonCycleStartToDelete = cells.filter(c => + c.isNode() && + (c as Node).getData()?.cycle === parentData.id && + (c as Node).getData()?.type !== 'cycle-start' + ); + return cycleStartNodes.length === 1 && (allChildren.length - nonCycleStartToDelete.length) === 1; + }) + .map(parentNode => ({ + parentNode, + cycleStartNode: graphRef.current!.getNodes().find( + n => n.getData()?.cycle === parentNode.getData().id && n.getData()?.type === 'cycle-start' + )! + })) + .filter(({ cycleStartNode }) => !!cycleStartNode); + graphRef.current?.startBatch('delete'); - // Remove parent-child relationships before removeCells - parentNodesToUpdate.forEach(parentNode => { - cells.filter(c => c.isNode() && (c as Node).getData()?.cycle === parentNode.getData()?.id) - .forEach(child => parentNode.removeChild(child)); - }); graphRef.current?.removeCells(cells); - // If parent is iteration/loop and only cycle-start remains, add add-node connected to it - parentNodesToUpdate.forEach(parentNode => { - const parentShape = parentNode.shape; - if (parentShape !== 'loop-node' && parentShape !== 'iteration-node') return; + parentsNeedingAddNode.forEach(({ parentNode, cycleStartNode }) => { const parentData = parentNode.getData(); - const remainingChildren = graphRef.current!.getNodes().filter( - n => n.getData()?.cycle === parentData.id - ); - const cycleStartNodes = remainingChildren.filter(n => n.getData()?.type === 'cycle-start'); - if (cycleStartNodes.length === 1 && remainingChildren.length === 1) { - const cycleStartNode = cycleStartNodes[0]; - const bbox = cycleStartNode.getBBox(); - const addNode = graphRef.current!.addNode({ - ...graphNodeLibrary.addStart, - x: bbox.x + 84, - y: bbox.y + 4, - data: { - type: 'add-node', - parentId: parentNode.id, - cycle: parentData.id, - label: t('workflow.addNode'), - icon: '+', - }, - }); - parentNode.addChild(addNode); - const sourcePort = cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right'; - const targetPort = addNode.getPorts().find(p => p.group === 'left')?.id || 'left'; - graphRef.current!.addEdge({ - source: { cell: cycleStartNode.id, port: sourcePort }, - target: { cell: addNode.id, port: targetPort }, - ...edgeAttrs, - }); - } + const bbox = cycleStartNode.getBBox(); + const addNode = graphRef.current!.addNode({ + ...graphNodeLibrary.addStart, + x: bbox.x + 84, + y: bbox.y + 4, + data: { type: 'add-node', parentId: parentNode.id, cycle: parentData.id, label: t('workflow.addNode'), icon: '+' }, + }); + parentNode.addChild(addNode); + graphRef.current!.addEdge({ + source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' }, + target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' }, + ...edgeAttrs, + }); }); + graphRef.current?.stopBatch('delete'); } return false; @@ -1199,13 +1269,39 @@ export const useWorkflowGraph = ({ }; if (dragData.type === 'loop' || dragData.type === 'iteration') { - graphRef.current.addNode({ + graphRef.current.startBatch('add-group') + const parentNode = graphRef.current.addNode({ ...graphNodeLibrary[dragData.type], x: point.x - 150, y: point.y - 100, id: cleanNodeData.id, data: { ...cleanNodeData, isGroup: true }, }); + const parentBBox = parentNode.getBBox() + const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + const cycleStartNode = graphRef.current.addNode({ + ...graphNodeLibrary.cycleStart, + x: parentBBox.x + 24, + y: parentBBox.y + 70, + id: cycleStartId, + data: { id: cycleStartId, type: 'cycle-start', parentId: cleanNodeData.id, isDefault: true, cycle: cleanNodeData.id }, + }) + const addNode = graphRef.current.addNode({ + ...graphNodeLibrary.addStart, + x: parentBBox.x + 24 + 84, + y: parentBBox.y + 70 + 4, + data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: cleanNodeData.id, cycle: cleanNodeData.id }, + }) + parentNode.addChild(cycleStartNode) + parentNode.addChild(addNode) + graphRef.current.addEdge({ + source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' }, + target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' }, + ...edgeAttrs, + }) + cycleStartNode.toFront() + addNode.toFront() + graphRef.current.stopBatch('add-group') } else if (dragData.type === 'if-else') { // Create condition node graphRef.current.addNode({ From 16926d9db528d16197f9f3ad1fe05040a995a3cf Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 27 Apr 2026 17:10:02 +0800 Subject: [PATCH 07/24] fix(web): tool node config reset --- .../Workflow/components/Properties/ToolConfig/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/ToolConfig/index.tsx b/web/src/views/Workflow/components/Properties/ToolConfig/index.tsx index 6e8bd0c0..73a5c087 100644 --- a/web/src/views/Workflow/components/Properties/ToolConfig/index.tsx +++ b/web/src/views/Workflow/components/Properties/ToolConfig/index.tsx @@ -242,10 +242,11 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({ className={parameter.type === 'boolean' ? 'rb:mb-0!' : ''} > {parameter.type === 'string' && parameter.enum && parameter.enum.length > 0 - ? ({ value: vo, label: vo }))} placeholder={t('common.pleaseSelect')} /> : parameter.type === 'boolean' - ? + ? : Date: Mon, 27 Apr 2026 17:42:56 +0800 Subject: [PATCH 08/24] fix(web): chat file icon --- web/src/components/Chat/ChatContent.tsx | 72 +------------------- web/src/components/Chat/MessageFiles.tsx | 87 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 69 deletions(-) create mode 100644 web/src/components/Chat/MessageFiles.tsx diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx index a785ea49..509004b0 100644 --- a/web/src/components/Chat/ChatContent.tsx +++ b/web/src/components/Chat/ChatContent.tsx @@ -8,12 +8,11 @@ import { type FC, useRef, useEffect, useState } from 'react' import clsx from 'clsx' import Markdown from '@/components/Markdown' import type { ChatContentProps } from './types' -import { Spin, Image, Flex, Button } from 'antd' +import { Spin, Flex, Button } from 'antd' import { SoundOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' -import AudioPlayer from './AudioPlayer' -import VideoPlayer from './VideoPlayer' +import MessageFiles from './MessageFiles' const getFileUrl = (file: any) => { return file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined) @@ -149,72 +148,7 @@ const ChatContent: FC = ({ {labelFormat(item)}
} - {item?.meta_data?.files && item.meta_data?.files.length > 0 && - {item.meta_data?.files?.map((file) => { - if (file.type.includes('image')) { - return ( -
- {file.name} -
- ) - } - if (file.type.includes('video')) { - return ( -
- {/*
- ) - } - if (file.type.includes('audio')) { - return ( -
- -
- ) - } - - const documentType = (file.file_type || file.type)?.split('/') - return ( - handleDownload(file)} - > -
-
-
{file.name}
-
{documentType?.[documentType.length - 1]} · {file.size}
-
-
- ) - })} -
} + {/* Message bubble */}
+ file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined) + +const DOC_ICONS: [string[], string][] = [ + [['pdf'], "rb:bg-[url('@/assets/images/file/pdf.svg')]"], + [['excel', 'spreadsheetml.sheet', 'xls', 'xlsx'], "rb:bg-[url('@/assets/images/file/excel.svg')]"], + [['csv'], "rb:bg-[url('@/assets/images/file/csv.svg')]"], + [['html'], "rb:bg-[url('@/assets/images/file/html.svg')]"], + [['json'], "rb:bg-[url('@/assets/images/file/json.svg')]"], + [['ppt'], "rb:bg-[url('@/assets/images/file/ppt.svg')]"], + [['markdown'], "rb:bg-[url('@/assets/images/file/md.svg')]"], + [['text'], "rb:bg-[url('@/assets/images/file/txt.svg')]"], + [['doc', 'docx', 'word', 'wordprocessingml.document'], "rb:bg-[url('@/assets/images/file/word.svg')]"], +] + +const getDocIcon = (parts: string[]) => { + const match = DOC_ICONS.find(([keys]) => keys.some(k => parts.includes(k))) + return match ? match[1] : "rb:bg-[url('@/assets/images/file/txt.svg')]" +} + +interface MessageFilesProps { + files: any[] + contentClassNames?: string | Record + onDownload: (file: any) => void +} + +const MessageFiles = ({ files, contentClassNames, onDownload }: MessageFilesProps) => { + if (!files?.length) return null + return ( + + {files.map((file) => { + const key = file.url || file.uid + if (file.type.includes('image')) { + return ( +
+ {file.name} +
+ ) + } + if (file.type.includes('video')) { + return ( +
+ +
+ ) + } + if (file.type.includes('audio')) { + return ( +
+ +
+ ) + } + const documentType = (file.file_type || file.type)?.split('/') + return ( + onDownload(file)} + > +
+
+
{file.name}
+
+ {documentType?.[documentType.length - 1]} · {file.size} +
+
+ + ) + })} + + ) +} + +export default MessageFiles From 3d9882643e345f63274f1fb0421aa410e3b29f21 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 27 Apr 2026 17:48:35 +0800 Subject: [PATCH 09/24] ci: add GitHub Actions workflow to sync all branches and tags to Gitee --- .github/workflows/sync-to-gitee.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/sync-to-gitee.yml b/.github/workflows/sync-to-gitee.yml index 71ddf22a..8bcad3b4 100644 --- a/.github/workflows/sync-to-gitee.yml +++ b/.github/workflows/sync-to-gitee.yml @@ -3,10 +3,7 @@ name: Sync to Gitee on: push: branches: - - main # Production - - develop # Integration - - 'release/*' # Release preparation - - 'hotfix/*' # Urgent fixes + - '*' # All branchs tags: - '*' # All version tags (v1.0.0, etc.) From 531d785629461beaef298717f13a2939e9bb1747 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Mon, 27 Apr 2026 17:56:58 +0800 Subject: [PATCH 10/24] fix(multimodal): support HTML image tags in document extraction and chat responses - Replace plain image URLs with `` HTML tags in multimodal and document extractor services - Propagate citations from workflow end events to client responses - Update system prompts to instruct LLMs to render images using Markdown `![alt](url)` with strict UUID-preserving URL copying --- api/app/controllers/service/app_api_controller.py | 2 +- .../core/workflow/nodes/document_extractor/node.py | 2 +- api/app/services/app_chat_service.py | 10 ++++++++-- api/app/services/draft_run_service.py | 10 ++++++++-- api/app/services/multimodal_service.py | 2 +- api/app/services/workflow_service.py | 13 ++++++++----- 6 files changed, 27 insertions(+), 12 deletions(-) diff --git a/api/app/controllers/service/app_api_controller.py b/api/app/controllers/service/app_api_controller.py index 93e88dc5..c2755bdc 100644 --- a/api/app/controllers/service/app_api_controller.py +++ b/api/app/controllers/service/app_api_controller.py @@ -296,7 +296,7 @@ async def chat( } ) - # 多 Agent 非流式返回 + # workflow 非流式返回 result = await app_chat_service.workflow_chat( message=payload.message, diff --git a/api/app/core/workflow/nodes/document_extractor/node.py b/api/app/core/workflow/nodes/document_extractor/node.py index ea1070f4..5fefbc94 100644 --- a/api/app/core/workflow/nodes/document_extractor/node.py +++ b/api/app/core/workflow/nodes/document_extractor/node.py @@ -182,7 +182,7 @@ class DocExtractorNode(BaseNode): mime_type=f"image/{ext}", is_file=True, ).model_dump()) - text = text + f"\n{placeholder}: {url}" + text = text + f"\n{placeholder}: " except Exception as e: logger.error(f"Node {self.node_id}: failed to save image {placeholder}: {e}") diff --git a/api/app/services/app_chat_service.py b/api/app/services/app_chat_service.py index 12f54c03..cc2b02f1 100644 --- a/api/app/services/app_chat_service.py +++ b/api/app/services/app_chat_service.py @@ -161,7 +161,10 @@ class AppChatService: f.type == FileType.DOCUMENT for f in files ): system_prompt += ( - "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: ," + "请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "重要:图片 URL 中包含 UUID(如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)," + "必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。" ) # 创建 LangChain Agent @@ -448,7 +451,10 @@ class AppChatService: ): from langchain.agents import create_agent system_prompt += ( - "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: ," + "请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "重要:图片 URL 中包含 UUID(如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)," + "必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。" ) # 创建 LangChain Agent diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 2566a50f..16d856ca 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -650,7 +650,10 @@ class AgentRunService: ) if has_doc_with_images: system_prompt += ( - "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: ," + "请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "重要:图片 URL 中包含 UUID(如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)," + "必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。" ) agent = LangChainAgent( @@ -924,7 +927,10 @@ class AgentRunService: ) if has_doc_with_images: system_prompt += ( - "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: ," + "请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "重要:图片 URL 中包含 UUID(如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)," + "必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。" ) # 创建 LangChain Agent diff --git a/api/app/services/multimodal_service.py b/api/app/services/multimodal_service.py index c362158c..dd021357 100644 --- a/api/app/services/multimodal_service.py +++ b/api/app/services/multimodal_service.py @@ -400,7 +400,7 @@ class MultimodalService: # 在文本内容中追加图片位置标记 if result and result[-1].get("type") in ("text", "document"): key = "text" if "text" in result[-1] else list(result[-1].keys())[-1] - result[-1][key] = result[-1].get(key, "") + f"\n[图片 {placeholder}]: {img_url}" + result[-1][key] = result[-1].get(key, "") + f"\n[图片 {placeholder}]: " # 将图片以视觉格式追加到消息内容中 img_file = FileInput( type=FileType.IMAGE, diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index b35656d9..27327e99 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -554,13 +554,16 @@ class WorkflowService: } } case "workflow_end": + data = { + "elapsed_time": payload.get("elapsed_time"), + "message_length": len(payload.get("output", "")), + "error": payload.get("error", "") + } + if "citations" in payload and payload["citations"]: + data["citations"] = payload["citations"] return { "event": "end", - "data": { - "elapsed_time": payload.get("elapsed_time"), - "message_length": len(payload.get("output", "")), - "error": payload.get("error", "") - } + "data": data } case "node_start" | "node_end" | "node_error" | "cycle_item": return None From 9a5ce7f7c65477372486b82d813a7efda12b970b Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Mon, 27 Apr 2026 17:57:06 +0800 Subject: [PATCH 11/24] refactor(memory): replace raw dict responses with Pydantic schema models in user memory controllers - Add user_memory_schema.py with typed Pydantic models for all user memory API responses: MemoryInsightReportData, UserSummaryData, GraphData, MemoryTypeStatItem, cache result models, and RelationshipEvolutionData - Refactor user_memory_controllers.py to construct schema instances and return model_dump() instead of raw dicts - Remove unused imports (datetime, timestamp_to_datetime, EndUserInfoResponse, EndUserInfoCreate, EndUser) --- .../controllers/user_memory_controllers.py | 177 ++++++++++++------ api/app/schemas/user_memory_schema.py | 118 ++++++++++++ 2 files changed, 242 insertions(+), 53 deletions(-) create mode 100644 api/app/schemas/user_memory_schema.py diff --git a/api/app/controllers/user_memory_controllers.py b/api/app/controllers/user_memory_controllers.py index 10b396a7..e7f5db4d 100644 --- a/api/app/controllers/user_memory_controllers.py +++ b/api/app/controllers/user_memory_controllers.py @@ -2,8 +2,8 @@ 用户记忆相关的控制器 包含用户摘要、记忆洞察、节点统计、图数据和用户档案等接口 """ -from typing import Optional -import datetime +from typing import Optional, List + from sqlalchemy.orm import Session from fastapi import APIRouter, Depends, Header @@ -12,7 +12,6 @@ from app.core.language_utils import get_language_from_header from app.core.logging_config import get_api_logger from app.core.response_utils import success, fail from app.core.error_codes import BizCode -from app.core.api_key_utils import timestamp_to_datetime from app.services.user_memory_service import ( UserMemoryService, analytics_memory_types, @@ -22,14 +21,25 @@ from app.services.user_memory_service import ( from app.services.memory_entity_relationship_service import MemoryEntityService, MemoryEmotion, MemoryInteraction from app.schemas.response_schema import ApiResponse from app.schemas.memory_storage_schema import GenerateCacheRequest +from app.schemas.user_memory_schema import ( + MemoryInsightReportData, + UserSummaryData, + SingleUserCacheResultData, + GenerateCacheErrorItem, + WorkspaceCacheResultData, + WorkspaceCacheErrorItem, + MemoryTypeStatItem, + GraphData, + GraphNodeData, + GraphEdgeData, + GraphStatistics, + RelationshipEvolutionData, +) from app.repositories.workspace_repository import WorkspaceRepository from app.repositories.end_user_repository import EndUserRepository from app.schemas.end_user_info_schema import ( - EndUserInfoResponse, - EndUserInfoCreate, EndUserInfoUpdate, ) -from app.models.end_user_model import EndUser from app.dependencies import get_current_user from app.models.user_model import User @@ -61,13 +71,22 @@ async def get_memory_insight_report_api( try: # 调用服务层获取缓存数据 result = await user_memory_service.get_cached_memory_insight(db, end_user_id) + data = MemoryInsightReportData( + memory_insight=result.get("memory_insight"), + behavior_pattern=result.get("behavior_pattern"), + key_findings=result.get("key_findings"), + growth_trajectory=result.get("growth_trajectory"), + updated_at=result.get("updated_at"), + is_cached=result["is_cached"], + message=result.get("message"), + ) - if result["is_cached"]: + if data.is_cached: api_logger.info(f"成功返回缓存的记忆洞察报告: end_user_id={end_user_id}") - return success(data=result, msg="查询成功") + return success(data=data.model_dump(), msg="查询成功") else: api_logger.info(f"记忆洞察报告缓存不存在: end_user_id={end_user_id}") - return success(data=result, msg="数据尚未生成") + return success(data=data.model_dump(), msg="数据尚未生成") except Exception as e: api_logger.error(f"记忆洞察报告查询失败: end_user_id={end_user_id}, error={str(e)}") return fail(BizCode.INTERNAL_ERROR, "记忆洞察报告查询失败", str(e)) @@ -105,13 +124,22 @@ async def get_user_summary_api( try: # 调用服务层获取缓存数据 result = await user_memory_service.get_cached_user_summary(db, end_user_id, model_id, language) + data = UserSummaryData( + user_summary=result.get("user_summary"), + personality=result.get("personality"), + core_values=result.get("core_values"), + one_sentence=result.get("one_sentence"), + updated_at=result.get("updated_at"), + is_cached=result["is_cached"], + message=result.get("message"), + ) - if result["is_cached"]: + if data.is_cached: api_logger.info(f"成功返回缓存的用户摘要: end_user_id={end_user_id}") - return success(data=result, msg="查询成功") + return success(data=data.model_dump(), msg="查询成功") else: api_logger.info(f"用户摘要缓存不存在: end_user_id={end_user_id}") - return success(data=result, msg="数据尚未生成") + return success(data=data.model_dump(), msg="数据尚未生成") except Exception as e: api_logger.error(f"用户摘要查询失败: end_user_id={end_user_id}, error={str(e)}") return fail(BizCode.INTERNAL_ERROR, "用户摘要查询失败", str(e)) @@ -165,32 +193,32 @@ async def generate_cache_api( language=language) # 构建响应 - result = { - "end_user_id": end_user_id, - "insight_success": insight_result["success"], - "summary_success": summary_result["success"], - "errors": [] - } - - # 收集错误信息 + errors: List[GenerateCacheErrorItem] = [] if not insight_result["success"]: - result["errors"].append({ - "type": "insight", - "error": insight_result.get("error") - }) + errors.append(GenerateCacheErrorItem( + type="insight", + error=insight_result.get("error"), + )) if not summary_result["success"]: - result["errors"].append({ - "type": "summary", - "error": summary_result.get("error") - }) + errors.append(GenerateCacheErrorItem( + type="summary", + error=summary_result.get("error"), + )) + + data = SingleUserCacheResultData( + end_user_id=end_user_id, + insight_success=insight_result["success"], + summary_success=summary_result["success"], + errors=errors, + ) # 记录结果 - if result["insight_success"] and result["summary_success"]: + if data.insight_success and data.summary_success: api_logger.info(f"成功为用户 {end_user_id} 生成缓存") else: - api_logger.warning(f"用户 {end_user_id} 的缓存生成部分失败: {result['errors']}") + api_logger.warning(f"用户 {end_user_id} 的缓存生成部分失败: {[e.model_dump() for e in errors]}") - return success(data=result, msg="生成完成") + return success(data=data.model_dump(), msg="生成完成") else: # 为整个工作空间生成 @@ -198,13 +226,29 @@ async def generate_cache_api( result = await user_memory_service.generate_cache_for_workspace(db, workspace_id, language=language) + ws_errors = [ + WorkspaceCacheErrorItem( + end_user_id=e.get("end_user_id"), + insight_error=e.get("insight_error"), + summary_error=e.get("summary_error"), + error=e.get("error"), + ) + for e in result.get("errors", []) + ] + data = WorkspaceCacheResultData( + total_users=result["total_users"], + successful=result["successful"], + failed=result["failed"], + errors=ws_errors, + ) + # 记录统计信息 api_logger.info( f"工作空间 {workspace_id} 批量生成完成: " - f"总数={result['total_users']}, 成功={result['successful']}, 失败={result['failed']}" + f"总数={data.total_users}, 成功={data.successful}, 失败={data.failed}" ) - return success(data=result, msg="批量生成完成") + return success(data=data.model_dump(), msg="批量生成完成") except Exception as e: api_logger.error(f"缓存生成失败: user={current_user.username}, error={str(e)}") @@ -231,11 +275,21 @@ async def get_node_statistics_api( # 调用新的记忆类型统计函数 result = await analytics_memory_types(db, end_user_id) + # 使用 schema 模型构建响应 + stat_items = [ + MemoryTypeStatItem( + type=item["type"], + count=item["count"], + percentage=item["percentage"], + ) + for item in result + ] + # 计算总数用于日志 - total_count = sum(item["count"] for item in result) + total_count = sum(item.count for item in stat_items) api_logger.info( - f"成功获取记忆类型统计: end_user_id={end_user_id}, 总记忆数={total_count}, 类型数={len(result)}") - return success(data=result, msg="查询成功") + f"成功获取记忆类型统计: end_user_id={end_user_id}, 总记忆数={total_count}, 类型数={len(stat_items)}") + return success(data=[item.model_dump() for item in stat_items], msg="查询成功") except Exception as e: api_logger.error(f"记忆类型查询失败: end_user_id={end_user_id}, error={str(e)}") return fail(BizCode.INTERNAL_ERROR, "记忆类型查询失败", str(e)) @@ -286,17 +340,26 @@ async def get_graph_data_api( depth=depth, center_node_id=center_node_id ) + + # 使用 schema 模型构建响应 + data = GraphData( + nodes=[GraphNodeData(**n) for n in result.get("nodes", [])], + edges=[GraphEdgeData(**e) for e in result.get("edges", [])], + statistics=GraphStatistics(**result.get("statistics", {})), + message=result.get("message"), + ) + # 检查是否有错误消息 - if "message" in result and result["statistics"]["total_nodes"] == 0: - api_logger.warning(f"图数据查询返回空结果: {result.get('message')}") - return success(data=result, msg=result.get("message", "查询成功")) + if data.message and data.statistics.total_nodes == 0: + api_logger.warning(f"图数据查询返回空结果: {data.message}") + return success(data=data.model_dump(), msg=data.message) api_logger.info( f"成功获取图数据: end_user_id={end_user_id}, " - f"nodes={result['statistics']['total_nodes']}, " - f"edges={result['statistics']['total_edges']}" + f"nodes={data.statistics.total_nodes}, " + f"edges={data.statistics.total_edges}" ) - return success(data=result, msg="查询成功") + return success(data=data.model_dump(), msg="查询成功") except Exception as e: api_logger.error(f"图数据查询失败: end_user_id={end_user_id}, error={str(e)}") @@ -323,16 +386,24 @@ async def get_community_graph_data_api( try: result = await analytics_community_graph_data(db=db, end_user_id=end_user_id) - if "message" in result and result["statistics"]["total_nodes"] == 0: - api_logger.warning(f"社区图谱查询返回空结果: {result.get('message')}") - return success(data=result, msg=result.get("message", "查询成功")) + # 使用 schema 模型构建响应 + data = GraphData( + nodes=[GraphNodeData(**n) for n in result.get("nodes", [])], + edges=[GraphEdgeData(**e) for e in result.get("edges", [])], + statistics=GraphStatistics(**result.get("statistics", {})), + message=result.get("message"), + ) + + if data.message and data.statistics.total_nodes == 0: + api_logger.warning(f"社区图谱查询返回空结果: {data.message}") + return success(data=data.model_dump(), msg=data.message) api_logger.info( f"成功获取社区图谱: end_user_id={end_user_id}, " - f"nodes={result['statistics']['total_nodes']}, " - f"edges={result['statistics']['total_edges']}" + f"nodes={data.statistics.total_nodes}, " + f"edges={data.statistics.total_edges}" ) - return success(data=result, msg="查询成功") + return success(data=data.model_dump(), msg="查询成功") except Exception as e: api_logger.error(f"社区图谱查询失败: end_user_id={end_user_id}, error={str(e)}") @@ -495,13 +566,13 @@ async def memory_space_relationship_evolution(id: str, label: str, await emotion.close() await interaction.close() - result = { - "emotion": emotion_result, - "interaction": interaction_result - } + data = RelationshipEvolutionData( + emotion=emotion_result, + interaction=interaction_result, + ) api_logger.info(f"关系演变查询成功: id={id}, table={label}") - return success(data=result, msg="关系演变") + return success(data=data.model_dump(), msg="关系演变") except Exception as e: api_logger.error(f"关系演变查询失败: id={id}, table={label}, error={str(e)}", exc_info=True) diff --git a/api/app/schemas/user_memory_schema.py b/api/app/schemas/user_memory_schema.py new file mode 100644 index 00000000..ea6570b3 --- /dev/null +++ b/api/app/schemas/user_memory_schema.py @@ -0,0 +1,118 @@ +""" +用户记忆相关的请求和响应模型 +包含用户摘要、记忆洞察、节点统计、图数据和用户档案等接口的 Schema +""" +from typing import Optional, List, Dict, Any + +from pydantic import BaseModel, Field + + +# ==================== 记忆洞察报告 ==================== + +class MemoryInsightReportData(BaseModel): + """记忆洞察报告数据""" + memory_insight: Optional[str] = Field(None, description="总体概述") + behavior_pattern: Optional[str] = Field(None, description="行为模式") + key_findings: Optional[List[str]] = Field(None, description="关键发现") + growth_trajectory: Optional[str] = Field(None, description="成长轨迹") + updated_at: Optional[int] = Field(None, description="更新时间戳(毫秒)") + is_cached: bool = Field(..., description="是否有缓存数据") + message: Optional[str] = Field(None, description="附加消息") + + +# ==================== 用户摘要 ==================== + +class UserSummaryData(BaseModel): + """用户摘要数据""" + user_summary: Optional[str] = Field(None, description="用户摘要") + personality: Optional[str] = Field(None, description="性格特征") + core_values: Optional[str] = Field(None, description="核心价值观") + one_sentence: Optional[str] = Field(None, description="一句话总结") + updated_at: Optional[int] = Field(None, description="更新时间戳(毫秒)") + is_cached: bool = Field(..., description="是否有缓存数据") + message: Optional[str] = Field(None, description="附加消息") + + +# ==================== 缓存生成 ==================== + +class GenerateCacheErrorItem(BaseModel): + """缓存生成错误项""" + type: Optional[str] = Field(None, description="错误类型 (insight/summary)") + error: Optional[str] = Field(None, description="错误信息") + + +class SingleUserCacheResultData(BaseModel): + """单用户缓存生成结果""" + end_user_id: str = Field(..., description="终端用户ID") + insight_success: bool = Field(..., description="洞察生成是否成功") + summary_success: bool = Field(..., description="摘要生成是否成功") + errors: List[GenerateCacheErrorItem] = Field(default_factory=list, description="错误列表") + + +class WorkspaceCacheErrorItem(BaseModel): + """工作空间缓存生成错误项""" + end_user_id: Optional[str] = Field(None, description="终端用户ID") + insight_error: Optional[str] = Field(None, description="洞察生成错误") + summary_error: Optional[str] = Field(None, description="摘要生成错误") + error: Optional[str] = Field(None, description="通用错误信息") + + +class WorkspaceCacheResultData(BaseModel): + """工作空间批量缓存生成结果""" + total_users: int = Field(..., description="总用户数") + successful: int = Field(..., description="成功数") + failed: int = Field(..., description="失败数") + errors: List[WorkspaceCacheErrorItem] = Field(default_factory=list, description="错误列表") + + +# ==================== 节点统计 ==================== + +class MemoryTypeStatItem(BaseModel): + """记忆类型统计项""" + type: str = Field(..., description="记忆类型枚举值") + count: int = Field(..., description="该类型的数量") + percentage: float = Field(..., description="该类型在所有记忆中的占比") + + +# ==================== 图数据 ==================== + +class GraphNodeData(BaseModel): + """图节点数据""" + id: str = Field(..., description="节点ID") + label: str = Field(..., description="节点类型标签") + properties: Dict[str, Any] = Field(default_factory=dict, description="节点属性") + caption: Optional[str] = Field(None, description="节点显示名称") + + +class GraphEdgeData(BaseModel): + """图边数据""" + id: str = Field(..., description="边ID") + source: str = Field(..., description="源节点ID") + target: str = Field(..., description="目标节点ID") + type: Optional[str] = Field(None, description="关系类型") + properties: Dict[str, Any] = Field(default_factory=dict, description="边属性") + caption: Optional[str] = Field(None, description="边显示名称") + + +class GraphStatistics(BaseModel): + """图统计信息""" + total_nodes: int = Field(0, description="节点总数") + total_edges: int = Field(0, description="边总数") + node_types: Dict[str, int] = Field(default_factory=dict, description="各节点类型数量") + edge_types: Optional[Dict[str, int]] = Field(default_factory=dict, description="各边类型数量") + + +class GraphData(BaseModel): + """图数据响应""" + nodes: List[GraphNodeData] = Field(..., description="节点列表") + edges: List[GraphEdgeData] = Field(..., description="边列表") + statistics: GraphStatistics = Field(..., description="统计信息") + message: Optional[str] = Field(None, description="附加消息") + + +# ==================== 关系演变 ==================== + +class RelationshipEvolutionData(BaseModel): + """关系演变数据""" + emotion: Any = Field(None, description="情绪数据") + interaction: Any = Field(None, description="交互频率数据") From 720af8d261315328275274ccc6c4c1217fbd2d7b Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 27 Apr 2026 18:04:55 +0800 Subject: [PATCH 12/24] fix(web): file icon --- web/src/components/Chat/MessageFiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/Chat/MessageFiles.tsx b/web/src/components/Chat/MessageFiles.tsx index 96c83fbd..b20e9ac8 100644 --- a/web/src/components/Chat/MessageFiles.tsx +++ b/web/src/components/Chat/MessageFiles.tsx @@ -56,7 +56,7 @@ const MessageFiles = ({ files, contentClassNames, onDownload }: MessageFilesProp
) } - const documentType = (file.file_type || file.type)?.split('/') + const documentType = (file.file_type || file.type)?.split('/') ?? [] return ( Date: Mon, 27 Apr 2026 18:39:33 +0800 Subject: [PATCH 13/24] fix(memory): use explicit None checks and remove unnecessary Optional type - Replace truthiness checks with 'is not None' for data.message in graph_data and community_graph endpoints to handle empty string correctly - Remove Optional wrapper from GraphStatistics.edge_types since it already has a default_factory --- api/app/controllers/user_memory_controllers.py | 4 ++-- api/app/schemas/user_memory_schema.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/app/controllers/user_memory_controllers.py b/api/app/controllers/user_memory_controllers.py index e7f5db4d..c8d24d92 100644 --- a/api/app/controllers/user_memory_controllers.py +++ b/api/app/controllers/user_memory_controllers.py @@ -350,7 +350,7 @@ async def get_graph_data_api( ) # 检查是否有错误消息 - if data.message and data.statistics.total_nodes == 0: + if data.message is not None and data.statistics.total_nodes == 0: api_logger.warning(f"图数据查询返回空结果: {data.message}") return success(data=data.model_dump(), msg=data.message) @@ -394,7 +394,7 @@ async def get_community_graph_data_api( message=result.get("message"), ) - if data.message and data.statistics.total_nodes == 0: + if data.message is not None and data.statistics.total_nodes == 0: api_logger.warning(f"社区图谱查询返回空结果: {data.message}") return success(data=data.model_dump(), msg=data.message) diff --git a/api/app/schemas/user_memory_schema.py b/api/app/schemas/user_memory_schema.py index ea6570b3..e0149ceb 100644 --- a/api/app/schemas/user_memory_schema.py +++ b/api/app/schemas/user_memory_schema.py @@ -99,7 +99,7 @@ class GraphStatistics(BaseModel): total_nodes: int = Field(0, description="节点总数") total_edges: int = Field(0, description="边总数") node_types: Dict[str, int] = Field(default_factory=dict, description="各节点类型数量") - edge_types: Optional[Dict[str, int]] = Field(default_factory=dict, description="各边类型数量") + edge_types: Dict[str, int] = Field(default_factory=dict, description="各边类型数量") class GraphData(BaseModel): From 7621321d1b16b023d3cf8bfba821dc76ef43f28e Mon Sep 17 00:00:00 2001 From: Ke Sun <33739460+keeees@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:50:26 +0800 Subject: [PATCH 14/24] =?UTF-8?q?Revert=20"refactor(memory):=20replace=20r?= =?UTF-8?q?aw=20dict=20responses=20with=20Pydantic=20schema=20mod=E2=80=A6?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/user_memory_controllers.py | 179 ++++++------------ api/app/schemas/user_memory_schema.py | 118 ------------ 2 files changed, 54 insertions(+), 243 deletions(-) delete mode 100644 api/app/schemas/user_memory_schema.py diff --git a/api/app/controllers/user_memory_controllers.py b/api/app/controllers/user_memory_controllers.py index c8d24d92..10b396a7 100644 --- a/api/app/controllers/user_memory_controllers.py +++ b/api/app/controllers/user_memory_controllers.py @@ -2,8 +2,8 @@ 用户记忆相关的控制器 包含用户摘要、记忆洞察、节点统计、图数据和用户档案等接口 """ -from typing import Optional, List - +from typing import Optional +import datetime from sqlalchemy.orm import Session from fastapi import APIRouter, Depends, Header @@ -12,6 +12,7 @@ from app.core.language_utils import get_language_from_header from app.core.logging_config import get_api_logger from app.core.response_utils import success, fail from app.core.error_codes import BizCode +from app.core.api_key_utils import timestamp_to_datetime from app.services.user_memory_service import ( UserMemoryService, analytics_memory_types, @@ -21,25 +22,14 @@ from app.services.user_memory_service import ( from app.services.memory_entity_relationship_service import MemoryEntityService, MemoryEmotion, MemoryInteraction from app.schemas.response_schema import ApiResponse from app.schemas.memory_storage_schema import GenerateCacheRequest -from app.schemas.user_memory_schema import ( - MemoryInsightReportData, - UserSummaryData, - SingleUserCacheResultData, - GenerateCacheErrorItem, - WorkspaceCacheResultData, - WorkspaceCacheErrorItem, - MemoryTypeStatItem, - GraphData, - GraphNodeData, - GraphEdgeData, - GraphStatistics, - RelationshipEvolutionData, -) from app.repositories.workspace_repository import WorkspaceRepository from app.repositories.end_user_repository import EndUserRepository from app.schemas.end_user_info_schema import ( + EndUserInfoResponse, + EndUserInfoCreate, EndUserInfoUpdate, ) +from app.models.end_user_model import EndUser from app.dependencies import get_current_user from app.models.user_model import User @@ -71,22 +61,13 @@ async def get_memory_insight_report_api( try: # 调用服务层获取缓存数据 result = await user_memory_service.get_cached_memory_insight(db, end_user_id) - data = MemoryInsightReportData( - memory_insight=result.get("memory_insight"), - behavior_pattern=result.get("behavior_pattern"), - key_findings=result.get("key_findings"), - growth_trajectory=result.get("growth_trajectory"), - updated_at=result.get("updated_at"), - is_cached=result["is_cached"], - message=result.get("message"), - ) - if data.is_cached: + if result["is_cached"]: api_logger.info(f"成功返回缓存的记忆洞察报告: end_user_id={end_user_id}") - return success(data=data.model_dump(), msg="查询成功") + return success(data=result, msg="查询成功") else: api_logger.info(f"记忆洞察报告缓存不存在: end_user_id={end_user_id}") - return success(data=data.model_dump(), msg="数据尚未生成") + return success(data=result, msg="数据尚未生成") except Exception as e: api_logger.error(f"记忆洞察报告查询失败: end_user_id={end_user_id}, error={str(e)}") return fail(BizCode.INTERNAL_ERROR, "记忆洞察报告查询失败", str(e)) @@ -124,22 +105,13 @@ async def get_user_summary_api( try: # 调用服务层获取缓存数据 result = await user_memory_service.get_cached_user_summary(db, end_user_id, model_id, language) - data = UserSummaryData( - user_summary=result.get("user_summary"), - personality=result.get("personality"), - core_values=result.get("core_values"), - one_sentence=result.get("one_sentence"), - updated_at=result.get("updated_at"), - is_cached=result["is_cached"], - message=result.get("message"), - ) - if data.is_cached: + if result["is_cached"]: api_logger.info(f"成功返回缓存的用户摘要: end_user_id={end_user_id}") - return success(data=data.model_dump(), msg="查询成功") + return success(data=result, msg="查询成功") else: api_logger.info(f"用户摘要缓存不存在: end_user_id={end_user_id}") - return success(data=data.model_dump(), msg="数据尚未生成") + return success(data=result, msg="数据尚未生成") except Exception as e: api_logger.error(f"用户摘要查询失败: end_user_id={end_user_id}, error={str(e)}") return fail(BizCode.INTERNAL_ERROR, "用户摘要查询失败", str(e)) @@ -193,32 +165,32 @@ async def generate_cache_api( language=language) # 构建响应 - errors: List[GenerateCacheErrorItem] = [] - if not insight_result["success"]: - errors.append(GenerateCacheErrorItem( - type="insight", - error=insight_result.get("error"), - )) - if not summary_result["success"]: - errors.append(GenerateCacheErrorItem( - type="summary", - error=summary_result.get("error"), - )) + result = { + "end_user_id": end_user_id, + "insight_success": insight_result["success"], + "summary_success": summary_result["success"], + "errors": [] + } - data = SingleUserCacheResultData( - end_user_id=end_user_id, - insight_success=insight_result["success"], - summary_success=summary_result["success"], - errors=errors, - ) + # 收集错误信息 + if not insight_result["success"]: + result["errors"].append({ + "type": "insight", + "error": insight_result.get("error") + }) + if not summary_result["success"]: + result["errors"].append({ + "type": "summary", + "error": summary_result.get("error") + }) # 记录结果 - if data.insight_success and data.summary_success: + if result["insight_success"] and result["summary_success"]: api_logger.info(f"成功为用户 {end_user_id} 生成缓存") else: - api_logger.warning(f"用户 {end_user_id} 的缓存生成部分失败: {[e.model_dump() for e in errors]}") + api_logger.warning(f"用户 {end_user_id} 的缓存生成部分失败: {result['errors']}") - return success(data=data.model_dump(), msg="生成完成") + return success(data=result, msg="生成完成") else: # 为整个工作空间生成 @@ -226,29 +198,13 @@ async def generate_cache_api( result = await user_memory_service.generate_cache_for_workspace(db, workspace_id, language=language) - ws_errors = [ - WorkspaceCacheErrorItem( - end_user_id=e.get("end_user_id"), - insight_error=e.get("insight_error"), - summary_error=e.get("summary_error"), - error=e.get("error"), - ) - for e in result.get("errors", []) - ] - data = WorkspaceCacheResultData( - total_users=result["total_users"], - successful=result["successful"], - failed=result["failed"], - errors=ws_errors, - ) - # 记录统计信息 api_logger.info( f"工作空间 {workspace_id} 批量生成完成: " - f"总数={data.total_users}, 成功={data.successful}, 失败={data.failed}" + f"总数={result['total_users']}, 成功={result['successful']}, 失败={result['failed']}" ) - return success(data=data.model_dump(), msg="批量生成完成") + return success(data=result, msg="批量生成完成") except Exception as e: api_logger.error(f"缓存生成失败: user={current_user.username}, error={str(e)}") @@ -275,21 +231,11 @@ async def get_node_statistics_api( # 调用新的记忆类型统计函数 result = await analytics_memory_types(db, end_user_id) - # 使用 schema 模型构建响应 - stat_items = [ - MemoryTypeStatItem( - type=item["type"], - count=item["count"], - percentage=item["percentage"], - ) - for item in result - ] - # 计算总数用于日志 - total_count = sum(item.count for item in stat_items) + total_count = sum(item["count"] for item in result) api_logger.info( - f"成功获取记忆类型统计: end_user_id={end_user_id}, 总记忆数={total_count}, 类型数={len(stat_items)}") - return success(data=[item.model_dump() for item in stat_items], msg="查询成功") + f"成功获取记忆类型统计: end_user_id={end_user_id}, 总记忆数={total_count}, 类型数={len(result)}") + return success(data=result, msg="查询成功") except Exception as e: api_logger.error(f"记忆类型查询失败: end_user_id={end_user_id}, error={str(e)}") return fail(BizCode.INTERNAL_ERROR, "记忆类型查询失败", str(e)) @@ -340,26 +286,17 @@ async def get_graph_data_api( depth=depth, center_node_id=center_node_id ) - - # 使用 schema 模型构建响应 - data = GraphData( - nodes=[GraphNodeData(**n) for n in result.get("nodes", [])], - edges=[GraphEdgeData(**e) for e in result.get("edges", [])], - statistics=GraphStatistics(**result.get("statistics", {})), - message=result.get("message"), - ) - # 检查是否有错误消息 - if data.message is not None and data.statistics.total_nodes == 0: - api_logger.warning(f"图数据查询返回空结果: {data.message}") - return success(data=data.model_dump(), msg=data.message) + if "message" in result and result["statistics"]["total_nodes"] == 0: + api_logger.warning(f"图数据查询返回空结果: {result.get('message')}") + return success(data=result, msg=result.get("message", "查询成功")) api_logger.info( f"成功获取图数据: end_user_id={end_user_id}, " - f"nodes={data.statistics.total_nodes}, " - f"edges={data.statistics.total_edges}" + f"nodes={result['statistics']['total_nodes']}, " + f"edges={result['statistics']['total_edges']}" ) - return success(data=data.model_dump(), msg="查询成功") + return success(data=result, msg="查询成功") except Exception as e: api_logger.error(f"图数据查询失败: end_user_id={end_user_id}, error={str(e)}") @@ -386,24 +323,16 @@ async def get_community_graph_data_api( try: result = await analytics_community_graph_data(db=db, end_user_id=end_user_id) - # 使用 schema 模型构建响应 - data = GraphData( - nodes=[GraphNodeData(**n) for n in result.get("nodes", [])], - edges=[GraphEdgeData(**e) for e in result.get("edges", [])], - statistics=GraphStatistics(**result.get("statistics", {})), - message=result.get("message"), - ) - - if data.message is not None and data.statistics.total_nodes == 0: - api_logger.warning(f"社区图谱查询返回空结果: {data.message}") - return success(data=data.model_dump(), msg=data.message) + if "message" in result and result["statistics"]["total_nodes"] == 0: + api_logger.warning(f"社区图谱查询返回空结果: {result.get('message')}") + return success(data=result, msg=result.get("message", "查询成功")) api_logger.info( f"成功获取社区图谱: end_user_id={end_user_id}, " - f"nodes={data.statistics.total_nodes}, " - f"edges={data.statistics.total_edges}" + f"nodes={result['statistics']['total_nodes']}, " + f"edges={result['statistics']['total_edges']}" ) - return success(data=data.model_dump(), msg="查询成功") + return success(data=result, msg="查询成功") except Exception as e: api_logger.error(f"社区图谱查询失败: end_user_id={end_user_id}, error={str(e)}") @@ -566,13 +495,13 @@ async def memory_space_relationship_evolution(id: str, label: str, await emotion.close() await interaction.close() - data = RelationshipEvolutionData( - emotion=emotion_result, - interaction=interaction_result, - ) + result = { + "emotion": emotion_result, + "interaction": interaction_result + } api_logger.info(f"关系演变查询成功: id={id}, table={label}") - return success(data=data.model_dump(), msg="关系演变") + return success(data=result, msg="关系演变") except Exception as e: api_logger.error(f"关系演变查询失败: id={id}, table={label}, error={str(e)}", exc_info=True) diff --git a/api/app/schemas/user_memory_schema.py b/api/app/schemas/user_memory_schema.py deleted file mode 100644 index e0149ceb..00000000 --- a/api/app/schemas/user_memory_schema.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -用户记忆相关的请求和响应模型 -包含用户摘要、记忆洞察、节点统计、图数据和用户档案等接口的 Schema -""" -from typing import Optional, List, Dict, Any - -from pydantic import BaseModel, Field - - -# ==================== 记忆洞察报告 ==================== - -class MemoryInsightReportData(BaseModel): - """记忆洞察报告数据""" - memory_insight: Optional[str] = Field(None, description="总体概述") - behavior_pattern: Optional[str] = Field(None, description="行为模式") - key_findings: Optional[List[str]] = Field(None, description="关键发现") - growth_trajectory: Optional[str] = Field(None, description="成长轨迹") - updated_at: Optional[int] = Field(None, description="更新时间戳(毫秒)") - is_cached: bool = Field(..., description="是否有缓存数据") - message: Optional[str] = Field(None, description="附加消息") - - -# ==================== 用户摘要 ==================== - -class UserSummaryData(BaseModel): - """用户摘要数据""" - user_summary: Optional[str] = Field(None, description="用户摘要") - personality: Optional[str] = Field(None, description="性格特征") - core_values: Optional[str] = Field(None, description="核心价值观") - one_sentence: Optional[str] = Field(None, description="一句话总结") - updated_at: Optional[int] = Field(None, description="更新时间戳(毫秒)") - is_cached: bool = Field(..., description="是否有缓存数据") - message: Optional[str] = Field(None, description="附加消息") - - -# ==================== 缓存生成 ==================== - -class GenerateCacheErrorItem(BaseModel): - """缓存生成错误项""" - type: Optional[str] = Field(None, description="错误类型 (insight/summary)") - error: Optional[str] = Field(None, description="错误信息") - - -class SingleUserCacheResultData(BaseModel): - """单用户缓存生成结果""" - end_user_id: str = Field(..., description="终端用户ID") - insight_success: bool = Field(..., description="洞察生成是否成功") - summary_success: bool = Field(..., description="摘要生成是否成功") - errors: List[GenerateCacheErrorItem] = Field(default_factory=list, description="错误列表") - - -class WorkspaceCacheErrorItem(BaseModel): - """工作空间缓存生成错误项""" - end_user_id: Optional[str] = Field(None, description="终端用户ID") - insight_error: Optional[str] = Field(None, description="洞察生成错误") - summary_error: Optional[str] = Field(None, description="摘要生成错误") - error: Optional[str] = Field(None, description="通用错误信息") - - -class WorkspaceCacheResultData(BaseModel): - """工作空间批量缓存生成结果""" - total_users: int = Field(..., description="总用户数") - successful: int = Field(..., description="成功数") - failed: int = Field(..., description="失败数") - errors: List[WorkspaceCacheErrorItem] = Field(default_factory=list, description="错误列表") - - -# ==================== 节点统计 ==================== - -class MemoryTypeStatItem(BaseModel): - """记忆类型统计项""" - type: str = Field(..., description="记忆类型枚举值") - count: int = Field(..., description="该类型的数量") - percentage: float = Field(..., description="该类型在所有记忆中的占比") - - -# ==================== 图数据 ==================== - -class GraphNodeData(BaseModel): - """图节点数据""" - id: str = Field(..., description="节点ID") - label: str = Field(..., description="节点类型标签") - properties: Dict[str, Any] = Field(default_factory=dict, description="节点属性") - caption: Optional[str] = Field(None, description="节点显示名称") - - -class GraphEdgeData(BaseModel): - """图边数据""" - id: str = Field(..., description="边ID") - source: str = Field(..., description="源节点ID") - target: str = Field(..., description="目标节点ID") - type: Optional[str] = Field(None, description="关系类型") - properties: Dict[str, Any] = Field(default_factory=dict, description="边属性") - caption: Optional[str] = Field(None, description="边显示名称") - - -class GraphStatistics(BaseModel): - """图统计信息""" - total_nodes: int = Field(0, description="节点总数") - total_edges: int = Field(0, description="边总数") - node_types: Dict[str, int] = Field(default_factory=dict, description="各节点类型数量") - edge_types: Dict[str, int] = Field(default_factory=dict, description="各边类型数量") - - -class GraphData(BaseModel): - """图数据响应""" - nodes: List[GraphNodeData] = Field(..., description="节点列表") - edges: List[GraphEdgeData] = Field(..., description="边列表") - statistics: GraphStatistics = Field(..., description="统计信息") - message: Optional[str] = Field(None, description="附加消息") - - -# ==================== 关系演变 ==================== - -class RelationshipEvolutionData(BaseModel): - """关系演变数据""" - emotion: Any = Field(None, description="情绪数据") - interaction: Any = Field(None, description="交互频率数据") From d3058ce379e2cbde455772bfd08aea53727f60fe Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 28 Apr 2026 15:04:13 +0800 Subject: [PATCH 15/24] fix(workspace): make delete workspace member async and invalidate user tokens --- api/app/controllers/workspace_controller.py | 4 ++-- api/app/services/workspace_service.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/api/app/controllers/workspace_controller.py b/api/app/controllers/workspace_controller.py index 47068288..abe43593 100644 --- a/api/app/controllers/workspace_controller.py +++ b/api/app/controllers/workspace_controller.py @@ -221,7 +221,7 @@ def update_workspace_members( @router.delete("/members/{member_id}", response_model=ApiResponse) @cur_workspace_access_guard() -def delete_workspace_member( +async def delete_workspace_member( member_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), @@ -230,7 +230,7 @@ def delete_workspace_member( workspace_id = current_user.current_workspace_id api_logger.info(f"用户 {current_user.username} 请求删除工作空间 {workspace_id} 的成员 {member_id}") - workspace_service.delete_workspace_member( + await workspace_service.delete_workspace_member( db=db, workspace_id=workspace_id, member_id=member_id, diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index 4034eb6d..199d5953 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -20,6 +20,7 @@ from app.models.workspace_model import ( ) from app.repositories import workspace_repository from app.repositories.workspace_invite_repository import WorkspaceInviteRepository +from app.services.session_service import SessionService from app.schemas.workspace_schema import ( InviteAcceptRequest, InviteValidateResponse, @@ -58,7 +59,7 @@ def switch_workspace( raise BusinessException(f"切换工作空间失败: {str(e)}", BizCode.INTERNAL_ERROR) -def delete_workspace_member( +async def delete_workspace_member( db: Session, workspace_id: uuid.UUID, member_id: uuid.UUID, @@ -80,6 +81,9 @@ def delete_workspace_member( workspace_member.user.current_workspace_id = None db.commit() business_logger.info(f"用户 {user.username} 成功删除工作空间 {workspace_id} 的成员 {member_id}") + + # 使被删除成员的所有 token 立即失效 + await SessionService.invalidate_all_user_tokens(str(workspace_member.user_id)) except Exception as e: db.rollback() business_logger.error(f"删除工作空间成员失败 - 工作空间: {workspace_id}, 成员: {member_id}, 错误: {str(e)}") From 7a0f08148ef335ba7163708d30e7e0353f739dd9 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 28 Apr 2026 16:10:18 +0800 Subject: [PATCH 16/24] fix(web): thinking_budget_tokens add min & default value --- .../components/ModelConfigModal.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx b/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx index bda18571..a9c94a34 100644 --- a/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx +++ b/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx @@ -49,6 +49,8 @@ const configFields = [ { key: 'n', max: 10, min: 1, step: 1, defaultValue: 1 }, ] +const min_thinking_budget_tokens = 128; +const default_thinking_budget_tokens = 1000; const ModelConfigModal = forwardRef(({ refresh, data, @@ -108,7 +110,7 @@ const ModelConfigModal = forwardRef( const newValues: ModelConfig = { capability: (option as Model).capability, deep_thinking: false, - thinking_budget_tokens: undefined, + thinking_budget_tokens: default_thinking_budget_tokens, json_output: false, } if (source === 'chat') { @@ -128,6 +130,12 @@ const ModelConfigModal = forwardRef( form.setFieldsValue({ ...rest }) }, [data?.default_model_config_id]) + useEffect(() => { + if (values?.deep_thinking && !values?.thinking_budget_tokens) { + form.setFieldValue('thinking_budget_tokens', default_thinking_budget_tokens) + } + }, [values?.deep_thinking]) + const handleReset = () => { if (!id) return resetAppModelConfig(id).then((res) => { @@ -178,7 +186,7 @@ const ModelConfigModal = forwardRef( name="thinking_budget_tokens" label={t('application.thinking_budget_tokens')} hidden={!['model', 'chat'].includes(source) || !(values?.deep_thinking || values?.capability?.includes('thinking'))} - extra={<>{t('application.range')}: [{0}, {t(`application.max_tokens`)}: {values?.max_tokens}]} + extra={<>{t('application.range')}: [{min_thinking_budget_tokens}, {t(`application.max_tokens`)}: {values?.max_tokens}]} rules={[ { required: values?.deep_thinking, message: t('common.pleaseEnter') }, { @@ -195,7 +203,7 @@ const ModelConfigModal = forwardRef( > Date: Tue, 28 Apr 2026 16:10:44 +0800 Subject: [PATCH 17/24] fix(app): adjust thinking budget tokens default and validation range The default thinking budget tokens value was changed from 10000 to 1024 in base.py, and the minimum validation constraint was updated from 1024 to 1 in app_schema.py to allow smaller budgets while maintaining backward compatibility. --- api/app/core/models/base.py | 2 +- api/app/schemas/app_schema.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index 86ac5fe0..6847a880 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -216,7 +216,7 @@ class RedBearModelFactory: # 深度思考模式:Claude 3.7 Sonnet 等支持思考的模型 # 通过 additional_model_request_fields 传递 thinking 块,关闭时不传(Bedrock 无 disabled 选项) if config.deep_thinking: - budget = config.thinking_budget_tokens or 10000 + budget = config.thinking_budget_tokens or 1024 params["additional_model_request_fields"] = { "thinking": {"type": "enabled", "budget_tokens": budget} } diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 89603322..7facf381 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -250,7 +250,7 @@ class ModelParameters(BaseModel): n: int = Field(default=1, ge=1, le=10, description="生成的回复数量") stop: Optional[List[str]] = Field(default=None, description="停止序列") deep_thinking: bool = Field(default=False, description="是否启用深度思考模式(需模型支持,如 DeepSeek-R1、QwQ 等)") - thinking_budget_tokens: Optional[int] = Field(default=None, ge=1024, le=131072, description="深度思考 token 预算(仅部分模型支持)") + thinking_budget_tokens: Optional[int] = Field(default=None, ge=1, le=131072, description="深度思考 token 预算(仅部分模型支持)") json_output: bool = Field(default=False, description="是否强制 JSON 格式输出(需模型支持 json_output 能力)") From 75fbe44839f3801f4790820f8983fb296c3372be Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 28 Apr 2026 16:17:31 +0800 Subject: [PATCH 18/24] fix(web): add min validator --- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + .../components/ModelConfigModal.tsx | 21 ++++++++++++------- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 2a7534c4..3a03fbc6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1538,6 +1538,7 @@ export const en = { json_output: 'Support JSON formatted output', thinking_budget_tokens: 'thinking budget tokens', thinking_budget_tokens_max_error: "Cannot exceed the max tokens limit ({{max}})", + thinking_budget_tokens_min_error: "Cannot be less than {{min}}", logSearchPlaceholder: 'Search log content', }, userMemory: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 6989cf3f..c7b24eb4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -868,6 +868,7 @@ export const zh = { json_output: '支持JSON格式化输出', thinking_budget_tokens: '深度思考预算Token数', thinking_budget_tokens_max_error: "不能超过 最大令牌数 ({{max}})", + thinking_budget_tokens_min_error: "不能小于 {{min}}", logSearchPlaceholder: '搜索日志内容', }, table: { diff --git a/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx b/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx index a9c94a34..d63e5b17 100644 --- a/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx +++ b/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx @@ -49,8 +49,8 @@ const configFields = [ { key: 'n', max: 10, min: 1, step: 1, defaultValue: 1 }, ] -const min_thinking_budget_tokens = 128; -const default_thinking_budget_tokens = 1000; +const minThinkingBudgetTokens = 128; +const defaultThinkingBudgetTokens = 1000; const ModelConfigModal = forwardRef(({ refresh, data, @@ -110,7 +110,7 @@ const ModelConfigModal = forwardRef( const newValues: ModelConfig = { capability: (option as Model).capability, deep_thinking: false, - thinking_budget_tokens: default_thinking_budget_tokens, + thinking_budget_tokens: defaultThinkingBudgetTokens, json_output: false, } if (source === 'chat') { @@ -132,7 +132,7 @@ const ModelConfigModal = forwardRef( useEffect(() => { if (values?.deep_thinking && !values?.thinking_budget_tokens) { - form.setFieldValue('thinking_budget_tokens', default_thinking_budget_tokens) + form.setFieldValue('thinking_budget_tokens', defaultThinkingBudgetTokens) } }, [values?.deep_thinking]) @@ -186,15 +186,20 @@ const ModelConfigModal = forwardRef( name="thinking_budget_tokens" label={t('application.thinking_budget_tokens')} hidden={!['model', 'chat'].includes(source) || !(values?.deep_thinking || values?.capability?.includes('thinking'))} - extra={<>{t('application.range')}: [{min_thinking_budget_tokens}, {t(`application.max_tokens`)}: {values?.max_tokens}]} + extra={<>{t('application.range')}: [{minThinkingBudgetTokens}, {t(`application.max_tokens`)}: {values?.max_tokens}]} rules={[ { required: values?.deep_thinking, message: t('common.pleaseEnter') }, { validator: (_, value) => { const maxTokens = values?.max_tokens const deep_thinking = values?.deep_thinking; - if (deep_thinking && value !== undefined && maxTokens !== undefined && value > maxTokens) { - return Promise.reject(t('application.thinking_budget_tokens_max_error', { max: maxTokens })) + if (deep_thinking && value !== undefined) { + if (value < minThinkingBudgetTokens) { + return Promise.reject(t('application.thinking_budget_tokens_min_error', { min: minThinkingBudgetTokens })) + } + if (maxTokens !== undefined && value > maxTokens) { + return Promise.reject(t('application.thinking_budget_tokens_max_error', { max: maxTokens })) + } } return Promise.resolve() } @@ -203,7 +208,7 @@ const ModelConfigModal = forwardRef( > Date: Tue, 28 Apr 2026 16:44:50 +0800 Subject: [PATCH 19/24] ci: add GitHub Actions workflow to sync all branches and tags to Gitee --- .github/workflows/sync-to-gitee.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-to-gitee.yml b/.github/workflows/sync-to-gitee.yml index 8bcad3b4..f3be5dbc 100644 --- a/.github/workflows/sync-to-gitee.yml +++ b/.github/workflows/sync-to-gitee.yml @@ -3,9 +3,9 @@ name: Sync to Gitee on: push: branches: - - '*' # All branchs + - '**' # All branchs tags: - - '*' # All version tags (v1.0.0, etc.) + - '**' # All version tags (v1.0.0, etc.) jobs: sync: From cab4deb2ffcf677cfd4c2ed4ac4f516ae2cba27a Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 28 Apr 2026 17:37:59 +0800 Subject: [PATCH 20/24] fix(web): workflow redo/undo --- .../Workflow/components/Nodes/AddNode.tsx | 77 ++-- .../Workflow/components/PortClickHandler.tsx | 358 ++++++++---------- .../views/Workflow/hooks/useWorkflowGraph.ts | 194 ++++++++-- web/src/views/Workflow/types.ts | 9 + 4 files changed, 345 insertions(+), 293 deletions(-) diff --git a/web/src/views/Workflow/components/Nodes/AddNode.tsx b/web/src/views/Workflow/components/Nodes/AddNode.tsx index 15f4aa1e..1c8eeee6 100644 --- a/web/src/views/Workflow/components/Nodes/AddNode.tsx +++ b/web/src/views/Workflow/components/Nodes/AddNode.tsx @@ -44,7 +44,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { if (cycleId) { const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); if (parentNode) { - parentNode.addChild(newNode); + parentNode.addChild(newNode, { silent: true }); } } @@ -77,57 +77,40 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { } }); - graph.stopBatch('add-node'); - - setTimeout(() => { - addedEdges.forEach(e => { - const src = graph.getCellById(e.getSourceCellId()); - const tgt = graph.getCellById(e.getTargetCellId()); - if (src?.isNode()) src.toFront(); - if (tgt?.isNode()) tgt.toFront(); - }); - }, 50); - // Automatically adjust loop node size const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); if (loopNode) { - const adjustLoopSize = () => { - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - if (childNodes.length > 0) { - const bounds = childNodes.reduce((acc, child) => { - const bbox = child.getBBox(); - return { - minX: Math.min(acc.minX, bbox.x), - minY: Math.min(acc.minY, bbox.y), - maxX: Math.max(acc.maxX, bbox.x + bbox.width), - maxY: Math.max(acc.maxY, bbox.y + bbox.height) - }; - }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); - - const padding = 50; - const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); - const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); - - loopNode.prop('size', { width: newWidth, height: newHeight }); - - // Update right port x position - const ports = loopNode.getPorts(); - ports.forEach(port => { - if (port.group === 'right' && port.args) { - loopNode.portProp(port.id!, 'args/x', newWidth); - } - }); - } - }; - - adjustLoopSize(); - - // Listen to child node movement events const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - childNodes.forEach((childNode: any) => { - childNode.on('change:position', adjustLoopSize); - }); + if (childNodes.length > 0) { + const bounds = childNodes.reduce((acc, child) => { + const bbox = child.getBBox(); + return { + minX: Math.min(acc.minX, bbox.x), + minY: Math.min(acc.minY, bbox.y), + maxX: Math.max(acc.maxX, bbox.x + bbox.width), + maxY: Math.max(acc.maxY, bbox.y + bbox.height) + }; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + const padding = 50; + const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); + const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); + loopNode.prop('size', { width: newWidth, height: newHeight }); + loopNode.getPorts().forEach(port => { + if (port.group === 'right' && port.args) { + loopNode.portProp(port.id!, 'args/x', newWidth); + } + }); + } } + + addedEdges.forEach(e => { + const src = graph.getCellById(e.getSourceCellId()); + const tgt = graph.getCellById(e.getTargetCellId()); + if (src?.isNode()) src.toFront(); + if (tgt?.isNode()) tgt.toFront(); + }); + + graph.stopBatch('add-node'); setOpen(false); }; diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx index 68aef867..402b5d37 100644 --- a/web/src/views/Workflow/components/PortClickHandler.tsx +++ b/web/src/views/Workflow/components/PortClickHandler.tsx @@ -43,71 +43,52 @@ const PortClickHandler: React.FC = ({ graph }) => { }; }, []); - // Handle node selection from popover menu and create new node with edge connection const handleNodeSelect = (selectedNodeType: any) => { if (!sourceNode || !graph) return; - graph.startBatch('add-node'); const sourceNodeData = sourceNode.getData(); const sourceNodeType = sourceNodeData?.type; - - // If it's a cycle-start node, handle the add-node placeholder + const isCycleSubNode = !!sourceNodeData.cycle; + const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration'; + const newNodeType = selectedNodeType.type; + + // Save add-node placeholder position before disabling history let addNodePosition = null; - const isCycleSubNode = sourceNodeData.cycle if (isCycleSubNode && sourceNodeType === 'cycle-start') { const cycleId = sourceNodeData.cycle; - const addNodes = graph.getNodes().filter((n: any) => + const addNodes = graph.getNodes().filter((n: any) => n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId ); - - if (addNodes.length > 0) { - const addNode = addNodes[0]; - addNodePosition = addNode.getBBox(); - addNode.remove(); - } + if (addNodes.length > 0) addNodePosition = addNodes[0].getBBox(); } - - // Calculate new node position to avoid overlapping + + // Calculate position const sourceBBox = sourceNode.getBBox(); - const nodeWidth = graphNodeLibrary[selectedNodeType.type]?.width || 120; - const nodeHeight = graphNodeLibrary[selectedNodeType.type]?.height || 88; - const horizontalSpacing = isCycleSubNode ? 48 : 80; - const verticalSpacing = 10; - - // Get source port group information + const nw = graphNodeLibrary[newNodeType]?.width || 120; + const nh = graphNodeLibrary[newNodeType]?.height || 88; + const hSpacing = isCycleSubNode ? 48 : 80; + const vSpacing = 10; const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort); const sourcePortGroup = sourcePortInfo?.group || sourcePort; - - // Calculate new node position - let newX, newY; + + let newX: number, newY: number; if (edgeInsertion) { - // Edge insertion: place new node on the same row as target, between source and target const targetBBox = edgeInsertion.targetCell.getBBox(); const gap = targetBBox.x - (sourceBBox.x + sourceBBox.width); - const requiredSpace = nodeWidth + horizontalSpacing * 4; - - // New node x: right after source + spacing - newX = sourceBBox.x + sourceBBox.width + horizontalSpacing; - // Same row as target node - newY = targetBBox.y + (targetBBox.height - nodeHeight) / 2; - - // If not enough space, shift target and all downstream nodes to the right + const requiredSpace = nw + hSpacing * 4; + newX = sourceBBox.x + sourceBBox.width + hSpacing; + newY = targetBBox.y + (targetBBox.height - nh) / 2; if (gap < requiredSpace) { const shiftX = requiredSpace - gap; const visited = new Set(); const shiftDownstream = (cell: any) => { - const cellId = cell.id; - if (visited.has(cellId)) return; - visited.add(cellId); + if (visited.has(cell.id)) return; + visited.add(cell.id); const pos = cell.getPosition(); cell.setPosition(pos.x + shiftX, pos.y); - // Recursively shift nodes connected from right ports graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => { - const tId = e.getTargetCellId(); - if (tId && !visited.has(tId)) { - const tCell = graph.getCellById(tId); - if (tCell?.isNode()) shiftDownstream(tCell); - } + const tCell = graph.getCellById(e.getTargetCellId()); + if (tCell?.isNode()) shiftDownstream(tCell); }); }; shiftDownstream(edgeInsertion.targetCell); @@ -115,209 +96,170 @@ const PortClickHandler: React.FC = ({ graph }) => { } else if (addNodePosition) { newX = addNodePosition.x; newY = addNodePosition.y; + } else if (sourcePortGroup === 'left') { + newX = sourceBBox.x - nw * 2 - hSpacing; + newY = sourceBBox.y; } else { - // Determine node placement direction based on port position - if (sourcePortGroup === 'left') { - // Left port: add node to the left - newX = sourceBBox.x - nodeWidth*2 - horizontalSpacing; - newY = sourceBBox.y; - } else { - // Right port: add node to the right - newX = sourceBBox.x + sourceBBox.width + horizontalSpacing; - newY = sourceBBox.y; - } - - // Check if position overlaps with existing nodes (only consider connected nodes) - const checkOverlap = (x: number, y: number) => { - // Get nodes connected to the source node - const connectedNodes = new Set(); - graph.getConnectedEdges(sourceNode).forEach((edge: any) => { - const sourceId = edge.getSourceCellId(); - const targetId = edge.getTargetCellId(); - if (sourceId !== sourceNode.id) connectedNodes.add(sourceId); - if (targetId !== sourceNode.id) connectedNodes.add(targetId); + newX = sourceBBox.x + sourceBBox.width + hSpacing; + newY = sourceBBox.y; + const connectedNodes = new Set(); + graph.getConnectedEdges(sourceNode).forEach((e: any) => { + [e.getSourceCellId(), e.getTargetCellId()].forEach((cid: string) => { + if (cid !== sourceNode.id) connectedNodes.add(cid); }); - - return graph.getNodes().some((node: any) => { - if (node.id === sourceNode.id) return false; - if (!connectedNodes.has(node.id)) return false; // Only consider connected nodes - const bbox = node.getBBox(); - return !(x + nodeWidth < bbox.x || x > bbox.x + bbox.width || - y + nodeHeight < bbox.y || y > bbox.y + bbox.height); + }); + const checkOverlap = (x: number, y: number) => + graph.getNodes().some((n: any) => { + if (n.id === sourceNode.id || !connectedNodes.has(n.id)) return false; + const b = n.getBBox(); + return !(x + nw < b.x || x > b.x + b.width || y + nh < b.y || y > b.y + b.height); }); - }; - - // If position is occupied, search downward for empty space - while (checkOverlap(newX, newY)) { - newY += nodeHeight + verticalSpacing; - } + while (checkOverlap(newX, newY)) newY += nh + vSpacing; } - - // Create new node - const id = `${selectedNodeType.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + + // Disable history for all graph mutations + graph.disableHistory(); + + // Remove add-node placeholder + if (isCycleSubNode && sourceNodeType === 'cycle-start') { + const cycleId = sourceNodeData.cycle; + graph.getNodes() + .filter((n: any) => n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) + .forEach((n: any) => n.remove()); + } + + const id = `${newNodeType.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const newNode = graph.addNode({ - ...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default), + ...(graphNodeLibrary[newNodeType] || graphNodeLibrary.default), x: newX, y: newY - (isCycleSubNode && sourceNodeType === 'cycle-start' ? 12 : 0), id, data: { id, - type: selectedNodeType.type, + type: newNodeType, icon: selectedNodeType.icon, - name: t(`workflow.${selectedNodeType.type}`), - cycle: sourceNodeData.cycle, // Inherit cycle from source node + name: t(`workflow.${newNodeType}`), + cycle: sourceNodeData.cycle, config: selectedNodeType.config || {} }, }); - // Add new node as child of parent node if (sourceNodeData.cycle) { const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle); - if (parentNode) { - parentNode.addChild(newNode); - } + if (parentNode) parentNode.addChild(newNode, { silent: true }); } - // Edge insertion: remove old edge immediately before creating new edges if (edgeInsertion) { const { edge: oldEdge } = edgeInsertion; - if (oldEdge.id && graph.getCellById(oldEdge.id)) { - graph.removeCell(oldEdge.id); - } else { - graph.removeEdge(oldEdge); - } + if (oldEdge.id && graph.getCellById(oldEdge.id)) graph.removeCell(oldEdge.id); + else graph.removeEdge(oldEdge); } - // Create edge connection - setTimeout(() => { - const newPorts = newNode.getPorts(); + const newPorts = newNode.getPorts(); + const addedCells: any[] = [newNode]; - const addedEdges: any[] = []; - if (edgeInsertion) { - // Edge insertion: create source→new and new→target edges - const { targetCell, targetPort: origTargetPort } = edgeInsertion; - const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; - const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; - addedEdges.push(graph.addEdge({ - source: { cell: sourceNode.id, port: sourcePort }, - target: { cell: newNode.id, port: newLeftPort }, - ...edgeAttrs - })); - addedEdges.push(graph.addEdge({ - source: { cell: newNode.id, port: newRightPort }, - target: { cell: targetCell.id, port: origTargetPort }, - ...edgeAttrs - })); - setEdgeInsertion(null); - } else if (sourcePortGroup === 'left') { - // Connect from left port to new node's right side - const targetPort = newPorts.find((port: any) => port.group === 'right')?.id || 'right'; - addedEdges.push(graph.addEdge({ - source: { cell: newNode.id, port: targetPort }, - target: { cell: sourceNode.id, port: sourcePort }, - ...edgeAttrs - })); - } else { - // Connect from right port to new node's left side - const targetPort = newPorts.find((port: any) => port.group === 'left')?.id || 'left'; - addedEdges.push(graph.addEdge({ - source: { cell: sourceNode.id, port: sourcePort }, - target: { cell: newNode.id, port: targetPort }, - ...edgeAttrs - })); - } - - // Adjust loop node size when child node is added via port within loop node - const cycleId = sourceNodeData.cycle; - if (cycleId) { - const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); + if (edgeInsertion) { + const { targetCell, targetPort: origTargetPort } = edgeInsertion; + const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; + const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; + addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: newLeftPort }, ...edgeAttrs })); + addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: newRightPort }, target: { cell: targetCell.id, port: origTargetPort }, ...edgeAttrs })); + setEdgeInsertion(null); + } else if (sourcePortGroup === 'left') { + const tp = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; + addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: tp }, target: { cell: sourceNode.id, port: sourcePort }, ...edgeAttrs })); + } else { + const tp = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; + addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: tp }, ...edgeAttrs })); + } - if (parentNode) { - const adjustLoopSize = () => { - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - if (childNodes.length > 0) { - const bounds = childNodes.reduce((acc: any, child: any) => { - const bbox = child.getBBox(); - return { - minX: Math.min(acc.minX, bbox.x), - minY: Math.min(acc.minY, bbox.y), - maxX: Math.max(acc.maxX, bbox.x + bbox.width), - maxY: Math.max(acc.maxY, bbox.y + bbox.height) - }; - }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + // If adding a loop/iteration node, create cycle-start, add-node and inner edge regardless of source type + if (isCycleContainer(newNodeType)) { + const parentBBox = newNode.getBBox(); + const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const cycleStartNode = graph.addNode({ + ...graphNodeLibrary.cycleStart, + x: parentBBox.x + 24, + y: parentBBox.y + 70, + id: cycleStartId, + data: { id: cycleStartId, type: 'cycle-start', parentId: id, isDefault: true, cycle: id }, + }); + const addNodePlaceholder = graph.addNode({ + ...graphNodeLibrary.addStart, + x: parentBBox.x + 24 + 84, + y: parentBBox.y + 70 + 4, + data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: id, cycle: id }, + }); + newNode.addChild(cycleStartNode, { silent: true }); + newNode.addChild(addNodePlaceholder, { silent: true }); + const innerEdge = graph.addEdge({ + source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find((p: any) => p.group === 'right')?.id || 'right' }, + target: { cell: addNodePlaceholder.id, port: addNodePlaceholder.getPorts().find((p: any) => p.group === 'left')?.id || 'left' }, + ...edgeAttrs, + }); + addedCells.push(cycleStartNode, addNodePlaceholder, innerEdge); + } - const padding = 50; - const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); - const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); - - parentNode.prop('size', { width: newWidth, height: newHeight }); - - // Update right port x position - const ports = parentNode.getPorts(); - ports.forEach((port: any) => { - if (port.group === 'right' && port.args) { - parentNode.portProp(port.id!, 'args/x', newWidth); - } - }); - } - }; - - adjustLoopSize(); - - // Listen to child node movement events - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - childNodes.forEach((childNode: any) => { - childNode.on('change:position', adjustLoopSize); + // Adjust parent size if adding inside a cycle container + const cycleId = sourceNodeData.cycle; + if (cycleId) { + const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); + if (parentNode) { + const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); + if (childNodes.length > 0) { + const bounds = childNodes.reduce((acc: any, child: any) => { + const b = child.getBBox(); + return { minX: Math.min(acc.minX, b.x), minY: Math.min(acc.minY, b.y), maxX: Math.max(acc.maxX, b.x + b.width), maxY: Math.max(acc.maxY, b.y + b.height) }; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + const padding = 50; + const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); + const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); + parentNode.prop('size', { width: newWidth, height: newHeight }); + parentNode.getPorts().forEach((port: any) => { + if (port.group === 'right' && port.args) parentNode.portProp(port.id!, 'args/x', newWidth); }); } } + } - const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration'; - const newNodeType = selectedNodeType.type; + // toFront + const bringCycleChildrenToFront = (cycleContainerId: string) => { + graph.getEdges().forEach((e: any) => { + const src = graph.getCellById(e.getSourceCellId()); + const tgt = graph.getCellById(e.getTargetCellId()); + if (src?.getData()?.cycle === cycleContainerId || tgt?.getData()?.cycle === cycleContainerId) e.toFront(); + }); + graph.getNodes().forEach((n: any) => { if (n.getData()?.cycle === cycleContainerId) n.toFront(); }); + }; - // Helper: bring all child nodes and their edges of a cycle container to front - const bringCycleChildrenToFront = (cycleContainerId: string) => { - - graph.getEdges().forEach((e: any) => { - const src = graph.getCellById(e.getSourceCellId()); - const tgt = graph.getCellById(e.getTargetCellId()); - if (src?.getData()?.cycle === cycleContainerId || tgt?.getData()?.cycle === cycleContainerId) e.toFront(); - }); - graph.getNodes().forEach((n: any) => { - if (n.getData()?.cycle === cycleContainerId) n.toFront(); - }); - }; + if (isCycleContainer(sourceNodeType)) { + newNode.toFront(); sourceNode.toFront(); bringCycleChildrenToFront(sourceNodeData.id); + if (isCycleContainer(newNodeType)) bringCycleChildrenToFront(id); + } else if (isCycleContainer(newNodeType)) { + newNode.toFront(); sourceNode.toFront(); bringCycleChildrenToFront(id); + } else { + addedCells.forEach(c => { if (c.isNode?.()) c.toFront(); }); + } - if (isCycleContainer(sourceNodeType)) { - console.log('isCycleContainer(sourceNodeType)') - // Case 4: source is a loop/iteration node — bring new node to front, then its children - newNode.toFront(); - sourceNode.toFront(); - bringCycleChildrenToFront(sourceNodeData.id); - } else if (isCycleContainer(newNodeType)) { - console.log('isCycleContainer(newNodeType)') - // Case 3: adding a loop/iteration node from a normal node — bring new node to front, then its children - newNode.toFront(); - sourceNode.toFront() - bringCycleChildrenToFront(id); - } else { - // Case 2: normal node → normal node - addedEdges.forEach(e => { - const src = graph.getCellById(e.getSourceCellId()); - const tgt = graph.getCellById(e.getTargetCellId()); - if (src?.isNode()) src.toFront(); - if (tgt?.isNode()) tgt.toFront(); - }); - } - graph.stopBatch('add-node'); - }, 50); + // Re-enable history and manually push one batch frame for all added cells + graph.enableHistory(); + const history = graph.getPlugin('history') as any; + if (history) { + const batchFrame = addedCells.map((cell: any) => ({ + batch: true, + event: 'cell:added', + data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() }, + options: {}, + })); + history.undoStack.push(batchFrame); + history.redoStack = []; + graph.trigger('history:change', { cmds: batchFrame, options: { name: 'add-node' } }); + } - // Clean up temporary element if (tempElement) { document.body.removeChild(tempElement); setTempElement(null); } - setPopoverVisible(false); }; @@ -393,4 +335,4 @@ const PortClickHandler: React.FC = ({ graph }) => { ); }; -export default PortClickHandler; \ No newline at end of file +export default PortClickHandler; diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index c81974a4..500a4527 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-27 16:30:30 + * @Last Modified time: 2026-04-28 13:49:11 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -16,7 +16,7 @@ import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'; import { useUser } from '@/store/user'; import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; import { conditionNodeHeight, conditionNodeItemHeight, conditionNodePortItemArgsY, defaultAbsolutePortGroups, defaultPortItems, edgeAttrs, edgeHoverTool, edge_color, edge_selected_color, edge_width, graphNodeLibrary, nodeLibrary, nodeRegisterLibrary, nodeWidth, notesConfig, portAttrs, portItemArgsY, portMarkup, portTextAttrs, unknownNode } from '../constant'; -import type { ChatVariable, NodeProperties, WorkflowConfig } from '../types'; +import type { ChatVariable, HistoryRecord, NodeProperties, WorkflowConfig } from '../types'; import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils'; import { useWorkflowStore } from '@/store/workflow'; @@ -85,6 +85,10 @@ export interface UseWorkflowGraphReturn { /** Get start node output variable list (user-defined + system variables) */ getStartNodeVariables: () => Array<{ name: string; type: string; readonly?: boolean }>; nodeClick: ({ node }: { node: Node }) => void; + /** All recorded history operations */ + historyRecords: HistoryRecord[]; + /** Clear history records */ + clearHistoryRecords: () => void; } /** @@ -118,7 +122,12 @@ export const useWorkflowGraph = ({ const featuresRef = useRef(undefined) const [canUndo, setCanUndo] = useState(false) const [canRedo, setCanRedo] = useState(false) - + const [historyRecords, setHistoryRecords] = useState([]) + const lastHistoryRef = useRef<{ cellIds: string[]; timestamp: number; type: string } | null>(null) + const undoRef = useRef<() => void>(() => {}) + const redoRef = useRef<() => void>(() => {}) + const syncChildRelationshipsRef = useRef<() => void>(() => {}) + const isSyncingRef = useRef(false) useEffect(() => { if (!graphRef.current) return graphRef.current.getNodes().forEach(node => { @@ -342,7 +351,7 @@ export const useWorkflowGraph = ({ if (parentNode) { const addedChild = graphRef.current?.addNode(childNode) if (addedChild) { - parentNode.addChild(addedChild) + parentNode.addChild(addedChild, { silent: true }) } } } @@ -373,8 +382,6 @@ export const useWorkflowGraph = ({ const newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2) const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight) - console.log('newWidth', newHeight, newWidth) - parentNode.prop('size', { width: newWidth, height: newHeight }) // Update x position of right group ports @@ -523,30 +530,28 @@ export const useWorkflowGraph = ({ const syncChildRelationships = () => { if (!graphRef.current) return const graph = graphRef.current - // Re-establish parent-child relationships based on cycle data + graph.disableHistory() graph.getNodes().forEach(node => { const cycleId = node.getData()?.cycle if (!cycleId) return const parentNode = graph.getCellById(cycleId) as Node | null if (!parentNode) return if (!parentNode.getChildren()?.some(c => c.id === node.id)) { - parentNode.addChild(node) + parentNode.addChild(node, { silent: true }) } }) - // Remove stale parent-child links (parent exists but child's cycle no longer points to it) graph.getNodes().forEach(node => { const children = node.getChildren() if (!children?.length) return children.forEach(child => { + if (!child.isNode()) return const childCycleId = (child as Node).getData?.()?.cycle if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) { - node.removeChild(child) + node.removeChild(child, { silent: true }) } }) }) - // Recalculate group node size based on current children resizeGroupNodes(graph) - // Bring child edges and nodes to front graph.getEdges().forEach(edge => { const src = graph.getCellById(edge.getSourceCellId()) const tgt = graph.getCellById(edge.getTargetCellId()) @@ -557,7 +562,9 @@ export const useWorkflowGraph = ({ graph.getNodes().forEach(node => { if (node.getData()?.cycle) node.toFront() }) + graph.enableHistory() } + syncChildRelationshipsRef.current = syncChildRelationships /** * Setup X6 graph plugins (MiniMap, Snapline, Clipboard, Keyboard) */ @@ -593,19 +600,44 @@ export const useWorkflowGraph = ({ new History({ enabled: false, beforeAddCommand(_event, args: any) { - const event = args?.key ? `cell:change:${args.key}` : _event; - const allowed = ['cell:added', 'cell:removed', 'cell:change:position', 'cell:change:source', 'cell:change:target']; - if (!allowed.includes(event)) return false; + const key = args?.key + if (key === 'attrs' || key === 'tools') return false }, }), ); - graphRef.current.on('history:change', () => { + const MERGE_INTERVAL = 1000 + graphRef.current.on('history:change', ({ cmds, options }: { cmds: any[]; options: any }) => { setCanUndo(graphRef.current?.canUndo() ?? false) setCanRedo(graphRef.current?.canRedo() ?? false) + console.log('history:change', cmds, options) + const batchName: string | undefined = options?.name + const actionType = batchName === 'undo' ? 'undo' : batchName === 'redo' ? 'redo' : batchName ? 'batch' : 'change' + const cellIds = [...new Set(cmds?.map((cmd: any) => cmd.data?.id).filter(Boolean))] + const now = Date.now() + const last = lastHistoryRef.current + const canMerge = + actionType === 'change' && + last?.type === 'change' && + now - last.timestamp < MERGE_INTERVAL && + cellIds.length > 0 && + cellIds.length === last.cellIds.length && + cellIds.every((id, i) => id === last.cellIds[i]) + if (canMerge) { + lastHistoryRef.current!.timestamp = now + setHistoryRecords(prev => { + const next = [...prev] + next[next.length - 1] = { ...next[next.length - 1], timestamp: now } + return next + }) + } else { + const record: HistoryRecord = { type: actionType, timestamp: now, batchName, cellIds } + lastHistoryRef.current = { cellIds, timestamp: now, type: actionType } + setHistoryRecords(prev => [...prev, record]) + } }) - graphRef.current.on('history:undo', syncChildRelationships) - graphRef.current.on('history:redo', syncChildRelationships) + graphRef.current.on('history:undo', () => { if (!isSyncingRef.current) syncChildRelationshipsRef.current() }) + graphRef.current.on('history:redo', () => { if (!isSyncingRef.current) syncChildRelationshipsRef.current() }) }; // 显示/隐藏连接桩 // const showPorts = (show: boolean) => { @@ -638,13 +670,13 @@ export const useWorkflowGraph = ({ vo.setData({ ...data, isSelected: false, - }); + }, { silent: true }); } }); node.setData({ ...nodeData, isSelected: true, - }); + }, { silent: true }); clearEdgeSelect() if (nodeData.type !== 'notes') { setSelectedNode(node); @@ -658,7 +690,7 @@ export const useWorkflowGraph = ({ const edgeClick = ({ edge }: { edge: Edge }) => { clearEdgeSelect(); edge.setAttrByPath('line/stroke', edge_selected_color); - edge.setData({ ...edge.getData(), isSelected: true }); + edge.setData({ ...edge.getData(), isSelected: true }, { silent: true }); clearNodeSelect(); }; /** @@ -673,7 +705,7 @@ export const useWorkflowGraph = ({ node.setData({ ...data, isSelected: false, - }); + }, { silent: true }); } }); setSelectedNode(null); @@ -683,7 +715,7 @@ export const useWorkflowGraph = ({ */ const clearEdgeSelect = () => { graphRef.current?.getEdges().forEach(e => { - e.setData({ ...e.getData(), isSelected: false, isNodeHover: false }); + e.setData({ ...e.getData(), isSelected: false, isNodeHover: false }, { silent: true }); e.setAttrByPath('line/stroke', edge_color); e.setAttrByPath('line/strokeWidth', edge_width); }); @@ -885,7 +917,7 @@ export const useWorkflowGraph = ({ y: bbox.y + 4, data: { type: 'add-node', parentId: parentNode.id, cycle: parentData.id, label: t('workflow.addNode'), icon: '+' }, }); - parentNode.addChild(addNode); + parentNode.addChild(addNode, { silent: true }); graphRef.current!.addEdge({ source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' }, target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' }, @@ -1112,7 +1144,7 @@ export const useWorkflowGraph = ({ graphRef.current?.getConnectedEdges(node).forEach(edge => { if (!edge.getData()?.isSelected) { edge.setAttrByPath('line/stroke', edge_selected_color); - edge.setData({ ...edge.getData(), isNodeHover: true }); + edge.setData({ ...edge.getData(), isNodeHover: true }, { silent: true }); } }); }); @@ -1120,7 +1152,7 @@ export const useWorkflowGraph = ({ graphRef.current?.getConnectedEdges(node).forEach(edge => { if (!edge.getData()?.isSelected) { edge.setAttrByPath('line/stroke', edge_color); - edge.setData({ ...edge.getData(), isNodeHover: false }); + edge.setData({ ...edge.getData(), isNodeHover: false }, { silent: true }); } }); }); @@ -1202,8 +1234,8 @@ export const useWorkflowGraph = ({ // Delete selected nodes and edges graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent); // Undo / Redo - graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { graphRef.current?.undo(); return false; }); - graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { graphRef.current?.redo(); return false; }); + graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { undo(); return false; }); + graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { redo(); return false; }); }; @@ -1269,14 +1301,14 @@ export const useWorkflowGraph = ({ }; if (dragData.type === 'loop' || dragData.type === 'iteration') { - graphRef.current.startBatch('add-group') + graph.disableHistory() const parentNode = graphRef.current.addNode({ ...graphNodeLibrary[dragData.type], x: point.x - 150, y: point.y - 100, id: cleanNodeData.id, data: { ...cleanNodeData, isGroup: true }, - }); + }) const parentBBox = parentNode.getBBox() const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` const cycleStartNode = graphRef.current.addNode({ @@ -1292,16 +1324,28 @@ export const useWorkflowGraph = ({ y: parentBBox.y + 70 + 4, data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: cleanNodeData.id, cycle: cleanNodeData.id }, }) - parentNode.addChild(cycleStartNode) - parentNode.addChild(addNode) - graphRef.current.addEdge({ + parentNode.addChild(cycleStartNode, { silent: true }) + parentNode.addChild(addNode, { silent: true }) + const newEdge = graphRef.current.addEdge({ source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' }, target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' }, ...edgeAttrs, }) cycleStartNode.toFront() addNode.toFront() - graphRef.current.stopBatch('add-group') + graph.enableHistory() + // Manually push a single batch frame covering all 4 cells into undoStack + const history = graph.getPlugin('history') as History + const makeBatchCmd = (cell: any) => ({ + batch: true, + event: 'cell:added', + data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() }, + options: {}, + }) + const batchFrame = [parentNode, cycleStartNode, addNode, newEdge].map(makeBatchCmd) + ;(history as any).undoStack.push(batchFrame) + ;(history as any).redoStack = [] + graph.trigger('history:change', { cmds: batchFrame, options: { name: 'add-group' } }) } else if (dragData.type === 'if-else') { // Create condition node graphRef.current.addNode({ @@ -1548,8 +1592,80 @@ export const useWorkflowGraph = ({ return userVars } - const undo = () => graphRef.current?.undo() - const redo = () => graphRef.current?.redo() + const clearHistoryRecords = () => { + setHistoryRecords([]) + lastHistoryRef.current = null + } + + const getStackCellIds = (cmds: any): string[] => { + const arr = Array.isArray(cmds) ? cmds : [cmds] + return [...new Set(arr.map((c: any) => c.data?.id).filter(Boolean))] + } + + const isSkippableFrame = (frame: any): boolean => { + const arr = Array.isArray(frame) ? frame : [frame] + return arr.every((c: any) => ['zIndex', 'attrs', 'tools'].includes(c.data?.key)) + } + + const undo = () => { + const history = graphRef.current?.getPlugin('history') as History | undefined + if (!history || history.getUndoSize() === 0) return + const undoStack = (history as any).undoStack as any[] + isSyncingRef.current = true + while (undoStack.length > 0 && isSkippableFrame(undoStack[undoStack.length - 1])) { + graphRef.current!.undo() + } + if (undoStack.length === 0) { + isSyncingRef.current = false + return + } + const topIds = getStackCellIds(undoStack[undoStack.length - 1]) + graphRef.current!.undo() + while (undoStack.length > 0) { + if (isSkippableFrame(undoStack[undoStack.length - 1])) { + graphRef.current!.undo() + continue + } + const nextIds = getStackCellIds(undoStack[undoStack.length - 1]) + if (nextIds.length === topIds.length && nextIds.every((id, i) => id === topIds[i])) { + graphRef.current!.undo() + } else { + break + } + } + isSyncingRef.current = false + syncChildRelationships() + } + + const redo = () => { + const history = graphRef.current?.getPlugin('history') as History | undefined + if (!history || history.getRedoSize() === 0) return + const redoStack = (history as any).redoStack as any[] + isSyncingRef.current = true + while (redoStack.length > 0 && isSkippableFrame(redoStack[redoStack.length - 1])) { + graphRef.current!.redo() + } + if (redoStack.length === 0) { + isSyncingRef.current = false + return + } + const topIds = getStackCellIds(redoStack[redoStack.length - 1]) + graphRef.current!.redo() + while (redoStack.length > 0) { + if (isSkippableFrame(redoStack[redoStack.length - 1])) { + graphRef.current!.redo() + continue + } + const nextIds = getStackCellIds(redoStack[redoStack.length - 1]) + if (nextIds.length === topIds.length && nextIds.every((id, i) => id === topIds[i])) { + graphRef.current!.redo() + } else { + break + } + } + isSyncingRef.current = false + syncChildRelationships() + } const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => { const { statement = '' } = value?.opening_statement || {} @@ -1593,7 +1709,7 @@ export const useWorkflowGraph = ({ // Reset all node execution status on every chatHistory change nodes.forEach(node => { const data = node.getData(); - node.setData({ ...data, executionStatus: '' }); + node.setData({ ...data, executionStatus: '' }, { silent: true }); }); const lastAssistant = [...chatHistory].reverse().find(item => item.role === 'assistant'); @@ -1602,7 +1718,7 @@ export const useWorkflowGraph = ({ if (typeof sub.status === 'string') { const node = nodes.find(n => n.getData()?.id === sub.node_id); if (node) { - node.setData({ ...node.getData(), executionStatus: sub.status }); + node.setData({ ...node.getData(), executionStatus: sub.status }, { silent: true }); } } }); @@ -1635,5 +1751,7 @@ export const useWorkflowGraph = ({ canRedo, undo, redo, + historyRecords, + clearHistoryRecords, }; }; diff --git a/web/src/views/Workflow/types.ts b/web/src/views/Workflow/types.ts index 1604aac2..16a64632 100644 --- a/web/src/views/Workflow/types.ts +++ b/web/src/views/Workflow/types.ts @@ -113,4 +113,13 @@ export interface ChatVariable { } export interface AddChatVariableRef { handleOpen: (value?: ChatVariable) => void; +} + +export type HistoryActionType = 'add' | 'remove' | 'change' | 'undo' | 'redo' | 'batch' + +export interface HistoryRecord { + type: HistoryActionType; + timestamp: number; + batchName?: string; + cellIds?: string[]; } \ No newline at end of file From 6f1029696978342e575ecad06c31bd05338b61f8 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 28 Apr 2026 18:34:06 +0800 Subject: [PATCH 21/24] fix(workspace): deactivate user when removed from last active workspace --- api/app/services/workspace_service.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index 199d5953..db641638 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -77,8 +77,24 @@ async def delete_workspace_member( BizCode.WORKSPACE_NOT_FOUND) try: + deleted_user = workspace_member.user workspace_member.is_active = False - workspace_member.user.current_workspace_id = None + deleted_user.current_workspace_id = None + + # 若被删除成员不是超级管理员且没有其他可用工作空间,则禁用该用户 + if not deleted_user.is_superuser: + remaining = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.user_id == deleted_user.id, + WorkspaceMember.workspace_id != workspace_id, + WorkspaceMember.is_active.is_(True), + ) + .count() + ) + if remaining == 0: + deleted_user.is_active = False + db.commit() business_logger.info(f"用户 {user.username} 成功删除工作空间 {workspace_id} 的成员 {member_id}") From 1817f52edf6c7338fce9b6b9dbeef197c9e3314e Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 29 Apr 2026 11:55:43 +0800 Subject: [PATCH 22/24] fix(web): ontology tag --- web/src/components/OverflowTags/index.tsx | 6 +++--- web/src/views/Ontology/index.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/src/components/OverflowTags/index.tsx b/web/src/components/OverflowTags/index.tsx index 9ad9cd92..82fdb2c9 100644 --- a/web/src/components/OverflowTags/index.tsx +++ b/web/src/components/OverflowTags/index.tsx @@ -3,14 +3,14 @@ import { Popover, type PopoverProps } from 'antd' import Tag, { type TagProps } from '@/components/Tag' interface OverflowTagsProps { - items: ReactNode[]; + items?: ReactNode[]; gap?: number; numTagColor?: TagProps['color']; numTag?: (num?: number) => ReactNode; popoverProps?: PopoverProps | false; } -const OverflowTags = ({ items, gap = 8, numTagColor = 'default', numTag, popoverProps }: OverflowTagsProps) => { +const OverflowTags = ({ items = [], gap = 8, numTagColor = 'default', numTag, popoverProps }: OverflowTagsProps) => { const containerRef = useRef(null) const measureRef = useRef(null) const [visibleCount, setVisibleCount] = useState(items.length) @@ -20,7 +20,7 @@ const OverflowTags = ({ items, gap = 8, numTagColor = 'default', numTag, popover if (!measure || containerWidth === 0) return const children = Array.from(measure.children) as HTMLElement[] - if (!children.length) return + if (!children.length) { setVisibleCount(0); return } // last child is the sample +N tag const extraTagWidth = (children[children.length - 1] as HTMLElement).offsetWidth diff --git a/web/src/views/Ontology/index.tsx b/web/src/views/Ontology/index.tsx index 1d4b9e94..8b29e343 100644 --- a/web/src/views/Ontology/index.tsx +++ b/web/src/views/Ontology/index.tsx @@ -166,10 +166,10 @@ const Ontology: FC = () => {
{item.scene_description}
-
+
{type}), {`+${item.type_num - 3}`}]} + items={item.entity_type ? [...item.entity_type.map((type, i) => {type}), {`+${item.type_num - 3}`}] : []} numTag={(num?: number) => {`+${item.type_num - 3 + (num ? num - 1 : 0)}`}} />
From 53f1b0e5869ee507644063d793a7925c880dc3b5 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 29 Apr 2026 12:24:34 +0800 Subject: [PATCH 23/24] fix(web): node executionStatus update remove silent --- web/src/views/Workflow/hooks/useWorkflowGraph.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 500a4527..0fda2935 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -134,7 +134,7 @@ export const useWorkflowGraph = ({ const data = node.getData() if (data?.type === 'if-else' || data?.type === 'question-classifier') { console.log('chatVariables', chatVariables) - node.setData({ ...data, chatVariables }, { silent: true }) + node.setData({ ...data, chatVariables }) } }) }, [chatVariables]) @@ -1709,7 +1709,7 @@ export const useWorkflowGraph = ({ // Reset all node execution status on every chatHistory change nodes.forEach(node => { const data = node.getData(); - node.setData({ ...data, executionStatus: '' }, { silent: true }); + node.setData({ ...data, executionStatus: '' }); }); const lastAssistant = [...chatHistory].reverse().find(item => item.role === 'assistant'); @@ -1718,7 +1718,7 @@ export const useWorkflowGraph = ({ if (typeof sub.status === 'string') { const node = nodes.find(n => n.getData()?.id === sub.node_id); if (node) { - node.setData({ ...node.getData(), executionStatus: sub.status }, { silent: true }); + node.setData({ ...node.getData(), executionStatus: sub.status }); } } }); From e38a60e1077d812f90bdbc6bfe5a129304bf7cfa Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Wed, 29 Apr 2026 20:24:10 +0800 Subject: [PATCH 24/24] feat(core): add configurable SANDBOX_URL for code node sandbox requests --- api/app/core/config.py | 2 ++ api/app/core/workflow/nodes/code/node.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/api/app/core/config.py b/api/app/core/config.py index 64c5520e..56a07f3f 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -241,6 +241,8 @@ class Settings: SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587")) SMTP_USER: str = os.getenv("SMTP_USER", "") SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "") + + SANDBOX_URL: str = os.getenv("SANDBOX_URL", "") REFLECTION_INTERVAL_SECONDS: float = float(os.getenv("REFLECTION_INTERVAL_SECONDS", "300")) HEALTH_CHECK_SECONDS: float = float(os.getenv("HEALTH_CHECK_SECONDS", "600")) diff --git a/api/app/core/workflow/nodes/code/node.py b/api/app/core/workflow/nodes/code/node.py index 69c660fe..d715be7d 100644 --- a/api/app/core/workflow/nodes/code/node.py +++ b/api/app/core/workflow/nodes/code/node.py @@ -14,6 +14,7 @@ from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes import BaseNode from app.core.workflow.nodes.code.config import CodeNodeConfig from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE +from app.core.config import settings logger = logging.getLogger(__name__) @@ -131,7 +132,7 @@ class CodeNode(BaseNode): async with httpx.AsyncClient(timeout=60) as client: response = await client.post( - "http://sandbox:8194/v1/sandbox/run", + f"{settings.SANDBOX_URL}:8194/v1/sandbox/run", headers={ "x-api-key": 'redbear-sandbox' },