From e1cf3bb3d20da491be31646aa25d3490a68e8c5b Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 10:21:35 +0800 Subject: [PATCH 01/44] fix(web): i18n update --- web/src/i18n/en.ts | 2 +- web/src/i18n/zh.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index d813f40f..e78964d5 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -116,7 +116,7 @@ export const en = { prompt: 'Prompt Engineering', skills: 'Skill Library', workbench: 'Workbench', - memoryRelated: 'Memory-Related', + memoryRelated: 'Memory Hub', advancedSettings: 'Advanced Settings', promptHistory: 'My history', platformManagement: 'Platform Management', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index fc846dcd..d206a1c6 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -116,7 +116,7 @@ export const zh = { prompt: '提示词工程', skills: '技能库', workbench: '工作台', - memoryRelated: '记忆相关', + memoryRelated: '记忆中枢', advancedSettings: '高级设置', promptHistory: '我的历史', platformManagement: '平台管理', From 2b52b32b96f2ee55f20c992978d52941442a38ec Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 11:36:14 +0800 Subject: [PATCH 02/44] fix(web): variable ui update --- web/src/i18n/en.ts | 3 + web/src/i18n/zh.ts | 3 + .../components/Editor/nodes/VariableNode.tsx | 16 +-- .../Editor/plugin/AutocompletePlugin.tsx | 83 ++++++------ .../components/Properties/VariableSelect.tsx | 124 +++++++++--------- .../Properties/hooks/useVariableList.ts | 4 +- .../Workflow/components/Properties/index.tsx | 8 +- 7 files changed, 121 insertions(+), 120 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e78964d5..59a303f1 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2552,6 +2552,9 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re 'list-operator.input_list': 'Input list', }, checkListHasErrors: 'Please resolve all issues in the checklist before publishing', + variableSelect: { + empty: 'No variables available', + }, }, emotionEngine: { emotionEngineConfig: 'Emotion Engine Configuration', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index d206a1c6..1c3791d4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2516,6 +2516,9 @@ export const zh = { 'list-operator.input_list': '输入变量', }, checkListHasErrors: '发布前确认检查清单中所有问题均已解决', + variableSelect: { + empty: '暂无变量', + }, }, emotionEngine: { emotionEngineConfig: '情感引擎配置', diff --git a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx index 5688342c..72e73220 100644 --- a/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx +++ b/web/src/views/Workflow/components/Editor/nodes/VariableNode.tsx @@ -48,17 +48,13 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ return ( - {data.isContext ? ( - 📄 - ) : data.group !== 'CONVERSATION' && !data.value.includes('conv') ? ( - - ) : } + {!data.isContext && data.group !== 'CONVERSATION' && !data.value.includes('conv') + ?
+ : null + } {!data.isContext && data.group !== 'CONVERSATION' && ( <> {!data.value.includes('conv') && <> @@ -73,7 +69,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({ )} )} - {data.label} + {data.label} ); }; diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index f9537032..9f718826 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -2,12 +2,13 @@ * @Author: ZhaoYing * @Date: 2025-12-23 16:22:51 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 16:51:04 + * @Last Modified time: 2026-04-13 11:12:18 */ import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical'; import { Space, Flex } from 'antd'; +import clsx from 'clsx'; import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands'; import type { NodeProperties } from '../../../types' @@ -284,23 +285,23 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { ref={popupRef} data-autocomplete-popup="true" onMouseDown={(e) => e.preventDefault()} - className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]" + className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" style={{ top: popupPosition.top, left: popupPosition.left, }} > -
- - {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { - const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; - const nodeIcon = nodeOptions[0]?.nodeData?.icon; - return ( -
- {nodeName !== 'undefined' && - {nodeIcon &&
} + + {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { + const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; + return ( +
+ {nodeName !== 'undefined' && +
{nodeName} - } +
+ } + {nodeOptions.map((option) => { const globalIndex = flatOptions.indexOf(option); const isExpanded = expandedParent?.key === option.key; @@ -310,14 +311,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { key={option.key} ref={(el) => { if (el) itemRefs.current.set(option.key, el); }} data-selected={selectedIndex === globalIndex} - className="rb:pl-6! rb:pr-3! rb:py-2!" + className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { + 'rb:bg-[#F6F6F6]': selectedIndex === globalIndex || isExpanded, + 'rb:cursor-not-allowed rb:opacity-65': option.disabled, + 'rb:cursor-pointer': !option.disabled, + })} align="center" justify="space-between" - style={{ - cursor: option.disabled ? 'not-allowed' : 'pointer', - background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white', - opacity: option.disabled ? 0.5 : 1, - }} onClick={() => { if (option.disabled) return; insertMention(option); @@ -337,26 +337,27 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { } }} > - {option.label && - {option.isContext ? '📄' : `{x}`} - {option.label} - } - - {option.dataType && {option.dataType}} - {hasChildren && } + {option.label && +
+ {`{x}`} {option.label} +
+ } + + {option.dataType && {option.dataType}} + {hasChildren &&
}
); })} -
- ); - })} -
-
+
+
+ ); + })} +
{/* Child variables panel - floats to the left */} {expandedParent?.children?.length && (
= ({ options }) => { }} onMouseEnter={() => setExpandedParent(expandedParent)} > - {/* Header */} -
- +
+ {expandedParent.nodeData.name}.{expandedParent.label} {expandedParent.dataType} @@ -377,19 +377,18 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { !child.disabled && insertMention(child)} onMouseEnter={() => setSelectedIndex(childIndex)} > - {child.label} - {child.dataType && {child.dataType}} + {child.label} + {child.dataType && {child.dataType}} ); })} diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index b28d7b4f..c0207cb5 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:40:13 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-08 10:48:21 + * @Last Modified time: 2026-04-13 11:25:40 */ import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react' import { createPortal } from 'react-dom' @@ -190,20 +190,30 @@ const VariableSelect: FC = ({ {/* Trigger */}
setOpen(o => !o)} > {multiple ? ( selectedValues.length > 0 ? ( - + {selectedValues.map(v => { const s = suggestionMap.get(v); if (!s) return null; @@ -214,11 +224,11 @@ const VariableSelect: FC = ({ return ( - {!isConv && nd?.icon &&
} + {!isConv && nd?.icon &&
} {!isConv && nd?.name && {nd.name}{sep}} - + {parent ? <>{parent.label}{sep}{s.label} : s.label} = ({ ); })} - + ) : ( - {placeholder} + {placeholder} ) ) : selectedSuggestion ? (
- - {!isConversation && nodeData?.icon &&
} - {!isConversation && nodeData?.name && {nodeData.name}} - {!isConversation && nodeData?.name && {sep}} - + + {!isConversation && nodeData?.icon &&
} + {!isConversation && nodeData?.name && {nodeData.name}} + {!isConversation && nodeData?.name && {sep}} + {parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label} : selectedSuggestion.label} @@ -266,18 +278,19 @@ const VariableSelect: FC = ({ {open && createPortal(
-
- {Object.entries(filteredGroups).map(([nodeId, suggestions]) => { +
+ {Object.entries(filteredGroups).map(([nodeId, suggestions], index) => { const nd = suggestions[0].nodeData; return ( -
- - {nd.icon &&
} +
+
{nd.name} - +
{suggestions.map(s => { const isSelected = multiple ? selectedValues.includes(`{{${s.value}}}`) @@ -288,11 +301,9 @@ const VariableSelect: FC = ({ { if (el) itemRefs.current.set(s.key, el); }} - className={clsx("rb:pl-6! rb:pr-3! rb:py-1.25! rb:rounded-lg!", { - 'rb:bg-[#e6f4ff]': isSelected || isExpanded, - 'rb:bg-white rb:hover:bg-[#F6F6F6]!': !(isSelected || isExpanded), - 'rb:opacity-60': s.disabled, - 'rb:cursor-not-allowed': s.disabled, + className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", { + 'rb:bg-[#F6F6F6]': isSelected || isExpanded, + 'rb:cursor-not-allowed rb:opacity-65': s.disabled, 'rb:cursor-pointer': !s.disabled, })} align="center" @@ -314,17 +325,16 @@ const VariableSelect: FC = ({ } }} > - +
{multiple && ( - + )} - {`{x}`} - {s.label} - - - {s.dataType && {s.dataType}} + {`{x}`} {s.label} +
- {hasChildren &&
} + + {s.dataType && {s.dataType}} + {hasChildren &&
}
); @@ -334,7 +344,7 @@ const VariableSelect: FC = ({ })} {Object.keys(filteredGroups).length === 0 && (
- {t('workflow.variableSelect.empty', '暂无变量')} + {t('workflow.variableSelect.empty')}
)}
@@ -346,18 +356,13 @@ const VariableSelect: FC = ({ {open && expandedParent?.children?.length && createPortal(
setExpandedParent(expandedParent)} > -
!expandedParent.disabled && handleSelect(expandedParent)} - > +
- - {expandedParent.nodeData.name}.{expandedParent.label} - + {expandedParent.nodeData.name}.{expandedParent.label} {expandedParent.dataType}
@@ -365,32 +370,27 @@ const VariableSelect: FC = ({ const isSelected = multiple ? selectedValues.includes(`{{${child.value}}}`) : `{{${child.value}}}` === value; - const hasGrandChildren = !!child.children?.length; return ( !child.disabled && handleSelect(child)} > - + {multiple && ( )} - {child.label} - - - {child.dataType && {child.dataType}} - {hasGrandChildren && } + {child.label} + + {child.dataType && {child.dataType}} + ); })} diff --git a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts index 3c4ea6f7..14dcced2 100644 --- a/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts +++ b/web/src/views/Workflow/components/Properties/hooks/useVariableList.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-01-19 17:00:26 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-08 10:12:27 + * @Last Modified time: 2026-04-13 10:44:17 */ /** * useVariableList Hook @@ -414,7 +414,7 @@ export const useVariableList = ( const pd = parentLoop.getData(); const pid = pd.id; if (pd.type === 'loop') { - (pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'String', `${pid}.${cv.name}`, pd)); + (pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${pid}.${cv.name}`, pd)); } else if (pd.type === 'iteration' && pd.config.input.defaultValue) { let itemType = 'object'; const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue); diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index b5bc2d2e..f826edd9 100644 --- a/web/src/views/Workflow/components/Properties/index.tsx +++ b/web/src/views/Workflow/components/Properties/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:39:59 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-10 17:24:19 + * @Last Modified time: 2026-04-13 10:44:19 */ import { type FC, useEffect, useState, useMemo } from "react"; import clsx from 'clsx' @@ -266,7 +266,7 @@ const Properties: FC = ({ key, label: cycleVar.name, type: 'variable', - dataType: cycleVar.type || 'String', + dataType: cycleVar.type || 'string', value: `${parentNodeId}.${cycleVar.name}`, nodeData: parentData, }); @@ -643,7 +643,7 @@ const Properties: FC = ({ key: contextKey, label: 'context', type: 'variable', - dataType: 'String', + dataType: 'string', value: `context`, nodeData: selectedNode.getData(), isContext: true, @@ -791,7 +791,7 @@ const Properties: FC = ({ key: `${selectedNode.id}_cycle_${cycleVar.name}`, label: cycleVar.name, type: 'variable', - dataType: cycleVar.type || 'String', + dataType: cycleVar.type || 'string', value: `${selectedNode.getData().id}.${cycleVar.name}`, nodeData: selectedNode.getData(), })); From 520ee7c132b3ed4bad8bd3c619e8a4ffb7f12097 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 12:01:37 +0800 Subject: [PATCH 03/44] fix(web): sub node connected --- .../views/Workflow/hooks/useWorkflowGraph.ts | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index f385acf3..5d0bb9c6 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-07 23:17:50 + * @Last Modified time: 2026-04-13 12:00:09 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -1022,24 +1022,39 @@ export const useWorkflowGraph = ({ graphRef.current.on('node:removed', blankClick) // When edge connected, bring connected nodes' ports to front - graphRef.current.on('edge:connected', ({ isNew }) => { - // Bring edge to front first, then bring child nodes above edges - // Parent (loop/iteration) nodes stay behind to avoid covering edges - // Reset any port hover state left from dragging + graphRef.current.on('edge:connected', ({ isNew, edge }) => { if (isNew) { - graphRef.current?.getNodes().forEach(node => { - if (!node.getData()?.cycle) node.toFront(); - }); - graphRef.current?.getEdges().forEach(edge => { - const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); - const targetCell = graphRef.current?.getCellById(edge.getTargetCellId()); - if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) { - edge.toFront(); - } - }); - graphRef.current?.getNodes().forEach(node => { - if (node.getData()?.cycle) node.toFront(); - }); + const sourceCellId = edge.getSourceCellId() + const targetCellId = edge.getTargetCellId() + const sourceCell = graphRef.current?.getCellById(sourceCellId); + const targetCell = graphRef.current?.getCellById(targetCellId); + + sourceCell?.toFront(); + targetCell?.toFront() + if (['loop', 'iteration'].includes(sourceCell?.getData()?.type)) { + graphRef.current?.getEdges().forEach(edge => { + const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); + const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId()); + if (edgeSourceCell?.getData()?.cycle === sourceCellId || edgeTargetCell?.getData()?.cycle === sourceCellId) { + edge.toFront(); + } + }); + graphRef.current?.getNodes().forEach(node => { + if (node.getData()?.cycle === sourceCellId) node.toFront(); + }); + } + if (['loop', 'iteration'].includes(targetCell?.getData()?.type)) { + graphRef.current?.getEdges().forEach(edge => { + const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId()); + const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId()); + if (edgeSourceCell?.getData()?.cycle === targetCellId || edgeTargetCell?.getData()?.cycle === targetCellId) { + edge.toFront(); + } + }); + graphRef.current?.getNodes().forEach(node => { + if (node.getData()?.cycle === targetCellId) node.toFront(); + }); + } } }); From 988d101e935de0b6898612cbec23edc45f2d366d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 12:12:49 +0800 Subject: [PATCH 04/44] fix(web): tool checklist --- .../Workflow/components/CheckList/index.tsx | 77 ++++--------------- 1 file changed, 13 insertions(+), 64 deletions(-) diff --git a/web/src/views/Workflow/components/CheckList/index.tsx b/web/src/views/Workflow/components/CheckList/index.tsx index 636ae9a9..0256416a 100644 --- a/web/src/views/Workflow/components/CheckList/index.tsx +++ b/web/src/views/Workflow/components/CheckList/index.tsx @@ -79,7 +79,6 @@ const specialValidators: Record boolean> = { } function isEmpty(val: any): boolean { - console.log('validateNode isEmpty', val, val === undefined || val === null || val === '') if (val === undefined || val === null || val === '') return true if (Array.isArray(val)) return val.length === 0 return false @@ -98,7 +97,6 @@ function validateNode(type: string, config: Record): CheckError[] { const specialKey = `${type}.${field}` const specialValidator = specialValidators[specialKey] const isInvalid = specialValidator ? specialValidator(val) : isEmpty(val) - console.log('validateNode', val, specialKey, specialValidator, isEmpty(val)) if (isInvalid) errors.push({ key: specialKey, message: '' }) }) @@ -114,62 +112,6 @@ function validateNode(type: string, config: Record): CheckError[] { return errors } -export async function runCheckOnGraph( - graph: import('@antv/x6').Graph, - t: (key: string) => string -): Promise { - const nodes = graph.getNodes() - const edges = graph.getEdges() - const targetIds = new Set() - const childTargetIds = new Set() - edges.forEach(e => { - targetIds.add(e.getTargetCellId()) - const srcData = graph.getCellById(e.getSourceCellId())?.getData() - const tgtData = graph.getCellById(e.getTargetCellId())?.getData() - if (srcData?.cycle && tgtData?.cycle && srcData.cycle === tgtData.cycle) { - childTargetIds.add(e.getTargetCellId()) - } - }) - - const checked: NodeCheckResult[] = [] - for (const node of nodes) { - const data = node.getData() - if (!data || ['add-node', 'notes', 'cycle-start', 'break'].includes(data.type)) continue - - const errors: CheckError[] = [] - const isChildNode = !!data.cycle - const hasIncoming = isChildNode ? childTargetIds.has(node.id) : !['start', 'cycle-start'].includes(data.type) ? targetIds.has(node.id) : true - if (!hasIncoming) errors.push({ key: 'notConnected', message: t('workflow.notConnected') }) - - const configErrors = validateNode(data.type, data.config ?? {}) - configErrors.forEach(e => { - errors.push({ key: e.key, message: `${t(`workflow.checkListErrors.${e.key}`)} ${t('workflow.cannotBeEmpty')}`.trim() }) - }) - - if (data.type === 'tool') { - const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id - const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {} - if (toolId) { - try { - const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }> - const operation = toolParameters?.operation - const method = operation ? methods.find(m => m.name === operation) : methods[0] - if (method) { - method.parameters - .filter(p => p.required && (toolParameters[p.name] === undefined || toolParameters[p.name] === null || toolParameters[p.name] === '')) - .forEach(p => errors.push({ key: 'tool.tool_parameters', message: `${p.name} ${t('workflow.cannotBeEmpty')}` })) - } - } catch { /* ignore */ } - } - } - - if (errors.length) { - checked.push({ id: node.id, name: data.name || t(`workflow.${data.type}`), type: data.type, icon: nodeIconMap[data.type] ?? '', errors }) - } - } - return checked -} - const CheckList: FC = ({ workflowRef, appId }) => { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -222,7 +164,8 @@ const CheckList: FC = ({ workflowRef, appId }) => { if (data.type === 'tool') { const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {} - if (toolId) { + + if (typeof toolId === 'string') { try { const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }> const operation = toolParameters?.operation @@ -251,21 +194,27 @@ const CheckList: FC = ({ workflowRef, appId }) => { return checked }, [workflowRef.current?.graphRef?.current, t]) + const scheduleCheckRef = useRef<() => void>() + const scheduleCheck = useCallback(() => { clearTimeout(timerRef.current) timerRef.current = setTimeout(async () => { setCheckResults(appId, await runCheck()) - }, 500) + }, 300) }, [runCheck]) + scheduleCheckRef.current = scheduleCheck + useEffect(() => { const graph = workflowRef.current?.graphRef?.current + console.log('graph') if (!graph) return - const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed'] - events.forEach(e => graph.on(e, scheduleCheck)) - scheduleCheck() + const handler = () => scheduleCheckRef.current?.() + const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed', 'edge:connected', 'edge:changed'] + events.forEach(e => graph.on(e, handler)) + scheduleCheckRef.current?.() return () => { - events.forEach(e => graph.off(e, scheduleCheck)) + events.forEach(e => graph.off(e, handler)) clearTimeout(timerRef.current) } }, [workflowRef.current?.graphRef?.current]) From efdee32f8566e9aadcaaa4a21347e62f9c6585f6 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 12:16:32 +0800 Subject: [PATCH 05/44] fix(web): update chat variable defaultValue validate rule --- .../components/AddChatVariable/ChatVariableModal.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx index 5666f3ab..5c80fab1 100644 --- a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx +++ b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-30 13:59:36 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-08 11:05:34 + * @Last Modified time: 2026-04-13 12:16:00 */ import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react'; import { Form, Input, Select, InputNumber, Button, Row, Col, Flex } from 'antd'; @@ -345,15 +345,16 @@ const ChatVariableModal = forwardRef { if (!value) return Promise.resolve(); try { JSON.parse(value); return Promise.resolve(); } catch { return Promise.reject(t('workflow.invalidJSON')); } } - } : {} - ]} + }] + : undefined + } > {type === 'number' ? From 62355186ef582b1dfcc4d7ad6c5f252296fd41da Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 13:38:10 +0800 Subject: [PATCH 06/44] fix(web): echarts grid --- web/src/views/UserMemoryDetail/components/InterestAreas.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/views/UserMemoryDetail/components/InterestAreas.tsx b/web/src/views/UserMemoryDetail/components/InterestAreas.tsx index 91554880..4d9be5b5 100644 --- a/web/src/views/UserMemoryDetail/components/InterestAreas.tsx +++ b/web/src/views/UserMemoryDetail/components/InterestAreas.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 18:32:53 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-16 14:27:12 + * @Last Modified time: 2026-04-13 13:37:43 */ import { useEffect, useState, forwardRef, useImperativeHandle, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -93,7 +93,7 @@ const InterestAreas = forwardRef<{ handleRefresh: () => void; }>((_props, ref) = ref={chartRef} option={{ color: Colors, - grid: { top: 8, left: 38, right: 8, bottom: 24 }, + grid: { top: 14, left: 38, right: 8, bottom: 24 }, xAxis: { type: 'category', data: keys.map(k => t(`implicitDetail.${k}`)), From 7a2a941ac4d94734cec11170de68b5a4ad240202 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Mon, 13 Apr 2026 13:47:59 +0800 Subject: [PATCH 07/44] refactor(neo4j): rename execute_query parameter from query to cypher Improves readability by making the parameter name explicitly reflect that it expects a Cypher query string rather than a generic query. --- api/app/repositories/neo4j/neo4j_connector.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/app/repositories/neo4j/neo4j_connector.py b/api/app/repositories/neo4j/neo4j_connector.py index ea8fa917..d20bf75f 100644 --- a/api/app/repositories/neo4j/neo4j_connector.py +++ b/api/app/repositories/neo4j/neo4j_connector.py @@ -77,11 +77,11 @@ class Neo4jConnector: """ await self.driver.close() - async def execute_query(self, query: str, json_format=False, **kwargs: Any) -> List[Dict[str, Any]]: + async def execute_query(self, cypher: str, json_format=False, **kwargs: Any) -> List[Dict[str, Any]]: """执行Cypher查询 Args: - query: Cypher查询语句 + cypher: Cypher查询语句 json_format: json格式化 **kwargs: 查询参数,将作为参数传递给Cypher查询 @@ -92,7 +92,7 @@ class Neo4jConnector: """ result = await self.driver.execute_query( - query, + cypher, database="neo4j", **kwargs ) From ac51ccaf1f23b75d79ae98172a323791b3d5c821 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 14:04:31 +0800 Subject: [PATCH 08/44] fix(web): ui fix --- .../Editor/plugin/AutocompletePlugin.tsx | 46 ++++++++++--------- .../components/Properties/VariableSelect.tsx | 2 +- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx index 9f718826..6d3b7a4f 100644 --- a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-23 16:22:51 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-13 11:12:18 + * @Last Modified time: 2026-04-13 14:00:07 */ import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; @@ -285,23 +285,24 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { ref={popupRef} data-autocomplete-popup="true" onMouseDown={(e) => e.preventDefault()} - className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" + className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2" style={{ top: popupPosition.top, left: popupPosition.left, }} > - - {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { - const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; - return ( -
- {nodeName !== 'undefined' && -
- {nodeName} -
- } - +
+ + {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => { + const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; + return ( +
+ {nodeName !== 'undefined' && +
+ {nodeName} +
+ } + {nodeOptions.map((option) => { const globalIndex = flatOptions.indexOf(option); const isExpanded = expandedParent?.key === option.key; @@ -344,20 +345,21 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => { } {option.dataType && {option.dataType}} - {hasChildren &&
} + {hasChildren &&
}
); })} - -
- ); - })} -
+ +
+ ); + })} +
+
{/* Child variables panel - floats to the left */} {expandedParent?.children?.length && (
= ({ options }) => { onClick={() => !child.disabled && insertMention(child)} onMouseEnter={() => setSelectedIndex(childIndex)} > - {child.label} + + {`{x}`} {child.label} + {child.dataType && {child.dataType}} ); diff --git a/web/src/views/Workflow/components/Properties/VariableSelect.tsx b/web/src/views/Workflow/components/Properties/VariableSelect.tsx index c0207cb5..5523c06e 100644 --- a/web/src/views/Workflow/components/Properties/VariableSelect.tsx +++ b/web/src/views/Workflow/components/Properties/VariableSelect.tsx @@ -334,7 +334,7 @@ const VariableSelect: FC = ({ {s.dataType && {s.dataType}} - {hasChildren &&
} + {hasChildren &&
}
); From 5bb9ce9018eee47a8b8277c446d4f2d9090ffae4 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Mon, 13 Apr 2026 14:40:57 +0800 Subject: [PATCH 09/44] fix(user): add user retrieval regardless of active status and update DSL config enrichment Added `get_user_by_id_regardless_active` in user repository to support activation/deactivation workflows, updated `user_service` to use it, and refactored `_enrich_release_config` in `app_dsl_service` to accept `default_model_config_id` as a parameter instead of reading from config dict. --- api/app/repositories/user_repository.py | 4 ++++ api/app/services/app_dsl_service.py | 10 ++++------ api/app/services/user_service.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/api/app/repositories/user_repository.py b/api/app/repositories/user_repository.py index 2dd76b04..6874f9bf 100644 --- a/api/app/repositories/user_repository.py +++ b/api/app/repositories/user_repository.py @@ -297,6 +297,10 @@ def get_user_by_id(db: Session, user_id: uuid.UUID) -> Optional[User]: """根据ID获取用户""" return UserRepository(db).get_user_by_id(user_id) +def get_user_by_id_regardless_active(db: Session, user_id: uuid.UUID) -> Optional[User]: + """根据ID获取用户(不过滤 is_active,用于启用/禁用场景)""" + return db.query(User).filter(User.id == user_id).first() + def get_user_by_email(db: Session, email: str) -> Optional[User]: """根据邮箱获取用户""" return UserRepository(db).get_user_by_email(email) diff --git a/api/app/services/app_dsl_service.py b/api/app/services/app_dsl_service.py index 8c198be4..3a897109 100644 --- a/api/app/services/app_dsl_service.py +++ b/api/app/services/app_dsl_service.py @@ -73,15 +73,14 @@ class AppDslService: AppType.MULTI_AGENT: "multi_agent_config", AppType.WORKFLOW: "workflow" }.get(app.type, "config") - config_data = self._enrich_release_config(app.type, release.config or {}) + config_data = self._enrich_release_config(app.type, release.config or {}, release.default_model_config_id) dsl = {**meta, "app": app_meta, config_key: config_data} return yaml.dump(dsl, default_flow_style=False, allow_unicode=True), f"{release.name}_v{release.version_name}.yaml" - def _enrich_release_config(self, app_type: str, cfg: dict) -> dict: + def _enrich_release_config(self, app_type: str, cfg: dict, default_model_config_id=None) -> dict: if app_type == AppType.AGENT: enriched = {**cfg} - if "default_model_config_id" in cfg: - enriched["default_model_config_ref"] = self._model_ref(cfg["default_model_config_id"]) + enriched["default_model_config_ref"] = self._model_ref(default_model_config_id) if "knowledge_retrieval" in cfg: enriched["knowledge_retrieval"] = self._enrich_knowledge_retrieval(cfg["knowledge_retrieval"]) if "tools" in cfg: @@ -91,8 +90,7 @@ class AppDslService: return enriched if app_type == AppType.MULTI_AGENT: enriched = {**cfg} - if "default_model_config_id" in cfg: - enriched["default_model_config_ref"] = self._model_ref(cfg["default_model_config_id"]) + enriched["default_model_config_ref"] = self._model_ref(default_model_config_id) if "master_agent_id" in cfg: enriched["master_agent_ref"] = self._release_ref(cfg["master_agent_id"]) if "sub_agents" in cfg: diff --git a/api/app/services/user_service.py b/api/app/services/user_service.py index 3122d282..43a58c5f 100644 --- a/api/app/services/user_service.py +++ b/api/app/services/user_service.py @@ -285,7 +285,7 @@ def activate_user(db: Session, user_id_to_activate: uuid.UUID, current_user: Use try: # 查找用户 business_logger.debug(f"查找待激活用户: {user_id_to_activate}") - db_user = user_repository.get_user_by_id(db, user_id=user_id_to_activate) + db_user = user_repository.get_user_by_id_regardless_active(db, user_id=user_id_to_activate) if not db_user: business_logger.warning(f"用户不存在: {user_id_to_activate}") raise BusinessException("用户不存在", code=BizCode.USER_NOT_FOUND) From 2b067ce08a013654fb875b09fbbb1eb04617061b Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 15:35:45 +0800 Subject: [PATCH 10/44] fix(web): third variable --- .../OpenStatementSettingModal.tsx | 4 +-- .../AddChatVariable/ChatVariableModal.tsx | 4 +-- .../components/Nodes/ConditionNode.tsx | 2 ++ .../Properties/AssignmentList/index.tsx | 27 ++++++++++++++++--- .../components/Properties/CaseList/index.tsx | 4 ++- .../Properties/ConditionList/index.tsx | 4 ++- .../Properties/GroupVariableList/index.tsx | 8 ++++-- .../views/Workflow/hooks/useWorkflowGraph.ts | 3 ++- 8 files changed, 43 insertions(+), 13 deletions(-) diff --git a/web/src/views/ApplicationConfig/components/FeaturesConfig/OpenStatementSettingModal.tsx b/web/src/views/ApplicationConfig/components/FeaturesConfig/OpenStatementSettingModal.tsx index 91d0d19f..a46d973a 100644 --- a/web/src/views/ApplicationConfig/components/FeaturesConfig/OpenStatementSettingModal.tsx +++ b/web/src/views/ApplicationConfig/components/FeaturesConfig/OpenStatementSettingModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-03-05 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 16:58:10 + * @Last Modified time: 2026-04-13 15:13:36 */ import { forwardRef, useImperativeHandle, useState } from 'react'; import { Button, Form, Input, Flex, App } from 'antd'; @@ -36,8 +36,6 @@ const OpenStatementSettingModal = forwardRef(); - console.log('chatVariables', chatVariables) - const handleClose = () => { setVisible(false); form.resetFields(); diff --git a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx index 5c80fab1..e4f62432 100644 --- a/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx +++ b/web/src/views/Workflow/components/AddChatVariable/ChatVariableModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-30 13:59:36 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-13 12:16:00 + * @Last Modified time: 2026-04-13 15:26:33 */ import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react'; import { Form, Input, Select, InputNumber, Button, Row, Col, Flex } from 'antd'; @@ -136,7 +136,7 @@ const ChatVariableModal = forwardRef { const defaultValue = Array.isArray(values.defaultValue) ? values.defaultValue.filter((v: any) => v !== undefined && v !== null && v !== '') - : values.type.includes('object') + : values.type.includes('object') && values.defaultValue ? JSON.parse(values.defaultValue) : values.defaultValue; refresh({ ...values, defaultValue }, editIndex); diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index 996ae5dd..625a1b4d 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -31,6 +31,8 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { }; const labelRender = (value: string) => { const filterOption = variableList.find(vo => `{{${vo.value}}}` === value) + ?? variableList.flatMap(vo => vo.children ?? []).find(child => `{{${child.value}}}` === value) + ?? variableList.flatMap(vo => vo.children ?? []).flatMap((child: any) => child.children ?? []).find((grandchild: any) => `{{${grandchild.value}}}` === value) if (filterOption) { return ( diff --git a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx index 98f86ecf..e24d531d 100644 --- a/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx +++ b/web/src/views/Workflow/components/Properties/AssignmentList/index.tsx @@ -30,6 +30,25 @@ const operationsObj = { ], } +const filterByDataType = (options: Suggestion[], dataType: string): Suggestion[] => + options.reduce((acc, vo) => { + if (vo.children?.length) { + const children = vo.children.reduce((cacc, child) => { + if (child.children?.length) { + const grandchildren = child.children.filter(gc => gc.dataType === dataType); + if (grandchildren.length) cacc.push({ ...child, children: grandchildren }); + } else if (child.dataType === dataType) { + cacc.push(child); + } + return cacc; + }, []); + if (children.length) acc.push({ ...vo, children }); + } else if (vo.dataType === dataType) { + acc.push(vo); + } + return acc; + }, []); + const AssignmentList: FC = ({ parentName, options = [], @@ -59,7 +78,9 @@ const AssignmentList: FC = ({ {fields.map(({ key, name, ...restField }) => { const variableSelector = form.getFieldValue([parentName, name, 'variable_selector']); - const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector); + const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector) + ?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === variableSelector) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === variableSelector); const dataType = selectedOption?.dataType; const operationOptions = dataType === 'number' ? operationsObj.number : operationsObj.default; @@ -119,7 +140,7 @@ const AssignmentList: FC = ({ {dataType === 'number' && operation === 'cover' ? vo.dataType === dataType) : options} + options={dataType ? filterByDataType(options, dataType) : options} size={size} className="rb:flex-1!" variant="filled" @@ -150,7 +171,7 @@ const AssignmentList: FC = ({ : vo.dataType === dataType) : options} + options={dataType ? filterByDataType(options, dataType) : options} size={size} className="rb:flex-1!" variant="filled" diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index 40353f64..f0a58517 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -329,7 +329,9 @@ const CaseList: FC = ({ const currentExpression = currentCase.expressions?.[conditionIndex] || {}; const currentOperator = currentExpression.operator; const leftFieldValue = currentExpression.left; - const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue); + const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === leftFieldValue); const leftFieldType = leftFieldOption?.dataType; const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || leftFieldType === 'file' || leftFieldType === 'array[object]' || leftFieldType === 'array[file]'; const operatorList = leftFieldType && operatorsObj[leftFieldType] diff --git a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx index ddf92971..3e9f3261 100644 --- a/web/src/views/Workflow/components/Properties/ConditionList/index.tsx +++ b/web/src/views/Workflow/components/Properties/ConditionList/index.tsx @@ -155,7 +155,9 @@ const ConditionList: FC = ({ const currentExpression = expressions[index] || {}; const currentOperator = currentExpression.operator; const leftFieldValue = currentExpression.left; - const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue); + const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === leftFieldValue) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === leftFieldValue); const leftFieldType = leftFieldOption?.dataType; const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || ['array[object]', 'object'].includes(leftFieldType as string); const operatorList = leftFieldType && ['array[object]', 'object'].includes(leftFieldType) diff --git a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx index 0100707c..24cdc89a 100644 --- a/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx +++ b/web/src/views/Workflow/components/Properties/GroupVariableList/index.tsx @@ -62,14 +62,18 @@ const GroupVariableList: FC = ({ */ useEffect(() => { if (!isCanAdd && value[0]) { - const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0]); + const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0]) + ?? options.flatMap(o => o.children ?? []).find(c => `{{${c.value}}}` === value[0]) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === value[0]); if (firstVariable) { form.setFieldValue(['group_type', 'output'], firstVariable.dataType); } } else if (isCanAdd) { value.forEach((item: any, index: number) => { if (item?.value?.[0]) { - const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0]); + const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0]) + ?? options.flatMap(o => o.children ?? []).find(c => `{{${c.value}}}` === item.value[0]) + ?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === item.value[0]); if (firstVariable) { form.setFieldValue(['group_type', index], firstVariable.dataType); } diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 5d0bb9c6..ac82b230 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-13 12:00:09 + * @Last Modified time: 2026-04-13 15:33:58 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -111,6 +111,7 @@ export const useWorkflowGraph = ({ graphRef.current.getNodes().forEach(node => { const data = node.getData() if (data?.type === 'if-else' || data?.type === 'question-classifier') { + console.log('chatVariables', chatVariables) node.setData({ ...data, chatVariables }, { silent: true }) } }) From ef8c7093b5aec0c87e49c72e561c98acc2c11c41 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Mon, 13 Apr 2026 18:32:43 +0800 Subject: [PATCH 11/44] refactor(memory): use MemorySummary node count for implicit memory metrics - Replace Statement-based implicit memory count (count/3) with actual MemorySummary node count filtered by DERIVED_FROM_STATEMENT relationship - Add minimum threshold of 5 MemorySummary nodes before reporting data - Add _build_empty_profile() to return structured empty profile when insufficient data exists, skipping unnecessary LLM calls --- api/app/services/implicit_memory_service.py | 61 +++++++++++++++++++++ api/app/services/user_memory_service.py | 22 ++++---- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/api/app/services/implicit_memory_service.py b/api/app/services/implicit_memory_service.py index 4bd11deb..10504fe7 100644 --- a/api/app/services/implicit_memory_service.py +++ b/api/app/services/implicit_memory_service.py @@ -379,12 +379,59 @@ class ImplicitMemoryService: raise + def _build_empty_profile(self) -> dict: + """构建 MemorySummary 不足时返回的固定空白画像数据""" + now_ms = int(datetime.now().timestamp() * 1000) + insufficient = "Insufficient data for analysis" + + def _empty_dimension(name: str) -> dict: + return { + "evidence": [insufficient], + "reasoning": f"No clear evidence found for {name} dimension", + "percentage": 0.0, + "dimension_name": name, + "confidence_level": 20, + } + + def _empty_category(name: str) -> dict: + return { + "evidence": [insufficient], + "percentage": 25.0, + "category_name": name, + "trending_direction": None, + } + + return { + "habits": [], + "portrait": { + "aesthetic": _empty_dimension("aesthetic"), + "creativity": _empty_dimension("creativity"), + "literature": _empty_dimension("literature"), + "technology": _empty_dimension("technology"), + "historical_trends": None, + "analysis_timestamp": now_ms, + "total_summaries_analyzed": 0, + }, + "preferences": [], + "interest_areas": { + "art": _empty_category("art"), + "tech": _empty_category("tech"), + "music": _empty_category("music"), + "lifestyle": _empty_category("lifestyle"), + "analysis_timestamp": now_ms, + "total_summaries_analyzed": 0, + }, + } + async def generate_complete_profile( self, user_id: str ) -> dict: """生成完整的用户画像(包含所有4个模块) + 需要该用户的 MemorySummary 节点数量 >= 5 才会真正调用 LLM 生成画像, + 否则返回固定的空白画像数据。 + Args: user_id: 用户ID @@ -394,6 +441,20 @@ class ImplicitMemoryService: logger.info(f"生成完整用户画像: user={user_id}") try: + # 前置检查:查询该用户有效的 MemorySummary 节点数量(排除孤立节点) + query = """ + MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) + WHERE n.end_user_id = $end_user_id + RETURN count(DISTINCT n) as count + """ + result = await self.neo4j_connector.execute_query(query, end_user_id=user_id) + memory_summary_count = result[0]["count"] if result and len(result) > 0 else 0 + logger.info(f"用户 MemorySummary 节点数量: {memory_summary_count} (user={user_id})") + + if memory_summary_count < 5: + logger.info(f"MemorySummary 数量不足 5(当前 {memory_summary_count}),返回空白画像: user={user_id}") + return self._build_empty_profile() + # 并行调用4个分析方法 preferences, portrait, interest_areas, habits = await asyncio.gather( self.get_preference_tags(user_id=user_id), diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index ab51d922..dcbedba6 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -1500,7 +1500,7 @@ async def analytics_memory_types( 2. 工作记忆 (WORKING_MEMORY) = 会话数量(通过 ConversationRepository.get_conversation_by_user_id 获取) 3. 短期记忆 (SHORT_TERM_MEMORY) = /short_term 接口返回的问答对数量 4. 显性记忆 (EXPLICIT_MEMORY) = 情景记忆 + 语义记忆(通过 MemoryBaseService.get_explicit_memory_count 获取) - 5. 隐性记忆 (IMPLICIT_MEMORY) = Statement 节点数量的三分之一 + 5. 隐性记忆 (IMPLICIT_MEMORY) = MemorySummary 节点数量(需 >= 5 才显示,否则为 0) 6. 情绪记忆 (EMOTIONAL_MEMORY) = 情绪标签统计总数(通过 MemoryBaseService.get_emotional_memory_count 获取) 7. 情景记忆 (EPISODIC_MEMORY) = memory_summary(通过 MemoryBaseService.get_episodic_memory_count 获取) 8. 遗忘记忆 (FORGET_MEMORY) = 激活值低于阈值的节点数(通过 MemoryBaseService.get_forget_memory_count 获取) @@ -1557,23 +1557,23 @@ async def analytics_memory_types( logger.warning(f"获取会话数量失败,工作记忆数量设为0: {str(e)}") work_count = 0 - # 获取隐性记忆数量(基于 Statement 节点数量的三分之一) + # 获取隐性记忆数量(基于有关联关系的 MemorySummary 节点数量,需 >= 5 才计入) implicit_count = 0 if end_user_id: try: - # 查询 Statement 节点数量 + # 只统计有 DERIVED_FROM_STATEMENT 关系的 MemorySummary 节点,排除孤立节点 query = """ - MATCH (n:Statement) + MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) WHERE n.end_user_id = $end_user_id - RETURN count(n) as count + RETURN count(DISTINCT n) as count """ result = await _neo4j_connector.execute_query(query, end_user_id=end_user_id) - statement_count = result[0]["count"] if result and len(result) > 0 else 0 - # 取三分之一作为隐性记忆数量 - implicit_count = round(statement_count / 3) - logger.debug(f"隐性记忆数量(Statement数量的1/3): {implicit_count} (Statement总数={statement_count}, end_user_id={end_user_id})") + memory_summary_count = result[0]["count"] if result and len(result) > 0 else 0 + # 仅当 MemorySummary 节点数量 >= 5 时才显示数量,否则为 0 + implicit_count = memory_summary_count if memory_summary_count >= 5 else 0 + logger.debug(f"隐性记忆数量(有效MemorySummary节点数): {implicit_count} (有效MemorySummary总数={memory_summary_count}, end_user_id={end_user_id})") except Exception as e: - logger.warning(f"获取Statement数量失败,隐性记忆数量设为0: {str(e)}") + logger.warning(f"获取MemorySummary数量失败,隐性记忆数量设为0: {str(e)}") implicit_count = 0 # 原有的基于行为习惯的统计方式(已注释) @@ -1639,7 +1639,7 @@ async def analytics_memory_types( "WORKING_MEMORY": work_count, # 工作记忆(基于会话数量) "SHORT_TERM_MEMORY": short_term_count, # 短期记忆(基于问答对数量) "EXPLICIT_MEMORY": explicit_count, # 显性记忆(情景记忆 + 语义记忆) - "IMPLICIT_MEMORY": implicit_count, # 隐性记忆(Statement数量的1/3) + "IMPLICIT_MEMORY": implicit_count, # 隐性记忆(MemorySummary节点数,需>=5) "EMOTIONAL_MEMORY": emotion_count, # 情绪记忆(使用情绪标签统计) "EPISODIC_MEMORY": episodic_count, # 情景记忆 "FORGET_MEMORY": forget_count # 遗忘记忆(激活值低于阈值) From 9470dd2f1e882d218da46d09b4cb717a30591a62 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Mon, 13 Apr 2026 18:47:56 +0800 Subject: [PATCH 12/44] refactor(memory): extract shared MemorySummary count query and replace magic number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move duplicated Neo4j MemorySummary count query into MemoryBaseService.get_valid_memory_summary_count() - Introduce MIN_MEMORY_SUMMARY_COUNT constant to replace hardcoded 5 - Fix import ordering in implicit_emotions_storage_repository - Use UTC consistently for date calculations (remove CST offset, datetime.now → datetime.utcnow) --- .../implicit_emotions_storage_repository.py | 20 +++++----- api/app/services/implicit_memory_service.py | 17 ++++----- api/app/services/memory_base_service.py | 38 +++++++++++++++++++ api/app/services/user_memory_service.py | 20 +++------- 4 files changed, 60 insertions(+), 35 deletions(-) diff --git a/api/app/repositories/implicit_emotions_storage_repository.py b/api/app/repositories/implicit_emotions_storage_repository.py index b6c40b40..b665924d 100644 --- a/api/app/repositories/implicit_emotions_storage_repository.py +++ b/api/app/repositories/implicit_emotions_storage_repository.py @@ -5,16 +5,9 @@ Implicit Emotions Storage Repository 事务由调用方控制,仓储层只使用 flush/refresh """ import logging -from datetime import date, datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Generator, Optional - -class TimeFilterUnavailableError(Exception): - """redis_client 不可用,无法执行时间轴筛选。 - - 调用方捕获此异常后可选择回退到 get_all_user_ids 进行全量处理。 - """ - import redis from sqlalchemy import exists, not_, select from sqlalchemy.orm import Session @@ -25,6 +18,13 @@ from app.models.implicit_emotions_storage_model import ImplicitEmotionsStorage logger = logging.getLogger(__name__) +class TimeFilterUnavailableError(Exception): + """redis_client 不可用,无法执行时间轴筛选。 + + 调用方捕获此异常后可选择回退到 get_all_user_ids 进行全量处理。 + """ + + class ImplicitEmotionsStorageRepository: """隐性记忆和情绪存储仓储类""" @@ -216,9 +216,7 @@ class ImplicitEmotionsStorageRepository: """ from sqlalchemy import String as SAString from sqlalchemy import cast - CST = timezone(timedelta(hours=8)) - now_cst = datetime.now(CST) - today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc).replace(tzinfo=None) + today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) tomorrow_start = today_start + timedelta(days=1) offset = 0 while True: diff --git a/api/app/services/implicit_memory_service.py b/api/app/services/implicit_memory_service.py index 10504fe7..7a186f33 100644 --- a/api/app/services/implicit_memory_service.py +++ b/api/app/services/implicit_memory_service.py @@ -34,6 +34,7 @@ from app.schemas.implicit_memory_schema import ( UserMemorySummary, ) from app.schemas.memory_config_schema import MemoryConfig +from app.services.memory_base_service import MIN_MEMORY_SUMMARY_COUNT from sqlalchemy.orm import Session logger = logging.getLogger(__name__) @@ -381,7 +382,7 @@ class ImplicitMemoryService: def _build_empty_profile(self) -> dict: """构建 MemorySummary 不足时返回的固定空白画像数据""" - now_ms = int(datetime.now().timestamp() * 1000) + now_ms = int(datetime.utcnow().timestamp() * 1000) insufficient = "Insufficient data for analysis" def _empty_dimension(name: str) -> dict: @@ -442,17 +443,13 @@ class ImplicitMemoryService: try: # 前置检查:查询该用户有效的 MemorySummary 节点数量(排除孤立节点) - query = """ - MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) - WHERE n.end_user_id = $end_user_id - RETURN count(DISTINCT n) as count - """ - result = await self.neo4j_connector.execute_query(query, end_user_id=user_id) - memory_summary_count = result[0]["count"] if result and len(result) > 0 else 0 + from app.services.memory_base_service import MemoryBaseService + base_service = MemoryBaseService() + memory_summary_count = await base_service.get_valid_memory_summary_count(user_id) logger.info(f"用户 MemorySummary 节点数量: {memory_summary_count} (user={user_id})") - if memory_summary_count < 5: - logger.info(f"MemorySummary 数量不足 5(当前 {memory_summary_count}),返回空白画像: user={user_id}") + if memory_summary_count < MIN_MEMORY_SUMMARY_COUNT: + logger.info(f"MemorySummary 数量不足 {MIN_MEMORY_SUMMARY_COUNT}(当前 {memory_summary_count}),返回空白画像: user={user_id}") return self._build_empty_profile() # 并行调用4个分析方法 diff --git a/api/app/services/memory_base_service.py b/api/app/services/memory_base_service.py index bc647752..e615af8b 100644 --- a/api/app/services/memory_base_service.py +++ b/api/app/services/memory_base_service.py @@ -265,12 +265,50 @@ async def Translation_English(modid, text, fields=None): # 其他类型(数字、布尔值、None等):原样返回 else: return text +# 隐性记忆画像生成所需的最低 MemorySummary 节点数量 +MIN_MEMORY_SUMMARY_COUNT = 5 + + class MemoryBaseService: """记忆服务基类,提供共享的辅助方法""" def __init__(self): self.neo4j_connector = Neo4jConnector() + async def get_valid_memory_summary_count( + self, + end_user_id: str + ) -> int: + """获取用户有效的 MemorySummary 节点数量(排除孤立节点)。 + + 只统计存在 DERIVED_FROM_STATEMENT 关系的 MemorySummary 节点。 + + Args: + end_user_id: 终端用户ID + + Returns: + 有效 MemorySummary 节点数量 + """ + try: + query = """ + MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) + WHERE n.end_user_id = $end_user_id + RETURN count(DISTINCT n) as count + """ + result = await self.neo4j_connector.execute_query( + query, end_user_id=end_user_id + ) + count = result[0]["count"] if result and len(result) > 0 else 0 + logger.debug( + f"有效 MemorySummary 节点数量: {count} (end_user_id={end_user_id})" + ) + return count + except Exception as e: + logger.error( + f"获取有效 MemorySummary 数量失败: {str(e)}", exc_info=True + ) + return 0 + @staticmethod def parse_timestamp(timestamp_value) -> Optional[int]: """ diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index dcbedba6..cc18447e 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -21,7 +21,7 @@ from app.repositories.end_user_repository import EndUserRepository from app.repositories.neo4j.cypher_queries import Graph_Node_query from app.repositories.neo4j.neo4j_connector import Neo4jConnector from app.schemas.memory_episodic_schema import EmotionSubject, EmotionType, type_mapping -from app.services.memory_base_service import MemoryBaseService +from app.services.memory_base_service import MemoryBaseService, MIN_MEMORY_SUMMARY_COUNT from app.services.memory_config_service import MemoryConfigService from app.services.memory_perceptual_service import MemoryPerceptualService from app.services.memory_short_service import ShortService @@ -1500,7 +1500,7 @@ async def analytics_memory_types( 2. 工作记忆 (WORKING_MEMORY) = 会话数量(通过 ConversationRepository.get_conversation_by_user_id 获取) 3. 短期记忆 (SHORT_TERM_MEMORY) = /short_term 接口返回的问答对数量 4. 显性记忆 (EXPLICIT_MEMORY) = 情景记忆 + 语义记忆(通过 MemoryBaseService.get_explicit_memory_count 获取) - 5. 隐性记忆 (IMPLICIT_MEMORY) = MemorySummary 节点数量(需 >= 5 才显示,否则为 0) + 5. 隐性记忆 (IMPLICIT_MEMORY) = MemorySummary 节点数量(需 >= MIN_MEMORY_SUMMARY_COUNT 才显示,否则为 0) 6. 情绪记忆 (EMOTIONAL_MEMORY) = 情绪标签统计总数(通过 MemoryBaseService.get_emotional_memory_count 获取) 7. 情景记忆 (EPISODIC_MEMORY) = memory_summary(通过 MemoryBaseService.get_episodic_memory_count 获取) 8. 遗忘记忆 (FORGET_MEMORY) = 激活值低于阈值的节点数(通过 MemoryBaseService.get_forget_memory_count 获取) @@ -1557,20 +1557,12 @@ async def analytics_memory_types( logger.warning(f"获取会话数量失败,工作记忆数量设为0: {str(e)}") work_count = 0 - # 获取隐性记忆数量(基于有关联关系的 MemorySummary 节点数量,需 >= 5 才计入) + # 获取隐性记忆数量(基于有关联关系的 MemorySummary 节点数量,需 >= MIN_MEMORY_SUMMARY_COUNT 才计入) implicit_count = 0 if end_user_id: try: - # 只统计有 DERIVED_FROM_STATEMENT 关系的 MemorySummary 节点,排除孤立节点 - query = """ - MATCH (n:MemorySummary)-[:DERIVED_FROM_STATEMENT]->(:Statement) - WHERE n.end_user_id = $end_user_id - RETURN count(DISTINCT n) as count - """ - result = await _neo4j_connector.execute_query(query, end_user_id=end_user_id) - memory_summary_count = result[0]["count"] if result and len(result) > 0 else 0 - # 仅当 MemorySummary 节点数量 >= 5 时才显示数量,否则为 0 - implicit_count = memory_summary_count if memory_summary_count >= 5 else 0 + memory_summary_count = await base_service.get_valid_memory_summary_count(end_user_id) + implicit_count = memory_summary_count if memory_summary_count >= MIN_MEMORY_SUMMARY_COUNT else 0 logger.debug(f"隐性记忆数量(有效MemorySummary节点数): {implicit_count} (有效MemorySummary总数={memory_summary_count}, end_user_id={end_user_id})") except Exception as e: logger.warning(f"获取MemorySummary数量失败,隐性记忆数量设为0: {str(e)}") @@ -1639,7 +1631,7 @@ async def analytics_memory_types( "WORKING_MEMORY": work_count, # 工作记忆(基于会话数量) "SHORT_TERM_MEMORY": short_term_count, # 短期记忆(基于问答对数量) "EXPLICIT_MEMORY": explicit_count, # 显性记忆(情景记忆 + 语义记忆) - "IMPLICIT_MEMORY": implicit_count, # 隐性记忆(MemorySummary节点数,需>=5) + "IMPLICIT_MEMORY": implicit_count, # 隐性记忆(MemorySummary节点数,需>=MIN_MEMORY_SUMMARY_COUNT) "EMOTIONAL_MEMORY": emotion_count, # 情绪记忆(使用情绪标签统计) "EPISODIC_MEMORY": episodic_count, # 情景记忆 "FORGET_MEMORY": forget_count # 遗忘记忆(激活值低于阈值) From 52e726eabcc6b879e33b0037f7e871a3f2dc2ecb Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 18:53:49 +0800 Subject: [PATCH 13/44] ci: add release notification workflow for merged PRs - Add GitHub Actions workflow to notify on merged release branch PRs - Implement HEAD sync check to ensure branch is up-to-date before notification - Fetch commit messages from merged PR for AI summarization - Integrate Alibaba Qwen AI to generate Chinese release summaries for QA team - Send formatted Markdown notifications to WeChat webhook with PR details and AI summary - Workflow triggers only on final PR merge to release branches to avoid duplicate notifications --- .github/workflows/release-notify.yml | 107 +++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 .github/workflows/release-notify.yml diff --git a/.github/workflows/release-notify.yml b/.github/workflows/release-notify.yml new file mode 100644 index 00000000..6b86db96 --- /dev/null +++ b/.github/workflows/release-notify.yml @@ -0,0 +1,107 @@ +name: Release Notify (Ali AI Final) + +on: + pull_request: + types: [closed] + +jobs: + notify: + if: > + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.base.ref, 'release') + runs-on: ubuntu-latest + + steps: + # 防止 GitHub HEAD 未同步 + - name: Wait for ref sync + run: sleep 3 + + # 1️⃣ 获取分支 HEAD + - name: Get HEAD + id: head + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + HEAD_SHA=$(curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/$REPO/git/ref/heads/$BASE_REF" \ + | jq -r '.object.sha') + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + + # 2️⃣ 判断是否最终PR + - name: Check Latest + id: check + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + HEAD_SHA: ${{ steps.head.outputs.head_sha }} + run: | + if [ "$MERGE_SHA" = "$HEAD_SHA" ]; then + echo "ok=true" >> $GITHUB_OUTPUT + else + echo "ok=false" >> $GITHUB_OUTPUT + fi + + # 3️⃣ 获取 commits + - name: Get Commits + if: steps.check.outputs.ok == 'true' + id: commits + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMITS_URL: ${{ github.event.pull_request.commits_url }} + run: | + curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + "$COMMITS_URL" \ + | jq -r '.[].commit.message' | head -n 20 > commits.txt + + # 4️⃣ 阿里 AI 总结(通义千问) + - name: AI Summary (Qwen) + if: steps.check.outputs.ok == 'true' + id: ai + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + run: | + COMMIT_MESSAGES=$(cat commits.txt) + + jq -n --arg msgs "请用中文总结以下代码提交,输出3-5条,面向测试人员: + $COMMIT_MESSAGES" \ + '{"model": "qwen-plus", "input": {"prompt": $msgs}}' > ai_payload.json + + SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H "Content-Type: application/json" \ + -d @ai_payload.json | jq -r '.output.text') + + echo "summary<> $GITHUB_OUTPUT + echo "$SUMMARY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # 5️⃣ 企业微信通知(Markdown) + - name: Notify WeChat + if: steps.check.outputs.ok == 'true' + env: + WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} + BRANCH: ${{ github.event.pull_request.base.ref }} + AUTHOR: ${{ github.event.pull_request.user.login }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + AI_SUMMARY: ${{ steps.ai.outputs.summary }} + run: | + jq -n \ + --arg branch "$BRANCH" \ + --arg author "$AUTHOR" \ + --arg title "$PR_TITLE" \ + --arg url "$PR_URL" \ + --arg summary "$AI_SUMMARY" \ + '{ + msgtype: "markdown", + markdown: { + content: "## 🚀 Release 发布通知\n> 📦 **分支**: \($branch)\n> 👤 **提交人**: \($author)\n> 📝 **标题**: \($title)\n\n### 🧠 AI变更摘要\n\($summary)\n\n---\n🔗 [查看PR详情](\($url))" + } + }' > wechat_payload.json + + curl -s "$WECHAT_WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d @wechat_payload.json From 70d4e79de1546b1cb36e856575f4270fa34eb356 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 13 Apr 2026 19:05:32 +0800 Subject: [PATCH 14/44] fix(web): breadcrumb ui --- web/src/components/Header/index.module.css | 8 +++++ web/src/components/Header/index.tsx | 37 +++++++++++----------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/web/src/components/Header/index.module.css b/web/src/components/Header/index.module.css index d39c91ec..525a2432 100644 --- a/web/src/components/Header/index.module.css +++ b/web/src/components/Header/index.module.css @@ -12,6 +12,14 @@ font-weight: 500; font-style: normal; } +.breadcrumbTitle { + display: inline-block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} .header :global(.ant-breadcrumb) { line-height: 31px; } diff --git a/web/src/components/Header/index.tsx b/web/src/components/Header/index.tsx index 49988223..f2eff014 100644 --- a/web/src/components/Header/index.tsx +++ b/web/src/components/Header/index.tsx @@ -14,7 +14,7 @@ */ import { type FC, useRef, useState } from 'react'; -import { Layout, Dropdown, Breadcrumb, Flex } from 'antd'; +import { Layout, Dropdown, Breadcrumb, Flex, Tooltip } from 'antd'; import type { MenuProps, BreadcrumbProps } from 'antd'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; @@ -136,27 +136,28 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { */ const formatBreadcrumbNames = () => { return breadcrumbs.filter(item => item.type !== 'group').map((menu, index) => { + const label = menu.i18nKey ? t(menu.i18nKey) : menu.label; + const isLast = index === breadcrumbs.length - 1; const item: any = { - title: menu.i18nKey ? t(menu.i18nKey) : menu.label, + title: ( + + {label} + + ), }; - // If it's the last item, don't set path - if (index === breadcrumbs.length - 1) { - return item; + if (!isLast) { + if ((menu as any).onClick) { + item.onClick = (e: React.MouseEvent) => { + e.preventDefault(); + (menu as any).onClick(e); + }; + item.href = '#'; + } else if (menu.path && menu.path !== '#') { + item.path = menu.path; + } } - - // If has custom onClick, use onClick and set href to '#' to show pointer cursor - if ((menu as any).onClick) { - item.onClick = (e: React.MouseEvent) => { - e.preventDefault(); - (menu as any).onClick(e); - }; - item.href = '#'; - } else if (menu.path && menu.path !== '#') { - // Only set path when path is not '#' - item.path = menu.path; - } - + return item; }); } From 1ff3748935b31ebc9a1ae3978ddbeaf3d1de1301 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:11:15 +0800 Subject: [PATCH 15/44] ci: remove release notification workflow - Delete release-notify.yml GitHub Actions workflow - Remove AI-powered release summary generation via Qwen API - Remove WeChat enterprise notification integration - Simplify CI/CD pipeline by consolidating notification logic --- .github/workflows/release-notify.yml | 107 --------------------------- 1 file changed, 107 deletions(-) delete mode 100644 .github/workflows/release-notify.yml diff --git a/.github/workflows/release-notify.yml b/.github/workflows/release-notify.yml deleted file mode 100644 index 6b86db96..00000000 --- a/.github/workflows/release-notify.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Release Notify (Ali AI Final) - -on: - pull_request: - types: [closed] - -jobs: - notify: - if: > - github.event.pull_request.merged == true && - startsWith(github.event.pull_request.base.ref, 'release') - runs-on: ubuntu-latest - - steps: - # 防止 GitHub HEAD 未同步 - - name: Wait for ref sync - run: sleep 3 - - # 1️⃣ 获取分支 HEAD - - name: Get HEAD - id: head - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - BASE_REF: ${{ github.event.pull_request.base.ref }} - run: | - HEAD_SHA=$(curl -s \ - -H "Authorization: Bearer $GH_TOKEN" \ - "https://api.github.com/repos/$REPO/git/ref/heads/$BASE_REF" \ - | jq -r '.object.sha') - echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT - - # 2️⃣ 判断是否最终PR - - name: Check Latest - id: check - env: - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - HEAD_SHA: ${{ steps.head.outputs.head_sha }} - run: | - if [ "$MERGE_SHA" = "$HEAD_SHA" ]; then - echo "ok=true" >> $GITHUB_OUTPUT - else - echo "ok=false" >> $GITHUB_OUTPUT - fi - - # 3️⃣ 获取 commits - - name: Get Commits - if: steps.check.outputs.ok == 'true' - id: commits - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMMITS_URL: ${{ github.event.pull_request.commits_url }} - run: | - curl -s \ - -H "Authorization: Bearer $GH_TOKEN" \ - "$COMMITS_URL" \ - | jq -r '.[].commit.message' | head -n 20 > commits.txt - - # 4️⃣ 阿里 AI 总结(通义千问) - - name: AI Summary (Qwen) - if: steps.check.outputs.ok == 'true' - id: ai - env: - DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} - run: | - COMMIT_MESSAGES=$(cat commits.txt) - - jq -n --arg msgs "请用中文总结以下代码提交,输出3-5条,面向测试人员: - $COMMIT_MESSAGES" \ - '{"model": "qwen-plus", "input": {"prompt": $msgs}}' > ai_payload.json - - SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ - -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ - -H "Content-Type: application/json" \ - -d @ai_payload.json | jq -r '.output.text') - - echo "summary<> $GITHUB_OUTPUT - echo "$SUMMARY" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - # 5️⃣ 企业微信通知(Markdown) - - name: Notify WeChat - if: steps.check.outputs.ok == 'true' - env: - WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} - BRANCH: ${{ github.event.pull_request.base.ref }} - AUTHOR: ${{ github.event.pull_request.user.login }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_URL: ${{ github.event.pull_request.html_url }} - AI_SUMMARY: ${{ steps.ai.outputs.summary }} - run: | - jq -n \ - --arg branch "$BRANCH" \ - --arg author "$AUTHOR" \ - --arg title "$PR_TITLE" \ - --arg url "$PR_URL" \ - --arg summary "$AI_SUMMARY" \ - '{ - msgtype: "markdown", - markdown: { - content: "## 🚀 Release 发布通知\n> 📦 **分支**: \($branch)\n> 👤 **提交人**: \($author)\n> 📝 **标题**: \($title)\n\n### 🧠 AI变更摘要\n\($summary)\n\n---\n🔗 [查看PR详情](\($url))" - } - }' > wechat_payload.json - - curl -s "$WECHAT_WEBHOOK" \ - -H 'Content-Type: application/json' \ - -d @wechat_payload.json From 77ed9faea102c365241221b24208f916362dba44 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:13:23 +0800 Subject: [PATCH 16/44] chore(.gitignore): add redbear-mem-benchmark to ignored paths - Add redbear-mem-benchmark directory to .gitignore - Prevents benchmark artifacts from being tracked in version control - Aligns with existing pattern of ignoring redbear-mem-metrics directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0ec6822c..a1896da7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ time.log celerybeat-schedule.db search_results.json redbear-mem-metrics/ +redbear-mem-benchmark/ pitch-deck/ api/migrations/versions From 7a4a02b2bb722c2e3b522d8bc7e0984abdf10c55 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:15:54 +0800 Subject: [PATCH 17/44] ci: add WeChat release notification workflow - Add GitHub Actions workflow to notify WeChat on release branch merges - Implement multi-step pipeline: sync ref, verify latest PR, fetch commits - Integrate Aliyun Qwen AI for automated Chinese commit message summarization - Send formatted Markdown notifications to WeChat webhook with release details - Include branch, author, PR title, AI summary, and PR link in notifications --- .github/workflows/release-notify-wechat.yml | 100 ++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/release-notify-wechat.yml diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml new file mode 100644 index 00000000..7b3378b0 --- /dev/null +++ b/.github/workflows/release-notify-wechat.yml @@ -0,0 +1,100 @@ +name: Release Notify Workflow + +on: + pull_request: + types: [closed] + +jobs: + notify: + if: > + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.base.ref, 'release') + runs-on: ubuntu-latest + + steps: + # 防止 GitHub HEAD 未同步 + - name: Wait for ref sync + run: sleep 3 + + # 1️⃣ 获取分支 HEAD + - name: Get HEAD + id: head + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + HEAD_SHA=$(curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/$REPO/git/ref/heads/$BASE_REF" \ + | jq -r '.object.sha') + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + + # 2️⃣ 判断是否最终PR + - name: Check Latest + id: check + env: + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + HEAD_SHA: ${{ steps.head.outputs.head_sha }} + run: | + if [ "$MERGE_SHA" = "$HEAD_SHA" ]; then + echo "ok=true" >> $GITHUB_OUTPUT + else + echo "ok=false" >> $GITHUB_OUTPUT + fi + + # 3️⃣ 获取 commits + - name: Get Commits + if: steps.check.outputs.ok == 'true' + id: commits + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMITS_URL: ${{ github.event.pull_request.commits_url }} + run: | + curl -s \ + -H "Authorization: Bearer $GH_TOKEN" \ + "$COMMITS_URL" \ + | jq -r '.[].commit.message' | head -n 20 > commits.txt + + # 4️⃣ 阿里 AI 总结(通义千问) + - name: AI Summary (Qwen) + if: steps.check.outputs.ok == 'true' + id: ai + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + run: | + COMMIT_MESSAGES=$(cat commits.txt) + + jq -n --arg msgs "请用中文总结以下代码提交,输出3-5条,面向测试人员: + $COMMIT_MESSAGES" \ + '{"model": "qwen-plus", "input": {"prompt": $msgs}}' > ai_payload.json + + SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ + -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H "Content-Type: application/json" \ + -d @ai_payload.json | jq -r '.output.text') + + echo "summary<> $GITHUB_OUTPUT + echo "$SUMMARY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # 5️⃣ 企业微信通知(Markdown) + - name: Notify WeChat + if: steps.check.outputs.ok == 'true' + env: + WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} + BRANCH: ${{ github.event.pull_request.base.ref }} + AUTHOR: ${{ github.event.pull_request.user.login }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + AI_SUMMARY: ${{ steps.ai.outputs.summary }} + run: | + CONTENT=$(printf '## 🚀 Release 发布通知\n> 📦 **分支**: %s\n> 👤 **提交人**: %s\n> 📝 **标题**: %s\n\n### 🧠 AI变更摘要\n%s\n\n---\n🔗 [查看PR详情](%s)' \ + "$BRANCH" "$AUTHOR" "$PR_TITLE" "$AI_SUMMARY" "$PR_URL") + + jq -n --arg content "$CONTENT" \ + '{"msgtype": "markdown", "markdown": {"content": $content}}' > wechat_payload.json + + curl -s "$WECHAT_WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d @wechat_payload.json From 8495aa5dde784b423f5fbe9522be34a9df910e38 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:18:11 +0800 Subject: [PATCH 18/44] ci(wechat-notify): replace shell string formatting with Python - Replace printf and jq command chain with Python script for payload generation - Improve readability by using Python string concatenation instead of nested printf format specifiers - Ensure proper JSON encoding with ensure_ascii=False to preserve Chinese characters - Simplify environment variable interpolation using os.environ dictionary access --- .github/workflows/release-notify-wechat.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 7b3378b0..ae5f3f6e 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -89,11 +89,22 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | - CONTENT=$(printf '## 🚀 Release 发布通知\n> 📦 **分支**: %s\n> 👤 **提交人**: %s\n> 📝 **标题**: %s\n\n### 🧠 AI变更摘要\n%s\n\n---\n🔗 [查看PR详情](%s)' \ - "$BRANCH" "$AUTHOR" "$PR_TITLE" "$AI_SUMMARY" "$PR_URL") - - jq -n --arg content "$CONTENT" \ - '{"msgtype": "markdown", "markdown": {"content": $content}}' > wechat_payload.json + python3 -c " + import json, os + content = ( + '## 🚀 Release 发布通知\n' + '> 📦 **分支**: ' + os.environ['BRANCH'] + '\n' + '> 👤 **提交人**: ' + os.environ['AUTHOR'] + '\n' + '> 📝 **标题**: ' + os.environ['PR_TITLE'] + '\n\n' + '### 🧠 AI变更摘要\n' + + os.environ['AI_SUMMARY'] + '\n\n' + '---\n' + '🔗 [查看PR详情](' + os.environ['PR_URL'] + ')' + ) + payload = {'msgtype': 'markdown', 'markdown': {'content': content}} + with open('wechat_payload.json', 'w') as f: + json.dump(payload, f, ensure_ascii=False) + " curl -s "$WECHAT_WEBHOOK" \ -H 'Content-Type: application/json' \ From b20971dc9565fb91925dbc4f461f607fb112f3ca Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:20:53 +0800 Subject: [PATCH 19/44] ci(wechat-notify): extract payload building logic to Python script - Create new `.github/scripts/build_wechat_payload.py` to handle WeChat payload generation - Replace inline Python string concatenation with dedicated script for better maintainability - Add checkout step to access the script during workflow execution - Simplify workflow by delegating payload construction to external script - Improve code readability and reusability for future notification enhancements --- .github/scripts/build_wechat_payload.py | 24 +++++++++++++++++++++ .github/workflows/release-notify-wechat.yml | 21 +++++------------- 2 files changed, 29 insertions(+), 16 deletions(-) create mode 100644 .github/scripts/build_wechat_payload.py diff --git a/.github/scripts/build_wechat_payload.py b/.github/scripts/build_wechat_payload.py new file mode 100644 index 00000000..5e292ee9 --- /dev/null +++ b/.github/scripts/build_wechat_payload.py @@ -0,0 +1,24 @@ +import json +import os + +branch = os.environ.get("BRANCH", "") +author = os.environ.get("AUTHOR", "") +pr_title = os.environ.get("PR_TITLE", "") +pr_url = os.environ.get("PR_URL", "") +ai_summary = os.environ.get("AI_SUMMARY", "") + +content = ( + "## 🚀 Release 发布通知\n" + f"> 📦 **分支**: {branch}\n" + f"> 👤 **提交人**: {author}\n" + f"> 📝 **标题**: {pr_title}\n\n" + "### 🧠 AI变更摘要\n" + f"{ai_summary}\n\n" + "---\n" + f"🔗 [查看PR详情]({pr_url})" +) + +payload = {"msgtype": "markdown", "markdown": {"content": content}} + +with open("wechat_payload.json", "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index ae5f3f6e..b6c8a04d 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -79,6 +79,10 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT # 5️⃣ 企业微信通知(Markdown) + - name: Checkout for script + if: steps.check.outputs.ok == 'true' + uses: actions/checkout@v4 + - name: Notify WeChat if: steps.check.outputs.ok == 'true' env: @@ -89,22 +93,7 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | - python3 -c " - import json, os - content = ( - '## 🚀 Release 发布通知\n' - '> 📦 **分支**: ' + os.environ['BRANCH'] + '\n' - '> 👤 **提交人**: ' + os.environ['AUTHOR'] + '\n' - '> 📝 **标题**: ' + os.environ['PR_TITLE'] + '\n\n' - '### 🧠 AI变更摘要\n' - + os.environ['AI_SUMMARY'] + '\n\n' - '---\n' - '🔗 [查看PR详情](' + os.environ['PR_URL'] + ')' - ) - payload = {'msgtype': 'markdown', 'markdown': {'content': content}} - with open('wechat_payload.json', 'w') as f: - json.dump(payload, f, ensure_ascii=False) - " + python3 .github/scripts/build_wechat_payload.py curl -s "$WECHAT_WEBHOOK" \ -H 'Content-Type: application/json' \ From 99559621c5aabc5861c3ef5d1487e5a3ec43d45e Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:24:50 +0800 Subject: [PATCH 20/44] ci(wechat-notify): inline payload building logic into workflow - Remove build_wechat_payload.py script and consolidate payload construction directly in workflow - Eliminate intermediate environment variables and file I/O operations for cleaner execution - Inline AI summary payload generation into curl request - Inline WeChat notification payload generation into curl request - Remove unnecessary checkout step since script is no longer needed - Simplify workflow by reducing file dependencies and improving readability --- .github/scripts/build_wechat_payload.py | 24 -------- .github/workflows/release-notify-wechat.yml | 62 +++++++-------------- 2 files changed, 21 insertions(+), 65 deletions(-) delete mode 100644 .github/scripts/build_wechat_payload.py diff --git a/.github/scripts/build_wechat_payload.py b/.github/scripts/build_wechat_payload.py deleted file mode 100644 index 5e292ee9..00000000 --- a/.github/scripts/build_wechat_payload.py +++ /dev/null @@ -1,24 +0,0 @@ -import json -import os - -branch = os.environ.get("BRANCH", "") -author = os.environ.get("AUTHOR", "") -pr_title = os.environ.get("PR_TITLE", "") -pr_url = os.environ.get("PR_URL", "") -ai_summary = os.environ.get("AI_SUMMARY", "") - -content = ( - "## 🚀 Release 发布通知\n" - f"> 📦 **分支**: {branch}\n" - f"> 👤 **提交人**: {author}\n" - f"> 📝 **标题**: {pr_title}\n\n" - "### 🧠 AI变更摘要\n" - f"{ai_summary}\n\n" - "---\n" - f"🔗 [查看PR详情]({pr_url})" -) - -payload = {"msgtype": "markdown", "markdown": {"content": content}} - -with open("wechat_payload.json", "w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index b6c8a04d..4264570f 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -13,31 +13,23 @@ jobs: steps: # 防止 GitHub HEAD 未同步 - - name: Wait for ref sync - run: sleep 3 + - run: sleep 3 # 1️⃣ 获取分支 HEAD - name: Get HEAD id: head - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - BASE_REF: ${{ github.event.pull_request.base.ref }} run: | HEAD_SHA=$(curl -s \ - -H "Authorization: Bearer $GH_TOKEN" \ - "https://api.github.com/repos/$REPO/git/ref/heads/$BASE_REF" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + https://api.github.com/repos/${{ github.repository }}/git/ref/heads/${{ github.event.pull_request.base.ref }} \ | jq -r '.object.sha') echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT # 2️⃣ 判断是否最终PR - name: Check Latest id: check - env: - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - HEAD_SHA: ${{ steps.head.outputs.head_sha }} run: | - if [ "$MERGE_SHA" = "$HEAD_SHA" ]; then + if [ "${{ github.event.pull_request.merge_commit_sha }}" = "${{ steps.head.outputs.head_sha }}" ]; then echo "ok=true" >> $GITHUB_OUTPUT else echo "ok=false" >> $GITHUB_OUTPUT @@ -47,54 +39,42 @@ jobs: - name: Get Commits if: steps.check.outputs.ok == 'true' id: commits - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMMITS_URL: ${{ github.event.pull_request.commits_url }} run: | curl -s \ - -H "Authorization: Bearer $GH_TOKEN" \ - "$COMMITS_URL" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + ${{ github.event.pull_request.commits_url }} \ | jq -r '.[].commit.message' | head -n 20 > commits.txt # 4️⃣ 阿里 AI 总结(通义千问) - name: AI Summary (Qwen) if: steps.check.outputs.ok == 'true' id: ai - env: - DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | - COMMIT_MESSAGES=$(cat commits.txt) - - jq -n --arg msgs "请用中文总结以下代码提交,输出3-5条,面向测试人员: - $COMMIT_MESSAGES" \ - '{"model": "qwen-plus", "input": {"prompt": $msgs}}' > ai_payload.json + CONTENT=$(cat commits.txt | sed ':a;N;$!ba;s/\n/\\n/g') SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ - -H "Authorization: Bearer $DASHSCOPE_API_KEY" \ + -H "Authorization: Bearer ${{ secrets.DASHSCOPE_API_KEY }}" \ -H "Content-Type: application/json" \ - -d @ai_payload.json | jq -r '.output.text') + -d "{ + \"model\": \"qwen-plus\", + \"input\": { + \"prompt\": \"请用中文总结以下代码提交,输出3-5条,面向测试人员:\\n$CONTENT\" + } + }" | jq -r '.output.text') echo "summary<> $GITHUB_OUTPUT echo "$SUMMARY" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT # 5️⃣ 企业微信通知(Markdown) - - name: Checkout for script - if: steps.check.outputs.ok == 'true' - uses: actions/checkout@v4 - - name: Notify WeChat if: steps.check.outputs.ok == 'true' - env: - WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} - BRANCH: ${{ github.event.pull_request.base.ref }} - AUTHOR: ${{ github.event.pull_request.user.login }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_URL: ${{ github.event.pull_request.html_url }} - AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | - python3 .github/scripts/build_wechat_payload.py - - curl -s "$WECHAT_WEBHOOK" \ + curl '${{ secrets.WECHAT_WEBHOOK }}' \ -H 'Content-Type: application/json' \ - -d @wechat_payload.json + -d "{ + \"msgtype\": \"markdown\", + \"markdown\": { + \"content\": \"## 🚀 Release 发布通知\n> 📦 **分支**: ${{ github.event.pull_request.base.ref }}\n> 👤 **提交人**: ${{ github.event.pull_request.user.login }}\n> 📝 **标题**: ${{ github.event.pull_request.title }}\n\n### 🧠 AI变更摘要\n${{ steps.ai.outputs.summary }}\n\n---\n🔗 [查看PR详情](${{ github.event.pull_request.html_url }})\" + } + }" From 90365cd026dee4de15ccedae5b63ece2bbd97354 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:28:10 +0800 Subject: [PATCH 21/44] ci(wechat-notify): refactor payload building to Python script - Extract WeChat notification payload construction from inline curl command - Move environment variables to explicit env section for clarity - Build JSON payload using Python for better string handling and readability - Write payload to temporary file and pass to curl via -d @wechat.json - Improves maintainability and reduces shell string escaping complexity --- .github/workflows/release-notify-wechat.yml | 33 ++++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 4264570f..0c1ad7ca 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -69,12 +69,31 @@ jobs: # 5️⃣ 企业微信通知(Markdown) - name: Notify WeChat if: steps.check.outputs.ok == 'true' + env: + WECHAT_WEBHOOK: ${{ secrets.WECHAT_WEBHOOK }} + BRANCH: ${{ github.event.pull_request.base.ref }} + AUTHOR: ${{ github.event.pull_request.user.login }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | - curl '${{ secrets.WECHAT_WEBHOOK }}' \ + python3 << 'PYEOF' + import json, os + content = ( + "## 🚀 Release 发布通知\n" + "> 📦 **分支**: " + os.environ["BRANCH"] + "\n" + "> 👤 **提交人**: " + os.environ["AUTHOR"] + "\n" + "> 📝 **标题**: " + os.environ["PR_TITLE"] + "\n\n" + "### 🧠 AI变更摘要\n" + + os.environ["AI_SUMMARY"] + "\n\n" + "---\n" + "🔗 [查看PR详情](" + os.environ["PR_URL"] + ")" + ) + payload = {"msgtype": "markdown", "markdown": {"content": content}} + with open("wechat.json", "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + PYEOF + + curl -s "$WECHAT_WEBHOOK" \ -H 'Content-Type: application/json' \ - -d "{ - \"msgtype\": \"markdown\", - \"markdown\": { - \"content\": \"## 🚀 Release 发布通知\n> 📦 **分支**: ${{ github.event.pull_request.base.ref }}\n> 👤 **提交人**: ${{ github.event.pull_request.user.login }}\n> 📝 **标题**: ${{ github.event.pull_request.title }}\n\n### 🧠 AI变更摘要\n${{ steps.ai.outputs.summary }}\n\n---\n🔗 [查看PR详情](${{ github.event.pull_request.html_url }})\" - } - }" + -d @wechat.json From bafcb5c5453fcd92a9cb9c6a3d0c3f8629b84338 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:30:15 +0800 Subject: [PATCH 22/44] ci(wechat-notify): replace curl with urllib for webhook request - Replace curl command with Python urllib.request for direct HTTP POST - Remove intermediate wechat.json file write, send payload directly - Add urllib.request import to Python script - Simplify workflow by eliminating file I/O and shell command dependency - Improves reliability by keeping notification logic entirely within Python --- .github/workflows/release-notify-wechat.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 0c1ad7ca..52fd0e44 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -78,7 +78,7 @@ jobs: AI_SUMMARY: ${{ steps.ai.outputs.summary }} run: | python3 << 'PYEOF' - import json, os + import json, os, urllib.request content = ( "## 🚀 Release 发布通知\n" "> 📦 **分支**: " + os.environ["BRANCH"] + "\n" @@ -90,10 +90,12 @@ jobs: "🔗 [查看PR详情](" + os.environ["PR_URL"] + ")" ) payload = {"msgtype": "markdown", "markdown": {"content": content}} - with open("wechat.json", "w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False) + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + os.environ["WECHAT_WEBHOOK"], + data=data, + headers={"Content-Type": "application/json"} + ) + resp = urllib.request.urlopen(req) + print(resp.read().decode()) PYEOF - - curl -s "$WECHAT_WEBHOOK" \ - -H 'Content-Type: application/json' \ - -d @wechat.json From c614bb5be7402051ef8035179ac9502373f4e741 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:33:30 +0800 Subject: [PATCH 23/44] ci(wechat-notify): refine AI prompt for commit summarization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update prompt instruction to request numbered list format - Remove title and preamble from AI output for cleaner formatting - Improve clarity by specifying "要点" (key points) in prompt - Enhance consistency of release notification messages --- .github/workflows/release-notify-wechat.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 52fd0e44..6894db15 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -58,7 +58,7 @@ jobs: -d "{ \"model\": \"qwen-plus\", \"input\": { - \"prompt\": \"请用中文总结以下代码提交,输出3-5条,面向测试人员:\\n$CONTENT\" + \"prompt\": \"请用中文总结以下代码提交,输出3-5条要点,面向测试人员。直接输出编号列表,不要输出标题或前言:\\n$CONTENT\" } }" | jq -r '.output.text') From 0d16e168e75ebe611ec6baab938ea4f7ef222881 Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Mon, 13 Apr 2026 19:36:27 +0800 Subject: [PATCH 24/44] ci(wechat-notify): refactor AI summary generation to Python - Replace curl with urllib.request for API calls to improve portability - Move API key to environment variable for better security practices - Inline Python script using heredoc for cleaner workflow definition - Add intermediate file (ai_summary.txt) to separate concerns between API call and output handling - Simplify JSON payload construction using Python's json module - Improve error handling with fallback message for failed AI generation --- .github/workflows/release-notify-wechat.yml | 38 +++++++++++++++------ 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index 6894db15..d8f4e5aa 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -49,19 +49,37 @@ jobs: - name: AI Summary (Qwen) if: steps.check.outputs.ok == 'true' id: ai + env: + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | - CONTENT=$(cat commits.txt | sed ':a;N;$!ba;s/\n/\\n/g') + python3 << 'PYEOF' + import json, os, urllib.request - SUMMARY=$(curl -s https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation \ - -H "Authorization: Bearer ${{ secrets.DASHSCOPE_API_KEY }}" \ - -H "Content-Type: application/json" \ - -d "{ - \"model\": \"qwen-plus\", - \"input\": { - \"prompt\": \"请用中文总结以下代码提交,输出3-5条要点,面向测试人员。直接输出编号列表,不要输出标题或前言:\\n$CONTENT\" - } - }" | jq -r '.output.text') + with open("commits.txt", "r") as f: + commits = f.read().strip() + prompt = "请用中文总结以下代码提交,输出3-5条要点,面向测试人员。直接输出编号列表,不要输出标题或前言:\n" + commits + payload = {"model": "qwen-plus", "input": {"prompt": prompt}} + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + + req = urllib.request.Request( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation", + data=data, + headers={ + "Authorization": "Bearer " + os.environ["DASHSCOPE_API_KEY"], + "Content-Type": "application/json" + } + ) + resp = urllib.request.urlopen(req) + result = json.loads(resp.read().decode()) + summary = result.get("output", {}).get("text", "AI 摘要生成失败") + print(summary) + + with open("ai_summary.txt", "w", encoding="utf-8") as f: + f.write(summary) + PYEOF + + SUMMARY=$(cat ai_summary.txt) echo "summary<> $GITHUB_OUTPUT echo "$SUMMARY" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT From 06075ffef504c0b58e286b5cf434cefc7633b68f Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 09:57:36 +0800 Subject: [PATCH 25/44] fix(web): calculate using the filtered breadcrumbs length --- web/src/components/Header/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/components/Header/index.tsx b/web/src/components/Header/index.tsx index f2eff014..d85a84b0 100644 --- a/web/src/components/Header/index.tsx +++ b/web/src/components/Header/index.tsx @@ -135,9 +135,10 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { * - Disables navigation for the last breadcrumb item */ const formatBreadcrumbNames = () => { - return breadcrumbs.filter(item => item.type !== 'group').map((menu, index) => { + const filtered = breadcrumbs.filter(item => item.type !== 'group'); + return filtered.map((menu, index) => { const label = menu.i18nKey ? t(menu.i18nKey) : menu.label; - const isLast = index === breadcrumbs.length - 1; + const isLast = index === filtered.length - 1; const item: any = { title: ( From 2d9986f9025dd577108dc1a4f7572e7e2a75fe89 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 10:03:46 +0800 Subject: [PATCH 26/44] fix(web): header user name --- web/src/components/Header/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/components/Header/index.tsx b/web/src/components/Header/index.tsx index d85a84b0..23a89894 100644 --- a/web/src/components/Header/index.tsx +++ b/web/src/components/Header/index.tsx @@ -77,7 +77,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { { key: '1', icon: - {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(0, 2) : user.username?.[0]} + {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(-2) : user.username[0]} , label: (<>
{user.username}
@@ -182,7 +182,7 @@ const AppHeader: FC<{source?: 'space' | 'manage';}> = ({source = 'manage'}) => { > - {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(user.username.length, -2) : user.username[0]} + {/[\u4e00-\u9fa5]/.test(user.username) ? user.username.slice(-2) : user.username[0]} {user.username}
Date: Tue, 14 Apr 2026 15:08:07 +0800 Subject: [PATCH 27/44] fix(web): change http body key name --- .../components/Properties/HttpRequest/EditableTable.tsx | 4 ++-- web/src/views/Workflow/hooks/useWorkflowGraph.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx index e0a27b47..e4b2cc29 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/EditableTable.tsx @@ -85,9 +85,9 @@ const EditableTable: FC = ({ return [ { title: t('workflow.config.name'), - dataIndex: 'name', + dataIndex: 'key', render: (_: any, __: TableRow, index: number) => ( - + ).map(([key, value]) => ({ key, value })) : group_variables } else if (type === 'http-request' && (key === 'headers' || key === 'params') && config[key] && typeof config[key] === 'object' && !Array.isArray(config[key]) && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { - nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([name, value]) => ({ name, value })) + nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([key, value]) => ({ key, value })) } else if (type === 'code' && key === 'code' && config[key] && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) { try { nodeLibraryConfig.config[key].defaultValue = decodeURIComponent(atob(config[key] as string)) @@ -1259,7 +1259,7 @@ export const useWorkflowGraph = ({ itemConfig[key] = {} if (value.length > 0) { value.forEach((vo: any) => { - itemConfig[key][vo.name] = vo.value + itemConfig[key][vo.key] = vo.value }) } } else if (data.config[key] && 'defaultValue' in data.config[key] && key !== 'knowledge_retrieval') { From 09650082103b1915369677603d4044313bcf9d95 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 14 Apr 2026 15:53:16 +0800 Subject: [PATCH 28/44] fix(http-request): support array and file variables in form-data files upload - Updated form-data handling to accept both single FileVariable and ArrayVariable containing FileVariable for file uploads - Fixed HTTP client redirect handling by enabling follow_redirects=True when downloading remote files - Adjusted config validation to correctly require list type for form-data fields instead of HttpFormData class --- .../workflow/nodes/http_request/config.py | 4 ++-- .../core/workflow/nodes/http_request/node.py | 19 ++++++++++++------- .../workflow/variable/variable_objects.py | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/api/app/core/workflow/nodes/http_request/config.py b/api/app/core/workflow/nodes/http_request/config.py index e1b84f0c..3473f666 100644 --- a/api/app/core/workflow/nodes/http_request/config.py +++ b/api/app/core/workflow/nodes/http_request/config.py @@ -72,8 +72,8 @@ class HttpContentTypeConfig(BaseModel): @classmethod def validate_data(cls, v, info): content_type = info.data.get("content_type") - if content_type == HttpContentType.FROM_DATA and not isinstance(v, HttpFormData): - raise ValueError("When content_type is 'form-data', data must be of type HttpFormData") + if content_type == HttpContentType.FROM_DATA and not isinstance(v, list): + raise ValueError("When content_type is 'form-data', data must be a list of HttpFormData") elif content_type in [HttpContentType.JSON] and not isinstance(v, str): raise ValueError("When content_type is JSON, data must be of type str") elif content_type in [HttpContentType.WWW_FORM] and not isinstance(v, dict): diff --git a/api/app/core/workflow/nodes/http_request/node.py b/api/app/core/workflow/nodes/http_request/node.py index 086bee4a..783c230b 100644 --- a/api/app/core/workflow/nodes/http_request/node.py +++ b/api/app/core/workflow/nodes/http_request/node.py @@ -260,17 +260,22 @@ class HttpRequestNode(BaseNode): )) case HttpContentType.FROM_DATA: data = {} - content["files"] = {} + files = [] for item in self.typed_config.body.data: + key = self._render_template(item.key, variable_pool) if item.type == "text": - data[self._render_template(item.key, variable_pool)] = self._render_template(item.value, - variable_pool) + data[key] = self._render_template(item.value, variable_pool) elif item.type == "file": - content["files"][self._render_template(item.key, variable_pool)] = ( - uuid.uuid4().hex, - await variable_pool.get_instance(item.value).get_content() - ) + file_instance = variable_pool.get_instance(item.value) + if isinstance(file_instance, ArrayVariable): + for v in file_instance.value: + if isinstance(v, FileVariable): + files.append((key, (uuid.uuid4().hex, await v.get_content()))) + elif isinstance(file_instance, FileVariable): + files.append((key, (uuid.uuid4().hex, await file_instance.get_content()))) content["data"] = data + if files: + content["files"] = files case HttpContentType.BINARY: content["files"] = [] file_instence = variable_pool.get_instance(self.typed_config.body.data) diff --git a/api/app/core/workflow/variable/variable_objects.py b/api/app/core/workflow/variable/variable_objects.py index 94f87287..2b849c94 100644 --- a/api/app/core/workflow/variable/variable_objects.py +++ b/api/app/core/workflow/variable/variable_objects.py @@ -84,7 +84,7 @@ class FileVariable(BaseVariable): total_bytes = 0 chunks = [] - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(follow_redirects=True) as client: async with client.stream("GET", self.value.url) as resp: resp.raise_for_status() async for chunk in resp.aiter_bytes(8192): From fa1e5ee43c962d66c6b20e4d12765fae996dc2c5 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 16:06:03 +0800 Subject: [PATCH 29/44] fix(web): adjust the value of End User Name --- web/src/views/UserMemoryDetail/Neo4j.tsx | 10 ++++++---- .../UserMemoryDetail/components/EndUserProfile.tsx | 7 ++++--- web/src/views/UserMemoryDetail/types.ts | 3 ++- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/web/src/views/UserMemoryDetail/Neo4j.tsx b/web/src/views/UserMemoryDetail/Neo4j.tsx index 51be7c8d..3fdaaed3 100644 --- a/web/src/views/UserMemoryDetail/Neo4j.tsx +++ b/web/src/views/UserMemoryDetail/Neo4j.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:57:26 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 18:59:53 + * @Last Modified time: 2026-04-14 16:04:08 */ /** * Neo4j User Memory Detail View @@ -22,7 +22,7 @@ import InterestDistribution from './components/InterestDistribution' import NodeStatistics from './components/NodeStatistics' import RelationshipNetwork from './components/RelationshipNetwork' import MemoryInsight from './components/MemoryInsight' -import type { EndUserProfileRef, MemoryInsightRef, AboutMeRef } from './types' +import type { EndUserProfileRef, MemoryInsightRef, AboutMeRef, EndUser } from './types' import { analyticsRefresh, } from '@/api/memory' @@ -39,8 +39,10 @@ const Neo4j: FC = () => { const [selectedKey, setSelectedKey] = useState(null) /** Update displayed name */ - const handleNameUpdate = (data: { other_name?: string; id: string }) => { - setName(data.other_name && data.other_name !== '' ? data.other_name : data.id) + const handleNameUpdate = (data?: EndUser) => { + if (!data) return + let name = data.other_name && data.other_name !== '' ? data.other_name : data.id || data.end_user_id + setName(name) } /** Navigate back */ diff --git a/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx b/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx index c689bf72..4dee9d4f 100644 --- a/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx +++ b/web/src/views/UserMemoryDetail/components/EndUserProfile.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 18:33:30 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-27 11:11:09 + * @Last Modified time: 2026-04-14 16:03:41 */ /** * End User Profile Component @@ -27,11 +27,11 @@ import type { EndUser, EndUserProfileModalRef, EndUserProfileRef } from '../type * Component props */ interface EndUserProfileProps { - onDataLoaded?: (data: { other_name?: string; id: string }) => void; + onDataLoaded?: (data?: EndUser) => void; className?: string; } -const EndUserProfile = forwardRef(({ className }, ref) => { +const EndUserProfile = forwardRef(({ className, onDataLoaded }, ref) => { const { t } = useTranslation() const { id } = useParams() const endUserProfileModalRef = useRef(null) @@ -51,6 +51,7 @@ const EndUserProfile = forwardRef(({ cla const userData = res as EndUser setData(userData) setLoading(false) + onDataLoaded?.(userData as EndUser) }) .finally(() => { setLoading(false) diff --git a/web/src/views/UserMemoryDetail/types.ts b/web/src/views/UserMemoryDetail/types.ts index 9e56bb5d..d8bc6f23 100644 --- a/web/src/views/UserMemoryDetail/types.ts +++ b/web/src/views/UserMemoryDetail/types.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:57:15 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-24 17:58:54 + * @Last Modified time: 2026-04-14 16:03:16 */ /** * User Memory Detail Types @@ -172,6 +172,7 @@ export interface EndUser { other_name: string; aliases: string | null; meta_data: Record; + id?: string; end_user_info_id: string; end_user_id: string; created_at: string; From e3265e4ba37982afabc8234060348412beedc994 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 14 Apr 2026 16:14:01 +0800 Subject: [PATCH 30/44] fix(http-request,embedding,naive): tighten form-data validation, reduce truncation length to 8000, and disable chunking for Excel The form-data validation now ensures all items in the list are of type HttpFormData. Truncation length for embedding inputs is reduced from 8191 to 8000 to accommodate tokenizer differences and avoid overflow. Excel parsing now disables chunking by setting chunk_token_num to 0, aligning with intended behavior for structured file ingestion. --- api/app/core/rag/app/naive.py | 2 +- api/app/core/rag/llm/embedding_model.py | 10 +++++++--- api/app/core/workflow/nodes/http_request/config.py | 3 ++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/api/app/core/rag/app/naive.py b/api/app/core/rag/app/naive.py index 72272347..93b96843 100644 --- a/api/app/core/rag/app/naive.py +++ b/api/app/core/rag/app/naive.py @@ -675,7 +675,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, parser_config["chunk_token_num"] = 0 else: sections = [(_, "") for _ in excel_parser(binary) if _] - parser_config["chunk_token_num"] = 12800 + parser_config["chunk_token_num"] = 0 elif re.search(r"\.(txt|py|js|java|c|cpp|h|php|go|ts|sh|cs|kt|sql)$", filename, re.IGNORECASE): callback(0.1, "Start to parse.") diff --git a/api/app/core/rag/llm/embedding_model.py b/api/app/core/rag/llm/embedding_model.py index 22e35a15..59210054 100644 --- a/api/app/core/rag/llm/embedding_model.py +++ b/api/app/core/rag/llm/embedding_model.py @@ -50,7 +50,9 @@ class OpenAIEmbed(Base): def encode(self, texts: list): # OpenAI requires batch size <=16 batch_size = 16 - texts = [truncate(t, 8191) for t in texts] + # Use 8000 instead of 8191 to leave safety margin for tokenizer differences + # between cl100k_base (used by truncate) and the actual embedding model + texts = [truncate(t, 8000) for t in texts] ress = [] total_tokens = 0 for i in range(0, len(texts), batch_size): @@ -63,7 +65,7 @@ class OpenAIEmbed(Base): return np.array(ress), total_tokens def encode_queries(self, text): - res = self.client.embeddings.create(input=[truncate(text, 8191)], model=self.model_name, encoding_format="float",extra_body={"drop_params": True}) + res = self.client.embeddings.create(input=[truncate(text, 8000)], model=self.model_name, encoding_format="float",extra_body={"drop_params": True}) return np.array(res.data[0].embedding), self.total_token_count(res) @@ -79,6 +81,7 @@ class LocalAIEmbed(Base): def encode(self, texts: list): batch_size = 16 + texts = [truncate(t, 8000) for t in texts] ress = [] for i in range(0, len(texts), batch_size): res = self.client.embeddings.create(input=texts[i : i + batch_size], model=self.model_name) @@ -173,6 +176,7 @@ class XinferenceEmbed(Base): def encode(self, texts: list): batch_size = 16 + texts = [truncate(t, 8000) for t in texts] ress = [] total_tokens = 0 for i in range(0, len(texts), batch_size): @@ -188,7 +192,7 @@ class XinferenceEmbed(Base): def encode_queries(self, text): res = None try: - res = self.client.embeddings.create(input=[text], model=self.model_name) + res = self.client.embeddings.create(input=[truncate(text, 8000)], model=self.model_name) return np.array(res.data[0].embedding), self.total_token_count(res) except Exception as _e: log_exception(_e, res) diff --git a/api/app/core/workflow/nodes/http_request/config.py b/api/app/core/workflow/nodes/http_request/config.py index 3473f666..72474436 100644 --- a/api/app/core/workflow/nodes/http_request/config.py +++ b/api/app/core/workflow/nodes/http_request/config.py @@ -72,7 +72,8 @@ class HttpContentTypeConfig(BaseModel): @classmethod def validate_data(cls, v, info): content_type = info.data.get("content_type") - if content_type == HttpContentType.FROM_DATA and not isinstance(v, list): + if content_type == HttpContentType.FROM_DATA and ( + not isinstance(v, list) or not all(isinstance(item, HttpFormData) for item in v)): raise ValueError("When content_type is 'form-data', data must be a list of HttpFormData") elif content_type in [HttpContentType.JSON] and not isinstance(v, str): raise ValueError("When content_type is JSON, data must be of type str") From 4f0e5d08662c46c7e50862b2dc8b792bfd4f0d0f Mon Sep 17 00:00:00 2001 From: Ke Sun Date: Tue, 14 Apr 2026 16:24:20 +0800 Subject: [PATCH 31/44] ci(wechat-notify): add Sourcery summary extraction with Qwen fallback - Extract Sourcery AI summary from PR body as primary source - Add fallback to Qwen AI summarization when Sourcery summary unavailable - Refactor notification payload to conditionally use Sourcery or Qwen summary - Update step conditions to skip Qwen processing when Sourcery summary found - Improve code formatting and indentation consistency in Python scripts - Reduce redundant file I/O by writing directly to GITHUB_OUTPUT --- .github/workflows/release-notify-wechat.yml | 88 +++++++++++++++------ 1 file changed, 63 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release-notify-wechat.yml b/.github/workflows/release-notify-wechat.yml index d8f4e5aa..bc67518b 100644 --- a/.github/workflows/release-notify-wechat.yml +++ b/.github/workflows/release-notify-wechat.yml @@ -35,20 +35,52 @@ jobs: echo "ok=false" >> $GITHUB_OUTPUT fi - # 3️⃣ 获取 commits - - name: Get Commits + # 3️⃣ 尝试从 PR body 提取 Sourcery 摘要 + - name: Extract Sourcery Summary if: steps.check.outputs.ok == 'true' - id: commits + id: sourcery + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + python3 << 'PYEOF' + import os, re + + body = os.environ.get("PR_BODY", "") or "" + match = re.search( + r"## Summary by Sourcery\s*\n(.*?)(?=\n## |\Z)", + body, + re.DOTALL + ) + + if match: + summary = match.group(1).strip() + found = "true" + else: + summary = "" + found = "false" + + with open("sourcery_summary.txt", "w", encoding="utf-8") as f: + f.write(summary) + + with open(os.environ["GITHUB_OUTPUT"], "a") as gh: + gh.write(f"found={found}\n") + gh.write("summary< commits.txt - # 4️⃣ 阿里 AI 总结(通义千问) - - name: AI Summary (Qwen) - if: steps.check.outputs.ok == 'true' - id: ai + - name: AI Summary (Qwen Fallback) + if: steps.check.outputs.ok == 'true' && steps.sourcery.outputs.found == 'false' + id: qwen env: DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} run: | @@ -56,34 +88,30 @@ jobs: import json, os, urllib.request with open("commits.txt", "r") as f: - commits = f.read().strip() + commits = f.read().strip() prompt = "请用中文总结以下代码提交,输出3-5条要点,面向测试人员。直接输出编号列表,不要输出标题或前言:\n" + commits payload = {"model": "qwen-plus", "input": {"prompt": prompt}} data = json.dumps(payload, ensure_ascii=False).encode("utf-8") req = urllib.request.Request( - "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation", - data=data, - headers={ - "Authorization": "Bearer " + os.environ["DASHSCOPE_API_KEY"], - "Content-Type": "application/json" - } + "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation", + data=data, + headers={ + "Authorization": "Bearer " + os.environ["DASHSCOPE_API_KEY"], + "Content-Type": "application/json" + } ) resp = urllib.request.urlopen(req) result = json.loads(resp.read().decode()) summary = result.get("output", {}).get("text", "AI 摘要生成失败") - print(summary) - with open("ai_summary.txt", "w", encoding="utf-8") as f: - f.write(summary) + with open(os.environ["GITHUB_OUTPUT"], "a") as gh: + gh.write("summary<> $GITHUB_OUTPUT - echo "$SUMMARY" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - # 5️⃣ 企业微信通知(Markdown) - name: Notify WeChat if: steps.check.outputs.ok == 'true' @@ -93,17 +121,27 @@ jobs: AUTHOR: ${{ github.event.pull_request.user.login }} PR_TITLE: ${{ github.event.pull_request.title }} PR_URL: ${{ github.event.pull_request.html_url }} - AI_SUMMARY: ${{ steps.ai.outputs.summary }} + SOURCERY_FOUND: ${{ steps.sourcery.outputs.found }} + SOURCERY_SUMMARY: ${{ steps.sourcery.outputs.summary }} + QWEN_SUMMARY: ${{ steps.qwen.outputs.summary }} run: | python3 << 'PYEOF' import json, os, urllib.request + + if os.environ.get("SOURCERY_FOUND") == "true": + label = "Summary by Sourcery" + summary = os.environ.get("SOURCERY_SUMMARY", "") + else: + label = "AI变更摘要" + summary = os.environ.get("QWEN_SUMMARY", "AI 摘要生成失败") + content = ( "## 🚀 Release 发布通知\n" "> 📦 **分支**: " + os.environ["BRANCH"] + "\n" "> 👤 **提交人**: " + os.environ["AUTHOR"] + "\n" "> 📝 **标题**: " + os.environ["PR_TITLE"] + "\n\n" - "### 🧠 AI变更摘要\n" + - os.environ["AI_SUMMARY"] + "\n\n" + "### 🧠 " + label + "\n" + + summary + "\n\n" "---\n" "🔗 [查看PR详情](" + os.environ["PR_URL"] + ")" ) From 3c2a78a449c77892c6cda50752ce758c6431474d Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 16:35:19 +0800 Subject: [PATCH 32/44] fix(web): Hide error message when workflow node error message equals empty string --- .../views/Workflow/components/Chat/Runtime.tsx | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/web/src/views/Workflow/components/Chat/Runtime.tsx b/web/src/views/Workflow/components/Chat/Runtime.tsx index 68bdc452..4a5be793 100644 --- a/web/src/views/Workflow/components/Chat/Runtime.tsx +++ b/web/src/views/Workflow/components/Chat/Runtime.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-24 17:57:08 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 14:05:50 + * @Last Modified time: 2026-04-14 16:33:33 */ /* * Runtime Component @@ -161,8 +161,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({ children: ( {/* Display error message for failed nodes */} - - {item.error && + {vo.content?.error && vo.content?.error !== '' && @@ -219,11 +218,11 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
} - /** Copy value to clipboard and show success message */ - const handleCopy = (value: string) => { - copy(value) - message.success(t('common.copySuccess')) - } + /** Copy value to clipboard and show success message */ + const handleCopy = (value: string) => { + copy(value) + message.success(t('common.copySuccess')) + } return (
= ({
) :
- {item.error && + {item.error && item.error !== '' && } {renderChild(item.subContent)} From 75e95bab01239a465cb4cb123e07d9debda73b68 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 14 Apr 2026 17:10:52 +0800 Subject: [PATCH 33/44] refactor(rag): simplify Excel parsing logic and remove redundant chunk_token_num assignment --- api/app/core/rag/app/naive.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/app/core/rag/app/naive.py b/api/app/core/rag/app/naive.py index 93b96843..312216dd 100644 --- a/api/app/core/rag/app/naive.py +++ b/api/app/core/rag/app/naive.py @@ -672,10 +672,15 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, excel_parser = ExcelParser() if parser_config.get("html4excel") and parser_config.get("html4excel").lower() == "true": sections = [(_, "") for _ in excel_parser.html(binary, 12) if _] - parser_config["chunk_token_num"] = 0 else: sections = [(_, "") for _ in excel_parser(binary) if _] - parser_config["chunk_token_num"] = 0 + callback(0.8, "Finish parsing.") + # Excel 每行直接作为一个 chunk,不经过 naive_merge 避免被 delimiter 拆分 + chunks = [s for s, _ in sections] + res.extend(tokenize_chunks(chunks, doc, is_english, None)) + res.extend(embed_res) + res.extend(url_res) + return res elif re.search(r"\.(txt|py|js|java|c|cpp|h|php|go|ts|sh|cs|kt|sql)$", filename, re.IGNORECASE): callback(0.1, "Start to parse.") From 811193dd756295c9ac89d3d54c3b03dac131c840 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Tue, 14 Apr 2026 17:28:24 +0800 Subject: [PATCH 34/44] fix(memory): make PgSQL the single source of truth for user entity aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip alias merging for user entities during dedup (_merge_attribute and _merge_entities_with_aliases) to prevent dirty data from overwriting PgSQL authoritative aliases - Add PgSQL→Neo4j alias sync after Neo4j write in write_tools to ensure Neo4j user entities always reflect the PgSQL source - Remove deduped_aliases (Neo4j history) from alias sync in extraction_orchestrator, only append newly extracted aliases to PgSQL - Guard Neo4j MERGE cypher to preserve existing aliases for user entities (name IN ['用户','我','User','I']) - Fix emotion_analytics_service query to use ExtractedEntity label and entity_type property --- .../core/memory/agent/utils/write_tools.py | 29 ++++- .../deduplication/deduped_and_disamb.py | 109 +++++------------- .../extraction_orchestrator.py | 44 ++----- api/app/repositories/neo4j/cypher_queries.py | 2 + api/app/services/emotion_analytics_service.py | 4 +- 5 files changed, 71 insertions(+), 117 deletions(-) diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index bae4643e..5e51beba 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -191,15 +191,34 @@ async def write( if success: logger.info("Successfully saved all data to Neo4j") - # 使用 Celery 异步任务触发聚类(不阻塞主流程) if all_entity_nodes: + end_user_id = all_entity_nodes[0].end_user_id + + # Neo4j 写入完成后,用 PgSQL 权威 aliases 覆盖 Neo4j 用户实体 + try: + from app.repositories.end_user_info_repository import EndUserInfoRepository + if end_user_id: + with get_db_context() as db_session: + info = EndUserInfoRepository(db_session).get_by_end_user_id(uuid.UUID(end_user_id)) + pg_aliases = info.aliases if info and info.aliases else [] + if pg_aliases: + await neo4j_connector.execute_query( + """ + MATCH (e:ExtractedEntity) + WHERE e.end_user_id = $end_user_id AND e.name IN ['用户', '我', 'User', 'I'] + SET e.aliases = $aliases + """, + end_user_id=end_user_id, aliases=pg_aliases, + ) + logger.info(f"[AliasSync] Neo4j 用户实体 aliases 已用 PgSQL 权威源覆盖: {pg_aliases}") + except Exception as sync_err: + logger.warning(f"[AliasSync] PgSQL→Neo4j aliases 同步失败(不影响主流程): {sync_err}") + + # 使用 Celery 异步任务触发聚类(不阻塞主流程) try: from app.tasks import run_incremental_clustering - end_user_id = all_entity_nodes[0].end_user_id new_entity_ids = [e.id for e in all_entity_nodes] - - # 异步提交 Celery 任务 task = run_incremental_clustering.apply_async( kwargs={ "end_user_id": end_user_id, @@ -207,7 +226,6 @@ async def write( "llm_model_id": str(memory_config.llm_model_id) if memory_config.llm_model_id else None, "embedding_model_id": str(memory_config.embedding_model_id) if memory_config.embedding_model_id else None, }, - # 设置任务优先级(低优先级,不影响主业务) priority=3, ) logger.info( @@ -215,7 +233,6 @@ async def write( f"task_id={task.id}, end_user_id={end_user_id}, entity_count={len(new_entity_ids)}" ) except Exception as e: - # 聚类任务提交失败不影响主流程 logger.error(f"[Clustering] 提交聚类任务失败(不影响主流程): {e}", exc_info=True) break diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py index 7e0976fe..8f659a27 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py @@ -82,51 +82,35 @@ def _merge_attribute(canonical: ExtractedEntityNode, ent: ExtractedEntityNode): canonical.connect_strength = next(iter(pair)) # 别名合并(去重保序,使用标准化工具) + # 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,去重合并时不修改 try: canonical_name = (getattr(canonical, "name", "") or "").strip() - incoming_name = (getattr(ent, "name", "") or "").strip() - - # 收集所有需要合并的别名 - all_aliases = [] - - # 1. 添加canonical现有的别名 - existing = getattr(canonical, "aliases", []) or [] - all_aliases.extend(existing) - - # 2. 添加incoming实体的名称(如果不同于canonical的名称) - if incoming_name and incoming_name != canonical_name: - all_aliases.append(incoming_name) - - # 3. 添加incoming实体的所有别名 - incoming = getattr(ent, "aliases", []) or [] - all_aliases.extend(incoming) - - # 4. 标准化并去重(优先使用alias_utils工具函数) - try: - from app.core.memory.utils.alias_utils import normalize_aliases - canonical.aliases = normalize_aliases(canonical_name, all_aliases) - except Exception: - # 如果导入失败,使用增强的去重逻辑 - seen_normalized = set() - unique_aliases = [] + if canonical_name.lower() not in _USER_PLACEHOLDER_NAMES: + incoming_name = (getattr(ent, "name", "") or "").strip() - for alias in all_aliases: - if not alias: - continue - - alias_stripped = str(alias).strip() - if not alias_stripped or alias_stripped == canonical_name: - continue - - # 标准化:转小写用于去重判断 - alias_normalized = alias_stripped.lower() - - if alias_normalized not in seen_normalized: - seen_normalized.add(alias_normalized) - unique_aliases.append(alias_stripped) + # 收集所有需要合并的别名 + all_aliases = list(getattr(canonical, "aliases", []) or []) + if incoming_name and incoming_name != canonical_name: + all_aliases.append(incoming_name) + all_aliases.extend(getattr(ent, "aliases", []) or []) - # 排序并赋值 - canonical.aliases = sorted(unique_aliases) + try: + from app.core.memory.utils.alias_utils import normalize_aliases + canonical.aliases = normalize_aliases(canonical_name, all_aliases) + except Exception: + seen_normalized = set() + unique_aliases = [] + for alias in all_aliases: + if not alias: + continue + alias_stripped = str(alias).strip() + if not alias_stripped or alias_stripped == canonical_name: + continue + alias_normalized = alias_stripped.lower() + if alias_normalized not in seen_normalized: + seen_normalized.add(alias_normalized) + unique_aliases.append(alias_stripped) + canonical.aliases = sorted(unique_aliases) except Exception: pass @@ -733,66 +717,37 @@ def fuzzy_match( def _merge_entities_with_aliases(canonical: ExtractedEntityNode, losing: ExtractedEntityNode): - """ 模糊匹配中的实体合并。 + """模糊匹配中的实体合并(别名部分)。 - 合并策略: - 1. 保留canonical的主名称不变 - 2. 将losing的主名称添加为alias(如果不同) - 3. 合并两个实体的所有aliases - 4. 自动去重(case-insensitive)并排序 - - Args: - canonical: 规范实体(保留) - losing: 被合并实体(删除) - - Note: - 使用alias_utils.normalize_aliases进行标准化去重 + 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,跳过合并。 """ - # 获取规范实体的名称 canonical_name = (getattr(canonical, "name", "") or "").strip() + if canonical_name.lower() in _USER_PLACEHOLDER_NAMES: + return + losing_name = (getattr(losing, "name", "") or "").strip() - # 收集所有需要合并的别名 - all_aliases = [] - - # 1. 添加canonical现有的别名 - current_aliases = getattr(canonical, "aliases", []) or [] - all_aliases.extend(current_aliases) - - # 2. 添加losing实体的名称(如果不同于canonical的名称) + all_aliases = list(getattr(canonical, "aliases", []) or []) if losing_name and losing_name != canonical_name: all_aliases.append(losing_name) + all_aliases.extend(getattr(losing, "aliases", []) or []) - # 3. 添加losing实体的所有别名 - losing_aliases = getattr(losing, "aliases", []) or [] - all_aliases.extend(losing_aliases) - - # 4. 标准化并去重(使用标准化后的字符串进行去重) try: from app.core.memory.utils.alias_utils import normalize_aliases canonical.aliases = normalize_aliases(canonical_name, all_aliases) except Exception: - # 如果导入失败,使用增强的去重逻辑 - # 使用标准化后的字符串作为key进行去重 seen_normalized = set() unique_aliases = [] - for alias in all_aliases: if not alias: continue - alias_stripped = str(alias).strip() if not alias_stripped or alias_stripped == canonical_name: continue - - # 标准化:转小写用于去重判断 alias_normalized = alias_stripped.lower() - if alias_normalized not in seen_normalized: seen_normalized.add(alias_normalized) unique_aliases.append(alias_stripped) - - # 排序并赋值 canonical.aliases = sorted(unique_aliases) # ========== 主循环:遍历所有实体对进行模糊匹配 ========== diff --git a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py index 5636dcb5..75fc87d2 100644 --- a/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py +++ b/api/app/core/memory/storage_services/extraction_engine/extraction_orchestrator.py @@ -1391,18 +1391,18 @@ class ExtractionOrchestrator: """ 将本轮提取的用户别名同步到 end_user 和 end_user_info 表。 - 注意:此方法在 Neo4j 写入之前调用,因此不能依赖 Neo4j 作为别名的权威数据源。 - 改为直接使用内存中去重后的 entity_nodes 的 aliases,与 PgSQL 已有的 aliases 合并。 + PgSQL end_user_info.aliases 是用户别名的唯一权威源。 + 此方法仅将本轮 LLM 从对话中新提取的别名增量追加到 PgSQL, + 不再从 Neo4j 二层去重合并历史别名,避免脏数据反向污染 PgSQL。 策略: - 1. 从内存中的 entity_nodes 提取本轮用户别名(current_aliases) - 2. 从去重后的 entity_nodes 中提取完整别名(含 Neo4j 二层去重合并的历史别名) - 3. 从 PgSQL end_user_info 读取已有的 aliases(db_aliases) - 4. 合并 db_aliases + deduped_aliases + current_aliases,去重保序 - 5. 写回 PgSQL + 1. 从本轮对话原始发言中提取用户别名(current_aliases) + 2. 从 PgSQL end_user_info 读取已有的 aliases(db_aliases) + 3. 合并 db_aliases + current_aliases,去重保序 + 4. 写回 PgSQL Args: - entity_nodes: 去重后的实体节点列表(内存中,含二层去重合并结果) + entity_nodes: 去重后的实体节点列表(内存中) dialog_data_list: 对话数据列表 """ try: @@ -1418,11 +1418,6 @@ class ExtractionOrchestrator: # 1. 提取本轮对话的用户别名(保持 LLM 提取的原始顺序,不排序) current_aliases = self._extract_current_aliases(entity_nodes, dialog_data_list) - # 1.5 从去重后的 entity_nodes 中提取完整别名 - # 二层去重会将 Neo4j 中已有的历史别名合并到 entity_nodes 中, - # 这里提取出来确保 PgSQL 与 Neo4j 的别名保持同步 - deduped_aliases = self._extract_deduped_entity_aliases(entity_nodes) - # 1.6 从 Neo4j 查询已有的 AI 助手别名,作为额外的排除源 # (防止 LLM 未提取出 AI 助手实体时,AI 别名泄漏到用户别名中) neo4j_assistant_aliases = await self._fetch_neo4j_assistant_aliases(end_user_id) @@ -1434,19 +1429,12 @@ class ExtractionOrchestrator: ] if len(current_aliases) < before_count: logger.info(f"通过 Neo4j AI 助手别名排除了 {before_count - len(current_aliases)} 个误归属别名") - # 同样过滤 deduped_aliases - deduped_aliases = [ - a for a in deduped_aliases - if a.strip().lower() not in neo4j_assistant_aliases - ] - if not current_aliases and not deduped_aliases: + if not current_aliases: logger.debug(f"本轮未提取到用户别名,跳过同步: end_user_id={end_user_id}") return logger.info(f"本轮对话提取的 aliases: {current_aliases}") - if deduped_aliases: - logger.info(f"去重后实体的完整 aliases(含历史): {deduped_aliases}") # 2. 同步到数据库 end_user_uuid = uuid.UUID(end_user_id) @@ -1457,21 +1445,15 @@ class ExtractionOrchestrator: logger.warning(f"未找到 end_user_id={end_user_id} 的用户记录") return - # 3. 从 PgSQL 读取已有 aliases 并与本轮合并 + # 3. 从 PgSQL 读取已有 aliases 并与本轮新增合并 info = EndUserInfoRepository(db).get_by_end_user_id(end_user_uuid) db_aliases = (info.aliases if info and info.aliases else []) # 过滤掉占位名称 db_aliases = [a for a in db_aliases if a.strip().lower() not in self.USER_PLACEHOLDER_NAMES] - # 合并:已有 + 去重后完整别名 + 本轮新增,去重保序 + # 合并:PgSQL 已有 + 本轮新增,去重保序(不再合并 Neo4j 历史别名) merged_aliases = list(db_aliases) seen_lower = {a.strip().lower() for a in merged_aliases} - # 先合并去重后实体的完整别名(含 Neo4j 历史别名) - for alias in deduped_aliases: - if alias.strip().lower() not in seen_lower: - merged_aliases.append(alias) - seen_lower.add(alias.strip().lower()) - # 再合并本轮新提取的别名 for alias in current_aliases: if alias.strip().lower() not in seen_lower: merged_aliases.append(alias) @@ -1505,9 +1487,7 @@ class ExtractionOrchestrator: info.aliases = merged_aliases logger.info(f"同步合并后 aliases 到 end_user_info: {merged_aliases}") else: - first_alias = current_aliases[0].strip() if current_aliases else ( - deduped_aliases[0].strip() if deduped_aliases else "" - ) + first_alias = current_aliases[0].strip() if current_aliases else "" # 确保 first_alias 不是占位名称 if first_alias and first_alias.lower() not in self.USER_PLACEHOLDER_NAMES: db.add(EndUserInfo( diff --git a/api/app/repositories/neo4j/cypher_queries.py b/api/app/repositories/neo4j/cypher_queries.py index 4b5273ac..daf04bcb 100644 --- a/api/app/repositories/neo4j/cypher_queries.py +++ b/api/app/repositories/neo4j/cypher_queries.py @@ -93,6 +93,8 @@ SET e.name = CASE WHEN entity.name IS NOT NULL AND entity.name <> '' THEN entity END, e.statement_id = CASE WHEN entity.statement_id IS NOT NULL AND entity.statement_id <> '' THEN entity.statement_id ELSE e.statement_id END, e.aliases = CASE + // 用户实体的 aliases 由 PgSQL end_user_info 作为唯一权威源,知识抽取完全不写入 + WHEN entity.name IN ['用户', '我', 'User', 'I'] THEN e.aliases WHEN entity.aliases IS NOT NULL AND size(entity.aliases) > 0 THEN CASE WHEN e.aliases IS NULL THEN entity.aliases diff --git a/api/app/services/emotion_analytics_service.py b/api/app/services/emotion_analytics_service.py index c226348e..9a215cd6 100644 --- a/api/app/services/emotion_analytics_service.py +++ b/api/app/services/emotion_analytics_service.py @@ -679,9 +679,9 @@ class EmotionAnalyticsService: # 查询用户的实体和标签 query = """ - MATCH (e:Entity) + MATCH (e:ExtractedEntity) WHERE e.end_user_id = $end_user_id - RETURN e.name as name, e.type as type + RETURN e.name as name, e.entity_type as type ORDER BY e.created_at DESC LIMIT 20 """ From 47c242e5135880c3a0e54ed2af2455b42f6323ff Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 17:43:58 +0800 Subject: [PATCH 35/44] fix(web): Compatible with Windows whitespace --- .../components/Properties/HttpRequest/index.tsx | 7 +++---- web/src/views/Workflow/hooks/useWorkflowGraph.ts | 13 +++++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx index b032016b..4cf7c150 100644 --- a/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx +++ b/web/src/views/Workflow/components/Properties/HttpRequest/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-09 18:35:43 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-02 17:17:06 + * @Last Modified time: 2026-04-14 17:36:53 */ import { type FC, useMemo, useRef, useState } from "react"; import { useTranslation } from 'react-i18next' @@ -35,9 +35,8 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an form.setFieldsValue({ auth }) } - const handleChangeBodyContentType = (e: any) => { - const value = e.target.value || e.target.value - form.setFieldValue(['body', 'data'], ['form-data', 'x-www-form-urlencoded'].includes(value) ? [{}] : undefined) + const handleChangeBodyContentType = () => { + form.setFieldValue(['body', 'data'], undefined) } // Handle error handling method change and update node ports accordingly diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 3280562a..535a4eb0 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-14 15:05:33 + * @Last Modified time: 2026-04-14 17:43:14 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -1200,9 +1200,6 @@ export const useWorkflowGraph = ({ }) || []; const edges = graphRef.current?.getEdges() || [] - - console.log('config', config) - const params = { ...config, features: featuresRef.current, @@ -1262,6 +1259,14 @@ export const useWorkflowGraph = ({ itemConfig[key][vo.key] = vo.value }) } + } else if (data.type === 'http-request' && key === 'body' && data.config[key] && 'defaultValue' in data.config[key]) { + const value = data.config[key].defaultValue + itemConfig[key] = value + if (value.content_type === 'json' && value.data && value.data !== '') { + itemConfig[key].data = value.data.replace(/\u00a0/g, ' ') + } else { + itemConfig[key].data = value.data + } } else if (data.config[key] && 'defaultValue' in data.config[key] && key !== 'knowledge_retrieval') { itemConfig[key] = data.config[key].defaultValue } else if (key === 'knowledge_retrieval' && data.config[key] && 'defaultValue' in data.config[key]) { From 441b21774d8cdfb4aaf19033593a777ee257ff00 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 14 Apr 2026 17:56:30 +0800 Subject: [PATCH 36/44] fix(rag): replace semicolon separators with newlines in Excel parser output --- api/app/core/rag/deepdoc/parser/excel_parser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/app/core/rag/deepdoc/parser/excel_parser.py b/api/app/core/rag/deepdoc/parser/excel_parser.py index d66a21a8..c3999be9 100644 --- a/api/app/core/rag/deepdoc/parser/excel_parser.py +++ b/api/app/core/rag/deepdoc/parser/excel_parser.py @@ -232,14 +232,14 @@ class RAGExcelParser: t = str(ti[i].value) if i < len(ti) else "" t += (":" if t else "") + str(c.value) fields.append(t) - line = "; ".join(fields) + line = "\n".join(fields) if sheetname.lower().find("sheet") < 0: - line += " ——" + sheetname + line += "\n——" + sheetname res.append(line) else: # 只有表头的情况 if header_fields: - line = "; ".join(header_fields) + line = "\n".join(header_fields) if sheetname.lower().find("sheet") < 0: line += " ——" + sheetname res.append(line) From 49e0801d1582c08bc7d746edd20d8314abc8bf29 Mon Sep 17 00:00:00 2001 From: lanceyq <1982376970@qq.com> Date: Tue, 14 Apr 2026 18:06:56 +0800 Subject: [PATCH 37/44] refactor(memory): unify user placeholder names and harden alias sync logic - Replace hardcoded user placeholder name lists in write_tools and user_memory_service with shared _USER_PLACEHOLDER_NAMES constant - Filter user placeholder names during alias merging in _merge_attribute to prevent cross-role alias contamination on non-user entities - Use toLower() in Cypher query for case-insensitive name matching - Change PgSQL->Neo4j alias sync condition from 'if pg_aliases' to 'if info is not None' so empty aliases correctly clear stale data --- api/app/core/memory/agent/utils/write_tools.py | 8 ++++++-- .../deduplication/deduped_and_disamb.py | 9 ++++++--- api/app/services/user_memory_service.py | 3 ++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/api/app/core/memory/agent/utils/write_tools.py b/api/app/core/memory/agent/utils/write_tools.py index 5e51beba..3b0ea1ee 100644 --- a/api/app/core/memory/agent/utils/write_tools.py +++ b/api/app/core/memory/agent/utils/write_tools.py @@ -14,6 +14,7 @@ from dotenv import load_dotenv from app.core.logging_config import get_agent_logger from app.core.memory.agent.utils.get_dialogs import get_chunked_dialogs +from app.core.memory.storage_services.extraction_engine.deduplication.deduped_and_disamb import _USER_PLACEHOLDER_NAMES from app.core.memory.storage_services.extraction_engine.extraction_orchestrator import ExtractionOrchestrator from app.core.memory.storage_services.extraction_engine.knowledge_extraction.memory_summary import \ memory_summary_generation @@ -201,14 +202,17 @@ async def write( with get_db_context() as db_session: info = EndUserInfoRepository(db_session).get_by_end_user_id(uuid.UUID(end_user_id)) pg_aliases = info.aliases if info and info.aliases else [] - if pg_aliases: + if info is not None: + # 将 Python 侧占位名集合作为参数传入,避免 Cypher 硬编码 + placeholder_names = list(_USER_PLACEHOLDER_NAMES) await neo4j_connector.execute_query( """ MATCH (e:ExtractedEntity) - WHERE e.end_user_id = $end_user_id AND e.name IN ['用户', '我', 'User', 'I'] + WHERE e.end_user_id = $end_user_id AND toLower(e.name) IN $placeholder_names SET e.aliases = $aliases """, end_user_id=end_user_id, aliases=pg_aliases, + placeholder_names=placeholder_names, ) logger.info(f"[AliasSync] Neo4j 用户实体 aliases 已用 PgSQL 权威源覆盖: {pg_aliases}") except Exception as sync_err: diff --git a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py index 8f659a27..715f190c 100644 --- a/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py +++ b/api/app/core/memory/storage_services/extraction_engine/deduplication/deduped_and_disamb.py @@ -88,11 +88,14 @@ def _merge_attribute(canonical: ExtractedEntityNode, ent: ExtractedEntityNode): if canonical_name.lower() not in _USER_PLACEHOLDER_NAMES: incoming_name = (getattr(ent, "name", "") or "").strip() - # 收集所有需要合并的别名 + # 收集所有需要合并的别名,过滤掉用户占位名避免污染非用户实体 all_aliases = list(getattr(canonical, "aliases", []) or []) - if incoming_name and incoming_name != canonical_name: + if incoming_name and incoming_name != canonical_name and incoming_name.lower() not in _USER_PLACEHOLDER_NAMES: all_aliases.append(incoming_name) - all_aliases.extend(getattr(ent, "aliases", []) or []) + all_aliases.extend( + a for a in (getattr(ent, "aliases", []) or []) + if a and a.strip().lower() not in _USER_PLACEHOLDER_NAMES + ) try: from app.core.memory.utils.alias_utils import normalize_aliases diff --git a/api/app/services/user_memory_service.py b/api/app/services/user_memory_service.py index cc18447e..9389ecfa 100644 --- a/api/app/services/user_memory_service.py +++ b/api/app/services/user_memory_service.py @@ -14,6 +14,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.core.logging_config import get_logger +from app.core.memory.storage_services.extraction_engine.deduplication.deduped_and_disamb import _USER_PLACEHOLDER_NAMES from app.core.memory.utils.llm.llm_utils import MemoryClientFactory from app.db import get_db_context from app.repositories.conversation_repository import ConversationRepository @@ -473,7 +474,7 @@ class UserMemoryService: allowed_fields = {'other_name', 'aliases', 'meta_data'} # 用户占位名称黑名单,不允许作为 other_name 或出现在 aliases 中 - _user_placeholder_names = {'用户', '我', 'User', 'I'} + _user_placeholder_names = _USER_PLACEHOLDER_NAMES # 过滤 other_name:不允许设置为占位名称 if 'other_name' in update_data and update_data['other_name'] and update_data['other_name'].strip() in _user_placeholder_names: From 017efdc3202b424dc48d483ecba3498cd50810ff Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Wed, 15 Apr 2026 11:03:44 +0800 Subject: [PATCH 38/44] fix(prompt-optimizer): support list content type in prompt optimizer --- api/app/services/prompt_optimizer_service.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/app/services/prompt_optimizer_service.py b/api/app/services/prompt_optimizer_service.py index fde8c4f9..b1de84d2 100644 --- a/api/app/services/prompt_optimizer_service.py +++ b/api/app/services/prompt_optimizer_service.py @@ -227,7 +227,14 @@ class PromptOptimizerService: content = getattr(chunk, "content", chunk) if not content: continue - buffer += content + if isinstance(content, str): + buffer += content + elif isinstance(content, list): + for _ in content: + buffer += _["text"] + else: + logger.error(f"Unsupported content type - {content}") + raise Exception("Unsupported content type") cache = buffer[:-20] # 尝试找到 "prompt": " 开始位置 From daaee63bd590ff069ed4d50bce45e38e976426cd Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 15 Apr 2026 12:15:16 +0800 Subject: [PATCH 39/44] refactor(custom-tools): coerce query and request body parameters to schema types --- api/app/core/tools/custom/base.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/api/app/core/tools/custom/base.py b/api/app/core/tools/custom/base.py index c03fe206..93ad03a7 100644 --- a/api/app/core/tools/custom/base.py +++ b/api/app/core/tools/custom/base.py @@ -221,7 +221,7 @@ class CustomTool(BaseTool): query_params = {} for param_name, param_info in operation.get("parameters", {}).items(): if param_info.get("in") == "query" and param_name in params: - query_params[param_name] = params[param_name] + query_params[param_name] = self._coerce_param(params[param_name], param_info.get("type", "string")) if query_params: from urllib.parse import urlencode @@ -251,21 +251,34 @@ class CustomTool(BaseTool): return headers @staticmethod - def _build_request_data(operation: Dict[str, Any], params: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def _coerce_param(value: Any, schema_type: str) -> Any: + """根据 schema 类型转换参数值""" + if value is None: + return value + try: + if schema_type == "integer": + return int(value) + elif schema_type == "number": + return float(value) + elif schema_type == "boolean": + if isinstance(value, str): + return value.lower() not in ("false", "0", "") + return bool(value) + except (ValueError, TypeError): + pass + return value + + def _build_request_data(self, operation: Dict[str, Any], params: Dict[str, Any]) -> Optional[Dict[str, Any]]: """构建请求数据""" if operation["method"] in ["POST", "PUT", "PATCH"]: request_body = operation.get("request_body") if request_body: - # 构建请求体数据 data = {} properties = request_body.get("properties", {}) - for prop_name, prop_schema in properties.items(): if prop_name in params: - data[prop_name] = params[prop_name] - + data[prop_name] = self._coerce_param(params[prop_name], prop_schema.get("type", "string")) return data if data else None - return None async def _send_http_request( From 737858731b132d3dd92c418560d84cac098d0bd5 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 15 Apr 2026 12:24:11 +0800 Subject: [PATCH 40/44] fix(core): conditionally apply thinking parameters based on model support --- api/app/core/models/base.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index 1de4b120..89a7dcee 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -112,22 +112,23 @@ class RedBearModelFactory: params["stream_usage"] = True # 深度思考模式 is_streaming = bool(config.extra_params.get("streaming")) - if is_streaming and not config.is_omni: - if provider == ModelProvider.VOLCANO: - # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 - thinking_config: Dict[str, Any] = { - "type": "enabled" if config.deep_thinking else "disabled" - } - if config.deep_thinking and config.thinking_budget_tokens: - thinking_config["budget_tokens"] = config.thinking_budget_tokens - params["extra_body"] = {"thinking": thinking_config} - else: - # 始终显式传递 enable_thinking,不支持该参数的模型(如 DeepSeek-R1)会直接忽略 - model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) - model_kwargs["enable_thinking"] = config.deep_thinking - if config.deep_thinking and config.thinking_budget_tokens: - model_kwargs["thinking_budget"] = config.thinking_budget_tokens - params["model_kwargs"] = model_kwargs + if config.support_thinking: + if is_streaming and not config.is_omni: + if provider == ModelProvider.VOLCANO: + # 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数 + thinking_config: Dict[str, Any] = { + "type": "enabled" if config.deep_thinking else "disabled" + } + if config.deep_thinking and config.thinking_budget_tokens: + thinking_config["budget_tokens"] = config.thinking_budget_tokens + params["extra_body"] = {"thinking": thinking_config} + else: + # 始终显式传递 enable_thinking,不支持该参数的模型(如 DeepSeek-R1)会直接忽略 + model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {}) + model_kwargs["enable_thinking"] = config.deep_thinking + if config.deep_thinking and config.thinking_budget_tokens: + model_kwargs["thinking_budget"] = config.thinking_budget_tokens + params["model_kwargs"] = model_kwargs return params elif provider == ModelProvider.DASHSCOPE: params = { From 3018d186f73c26eda25b1acf6ba95d283c933cfc Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Wed, 15 Apr 2026 13:56:08 +0800 Subject: [PATCH 41/44] fix(custom-tools): remove parameter coercion in custom tool base class --- api/app/core/tools/custom/base.py | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/api/app/core/tools/custom/base.py b/api/app/core/tools/custom/base.py index 93ad03a7..c03fe206 100644 --- a/api/app/core/tools/custom/base.py +++ b/api/app/core/tools/custom/base.py @@ -221,7 +221,7 @@ class CustomTool(BaseTool): query_params = {} for param_name, param_info in operation.get("parameters", {}).items(): if param_info.get("in") == "query" and param_name in params: - query_params[param_name] = self._coerce_param(params[param_name], param_info.get("type", "string")) + query_params[param_name] = params[param_name] if query_params: from urllib.parse import urlencode @@ -251,34 +251,21 @@ class CustomTool(BaseTool): return headers @staticmethod - def _coerce_param(value: Any, schema_type: str) -> Any: - """根据 schema 类型转换参数值""" - if value is None: - return value - try: - if schema_type == "integer": - return int(value) - elif schema_type == "number": - return float(value) - elif schema_type == "boolean": - if isinstance(value, str): - return value.lower() not in ("false", "0", "") - return bool(value) - except (ValueError, TypeError): - pass - return value - - def _build_request_data(self, operation: Dict[str, Any], params: Dict[str, Any]) -> Optional[Dict[str, Any]]: + def _build_request_data(operation: Dict[str, Any], params: Dict[str, Any]) -> Optional[Dict[str, Any]]: """构建请求数据""" if operation["method"] in ["POST", "PUT", "PATCH"]: request_body = operation.get("request_body") if request_body: + # 构建请求体数据 data = {} properties = request_body.get("properties", {}) + for prop_name, prop_schema in properties.items(): if prop_name in params: - data[prop_name] = self._coerce_param(params[prop_name], prop_schema.get("type", "string")) + data[prop_name] = params[prop_name] + return data if data else None + return None async def _send_http_request( From ed765b7c262712ba19a6a303c9838f72d857ff17 Mon Sep 17 00:00:00 2001 From: Eternity <1533512157@qq.com> Date: Wed, 15 Apr 2026 13:19:02 +0800 Subject: [PATCH 42/44] fix(prompt-optimizer): handle escaped quotes in JSON parsing --- api/app/controllers/prompt_optimizer_controller.py | 5 +++-- api/app/services/prompt_optimizer_service.py | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/api/app/controllers/prompt_optimizer_controller.py b/api/app/controllers/prompt_optimizer_controller.py index 80f14cd3..b9fc697c 100644 --- a/api/app/controllers/prompt_optimizer_controller.py +++ b/api/app/controllers/prompt_optimizer_controller.py @@ -124,10 +124,11 @@ async def get_prompt_opt( skill=data.skill ): # chunk 是 prompt 的增量内容 - yield f"event:message\ndata: {json.dumps(chunk)}\n\n" + yield f"event:message\ndata: {json.dumps(chunk, ensure_ascii=False)}\n\n" except Exception as e: yield f"event:error\ndata: {json.dumps( - {"error": str(e)} + {"error": str(e)}, + ensure_ascii=False )}\n\n" yield "event:end\ndata: {}\n\n" diff --git a/api/app/services/prompt_optimizer_service.py b/api/app/services/prompt_optimizer_service.py index b1de84d2..30901111 100644 --- a/api/app/services/prompt_optimizer_service.py +++ b/api/app/services/prompt_optimizer_service.py @@ -236,8 +236,11 @@ class PromptOptimizerService: logger.error(f"Unsupported content type - {content}") raise Exception("Unsupported content type") cache = buffer[:-20] + last_idx = 19 + while cache and cache[-1] == '\\' and last_idx > 0: + cache = buffer[:-last_idx] + last_idx -= 1 - # 尝试找到 "prompt": " 开始位置 if prompt_finished: continue @@ -279,7 +282,7 @@ class PromptOptimizerService: def parser_prompt_variables(prompt: str): try: pattern = r'\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}' - matches = re.findall(pattern, prompt) + matches = re.findall(pattern, str(prompt)) variables = list(set(matches)) return variables except Exception as e: From 71e5b6586a1186b88d81e90ba1d02bfe41db6b98 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 15 Apr 2026 14:38:40 +0800 Subject: [PATCH 43/44] fix(web): prompt editor --- web/src/components/Chat/PromptChatPanel.tsx | 39 ++++++++ .../components/AiPromptModal.tsx | 56 +++++------ .../components/Editor/index.tsx | 93 +++++++++++++------ web/src/views/Prompt/index.tsx | 59 ++++++------ 4 files changed, 165 insertions(+), 82 deletions(-) create mode 100644 web/src/components/Chat/PromptChatPanel.tsx diff --git a/web/src/components/Chat/PromptChatPanel.tsx b/web/src/components/Chat/PromptChatPanel.tsx new file mode 100644 index 00000000..51343ae1 --- /dev/null +++ b/web/src/components/Chat/PromptChatPanel.tsx @@ -0,0 +1,39 @@ +import { forwardRef, useImperativeHandle, useState } from 'react' +import ChatContent from './ChatContent' +import type { ChatItem } from './types' +import type { ReactNode } from 'react' + +export interface PromptChatPanelRef { + append: (item: ChatItem) => void + clear: () => void +} + +interface PromptChatPanelProps { + classNames?: string + contentClassNames?: string + empty: ReactNode + labelFormat: (item: ChatItem) => any +} + +const PromptChatPanel = forwardRef((props, ref) => { + const [chatList, setChatList] = useState([]) + + useImperativeHandle(ref, () => ({ + append: (item) => setChatList(prev => [...prev, item]), + clear: () => setChatList([]), + })) + + return ( + + ) +}) + +export default PromptChatPanel diff --git a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx index 1666e075..fd2dc595 100644 --- a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx +++ b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:26:44 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-20 13:53:05 + * @Last Modified time: 2026-04-15 14:21:55 */ /** * AI Prompt Assistant Modal @@ -20,10 +20,9 @@ import { updatePromptMessages, createPromptSessions } from '@/api/prompt' import type { AiPromptModalRef, AiPromptVariableModalRef, AiPromptForm } from '../types' import RbModal from '@/components/RbModal' import type { Model } from '@/views/ModelManagement/types' -import ChatContent from '@/components/Chat/ChatContent' import Empty from '@/components/Empty' import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpty.svg' -import type { ChatItem } from '@/components/Chat/types' +import PromptChatPanel, { type PromptChatPanelRef } from '@/components/Chat/PromptChatPanel' import ModelSelect from '@/components/ModelSelect' import AiPromptVariableModal from './AiPromptVariableModal' import { type SSEMessage } from '@/utils/stream' @@ -55,12 +54,14 @@ const AiPromptModal = forwardRef(({ const [visible, setVisible] = useState(false); const [loading, setLoading] = useState(false) const [form] = Form.useForm() - const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [promptSession, setPromptSession] = useState(null) + const [hasPrompt, setHasPrompt] = useState(false) const aiPromptVariableModalRef = useRef(null) + const chatPanelRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef('') + const isStreamingRef = useRef(false) const values = Form.useWatch([], form) @@ -68,12 +69,13 @@ const AiPromptModal = forwardRef(({ const handleClose = () => { setVisible(false); setLoading(false) - setChatList([]) + chatPanelRef.current?.clear() setVariables([]) form.setFieldsValue({ message: undefined, current_prompt: undefined, }) + setHasPrompt(false) }; /** Open modal and create new prompt session */ @@ -102,9 +104,7 @@ const AiPromptModal = forwardRef(({ } const messageContent = values.message setLoading(true) - setChatList(prev => { - return [...prev, { role: 'user', content: messageContent}] - }) + chatPanelRef.current?.append({ role: 'user', content: messageContent }) form.setFieldsValue({ message: undefined, current_prompt: undefined }) const handleStreamMessage = (data: SSEMessage[]) => { @@ -114,6 +114,8 @@ const AiPromptModal = forwardRef(({ switch (item.event) { case 'start': currentPromptValueRef.current = '' + isStreamingRef.current = true + setHasPrompt(true) if (editorRef.current?.clear) { editorRef.current.clear(); } @@ -123,15 +125,12 @@ const AiPromptModal = forwardRef(({ currentPromptValueRef.current += content; if (editorRef.current?.appendText) { editorRef.current.appendText(content); - editorRef.current.scrollToBottom(); } else { form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) } } if (desc) { - setChatList(prev => { - return [...prev, { role: 'assistant', content: desc }] - }) + chatPanelRef.current?.append({ role: 'assistant', content: desc }) } if (variables) { setVariables(variables) @@ -139,6 +138,7 @@ const AiPromptModal = forwardRef(({ break; case 'end': setLoading(false) + isStreamingRef.current = false // Sync form value when stream ends form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) break @@ -193,7 +193,6 @@ const AiPromptModal = forwardRef(({ setIsFocus(false) } - console.log(values) return ( (({ body: 'rb:p-0! rb:border-t rb:border-t-[#EBEBEB]' }} > -
+
(({ /> - } - data={chatList || []} - streamLoading={false} - labelPosition="top" labelFormat={(item) => item.role === 'user' ? t(`${source}.you`) : t(`${source}.ai`)} /> (({ - - {values?.current_prompt - ? + form.setFieldValue('current_prompt', value)} + height="rb:h-[calc(100vh-276px)]" + className={clsx('rb:bg-white! rb:border-none! rb:p-0!')} + onChange={(value) => { + if (!isStreamingRef.current) { + form.setFieldValue('current_prompt', value) + } + }} /> - : - } - + + : + }
diff --git a/web/src/views/ApplicationConfig/components/Editor/index.tsx b/web/src/views/ApplicationConfig/components/Editor/index.tsx index ab89a610..c3edc1fc 100644 --- a/web/src/views/ApplicationConfig/components/Editor/index.tsx +++ b/web/src/views/ApplicationConfig/components/Editor/index.tsx @@ -2,14 +2,14 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:25:17 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-26 11:18:04 + * @Last Modified time: 2026-04-15 14:00:07 */ /** * Rich text editor component using Lexical framework * Provides text editing with insert, append, clear, and scroll capabilities */ -import {forwardRef, useImperativeHandle } from 'react'; +import {forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import clsx from 'clsx'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; @@ -50,7 +50,7 @@ interface LexicalEditorProps { /** Callback when content changes */ onChange?: (value: string) => void; /** Editor height in pixels */ - height?: number; + height?: string; disabled?: boolean; } @@ -73,9 +73,42 @@ const EditorContent = forwardRef(({ value, placeholder = "Please enter content...", onChange, - disabled + disabled, + height }, ref) => { const [editor] = useLexicalComposerContext(); + const scrollRef = useRef(null); + const pendingTextRef = useRef(''); + const rafRef = useRef(null); + const isAppendingRef = useRef(false); + const scrollTopRef = useRef(0); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onPointerDown = () => { + if (!isAppendingRef.current) scrollTopRef.current = el.scrollTop; + }; + el.addEventListener('pointerdown', onPointerDown); + return () => el.removeEventListener('pointerdown', onPointerDown); + }, []); + + useEffect(() => { + return editor.registerUpdateListener(({ tags }) => { + if (!scrollRef.current) return; + if (tags.has('append-text')) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } else { + scrollRef.current.scrollTop = scrollTopRef.current; + } + }); + }, [editor]); + + const scrollToBottom = () => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }; /** * Expose editor methods to parent component @@ -94,24 +127,33 @@ const EditorContent = forwardRef(({ }); }, appendText: (text: string) => { - editor.update(() => { - const root = $getRoot(); - const lastChild = root.getLastChild(); - if (lastChild && $isParagraphNode(lastChild)) { - const lastTextNode = lastChild.getLastChild(); - if (lastTextNode && $isTextNode(lastTextNode)) { - const currentText = lastTextNode.getTextContent(); - lastTextNode.setTextContent(currentText + text); + pendingTextRef.current += text; + if (rafRef.current !== null) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + const batch = pendingTextRef.current; + pendingTextRef.current = ''; + if (scrollRef.current) scrollTopRef.current = scrollRef.current.scrollTop; + isAppendingRef.current = true; + editor.update(() => { + const root = $getRoot(); + const lastChild = root.getLastChild(); + if (lastChild && $isParagraphNode(lastChild)) { + const lastTextNode = lastChild.getLastChild(); + if (lastTextNode && $isTextNode(lastTextNode)) { + lastTextNode.setTextContent(lastTextNode.getTextContent() + batch); + } else { + lastChild.append($createTextNode(batch)); + } } else { - const textNode = $createTextNode(text); - lastChild.append(textNode); + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(batch)); + root.append(paragraph); } - } else { - const paragraph = $createParagraphNode(); - const textNode = $createTextNode(text); - paragraph.append(textNode); - root.append(paragraph); - } + }, { + tag: 'append-text', + onUpdate: () => { isAppendingRef.current = false; } + }); }); }, clear: () => { @@ -122,21 +164,16 @@ const EditorContent = forwardRef(({ root.append(paragraph); }); }, - scrollToBottom: () => { - const editorElement = editor.getRootElement(); - if (editorElement) { - editorElement.scrollTop = editorElement.scrollHeight; - } - } + scrollToBottom, }), [editor]); return ( -
+
{ const { message } = App.useApp() const [loading, setLoading] = useState(false) const [form] = Form.useForm() - const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [promptSession, setPromptSession] = useState(null) const aiPromptVariableModalRef = useRef(null) const promptSaveModalRef = useRef(null) + const chatPanelRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef(undefined) + const isStreamingRef = useRef(false) + const [hasPrompt, setHasPrompt] = useState(false) const values = Form.useWatch([], form) const [editVo, setEditVo] = useState(null) @@ -56,14 +57,14 @@ const Prompt: FC = () => { useEffect(() => { if (editVo?.id) { form.setFieldValue('current_prompt', editVo.prompt) - setChatList([]) + setHasPrompt(true) + chatPanelRef.current?.clear() } updateSession() }, [editVo]) /** Update session ID */ const updateSession = () => { - console.log('updateSession') createPromptSessions().then(res => { const response = res as { id: string } setPromptSession(response.id) @@ -83,9 +84,7 @@ const Prompt: FC = () => { } const messageContent = values.message setLoading(true) - setChatList(prev => { - return [...prev, { role: 'user', content: messageContent}] - }) + chatPanelRef.current?.append({ role: 'user', content: messageContent }) form.setFieldsValue({ message: undefined, current_prompt: undefined }) const handleStreamMessage = (data: SSEMessage[]) => { @@ -95,33 +94,35 @@ const Prompt: FC = () => { switch (item.event) { case 'start': currentPromptValueRef.current = '' + isStreamingRef.current = true + setHasPrompt(true) if (editorRef.current?.clear) { editorRef.current.clear(); } break; case 'message': - if (typeof content === 'string') { + if (content) { currentPromptValueRef.current += content; if (editorRef.current?.appendText) { editorRef.current.appendText(content); - editorRef.current.scrollToBottom(); } else { form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) } } if (desc) { - setChatList(prev => { - return [...prev, { role: 'assistant', content: desc }] - }) + chatPanelRef.current?.append({ role: 'assistant', content: desc }) } if (variables) { setVariables(variables) } + console.log('currentPromptValueRef.current', currentPromptValueRef.current) break; case 'end': setLoading(false) + isStreamingRef.current = false // Sync form values when stream ends form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) + console.log('currentPromptValueRef.current', currentPromptValueRef.current) break } }) @@ -164,7 +165,8 @@ const Prompt: FC = () => { const handleRefresh = () => { form.setFieldValue('current_prompt', undefined) currentPromptValueRef.current = undefined; - setChatList([]) + setHasPrompt(false) + chatPanelRef.current?.clear() setEditVo(null) updateSession() } @@ -193,13 +195,11 @@ const Prompt: FC = () => { headerType="borderless" bodyClassName="rb:px-4! rb:pt-0! rb:pb-3!" > - } - data={chatList || []} - streamLoading={false} - labelPosition="top" labelFormat={(item) => item.role === 'user' ? t(`prompt.you`) : t(`prompt.ai`)} /> { > } > - - {values?.current_prompt - ? + form.setFieldValue('current_prompt', value)} + height="rb:h-[calc(100vh-193px)]" + className="rb:bg-white! rb:border-none! rb:p-0! rb:text-[#212332] rb:leading-5" + onChange={(value) => { + if (!isStreamingRef.current) { + form.setFieldValue('current_prompt', value) + } + }} /> - : - } - + + : + }
From 5ac2d5602e5fa77530c9ad31adb4aac5363d55d5 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 15 Apr 2026 14:50:19 +0800 Subject: [PATCH 44/44] Revert "fix(web): prompt editor" This reverts commit 71e5b6586a1186b88d81e90ba1d02bfe41db6b98. --- web/src/components/Chat/PromptChatPanel.tsx | 39 -------- .../components/AiPromptModal.tsx | 56 ++++++----- .../components/Editor/index.tsx | 93 ++++++------------- web/src/views/Prompt/index.tsx | 59 ++++++------ 4 files changed, 82 insertions(+), 165 deletions(-) delete mode 100644 web/src/components/Chat/PromptChatPanel.tsx diff --git a/web/src/components/Chat/PromptChatPanel.tsx b/web/src/components/Chat/PromptChatPanel.tsx deleted file mode 100644 index 51343ae1..00000000 --- a/web/src/components/Chat/PromptChatPanel.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { forwardRef, useImperativeHandle, useState } from 'react' -import ChatContent from './ChatContent' -import type { ChatItem } from './types' -import type { ReactNode } from 'react' - -export interface PromptChatPanelRef { - append: (item: ChatItem) => void - clear: () => void -} - -interface PromptChatPanelProps { - classNames?: string - contentClassNames?: string - empty: ReactNode - labelFormat: (item: ChatItem) => any -} - -const PromptChatPanel = forwardRef((props, ref) => { - const [chatList, setChatList] = useState([]) - - useImperativeHandle(ref, () => ({ - append: (item) => setChatList(prev => [...prev, item]), - clear: () => setChatList([]), - })) - - return ( - - ) -}) - -export default PromptChatPanel diff --git a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx index fd2dc595..1666e075 100644 --- a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx +++ b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:26:44 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-15 14:21:55 + * @Last Modified time: 2026-03-20 13:53:05 */ /** * AI Prompt Assistant Modal @@ -20,9 +20,10 @@ import { updatePromptMessages, createPromptSessions } from '@/api/prompt' import type { AiPromptModalRef, AiPromptVariableModalRef, AiPromptForm } from '../types' import RbModal from '@/components/RbModal' import type { Model } from '@/views/ModelManagement/types' +import ChatContent from '@/components/Chat/ChatContent' import Empty from '@/components/Empty' import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpty.svg' -import PromptChatPanel, { type PromptChatPanelRef } from '@/components/Chat/PromptChatPanel' +import type { ChatItem } from '@/components/Chat/types' import ModelSelect from '@/components/ModelSelect' import AiPromptVariableModal from './AiPromptVariableModal' import { type SSEMessage } from '@/utils/stream' @@ -54,14 +55,12 @@ const AiPromptModal = forwardRef(({ const [visible, setVisible] = useState(false); const [loading, setLoading] = useState(false) const [form] = Form.useForm() + const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [promptSession, setPromptSession] = useState(null) - const [hasPrompt, setHasPrompt] = useState(false) const aiPromptVariableModalRef = useRef(null) - const chatPanelRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef('') - const isStreamingRef = useRef(false) const values = Form.useWatch([], form) @@ -69,13 +68,12 @@ const AiPromptModal = forwardRef(({ const handleClose = () => { setVisible(false); setLoading(false) - chatPanelRef.current?.clear() + setChatList([]) setVariables([]) form.setFieldsValue({ message: undefined, current_prompt: undefined, }) - setHasPrompt(false) }; /** Open modal and create new prompt session */ @@ -104,7 +102,9 @@ const AiPromptModal = forwardRef(({ } const messageContent = values.message setLoading(true) - chatPanelRef.current?.append({ role: 'user', content: messageContent }) + setChatList(prev => { + return [...prev, { role: 'user', content: messageContent}] + }) form.setFieldsValue({ message: undefined, current_prompt: undefined }) const handleStreamMessage = (data: SSEMessage[]) => { @@ -114,8 +114,6 @@ const AiPromptModal = forwardRef(({ switch (item.event) { case 'start': currentPromptValueRef.current = '' - isStreamingRef.current = true - setHasPrompt(true) if (editorRef.current?.clear) { editorRef.current.clear(); } @@ -125,12 +123,15 @@ const AiPromptModal = forwardRef(({ currentPromptValueRef.current += content; if (editorRef.current?.appendText) { editorRef.current.appendText(content); + editorRef.current.scrollToBottom(); } else { form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) } } if (desc) { - chatPanelRef.current?.append({ role: 'assistant', content: desc }) + setChatList(prev => { + return [...prev, { role: 'assistant', content: desc }] + }) } if (variables) { setVariables(variables) @@ -138,7 +139,6 @@ const AiPromptModal = forwardRef(({ break; case 'end': setLoading(false) - isStreamingRef.current = false // Sync form value when stream ends form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) break @@ -193,6 +193,7 @@ const AiPromptModal = forwardRef(({ setIsFocus(false) } + console.log(values) return ( (({ body: 'rb:p-0! rb:border-t rb:border-t-[#EBEBEB]' }} > -
+
(({ /> - } + data={chatList || []} + streamLoading={false} + labelPosition="top" labelFormat={(item) => item.role === 'user' ? t(`${source}.you`) : t(`${source}.ai`)} /> (({ - {hasPrompt - ? - + {values?.current_prompt + ? { - if (!isStreamingRef.current) { - form.setFieldValue('current_prompt', value) - } - }} + className="rb:h-119 rb:bg-white! rb:border-none! rb:p-0!" + onChange={(value) => form.setFieldValue('current_prompt', value)} /> - - : - } + : + } +
diff --git a/web/src/views/ApplicationConfig/components/Editor/index.tsx b/web/src/views/ApplicationConfig/components/Editor/index.tsx index c3edc1fc..ab89a610 100644 --- a/web/src/views/ApplicationConfig/components/Editor/index.tsx +++ b/web/src/views/ApplicationConfig/components/Editor/index.tsx @@ -2,14 +2,14 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:25:17 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-15 14:00:07 + * @Last Modified time: 2026-02-26 11:18:04 */ /** * Rich text editor component using Lexical framework * Provides text editing with insert, append, clear, and scroll capabilities */ -import {forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; +import {forwardRef, useImperativeHandle } from 'react'; import clsx from 'clsx'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; @@ -50,7 +50,7 @@ interface LexicalEditorProps { /** Callback when content changes */ onChange?: (value: string) => void; /** Editor height in pixels */ - height?: string; + height?: number; disabled?: boolean; } @@ -73,42 +73,9 @@ const EditorContent = forwardRef(({ value, placeholder = "Please enter content...", onChange, - disabled, - height + disabled }, ref) => { const [editor] = useLexicalComposerContext(); - const scrollRef = useRef(null); - const pendingTextRef = useRef(''); - const rafRef = useRef(null); - const isAppendingRef = useRef(false); - const scrollTopRef = useRef(0); - - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - const onPointerDown = () => { - if (!isAppendingRef.current) scrollTopRef.current = el.scrollTop; - }; - el.addEventListener('pointerdown', onPointerDown); - return () => el.removeEventListener('pointerdown', onPointerDown); - }, []); - - useEffect(() => { - return editor.registerUpdateListener(({ tags }) => { - if (!scrollRef.current) return; - if (tags.has('append-text')) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } else { - scrollRef.current.scrollTop = scrollTopRef.current; - } - }); - }, [editor]); - - const scrollToBottom = () => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }; /** * Expose editor methods to parent component @@ -127,33 +94,24 @@ const EditorContent = forwardRef(({ }); }, appendText: (text: string) => { - pendingTextRef.current += text; - if (rafRef.current !== null) return; - rafRef.current = requestAnimationFrame(() => { - rafRef.current = null; - const batch = pendingTextRef.current; - pendingTextRef.current = ''; - if (scrollRef.current) scrollTopRef.current = scrollRef.current.scrollTop; - isAppendingRef.current = true; - editor.update(() => { - const root = $getRoot(); - const lastChild = root.getLastChild(); - if (lastChild && $isParagraphNode(lastChild)) { - const lastTextNode = lastChild.getLastChild(); - if (lastTextNode && $isTextNode(lastTextNode)) { - lastTextNode.setTextContent(lastTextNode.getTextContent() + batch); - } else { - lastChild.append($createTextNode(batch)); - } + editor.update(() => { + const root = $getRoot(); + const lastChild = root.getLastChild(); + if (lastChild && $isParagraphNode(lastChild)) { + const lastTextNode = lastChild.getLastChild(); + if (lastTextNode && $isTextNode(lastTextNode)) { + const currentText = lastTextNode.getTextContent(); + lastTextNode.setTextContent(currentText + text); } else { - const paragraph = $createParagraphNode(); - paragraph.append($createTextNode(batch)); - root.append(paragraph); + const textNode = $createTextNode(text); + lastChild.append(textNode); } - }, { - tag: 'append-text', - onUpdate: () => { isAppendingRef.current = false; } - }); + } else { + const paragraph = $createParagraphNode(); + const textNode = $createTextNode(text); + paragraph.append(textNode); + root.append(paragraph); + } }); }, clear: () => { @@ -164,16 +122,21 @@ const EditorContent = forwardRef(({ root.append(paragraph); }); }, - scrollToBottom, + scrollToBottom: () => { + const editorElement = editor.getRootElement(); + if (editorElement) { + editorElement.scrollTop = editorElement.scrollHeight; + } + } }), [editor]); return ( -
+
{ const { message } = App.useApp() const [loading, setLoading] = useState(false) const [form] = Form.useForm() + const [chatList, setChatList] = useState([]) const [variables, setVariables] = useState([]) const [promptSession, setPromptSession] = useState(null) const aiPromptVariableModalRef = useRef(null) const promptSaveModalRef = useRef(null) - const chatPanelRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef(undefined) - const isStreamingRef = useRef(false) - const [hasPrompt, setHasPrompt] = useState(false) const values = Form.useWatch([], form) const [editVo, setEditVo] = useState(null) @@ -57,14 +56,14 @@ const Prompt: FC = () => { useEffect(() => { if (editVo?.id) { form.setFieldValue('current_prompt', editVo.prompt) - setHasPrompt(true) - chatPanelRef.current?.clear() + setChatList([]) } updateSession() }, [editVo]) /** Update session ID */ const updateSession = () => { + console.log('updateSession') createPromptSessions().then(res => { const response = res as { id: string } setPromptSession(response.id) @@ -84,7 +83,9 @@ const Prompt: FC = () => { } const messageContent = values.message setLoading(true) - chatPanelRef.current?.append({ role: 'user', content: messageContent }) + setChatList(prev => { + return [...prev, { role: 'user', content: messageContent}] + }) form.setFieldsValue({ message: undefined, current_prompt: undefined }) const handleStreamMessage = (data: SSEMessage[]) => { @@ -94,35 +95,33 @@ const Prompt: FC = () => { switch (item.event) { case 'start': currentPromptValueRef.current = '' - isStreamingRef.current = true - setHasPrompt(true) if (editorRef.current?.clear) { editorRef.current.clear(); } break; case 'message': - if (content) { + if (typeof content === 'string') { currentPromptValueRef.current += content; if (editorRef.current?.appendText) { editorRef.current.appendText(content); + editorRef.current.scrollToBottom(); } else { form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) } } if (desc) { - chatPanelRef.current?.append({ role: 'assistant', content: desc }) + setChatList(prev => { + return [...prev, { role: 'assistant', content: desc }] + }) } if (variables) { setVariables(variables) } - console.log('currentPromptValueRef.current', currentPromptValueRef.current) break; case 'end': setLoading(false) - isStreamingRef.current = false // Sync form values when stream ends form.setFieldsValue({ current_prompt: currentPromptValueRef.current }) - console.log('currentPromptValueRef.current', currentPromptValueRef.current) break } }) @@ -165,8 +164,7 @@ const Prompt: FC = () => { const handleRefresh = () => { form.setFieldValue('current_prompt', undefined) currentPromptValueRef.current = undefined; - setHasPrompt(false) - chatPanelRef.current?.clear() + setChatList([]) setEditVo(null) updateSession() } @@ -195,11 +193,13 @@ const Prompt: FC = () => { headerType="borderless" bodyClassName="rb:px-4! rb:pt-0! rb:pb-3!" > - } + data={chatList || []} + streamLoading={false} + labelPosition="top" labelFormat={(item) => item.role === 'user' ? t(`prompt.you`) : t(`prompt.ai`)} /> { > } > - {hasPrompt - ? - + {values?.current_prompt + ? { - if (!isStreamingRef.current) { - form.setFieldValue('current_prompt', value) - } - }} + className="rb:h-[calc(100vh-193px)] rb:bg-white! rb:border-none! rb:p-0! rb:text-[#212332] rb:leading-5" + onChange={(value) => form.setFieldValue('current_prompt', value)} /> - - : - } + : + } +