Date: Mon, 27 Apr 2026 16:31:10 +0800
Subject: [PATCH 087/105] 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 088/105] 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
- ?
}
- {item?.meta_data?.files && item.meta_data?.files.length > 0 &&
- {item.meta_data?.files?.map((file) => {
- if (file.type.includes('image')) {
- return (
-
-
-
- )
- }
- 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 (
+
+
+
+ )
+ }
+ 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 090/105] 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 091/105] 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 `` 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 格式  展示对应图片。"
+ "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]:
,"
+ "请在回答中用 Markdown 格式  展示对应图片。"
+ "重要:图片 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 格式  展示对应图片。"
+ "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]:
,"
+ "请在回答中用 Markdown 格式  展示对应图片。"
+ "重要:图片 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 格式  展示对应图片。"
+ "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]:
,"
+ "请在回答中用 Markdown 格式  展示对应图片。"
+ "重要:图片 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 格式  展示对应图片。"
+ "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]:
,"
+ "请在回答中用 Markdown 格式  展示对应图片。"
+ "重要:图片 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 092/105] 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 093/105] 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 094/105] 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 095/105] =?UTF-8?q?Revert=20"refactor(memory):=20replace?=
=?UTF-8?q?=20raw=20dict=20responses=20with=20Pydantic=20schema=20mod?=
=?UTF-8?q?=E2=80=A6"?=
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 096/105] 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 097/105] 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 098/105] 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 099/105] 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 100/105] 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 101/105] 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 102/105] 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 103/105] 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 104/105] 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 105/105] 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'
},