From 3e48d620b2853b44d3b386ef50ebfbe1c62379bc Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 14 Apr 2026 17:59:24 +0800 Subject: [PATCH 1/8] feat(web): table support pagesize --- web/src/components/Table/index.tsx | 6 +++--- web/src/views/Index/index.tsx | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/components/Table/index.tsx b/web/src/components/Table/index.tsx index bb79b4bc..d6cb3c68 100644 --- a/web/src/components/Table/index.tsx +++ b/web/src/components/Table/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 15:29:46 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 14:52:23 + * @Last Modified time: 2026-04-14 17:55:15 */ /** * RbTable Component @@ -27,7 +27,7 @@ import { useTranslation } from 'react-i18next'; import { request } from '@/utils/request'; import Empty from '@/components/Empty'; -interface TablePaginationConfig { pagesize: number; page: number; } +interface TablePaginationConfig { pagesize?: number; page?: number; } /** Props interface for Table component */ interface TableComponentProps, Q = Record> extends Omit, 'pagination'> { @@ -102,7 +102,7 @@ const RbTable = forwardRef(, Q = Record { rowKey="id" bordered={false} scrollY="100%" + pagination={{pagesize: 10}} /> From 643a3fbe094e97d44b6ceb23b8f70b5e67ace039 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 15 Apr 2026 16:09:38 +0800 Subject: [PATCH 2/8] feat(web): node run status --- web/src/components/CodeMirrorEditor/index.tsx | 6 +-- web/src/store/workflow.ts | 8 ++++ .../views/Workflow/components/Chat/Chat.tsx | 17 +++++-- .../views/Workflow/components/NodeLibrary.tsx | 45 +++++++++---------- .../components/Nodes/ConditionNode.tsx | 17 +++++-- .../Workflow/components/Nodes/LoopNode.tsx | 15 ++++++- .../Workflow/components/Nodes/NormalNode.tsx | 17 +++++-- .../views/Workflow/hooks/useWorkflowGraph.ts | 30 ++++++++++++- 8 files changed, 115 insertions(+), 40 deletions(-) diff --git a/web/src/components/CodeMirrorEditor/index.tsx b/web/src/components/CodeMirrorEditor/index.tsx index ec2a6780..8671992a 100644 --- a/web/src/components/CodeMirrorEditor/index.tsx +++ b/web/src/components/CodeMirrorEditor/index.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-02-04 17:20:52 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-02-04 17:20:52 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-04-14 18:24:29 */ import { useEffect, useRef, useMemo } from 'react'; import { EditorView, basicSetup } from 'codemirror'; @@ -156,7 +156,7 @@ const CodeMirrorEditor = ({
); }; diff --git a/web/src/store/workflow.ts b/web/src/store/workflow.ts index 0999d35a..382d9255 100644 --- a/web/src/store/workflow.ts +++ b/web/src/store/workflow.ts @@ -6,11 +6,15 @@ */ import { create } from 'zustand' import type { NodeCheckResult } from '@/views/Workflow/components/CheckList' +import type { ChatItem } from '@/components/Chat/types' interface WorkflowState { checkResults: Record setCheckResults: (appId: string, results: NodeCheckResult[]) => void getCheckResults: (appId: string) => NodeCheckResult[] + chatHistoryMap: Record + setChatHistory: (conversationId: string, history: ChatItem[]) => void + getChatHistory: (conversationId: string) => ChatItem[] } export const useWorkflowStore = create((set, get) => ({ @@ -18,4 +22,8 @@ export const useWorkflowStore = create((set, get) => ({ setCheckResults: (appId, results) => set(state => ({ checkResults: { ...state.checkResults, [appId]: results } })), getCheckResults: (appId) => get().checkResults[appId] ?? [], + chatHistoryMap: {}, + setChatHistory: (conversationId, history) => + set(state => ({ chatHistoryMap: { ...state.chatHistoryMap, [conversationId]: history } })), + getChatHistory: (conversationId) => get().chatHistoryMap[conversationId] ?? [], })) diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index e1a0ad95..19b06a0d 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:10:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-07 18:07:38 + * @Last Modified time: 2026-04-15 15:57:35 */ /** * Workflow Chat Component @@ -41,12 +41,15 @@ import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar' import Runtime from './Runtime'; import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; import { replaceVariables } from '@/views/ApplicationConfig/Agent'; +import { useWorkflowStore } from '@/store/workflow'; -const Chat = forwardRef(({ +const Chat = forwardRef(({ // eslint-disable-line appId, graphRef, features }, ref) => { const { t } = useTranslation() const { message: messageApi } = App.useApp() + const { setChatHistory } = useWorkflowStore() + const conversationIdRef = useRef('draft') const toolbarRef = useRef(null) const [toolbarReady, setToolbarReady] = useState(false) const toolbarCallbackRef = useCallback((node: ChatToolbarRef | null) => { @@ -118,6 +121,7 @@ const Chat = forwardRef; - status?: 'completed' | 'failed', + status?: 'completed' | 'failed' | 'running', citations?: { document_id: string; file_name: string; @@ -231,6 +235,7 @@ const Chat = forwardRef { + setChatHistory(conversationIdRef.current, chatList) + }, [chatList]) + return ( diff --git a/web/src/views/Workflow/components/NodeLibrary.tsx b/web/src/views/Workflow/components/NodeLibrary.tsx index e6190adb..525c09ae 100644 --- a/web/src/views/Workflow/components/NodeLibrary.tsx +++ b/web/src/views/Workflow/components/NodeLibrary.tsx @@ -34,29 +34,24 @@ const NodeLibrary: FC<{ collapsed: boolean; handleToggle: () => void }> = ({ col > {collapsed - ? <> - {nodeLibrary.map(category => ( - <> - {category.nodes - .filter(node => node.type !== 'cycle-start' && node.type !== 'break') - .map((node, nodeIndex) => ( - -
{ - e.dataTransfer.setData('application/reactflow', node.type); - e.dataTransfer.setData('application/json', JSON.stringify(node)); - }} - > -
-
- - )) - } - - ))} - + ? nodeLibrary.flatMap(category => + category.nodes + .filter(node => node.type !== 'cycle-start' && node.type !== 'break') + .map(node => ( + +
{ + e.dataTransfer.setData('application/reactflow', node.type); + e.dataTransfer.setData('application/json', JSON.stringify(node)); + }} + > +
+
+ + )) + ) : nodeLibrary.map(category => (
void }> = ({ col {category.nodes .filter(node => node.type !== 'cycle-start' && node.type !== 'break') - .map((node, nodeIndex) => ( + .map((node) => ( { return (
-
{data.name ?? t(`workflow.${data.type}`)}
+
{data.name ?? t(`workflow.${data.type}`)}
+ {data.executionStatus === 'completed' + ? + : data.executionStatus === 'failed' + ? + : data.executionStatus === 'running' + ? + : null + } {data.type === 'question-classifier' && diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx index ca0eaeff..c540db76 100644 --- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx +++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import clsx from 'clsx'; import type { ReactShapeConfig } from '@antv/x6-react-shape'; import { Flex } from 'antd'; +import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'; import { graphNodeLibrary, edgeAttrs } from '../../constant'; import NodeTools from './NodeTools' @@ -131,12 +132,22 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { return (
-
{data.name ?? t(`workflow.${data.type}`)}
+
{data.name ?? t(`workflow.${data.type}`)}
+ {data.executionStatus === 'completed' + ? + : data.executionStatus === 'failed' + ? + : data.executionStatus === 'running' + ? + : null + }
diff --git a/web/src/views/Workflow/components/Nodes/NormalNode.tsx b/web/src/views/Workflow/components/Nodes/NormalNode.tsx index f947d004..ce936be9 100644 --- a/web/src/views/Workflow/components/Nodes/NormalNode.tsx +++ b/web/src/views/Workflow/components/Nodes/NormalNode.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx'; import { useTranslation } from 'react-i18next' import type { ReactShapeConfig } from '@antv/x6-react-shape'; import { Flex } from 'antd'; +import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'; import NodeTools from './NodeTools' @@ -11,13 +12,23 @@ const NormalNode: ReactShapeConfig['component'] = ({ node }) => { return (
-
{data.name ?? t(`workflow.${data.type}`)}
+
{data.name ?? t(`workflow.${data.type}`)}
+ {data.executionStatus === 'completed' + ? + : data.executionStatus === 'failed' + ? + : data.executionStatus === 'running' + ? + : null + }
{t('workflow.clickToConfigure')}
diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index f385acf3..516bc24c 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-15 16:02:49 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; import { register } from '@antv/x6-react-shape'; @@ -18,6 +18,7 @@ import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; import { conditionNodeHeight, conditionNodeItemHeight, conditionNodePortItemArgsY, defaultAbsolutePortGroups, defaultPortItems, edgeAttrs, edgeHoverTool, edge_color, edge_selected_color, edge_width, graphNodeLibrary, nodeLibrary, nodeRegisterLibrary, nodeWidth, notesConfig, portAttrs, portItemArgsY, portMarkup, portTextAttrs, unknownNode } from '../constant'; import type { ChatVariable, NodeProperties, WorkflowConfig } from '../types'; import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils'; +import { useWorkflowStore } from '@/store/workflow'; /** * Props for useWorkflowGraph hook @@ -94,6 +95,8 @@ export const useWorkflowGraph = ({ const { message } = App.useApp(); const { t } = useTranslation() const { user } = useUser(); + const { chatHistoryMap } = useWorkflowStore() + const chatHistory = Object.values(chatHistoryMap).at(-1) ?? [] // Refs const graphRef = useRef(); @@ -1425,6 +1428,31 @@ export const useWorkflowGraph = ({ } } } + useEffect(() => { + if (!graphRef.current) return; + const nodes = graphRef.current.getNodes(); + + const lastWithSub = [...chatHistory].reverse().find(item => item.subContent?.length); + // Reset all node execution status first + nodes.forEach(node => { + const data = node.getData(); + if (typeof data.status === 'string') { + node.setData({ ...data, executionStatus: undefined }); + } + }); + if (!lastWithSub?.subContent) return; + // Build a nodeId -> status map first + const statusMap: Record = {}; + lastWithSub.subContent.forEach(sub => { + if (typeof sub.status === 'string') { + statusMap[sub.node_id] = sub.status; + const node = nodes.find(n => n.getData()?.id === sub.node_id); + if (node) { + node.setData({ ...node.getData(), executionStatus: sub.status }); + } + } + }); + }, [chatHistory, graphRef.current]); return { config, From 10a91ec5cb70b7d72b3275988144d7c5895b8eb4 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Mon, 20 Apr 2026 16:08:26 +0800 Subject: [PATCH 3/8] feat(web): workflow support undo/redo --- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + .../Workflow/components/CanvasToolbar.tsx | 23 +++++++--- .../views/Workflow/hooks/useWorkflowGraph.ts | 43 ++++++++++++++++++- web/src/views/Workflow/index.tsx | 8 ++++ 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 6bcc5034..9932da0c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2508,6 +2508,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re arrange: 'Arrange', redo: 'Redo', undo: 'Undo', + fit: 'Fit View', input: 'Input', output: 'Output', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index fff8c1af..59afebb2 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -2472,6 +2472,7 @@ export const zh = { arrange: '整理', redo: '重做', undo: '撤销', + fit: '自适应', input: '输入', output: '输出', diff --git a/web/src/views/Workflow/components/CanvasToolbar.tsx b/web/src/views/Workflow/components/CanvasToolbar.tsx index 6a2cbc7f..1bbb51f2 100644 --- a/web/src/views/Workflow/components/CanvasToolbar.tsx +++ b/web/src/views/Workflow/components/CanvasToolbar.tsx @@ -1,8 +1,9 @@ import type { FC } from 'react'; -import { Select, Divider } from 'antd'; -import { PlusOutlined, MinusOutlined, FileAddOutlined } from '@ant-design/icons' +import { Select, Divider, Tooltip } from 'antd'; +import { PlusOutlined, MinusOutlined, FileAddOutlined, UndoOutlined, RedoOutlined } from '@ant-design/icons' import clsx from 'clsx' import { Node } from '@antv/x6'; +import { useTranslation } from 'react-i18next' import type { GraphRef } from '../types' @@ -15,6 +16,10 @@ interface CanvasToolbarProps { setIsHandMode: React.Dispatch>; zoomLevel: number; addNotes: () => void; + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; } const CanvasToolbar: FC = ({ @@ -22,12 +27,13 @@ const CanvasToolbar: FC = ({ miniMapRef, graphRef, zoomLevel, - // canUndo, - // canRedo, - // onUndo, - // onRedo, + canUndo, + canRedo, + onUndo, + onRedo, addNotes, }) => { + const { t } = useTranslation() return ( <> {/* 小地图 */} @@ -63,13 +69,16 @@ const CanvasToolbar: FC = ({ { label: '125%', value: 125 }, { label: '150%', value: 150 }, { label: '200%', value: 200 }, - { label: '自适应', value: 'fit' }, + { label: t('workflow.fit'), value: 'fit' }, ]} variant='borderless' size="small" /> graphRef.current?.zoom(0.1)} /> + + +
diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index f385acf3..14bffaec 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,9 +2,10 @@ * @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-20 16:00:26 */ -import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6'; +import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6'; +import type { HistoryCommand as Command } from '@antv/x6/lib/plugin/history/type'; import { register } from '@antv/x6-react-shape'; import type { PortMetadata } from '@antv/x6/lib/model/port'; import { App } from 'antd'; @@ -63,6 +64,14 @@ export interface UseWorkflowGraphReturn { copyEvent: () => boolean | void; /** Handler for paste keyboard event */ parseEvent: () => boolean | void; + /** Whether undo is available */ + canUndo: boolean; + /** Whether redo is available */ + canRedo: boolean; + /** Undo last action */ + undo: () => void; + /** Redo last undone action */ + redo: () => void; /** Function to save workflow configuration */ handleSave: (flag?: boolean) => Promise; /** Chat variables for workflow */ @@ -105,6 +114,8 @@ export const useWorkflowGraph = ({ const [config, setConfig] = useState(null); const [chatVariables, setChatVariables] = useState([]) const featuresRef = useRef(undefined) + const [canUndo, setCanUndo] = useState(false) + const [canRedo, setCanRedo] = useState(false) useEffect(() => { if (!graphRef.current) return @@ -469,6 +480,8 @@ export const useWorkflowGraph = ({ graphRef.current.getNodes().forEach(node => { if (node.getData()?.cycle) node.toFront(); }); + graphRef.current.enableHistory() + graphRef.current.cleanHistory() } }, 200) } @@ -504,6 +517,22 @@ export const useWorkflowGraph = ({ global: true, }), ); + graphRef.current.use( + new History({ + enabled: false, + beforeAddCommand(_event, args: any) { + const event = args?.key ? `cell:change:${args.key}` : _event; + if (event.startsWith('cell:change:') && + event !== 'cell:change:position' && + event !== 'cell:change:source' && + event !== 'cell:change:target') return false; + }, + }), + ); + graphRef.current.on('history:change', ({ cmds }: { cmds: Command[] }) => { + setCanUndo(graphRef.current?.canUndo() ?? false) + setCanRedo(graphRef.current?.canRedo() ?? false) + }) }; // 显示/隐藏连接桩 // const showPorts = (show: boolean) => { @@ -1077,6 +1106,9 @@ export const useWorkflowGraph = ({ graphRef.current.bindKey(['ctrl+v', 'cmd+v'], parseEvent); // Delete selected nodes and edges graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent); + // Undo / Redo + graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { graphRef.current?.undo(); return false; }); + graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { graphRef.current?.redo(); return false; }); }; @@ -1390,6 +1422,9 @@ export const useWorkflowGraph = ({ return userVars } + const undo = () => graphRef.current?.undo() + const redo = () => graphRef.current?.redo() + const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => { const { statement = '' } = value?.opening_statement || {} featuresRef.current = value @@ -1449,5 +1484,9 @@ export const useWorkflowGraph = ({ handleSaveFeaturesConfig, features: featuresRef.current, getStartNodeVariables, + canUndo, + canRedo, + undo, + redo, }; }; diff --git a/web/src/views/Workflow/index.tsx b/web/src/views/Workflow/index.tsx index 26d7420c..f98cf308 100644 --- a/web/src/views/Workflow/index.tsx +++ b/web/src/views/Workflow/index.tsx @@ -39,6 +39,10 @@ const Workflow = forwardRef { @@ -96,6 +100,10 @@ const Workflow = forwardRef
From a2df14f6586541b6f63888b8d0a5d6412271a5c3 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 21 Apr 2026 15:00:28 +0800 Subject: [PATCH 4/8] fix(web): stream support abort --- web/src/api/application.ts | 12 ++-- web/src/api/memory.ts | 4 +- web/src/api/prompt.ts | 4 +- web/src/utils/stream.ts | 60 +++++++++++-------- .../ApplicationConfig/TestChat/index.tsx | 9 ++- .../components/AiPromptModal.tsx | 13 ++-- .../ApplicationConfig/components/Chat.tsx | 10 +++- web/src/views/Conversation/index.tsx | 9 ++- .../components/Result.tsx | 15 ++++- web/src/views/Prompt/index.tsx | 12 +++- .../views/Workflow/components/Chat/Chat.tsx | 9 ++- .../Workflow/components/Properties/index.tsx | 6 +- 12 files changed, 109 insertions(+), 54 deletions(-) diff --git a/web/src/api/application.ts b/web/src/api/application.ts index 5614232e..6965f363 100644 --- a/web/src/api/application.ts +++ b/web/src/api/application.ts @@ -53,12 +53,12 @@ export const saveWorkflowConfig = (app_id: string, values: WorkflowConfig) => { return request.put(`/apps/${app_id}/workflow`, values) } // Model comparison test run -export const runCompare = (app_id: string, values: Record, onMessage?: (data: SSEMessage[]) => void) => { - return handleSSE(`/apps/${app_id}/draft/run/compare`, values, onMessage) +export const runCompare = (app_id: string, values: Record, onMessage?: (data: SSEMessage[]) => void, onAbort?: (abort: () => void) => void) => { + return handleSSE(`/apps/${app_id}/draft/run/compare`, values, onMessage, undefined, onAbort) } // Test run -export const draftRun = (app_id: string, values: Record, onMessage?: (data: SSEMessage[]) => void) => { - return handleSSE(`/apps/${app_id}/draft/run`, values, onMessage) +export const draftRun = (app_id: string, values: Record, onMessage?: (data: SSEMessage[]) => void, onAbort?: (abort: () => void) => void) => { + return handleSSE(`/apps/${app_id}/draft/run`, values, onMessage, undefined, onAbort) } // Delete application export const deleteApplication = (app_id: string) => { @@ -93,12 +93,12 @@ export const getConversationHistory = (share_token: string, data: { page: number }) } // Send conversation -export const sendConversation = (values: QueryParams, onMessage: (data: SSEMessage[]) => void, shareToken: string) => { +export const sendConversation = (values: QueryParams, onMessage: (data: SSEMessage[]) => void, shareToken: string, onAbort?: (abort: () => void) => void) => { return handleSSE(`/public/share/chat`, values, onMessage, { headers: { 'Authorization': `Bearer ${shareToken}` } - }) + }, onAbort) } // Get conversation details export const getConversationDetail = (share_token: string, conversation_id: string) => { diff --git a/web/src/api/memory.ts b/web/src/api/memory.ts index 077cdf53..77801c63 100644 --- a/web/src/api/memory.ts +++ b/web/src/api/memory.ts @@ -274,8 +274,8 @@ export const updateMemoryExtractionConfig = (values: ExtractionConfigForm) => { return request.post('/memory-storage/update_config_extracted', values) } // Memory Extraction Engine - Pilot run -export const pilotRunMemoryExtractionConfig = (values: { config_id: number | string; dialogue_text: string; custom_text?: string; }, onMessage?: (data: SSEMessage[]) => void) => { - return handleSSE('/memory-storage/pilot_run', values, onMessage) +export const pilotRunMemoryExtractionConfig = (values: { config_id: number | string; dialogue_text: string; custom_text?: string; }, onMessage?: (data: SSEMessage[]) => void, onAbort?: (abort: () => void) => void) => { + return handleSSE('/memory-storage/pilot_run', values, onMessage, undefined, onAbort) } // Emotion Engine - Get configuration export const getMemoryEmotionConfig = (config_id: number | string) => { diff --git a/web/src/api/prompt.ts b/web/src/api/prompt.ts index 55398ca5..ea641c56 100644 --- a/web/src/api/prompt.ts +++ b/web/src/api/prompt.ts @@ -14,8 +14,8 @@ export const createPromptSessions = () => { return request.post(`/prompt/sessions`) } // Get prompt optimization -export const updatePromptMessages = (session_id: string, data: AiPromptForm, onMessage?: (data: SSEMessage[]) => void) => { - return handleSSE(`/prompt/sessions/${session_id}/messages`, data, onMessage) +export const updatePromptMessages = (session_id: string, data: AiPromptForm, onMessage?: (data: SSEMessage[]) => void, config?: any, onAbort?: (abort: () => void) => void) => { + return handleSSE(`/prompt/sessions/${session_id}/messages`, data, onMessage, config, onAbort) } // Prompt release list export const getPromptReleaseListUrl = '/prompt/releases/list' diff --git a/web/src/utils/stream.ts b/web/src/utils/stream.ts index ba966159..77459120 100644 --- a/web/src/utils/stream.ts +++ b/web/src/utils/stream.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-02 16:35:43 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-18 14:32:40 + * @Last Modified time: 2026-04-21 14:20:39 */ /** * Server-Sent Events (SSE) Stream Utility Module @@ -148,7 +148,7 @@ function parseDataContent(dataContent: string): string | object { * @param config - Additional request configuration * @returns Fetch response */ -const makeSSERequest = async (url: string, data: any, token: string, config = { headers: {} }) => { +const makeSSERequest = async (url: string, data: any, token: string, config = { headers: {} }, signal?: AbortSignal) => { return fetch(`${API_PREFIX}${url}`, { method: 'POST', headers: { @@ -156,7 +156,8 @@ const makeSSERequest = async (url: string, data: any, token: string, config = { 'Authorization': `Bearer ${token}`, ...config.headers, }, - body: JSON.stringify(data) + body: JSON.stringify(data), + signal, }); }; @@ -167,10 +168,14 @@ const makeSSERequest = async (url: string, data: any, token: string, config = { * @param onMessage - Callback for each parsed message * @param config - Additional request configuration */ -export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMessage[]) => void, config = { headers: {} }) => { +export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMessage[]) => void, config = { headers: {} }, onAbort?: (abort: () => void) => void) => { + const controller = new AbortController(); + const abort = () => controller.abort(); + onAbort?.(abort); + try { let token = cookieUtils.get('authToken'); - let response = await makeSSERequest(url, data, token || '', config); + let response = await makeSSERequest(url, data, token || '', config, controller.signal); switch (response.status) { case 500: @@ -199,7 +204,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe } try { const newToken = await refreshTokenForSSE(); - response = await makeSSERequest(url, data, newToken, config); + response = await makeSSERequest(url, data, newToken, config, controller.signal); } catch (refreshError) { return; } @@ -211,30 +216,37 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe const decoder = new TextDecoder(); let buffer = ''; // Buffer for handling incomplete messages - while (true) { - const { done, value } = await reader.read(); - if (done) break; + try { + while (true) { + const { done, value } = await reader.read(); + if (done || controller.signal.aborted) break; - const chunk = decoder.decode(value, { stream: true }); - buffer += chunk; + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; - // Process complete events - const events = buffer.split('\n\n'); - buffer = events.pop() || ''; // Keep last potentially incomplete event + // Process complete events + const events = buffer.split('\n\n'); + buffer = events.pop() || ''; // Keep last potentially incomplete event - for (const event of events) { - if (event.trim() && onMessage) { - onMessage(parseSSEToJSON(event) ?? {}); + for (const event of events) { + if (event.trim() && onMessage) { + onMessage(parseSSEToJSON(event) ?? {}); + } } } - } - // Process remaining buffer content - if (buffer.trim() && onMessage) { - onMessage(parseSSEToJSON(buffer) ?? {}); + // Process remaining buffer content + if (!controller.signal.aborted && buffer.trim() && onMessage) { + onMessage(parseSSEToJSON(buffer) ?? {}); + } + } finally { + reader.cancel(); + } + } catch (error: any) { + if (error?.name !== 'AbortError') { + console.error('Request failed:', error); + throw error; } - } catch (error) { - console.error('Request failed:', error); - throw error; } + }; \ No newline at end of file diff --git a/web/src/views/ApplicationConfig/TestChat/index.tsx b/web/src/views/ApplicationConfig/TestChat/index.tsx index bfb9b569..b62efc6b 100644 --- a/web/src/views/ApplicationConfig/TestChat/index.tsx +++ b/web/src/views/ApplicationConfig/TestChat/index.tsx @@ -92,6 +92,7 @@ const TestChat: FC = ({ const audioPollingRef = useRef>>(new Map()) const streamLoadingRef = useRef(false) const [audioStatusMap, setAudioStatusMap] = useState>({}) + const abortRef = useRef<(() => void) | null>(null) useEffect(() => { getVariables() @@ -99,6 +100,8 @@ const TestChat: FC = ({ useEffect(() => { return () => { + abortRef.current?.() + abortRef.current = null audioPollingRef.current.forEach(timer => clearInterval(timer)) audioPollingRef.current.clear() } @@ -262,7 +265,8 @@ const TestChat: FC = ({ draftRun( application.id, formatParams((msg || message) as string, conversationId, files, params), - handleStreamMessage + handleStreamMessage, + (abort) => { abortRef.current = abort } ) .catch(() => { updateErrorAssistantMessage(0) @@ -373,7 +377,8 @@ const TestChat: FC = ({ draftRun( application.id, formatParams((msg || message) as string, conversationId, files, params), - handleWorkflowStreamMessage + handleWorkflowStreamMessage, + (abort) => { abortRef.current = abort } ) .catch((error) => { const errorInfo = JSON.parse(error.message) diff --git a/web/src/views/ApplicationConfig/components/AiPromptModal.tsx b/web/src/views/ApplicationConfig/components/AiPromptModal.tsx index 1666e075..96a0c7b5 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-21 14:50:21 */ /** * AI Prompt Assistant Modal @@ -61,11 +61,14 @@ const AiPromptModal = forwardRef(({ const aiPromptVariableModalRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef('') + const abortRef = useRef<(() => void) | null>(null) const values = Form.useWatch([], form) /** Close modal and reset state */ const handleClose = () => { + abortRef.current?.() + abortRef.current = null setVisible(false); setLoading(false) setChatList([]) @@ -148,7 +151,7 @@ const AiPromptModal = forwardRef(({ updatePromptMessages(promptSession, { ...values, skill: source === 'skills' - }, handleStreamMessage) + }, handleStreamMessage, undefined, abort => { abortRef.current = abort }) .finally(() => { setLoading(false) }) @@ -221,7 +224,7 @@ const AiPromptModal = forwardRef(({ } data={chatList || []} @@ -292,10 +295,10 @@ const AiPromptModal = forwardRef(({ {values?.current_prompt ? form.setFieldValue('current_prompt', value)} /> - : + : }
diff --git a/web/src/views/ApplicationConfig/components/Chat.tsx b/web/src/views/ApplicationConfig/components/Chat.tsx index eb3a9ea0..6cf7b438 100644 --- a/web/src/views/ApplicationConfig/components/Chat.tsx +++ b/web/src/views/ApplicationConfig/components/Chat.tsx @@ -73,11 +73,14 @@ const Chat: FC = ({ const [message, setMessage] = useState(undefined) const [features, setFeatures] = useState({} as FeaturesConfigForm) const [audioStatusMap, setAudioStatusMap] = useState>({}) + const abortRef = useRef<(() => void) | null>(null) useEffect(() => { setCompareLoading(false) setLoading(false) return () => { + abortRef.current?.() + abortRef.current = null audioPollingRef.current.forEach(timer => clearInterval(timer)) audioPollingRef.current.clear() } @@ -85,6 +88,8 @@ const Chat: FC = ({ useEffect(() => { return () => { + abortRef.current?.() + abortRef.current = null audioPollingRef.current.forEach(timer => clearInterval(timer)) audioPollingRef.current.clear() } @@ -393,7 +398,7 @@ const Chat: FC = ({ parallel: true, stream: true, timeout: 60, - }, handleStreamMessage) + }, handleStreamMessage, (abort) => { abortRef.current = abort }) .catch(() => { setLoading(false) setCompareLoading(false) @@ -537,7 +542,8 @@ const Chat: FC = ({ } }), }, - handleStreamMessage + handleStreamMessage, + (abort) => { abortRef.current = abort } ) .catch(() => { setLoading(false) diff --git a/web/src/views/Conversation/index.tsx b/web/src/views/Conversation/index.tsx index 778279d3..a562aaeb 100644 --- a/web/src/views/Conversation/index.tsx +++ b/web/src/views/Conversation/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:58:03 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-13 18:32:58 + * @Last Modified time: 2026-04-21 14:27:15 */ /** * Conversation Page @@ -53,6 +53,7 @@ const Conversation: FC = () => { const scrollRef = useRef(null); const toolbarRef = useRef(null) const audioPollingRef = useRef>>(new Map()) + const abortRef = useRef<(() => void) | null>(null) const [shareToken, setShareToken] = useState(localStorage.getItem(`shareToken_${token}`)) const [fileList, setFileList] = useState([]) const [webSearch, setWebSearch] = useState(false) @@ -67,6 +68,8 @@ const Conversation: FC = () => { useEffect(() => { return () => { + abortRef.current?.() + abortRef.current = null audioPollingRef.current.forEach((timer) => clearInterval(timer)) audioPollingRef.current.clear() } @@ -150,6 +153,8 @@ const Conversation: FC = () => { const handleChangeHistory = (id: string | null) => { if (id !== conversation_id) setConversationId(id) if (!id) setMessage('') + abortRef.current?.() + abortRef.current = null } useEffect(() => { @@ -406,7 +411,7 @@ const Conversation: FC = () => { }), variables: params, thinking, - }, handleStreamMessage, shareToken) + }, handleStreamMessage, shareToken, (abort) => { abortRef.current = abort }) .catch(() => { setLoading(false) streamLoadingRef.current = false diff --git a/web/src/views/MemoryExtractionEngine/components/Result.tsx b/web/src/views/MemoryExtractionEngine/components/Result.tsx index 2fa8788f..c1dcf88b 100644 --- a/web/src/views/MemoryExtractionEngine/components/Result.tsx +++ b/web/src/views/MemoryExtractionEngine/components/Result.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:30:11 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-26 15:46:30 + * @Last Modified time: 2026-04-21 14:54:14 */ /** * Result Component @@ -10,7 +10,7 @@ * Shows text preprocessing, knowledge extraction, node/edge creation, and deduplication */ -import { type FC, useState } from 'react' +import { type FC, useState, useRef, useEffect } from 'react' import { useParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { Space, Button, Progress, Form, Input, Flex } from 'antd' @@ -105,7 +105,14 @@ const Result: FC = ({ loading, handleSave }) => { const [runForm] = Form.useForm() const customText = Form.useWatch(['custom_text'], runForm) + const abortRef = useRef<(() => void) | null>(null) + useEffect(() => { + return () => { + abortRef.current?.() + abortRef.current = null; + } + }, []) /** Run pilot test */ const handleRun = () => { if(!id) return @@ -229,11 +236,13 @@ const Result: FC = ({ loading, handleSave }) => { }) } setRunLoading(true) + abortRef.current?.() + abortRef.current = null; pilotRunMemoryExtractionConfig({ config_id: id, dialogue_text: t('memoryExtractionEngine.exampleText'), custom_text: runForm.getFieldValue('custom_text') - }, handleStreamMessage) + }, handleStreamMessage, (abort) => { abortRef.current = abort }) .finally(() => { setRunLoading(false) }) diff --git a/web/src/views/Prompt/index.tsx b/web/src/views/Prompt/index.tsx index 0475b40a..9d90ee4b 100644 --- a/web/src/views/Prompt/index.tsx +++ b/web/src/views/Prompt/index.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 17:44:15 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-27 15:14:58 + * @Last Modified time: 2026-04-21 14:24:00 */ /** * Prompt Editor Component @@ -46,9 +46,17 @@ const Prompt: FC = () => { const promptSaveModalRef = useRef(null) const editorRef = useRef(null) const currentPromptValueRef = useRef(undefined) + const abortRef = useRef<(() => void) | null>(null) const values = Form.useWatch([], form) const [editVo, setEditVo] = useState(null) + useEffect(() => { + return () => { + abortRef.current?.() + abortRef.current = null + } + }, []) + useEffect(() => { setEditVo(state) }, [state]) @@ -126,7 +134,7 @@ const Prompt: FC = () => { } }) }; - updatePromptMessages((promptSession) as string, values, handleStreamMessage) + updatePromptMessages((promptSession) as string, values, handleStreamMessage, undefined, (abort) => { abortRef.current = abort }) .finally(() => { setLoading(false) }) diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 19b06a0d..a6b4a2a8 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-06 21:10:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-15 15:57:35 + * @Last Modified time: 2026-04-21 14:59:13 */ /** * Workflow Chat Component @@ -51,6 +51,7 @@ const Chat = forwardRef('draft') const toolbarRef = useRef(null) + const abortRef = useRef<(() => void) | null>(null) const [toolbarReady, setToolbarReady] = useState(false) const toolbarCallbackRef = useCallback((node: ChatToolbarRef | null) => { (toolbarRef as React.MutableRefObject).current = node @@ -65,6 +66,8 @@ const Chat = forwardRef([]) const [message, setMessage] = useState(undefined) + console.log('abortRef', abortRef) + /** * Opens the chat drawer and loads workflow variables from the start node */ @@ -116,6 +119,8 @@ const Chat = forwardRef { + abortRef.current?.() + abortRef.current = null; setOpen(false) setToolbarReady(false) setChatList([]) @@ -395,7 +400,7 @@ const Chat = forwardRef { abortRef.current = abort }) .catch((error) => { const errorInfo = JSON.parse(error.message) setChatList(prev => { diff --git a/web/src/views/Workflow/components/Properties/index.tsx b/web/src/views/Workflow/components/Properties/index.tsx index f826edd9..19b24ea4 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-13 10:44:19 + * @Last Modified time: 2026-04-21 14:15:33 */ import { type FC, useEffect, useState, useMemo } from "react"; import clsx from 'clsx' @@ -153,7 +153,9 @@ const Properties: FC = ({ selectedNode?.setData({ ...nodeData, ...allRest, - }) + }, + // { deep: false } + ) } }, [values, selectedNode, form]) From 8cab49c2b178e9004b2d736269decd4b896f909e Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 21 Apr 2026 15:07:16 +0800 Subject: [PATCH 5/8] fix(web): abort reset --- web/src/views/ApplicationConfig/components/Chat.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/src/views/ApplicationConfig/components/Chat.tsx b/web/src/views/ApplicationConfig/components/Chat.tsx index 6cf7b438..dc8272bf 100644 --- a/web/src/views/ApplicationConfig/components/Chat.tsx +++ b/web/src/views/ApplicationConfig/components/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:27:39 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-10 18:51:43 + * @Last Modified time: 2026-04-21 15:06:40 */ /** * Chat debugging component for application testing @@ -79,8 +79,6 @@ const Chat: FC = ({ setCompareLoading(false) setLoading(false) return () => { - abortRef.current?.() - abortRef.current = null audioPollingRef.current.forEach(timer => clearInterval(timer)) audioPollingRef.current.clear() } From 1a826c0026328a1558dade8328d0c2f09d21bf3a Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 21 Apr 2026 15:08:15 +0800 Subject: [PATCH 6/8] Revert "fix(web): abort reset" This reverts commit 8cab49c2b178e9004b2d736269decd4b896f909e. --- web/src/views/ApplicationConfig/components/Chat.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/views/ApplicationConfig/components/Chat.tsx b/web/src/views/ApplicationConfig/components/Chat.tsx index dc8272bf..6cf7b438 100644 --- a/web/src/views/ApplicationConfig/components/Chat.tsx +++ b/web/src/views/ApplicationConfig/components/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:27:39 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-21 15:06:40 + * @Last Modified time: 2026-04-10 18:51:43 */ /** * Chat debugging component for application testing @@ -79,6 +79,8 @@ const Chat: FC = ({ setCompareLoading(false) setLoading(false) return () => { + abortRef.current?.() + abortRef.current = null audioPollingRef.current.forEach(timer => clearInterval(timer)) audioPollingRef.current.clear() } From 9533a9a69326d9ad470d10a8faf365fbe5ed4c12 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 21 Apr 2026 17:41:21 +0800 Subject: [PATCH 7/8] feat(workflow): support output node for workflow termination and streaming text output --- .../core/workflow/adapters/dify/converter.py | 41 ++++++++++++++-- .../workflow/adapters/dify/dify_adapter.py | 14 +++--- .../memory_bear/memory_bear_converter.py | 2 + api/app/core/workflow/engine/graph_builder.py | 16 ++++-- api/app/core/workflow/executor.py | 17 ++++++- api/app/core/workflow/nodes/configs.py | 2 + api/app/core/workflow/nodes/enums.py | 1 + api/app/core/workflow/nodes/llm/node.py | 3 +- api/app/core/workflow/nodes/node_factory.py | 7 ++- .../core/workflow/nodes/output/__init__.py | 4 ++ api/app/core/workflow/nodes/output/config.py | 14 ++++++ api/app/core/workflow/nodes/output/node.py | 49 +++++++++++++++++++ api/app/core/workflow/validator.py | 6 +-- 13 files changed, 153 insertions(+), 23 deletions(-) create mode 100644 api/app/core/workflow/nodes/output/__init__.py create mode 100644 api/app/core/workflow/nodes/output/config.py create mode 100644 api/app/core/workflow/nodes/output/node.py diff --git a/api/app/core/workflow/adapters/dify/converter.py b/api/app/core/workflow/adapters/dify/converter.py index ad9312e1..9daa71cc 100644 --- a/api/app/core/workflow/adapters/dify/converter.py +++ b/api/app/core/workflow/adapters/dify/converter.py @@ -81,6 +81,7 @@ class DifyConverter(BaseConverter): NodeType.START: self.convert_start_node_config, NodeType.LLM: self.convert_llm_node_config, NodeType.END: self.convert_end_node_config, + NodeType.OUTPUT: self.convert_output_node_config, NodeType.IF_ELSE: self.convert_if_else_node_config, NodeType.LOOP: self.convert_loop_node_config, NodeType.ITERATION: self.convert_iteration_node_config, @@ -174,12 +175,20 @@ class DifyConverter(BaseConverter): "file": VariableType.FILE, "paragraph": VariableType.STRING, "text-input": VariableType.STRING, + "string": VariableType.STRING, "number": VariableType.NUMBER, - "checkbox": VariableType.BOOLEAN, - "file-list": VariableType.ARRAY_FILE, - "select": VariableType.STRING, "integer": VariableType.NUMBER, "float": VariableType.NUMBER, + "checkbox": VariableType.BOOLEAN, + "boolean": VariableType.BOOLEAN, + "object": VariableType.OBJECT, + "file-list": VariableType.ARRAY_FILE, + "array[string]": VariableType.ARRAY_STRING, + "array[number]": VariableType.ARRAY_NUMBER, + "array[boolean]": VariableType.ARRAY_BOOLEAN, + "array[object]": VariableType.ARRAY_OBJECT, + "array[file]": VariableType.ARRAY_FILE, + "select": VariableType.STRING, } var_type = type_map.get(source_type, source_type) return var_type @@ -274,7 +283,18 @@ class DifyConverter(BaseConverter): def convert_start_node_config(self, node: dict) -> dict: node_data = node["data"] start_vars = [] - for var in node_data["variables"]: + # workflow mode 用 user_input_form,advanced-chat 用 variables + raw_vars = node_data.get("variables") or [] + if not raw_vars: + for form_item in node_data.get("user_input_form") or []: + # 每个 form_item 是 {"text-input": {...}} 或 {"paragraph": {...}} 等 + for input_type, var in form_item.items(): + var["type"] = input_type + var.setdefault("variable", var.get("variable", "")) + var.setdefault("required", var.get("required", False)) + var.setdefault("label", var.get("label", "")) + raw_vars.append(var) + for var in raw_vars: var_type = self.variable_type_map(var["type"]) if not var_type: self.errors.append( @@ -404,6 +424,19 @@ class DifyConverter(BaseConverter): self.config_validate(node["id"], node["data"]["title"], EndNodeConfig, result) return result + def convert_output_node_config(self, node: dict) -> dict: + node_data = node["data"] + outputs = [] + for item in node_data.get("outputs", []): + value_selector = item.get("value_selector") or [] + var_type = self.variable_type_map(item.get("value_type", "string")) or VariableType.STRING + outputs.append({ + "name": item.get("variable") or item.get("name", ""), + "type": var_type, + "value": self._process_list_variable_literal(value_selector) or "", + }) + return {"outputs": outputs} + def convert_if_else_node_config(self, node: dict) -> dict: node_data = node["data"] cases = [] diff --git a/api/app/core/workflow/adapters/dify/dify_adapter.py b/api/app/core/workflow/adapters/dify/dify_adapter.py index c699f877..ec33cc71 100644 --- a/api/app/core/workflow/adapters/dify/dify_adapter.py +++ b/api/app/core/workflow/adapters/dify/dify_adapter.py @@ -30,6 +30,7 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): "start": NodeType.START, "llm": NodeType.LLM, "answer": NodeType.END, + "end": NodeType.OUTPUT, "if-else": NodeType.IF_ELSE, "loop-start": NodeType.CYCLE_START, "iteration-start": NodeType.CYCLE_START, @@ -86,13 +87,6 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): require_fields = frozenset({'app', 'kind', 'version', 'workflow'}) if not all(field in self.config for field in require_fields): return False - if self.config.get("app", {}).get("mode") == "workflow": - self.errors.append(ExceptionDefinition( - type=ExceptionType.PLATFORM, - detail="workflow mode is not supported" - )) - return False - for node in self.origin_nodes: if not self._valid_nodes(node): return False @@ -114,7 +108,11 @@ class DifyAdapter(BasePlatformAdapter, DifyConverter): if edge: self.edges.append(edge) - for variable in self.config.get("workflow").get("conversation_variables"): + mode = self.config.get("app", {}).get("mode", "advanced-chat") + conv_variables = self.config.get("workflow").get("conversation_variables") or [] + if mode == "workflow": + conv_variables = [] + for variable in conv_variables: con_var = self._convert_variable(variable) if variable: self.conv_variables.append(con_var) diff --git a/api/app/core/workflow/adapters/memory_bear/memory_bear_converter.py b/api/app/core/workflow/adapters/memory_bear/memory_bear_converter.py index 0f44ad72..8c0c1e00 100644 --- a/api/app/core/workflow/adapters/memory_bear/memory_bear_converter.py +++ b/api/app/core/workflow/adapters/memory_bear/memory_bear_converter.py @@ -24,6 +24,7 @@ from app.core.workflow.nodes.configs import ( NoteNodeConfig, ListOperatorNodeConfig, DocExtractorNodeConfig, + OutputNodeConfig, ) from app.core.workflow.nodes.enums import NodeType @@ -36,6 +37,7 @@ class MemoryBearConverter(BaseConverter): NodeType.START: StartNodeConfig, NodeType.END: EndNodeConfig, NodeType.ANSWER: EndNodeConfig, + NodeType.OUTPUT: OutputNodeConfig, NodeType.LLM: LLMNodeConfig, NodeType.AGENT: AgentNodeConfig, NodeType.IF_ELSE: IfElseNodeConfig, diff --git a/api/app/core/workflow/engine/graph_builder.py b/api/app/core/workflow/engine/graph_builder.py index e0bdebf3..8c1a799c 100644 --- a/api/app/core/workflow/engine/graph_builder.py +++ b/api/app/core/workflow/engine/graph_builder.py @@ -144,7 +144,7 @@ class GraphBuilder: (node_info["id"], node_info["branch"]) ) else: - if self.get_node_type(node_info["id"]) == NodeType.END: + if self.get_node_type(node_info["id"]) in (NodeType.END, NodeType.OUTPUT): output_nodes.append(node_info["id"]) non_branch_nodes.append(node_info["id"]) @@ -187,7 +187,17 @@ class GraphBuilder: for end_node in self.end_nodes: end_node_id = end_node.get("id") config = end_node.get("config", {}) - output = config.get("output") + node_type = end_node.get("type") + + # Output node: STRING type items participate in streaming text output + if node_type == NodeType.OUTPUT: + outputs_list = config.get("outputs", []) + output = "\n".join( + item.get("value", "") for item in outputs_list + if item.get("value") and item.get("type", "string") == "string" + ) or None + else: + output = config.get("output") # Skip End nodes without output configuration if not output: @@ -515,7 +525,7 @@ class GraphBuilder: self.end_nodes = [ node for node in self.nodes - if node.get("type") == "end" and node.get("id") in self.reachable_nodes + if node.get("type") in ("end", "output") and node.get("id") in self.reachable_nodes ] self._build_adj() self._find_upstream_activation_dep: Callable = lru_cache( diff --git a/api/app/core/workflow/executor.py b/api/app/core/workflow/executor.py index 0a820826..6ac48ede 100644 --- a/api/app/core/workflow/executor.py +++ b/api/app/core/workflow/executor.py @@ -258,6 +258,21 @@ class WorkflowExecutor: end_time = datetime.datetime.now() elapsed_time = (end_time - start_time).total_seconds() + # For output nodes, collect structured results from variable_pool and serialize to JSON + output_node_ids = [ + node["id"] for node in self.workflow_config.get("nodes", []) + if node.get("type") == "output" + ] + if output_node_ids: + structured_output = {} + for node_id in output_node_ids: + node_output = self.variable_pool.get_node_output(node_id, default=None, strict=False) + if node_output: + structured_output.update(node_output) + final_output = structured_output if structured_output else full_content + else: + final_output = full_content + # Append messages for user and assistant if input_data.get("files"): result["messages"].extend( @@ -301,7 +316,7 @@ class WorkflowExecutor: self.execution_context, self.variable_pool, elapsed_time, - full_content, + final_output, success=True) } diff --git a/api/app/core/workflow/nodes/configs.py b/api/app/core/workflow/nodes/configs.py index 5ec029cc..352e6f2a 100644 --- a/api/app/core/workflow/nodes/configs.py +++ b/api/app/core/workflow/nodes/configs.py @@ -26,6 +26,7 @@ from app.core.workflow.nodes.variable_aggregator.config import VariableAggregato from app.core.workflow.nodes.notes.config import NoteNodeConfig from app.core.workflow.nodes.list_operator.config import ListOperatorNodeConfig from app.core.workflow.nodes.document_extractor.config import DocExtractorNodeConfig +from app.core.workflow.nodes.output.config import OutputNodeConfig __all__ = [ # 基础类 @@ -54,4 +55,5 @@ __all__ = [ "NoteNodeConfig", "ListOperatorNodeConfig", "DocExtractorNodeConfig", + "OutputNodeConfig" ] diff --git a/api/app/core/workflow/nodes/enums.py b/api/app/core/workflow/nodes/enums.py index bd0d8426..0c0e8fb8 100644 --- a/api/app/core/workflow/nodes/enums.py +++ b/api/app/core/workflow/nodes/enums.py @@ -25,6 +25,7 @@ class NodeType(StrEnum): MEMORY_WRITE = "memory-write" DOCUMENT_EXTRACTOR = "document-extractor" LIST_OPERATOR = "list-operator" + OUTPUT = "output" UNKNOWN = "unknown" NOTES = "notes" diff --git a/api/app/core/workflow/nodes/llm/node.py b/api/app/core/workflow/nodes/llm/node.py index db7f1009..352e735d 100644 --- a/api/app/core/workflow/nodes/llm/node.py +++ b/api/app/core/workflow/nodes/llm/node.py @@ -5,7 +5,6 @@ LLM 节点实现 """ import logging -import re from typing import Any from langchain_core.messages import AIMessage @@ -81,7 +80,7 @@ class LLMNode(BaseNode): def _render_context(self, message: str, variable_pool: VariablePool): context = f"{self._render_template(self.typed_config.context, variable_pool)}" - return re.sub(r"{{context}}", context, message) + return message.replace("{{context}}", context) async def _prepare_llm( self, diff --git a/api/app/core/workflow/nodes/node_factory.py b/api/app/core/workflow/nodes/node_factory.py index 1dfcce74..bd1a80a3 100644 --- a/api/app/core/workflow/nodes/node_factory.py +++ b/api/app/core/workflow/nodes/node_factory.py @@ -28,6 +28,7 @@ from app.core.workflow.nodes.breaker import BreakNode from app.core.workflow.nodes.tool import ToolNode from app.core.workflow.nodes.document_extractor import DocExtractorNode from app.core.workflow.nodes.list_operator import ListOperatorNode +from app.core.workflow.nodes.output import OutputNode logger = logging.getLogger(__name__) @@ -53,7 +54,8 @@ WorkflowNode = Union[ MemoryWriteNode, CodeNode, DocExtractorNode, - ListOperatorNode + ListOperatorNode, + OutputNode ] @@ -86,7 +88,8 @@ class NodeFactory: NodeType.MEMORY_WRITE: MemoryWriteNode, NodeType.CODE: CodeNode, NodeType.DOCUMENT_EXTRACTOR: DocExtractorNode, - NodeType.LIST_OPERATOR: ListOperatorNode + NodeType.LIST_OPERATOR: ListOperatorNode, + NodeType.OUTPUT: OutputNode, } @classmethod diff --git a/api/app/core/workflow/nodes/output/__init__.py b/api/app/core/workflow/nodes/output/__init__.py new file mode 100644 index 00000000..911e3fa1 --- /dev/null +++ b/api/app/core/workflow/nodes/output/__init__.py @@ -0,0 +1,4 @@ +from app.core.workflow.nodes.output.node import OutputNode +from app.core.workflow.nodes.output.config import OutputNodeConfig + +__all__ = ["OutputNode", "OutputNodeConfig"] diff --git a/api/app/core/workflow/nodes/output/config.py b/api/app/core/workflow/nodes/output/config.py new file mode 100644 index 00000000..bfb59995 --- /dev/null +++ b/api/app/core/workflow/nodes/output/config.py @@ -0,0 +1,14 @@ +from typing import Any +from pydantic import Field +from app.core.workflow.nodes.base_config import BaseNodeConfig +from app.core.workflow.variable.base_variable import VariableType + + +class OutputItemConfig(BaseNodeConfig): + name: str + type: VariableType = VariableType.STRING + value: Any = "" + + +class OutputNodeConfig(BaseNodeConfig): + outputs: list[OutputItemConfig] = Field(default_factory=list) diff --git a/api/app/core/workflow/nodes/output/node.py b/api/app/core/workflow/nodes/output/node.py new file mode 100644 index 00000000..4f89a925 --- /dev/null +++ b/api/app/core/workflow/nodes/output/node.py @@ -0,0 +1,49 @@ +""" +Output 节点实现 + +工作流的输出节点(类似 Dify workflow 的 end 节点), +用于定义工作流的最终输出变量,不产生流式输出。 +""" + +import logging +from typing import Any + +from app.core.workflow.engine.state_manager import WorkflowState +from app.core.workflow.engine.variable_pool import VariablePool +from app.core.workflow.nodes.base_node import BaseNode +from app.core.workflow.variable.base_variable import VariableType + +logger = logging.getLogger(__name__) + + +class OutputNode(BaseNode): + """ + Output 节点 + + 工作流的输出节点,收集并输出指定变量的值。 + """ + + def _output_types(self) -> dict[str, VariableType]: + outputs = self.config.get("outputs", []) + return { + item["name"]: VariableType(item.get("type", VariableType.STRING)) + for item in outputs if item.get("name") + } + + async def execute(self, state: WorkflowState, variable_pool: VariablePool) -> dict[str, Any]: + outputs = self.config.get("outputs", []) + result = {} + for item in outputs: + name = item.get("name") + if not name: + continue + var_type = VariableType(item.get("type", VariableType.STRING)) + value = item.get("value", "") + if var_type == VariableType.STRING: + result[name] = self._render_template(str(value), variable_pool, strict=False) + elif isinstance(value, str) and value.strip().startswith("{{") and value.strip().endswith("}}"): + selector = value.strip()[2:-2].strip() + result[name] = variable_pool.get_value(selector, default=None, strict=False) + else: + result[name] = value + return result diff --git a/api/app/core/workflow/validator.py b/api/app/core/workflow/validator.py index 7aa107cf..36a90be6 100644 --- a/api/app/core/workflow/validator.py +++ b/api/app/core/workflow/validator.py @@ -132,10 +132,10 @@ class WorkflowValidator: errors.append(f"工作流只能有一个 start 节点,当前有 {len(start_nodes)} 个") if index == len(graphs) - 1: - # 2. 验证 主图end 节点(至少一个) - end_nodes = [n for n in nodes if n.get("type") == NodeType.END] + # 2. 验证 主图end 节点(至少一个,output 节点也可作为终止节点) + end_nodes = [n for n in nodes if n.get("type") in [NodeType.END, NodeType.OUTPUT]] if len(end_nodes) == 0: - errors.append("工作流必须至少有一个 end 节点") + errors.append("工作流必须至少有一个 end 节点 或 output节点") # 3. 验证节点 ID 唯一性 node_ids = [n.get("id") for n in nodes if n.get("type") != NodeType.NOTES] From 93d4607b148b72e011b4e97aea975853b8b3c7b7 Mon Sep 17 00:00:00 2001 From: Timebomb2018 <18868801967@163.com> Date: Tue, 21 Apr 2026 17:50:31 +0800 Subject: [PATCH 8/8] fix(workflow): normalize output node type comparison and fix validator error message spacing --- api/app/core/workflow/engine/graph_builder.py | 3 ++- api/app/core/workflow/validator.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/app/core/workflow/engine/graph_builder.py b/api/app/core/workflow/engine/graph_builder.py index 8c1a799c..5ecf41d2 100644 --- a/api/app/core/workflow/engine/graph_builder.py +++ b/api/app/core/workflow/engine/graph_builder.py @@ -21,6 +21,7 @@ from app.core.workflow.nodes import NodeFactory from app.core.workflow.nodes.enums import NodeType, BRANCH_NODES from app.core.workflow.utils.expression_evaluator import evaluate_condition from app.core.workflow.validator import WorkflowValidator +from app.core.workflow.variable.base_variable import VariableType logger = logging.getLogger(__name__) @@ -194,7 +195,7 @@ class GraphBuilder: outputs_list = config.get("outputs", []) output = "\n".join( item.get("value", "") for item in outputs_list - if item.get("value") and item.get("type", "string") == "string" + if item.get("value") and item.get("type", VariableType.STRING) == VariableType.STRING ) or None else: output = config.get("output") diff --git a/api/app/core/workflow/validator.py b/api/app/core/workflow/validator.py index 36a90be6..962291d4 100644 --- a/api/app/core/workflow/validator.py +++ b/api/app/core/workflow/validator.py @@ -135,7 +135,7 @@ class WorkflowValidator: # 2. 验证 主图end 节点(至少一个,output 节点也可作为终止节点) end_nodes = [n for n in nodes if n.get("type") in [NodeType.END, NodeType.OUTPUT]] if len(end_nodes) == 0: - errors.append("工作流必须至少有一个 end 节点 或 output节点") + errors.append("工作流必须至少有一个 end 节点 或 output 节点") # 3. 验证节点 ID 唯一性 node_ids = [n.get("id") for n in nodes if n.get("type") != NodeType.NOTES]