From 2f3a61b51b3edc899900c766569d9e8384b15288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Thu, 23 Oct 2025 20:34:41 +0800 Subject: [PATCH 01/10] fix: missing import dsl version incompatible modal (#27338) --- web/app/components/app/create-from-dsl-modal/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index e1a556a709..0c137abb71 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -132,8 +132,6 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS importedVersion: imported_dsl_version ?? '', systemVersion: current_dsl_version ?? '', }) - if (onClose) - onClose() setTimeout(() => { setShowErrorModal(true) }, 300) From 53b21eea61a76f7e4eae2fecd5b95b7991a46d73 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 23 Oct 2025 22:29:02 +0800 Subject: [PATCH 02/10] Promote GraphRuntimeState snapshot loading to class factory (#27222) --- .../workflow/runtime/graph_runtime_state.py | 196 ++++++++++++------ .../entities/test_graph_runtime_state.py | 70 +++++-- 2 files changed, 195 insertions(+), 71 deletions(-) diff --git a/api/core/workflow/runtime/graph_runtime_state.py b/api/core/workflow/runtime/graph_runtime_state.py index 486718dc62..4c322c6aa6 100644 --- a/api/core/workflow/runtime/graph_runtime_state.py +++ b/api/core/workflow/runtime/graph_runtime_state.py @@ -5,6 +5,7 @@ import json from collections.abc import Mapping, Sequence from collections.abc import Mapping as TypingMapping from copy import deepcopy +from dataclasses import dataclass from typing import Any, Protocol from pydantic.json import pydantic_encoder @@ -106,6 +107,23 @@ class GraphProtocol(Protocol): def get_outgoing_edges(self, node_id: str) -> Sequence[object]: ... +@dataclass(slots=True) +class _GraphRuntimeStateSnapshot: + """Immutable view of a serialized runtime state snapshot.""" + + start_at: float + total_tokens: int + node_run_steps: int + llm_usage: LLMUsage + outputs: dict[str, Any] + variable_pool: VariablePool + has_variable_pool: bool + ready_queue_dump: str | None + graph_execution_dump: str | None + response_coordinator_dump: str | None + paused_nodes: tuple[str, ...] + + class GraphRuntimeState: """Mutable runtime state shared across graph execution components.""" @@ -293,69 +311,28 @@ class GraphRuntimeState: return json.dumps(snapshot, default=pydantic_encoder) - def loads(self, data: str | Mapping[str, Any]) -> None: + @classmethod + def from_snapshot(cls, data: str | Mapping[str, Any]) -> GraphRuntimeState: """Restore runtime state from a serialized snapshot.""" - payload: dict[str, Any] - if isinstance(data, str): - payload = json.loads(data) - else: - payload = dict(data) + snapshot = cls._parse_snapshot_payload(data) - version = payload.get("version") - if version != "1.0": - raise ValueError(f"Unsupported GraphRuntimeState snapshot version: {version}") + state = cls( + variable_pool=snapshot.variable_pool, + start_at=snapshot.start_at, + total_tokens=snapshot.total_tokens, + llm_usage=snapshot.llm_usage, + outputs=snapshot.outputs, + node_run_steps=snapshot.node_run_steps, + ) + state._apply_snapshot(snapshot) + return state - self._start_at = float(payload.get("start_at", 0.0)) - total_tokens = int(payload.get("total_tokens", 0)) - if total_tokens < 0: - raise ValueError("total_tokens must be non-negative") - self._total_tokens = total_tokens + def loads(self, data: str | Mapping[str, Any]) -> None: + """Restore runtime state from a serialized snapshot (legacy API).""" - node_run_steps = int(payload.get("node_run_steps", 0)) - if node_run_steps < 0: - raise ValueError("node_run_steps must be non-negative") - self._node_run_steps = node_run_steps - - llm_usage_payload = payload.get("llm_usage", {}) - self._llm_usage = LLMUsage.model_validate(llm_usage_payload) - - self._outputs = deepcopy(payload.get("outputs", {})) - - variable_pool_payload = payload.get("variable_pool") - if variable_pool_payload is not None: - self._variable_pool = VariablePool.model_validate(variable_pool_payload) - - ready_queue_payload = payload.get("ready_queue") - if ready_queue_payload is not None: - self._ready_queue = self._build_ready_queue() - self._ready_queue.loads(ready_queue_payload) - else: - self._ready_queue = None - - graph_execution_payload = payload.get("graph_execution") - self._graph_execution = None - self._pending_graph_execution_workflow_id = None - if graph_execution_payload is not None: - try: - execution_payload = json.loads(graph_execution_payload) - self._pending_graph_execution_workflow_id = execution_payload.get("workflow_id") - except (json.JSONDecodeError, TypeError, AttributeError): - self._pending_graph_execution_workflow_id = None - self.graph_execution.loads(graph_execution_payload) - - response_payload = payload.get("response_coordinator") - if response_payload is not None: - if self._graph is not None: - self.response_coordinator.loads(response_payload) - else: - self._pending_response_coordinator_dump = response_payload - else: - self._pending_response_coordinator_dump = None - self._response_coordinator = None - - paused_nodes_payload = payload.get("paused_nodes", []) - self._paused_nodes = set(map(str, paused_nodes_payload)) + snapshot = self._parse_snapshot_payload(data) + self._apply_snapshot(snapshot) def register_paused_node(self, node_id: str) -> None: """Record a node that should resume when execution is continued.""" @@ -391,3 +368,106 @@ class GraphRuntimeState: module = importlib.import_module("core.workflow.graph_engine.response_coordinator") coordinator_cls = module.ResponseStreamCoordinator return coordinator_cls(variable_pool=self.variable_pool, graph=graph) + + # ------------------------------------------------------------------ + # Snapshot helpers + # ------------------------------------------------------------------ + @classmethod + def _parse_snapshot_payload(cls, data: str | Mapping[str, Any]) -> _GraphRuntimeStateSnapshot: + payload: dict[str, Any] + if isinstance(data, str): + payload = json.loads(data) + else: + payload = dict(data) + + version = payload.get("version") + if version != "1.0": + raise ValueError(f"Unsupported GraphRuntimeState snapshot version: {version}") + + start_at = float(payload.get("start_at", 0.0)) + + total_tokens = int(payload.get("total_tokens", 0)) + if total_tokens < 0: + raise ValueError("total_tokens must be non-negative") + + node_run_steps = int(payload.get("node_run_steps", 0)) + if node_run_steps < 0: + raise ValueError("node_run_steps must be non-negative") + + llm_usage_payload = payload.get("llm_usage", {}) + llm_usage = LLMUsage.model_validate(llm_usage_payload) + + outputs_payload = deepcopy(payload.get("outputs", {})) + + variable_pool_payload = payload.get("variable_pool") + has_variable_pool = variable_pool_payload is not None + variable_pool = VariablePool.model_validate(variable_pool_payload) if has_variable_pool else VariablePool() + + ready_queue_payload = payload.get("ready_queue") + graph_execution_payload = payload.get("graph_execution") + response_payload = payload.get("response_coordinator") + paused_nodes_payload = payload.get("paused_nodes", []) + + return _GraphRuntimeStateSnapshot( + start_at=start_at, + total_tokens=total_tokens, + node_run_steps=node_run_steps, + llm_usage=llm_usage, + outputs=outputs_payload, + variable_pool=variable_pool, + has_variable_pool=has_variable_pool, + ready_queue_dump=ready_queue_payload, + graph_execution_dump=graph_execution_payload, + response_coordinator_dump=response_payload, + paused_nodes=tuple(map(str, paused_nodes_payload)), + ) + + def _apply_snapshot(self, snapshot: _GraphRuntimeStateSnapshot) -> None: + self._start_at = snapshot.start_at + self._total_tokens = snapshot.total_tokens + self._node_run_steps = snapshot.node_run_steps + self._llm_usage = snapshot.llm_usage.model_copy() + self._outputs = deepcopy(snapshot.outputs) + if snapshot.has_variable_pool or self._variable_pool is None: + self._variable_pool = snapshot.variable_pool + + self._restore_ready_queue(snapshot.ready_queue_dump) + self._restore_graph_execution(snapshot.graph_execution_dump) + self._restore_response_coordinator(snapshot.response_coordinator_dump) + self._paused_nodes = set(snapshot.paused_nodes) + + def _restore_ready_queue(self, payload: str | None) -> None: + if payload is not None: + self._ready_queue = self._build_ready_queue() + self._ready_queue.loads(payload) + else: + self._ready_queue = None + + def _restore_graph_execution(self, payload: str | None) -> None: + self._graph_execution = None + self._pending_graph_execution_workflow_id = None + + if payload is None: + return + + try: + execution_payload = json.loads(payload) + self._pending_graph_execution_workflow_id = execution_payload.get("workflow_id") + except (json.JSONDecodeError, TypeError, AttributeError): + self._pending_graph_execution_workflow_id = None + + self.graph_execution.loads(payload) + + def _restore_response_coordinator(self, payload: str | None) -> None: + if payload is None: + self._pending_response_coordinator_dump = None + self._response_coordinator = None + return + + if self._graph is not None: + self.response_coordinator.loads(payload) + self._pending_response_coordinator_dump = None + return + + self._pending_response_coordinator_dump = payload + self._response_coordinator = None diff --git a/api/tests/unit_tests/core/workflow/entities/test_graph_runtime_state.py b/api/tests/unit_tests/core/workflow/entities/test_graph_runtime_state.py index 5ecaeb60ac..deff06fc5d 100644 --- a/api/tests/unit_tests/core/workflow/entities/test_graph_runtime_state.py +++ b/api/tests/unit_tests/core/workflow/entities/test_graph_runtime_state.py @@ -8,6 +8,18 @@ from core.model_runtime.entities.llm_entities import LLMUsage from core.workflow.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool +class StubCoordinator: + def __init__(self) -> None: + self.state = "initial" + + def dumps(self) -> str: + return json.dumps({"state": self.state}) + + def loads(self, data: str) -> None: + payload = json.loads(data) + self.state = payload["state"] + + class TestGraphRuntimeState: def test_property_getters_and_setters(self): # FIXME(-LAN-): Mock VariablePool if needed @@ -191,17 +203,6 @@ class TestGraphRuntimeState: graph_execution.exceptions_count = 4 graph_execution.started = True - class StubCoordinator: - def __init__(self) -> None: - self.state = "initial" - - def dumps(self) -> str: - return json.dumps({"state": self.state}) - - def loads(self, data: str) -> None: - payload = json.loads(data) - self.state = payload["state"] - mock_graph = MagicMock() stub = StubCoordinator() with patch.object(GraphRuntimeState, "_build_response_coordinator", return_value=stub): @@ -211,8 +212,7 @@ class TestGraphRuntimeState: snapshot = state.dumps() - restored = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) - restored.loads(snapshot) + restored = GraphRuntimeState.from_snapshot(snapshot) assert restored.total_tokens == 10 assert restored.node_run_steps == 3 @@ -235,3 +235,47 @@ class TestGraphRuntimeState: restored.attach_graph(mock_graph) assert new_stub.state == "configured" + + def test_loads_rehydrates_existing_instance(self): + variable_pool = VariablePool() + variable_pool.add(("node", "key"), "value") + + state = GraphRuntimeState(variable_pool=variable_pool, start_at=time()) + state.total_tokens = 7 + state.node_run_steps = 2 + state.set_output("foo", "bar") + state.ready_queue.put("node-1") + + execution = state.graph_execution + execution.workflow_id = "wf-456" + execution.started = True + + mock_graph = MagicMock() + original_stub = StubCoordinator() + with patch.object(GraphRuntimeState, "_build_response_coordinator", return_value=original_stub): + state.attach_graph(mock_graph) + + original_stub.state = "configured" + snapshot = state.dumps() + + new_stub = StubCoordinator() + with patch.object(GraphRuntimeState, "_build_response_coordinator", return_value=new_stub): + restored = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + restored.attach_graph(mock_graph) + restored.loads(snapshot) + + assert restored.total_tokens == 7 + assert restored.node_run_steps == 2 + assert restored.get_output("foo") == "bar" + assert restored.ready_queue.qsize() == 1 + assert restored.ready_queue.get(timeout=0.01) == "node-1" + + restored_segment = restored.variable_pool.get(("node", "key")) + assert restored_segment is not None + assert restored_segment.value == "value" + + restored_execution = restored.graph_execution + assert restored_execution.workflow_id == "wf-456" + assert restored_execution.started is True + + assert new_stub.state == "configured" From 7fa0ad31614f24a99667bec94987e9face6aed43 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 23 Oct 2025 22:56:08 +0800 Subject: [PATCH 03/10] fix: Render variables in Question Classifier class names (#27356) --- .../question_classifier/question_classifier_node.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/api/core/workflow/nodes/question_classifier/question_classifier_node.py b/api/core/workflow/nodes/question_classifier/question_classifier_node.py index 3f37fc481b..948a1cead7 100644 --- a/api/core/workflow/nodes/question_classifier/question_classifier_node.py +++ b/api/core/workflow/nodes/question_classifier/question_classifier_node.py @@ -193,15 +193,19 @@ class QuestionClassifierNode(Node): finish_reason = event.finish_reason break - category_name = node_data.classes[0].name - category_id = node_data.classes[0].id + rendered_classes = [ + c.model_copy(update={"name": variable_pool.convert_template(c.name).text}) for c in node_data.classes + ] + + category_name = rendered_classes[0].name + category_id = rendered_classes[0].id if "" in result_text: result_text = re.sub(r"]*>[\s\S]*?", "", result_text, flags=re.IGNORECASE) result_text_json = parse_and_check_json_markdown(result_text, []) # result_text_json = json.loads(result_text.strip('```JSON\n')) if "category_name" in result_text_json and "category_id" in result_text_json: category_id_result = result_text_json["category_id"] - classes = node_data.classes + classes = rendered_classes classes_map = {class_.id: class_.name for class_ in classes} category_ids = [_class.id for _class in classes] if category_id_result in category_ids: From 8ff6de91b0ae1cecd3147762452080cb40c5e93b Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 23 Oct 2025 23:18:20 +0800 Subject: [PATCH 04/10] Fix UpdatedVariable truncation crash (#27359) Signed-off-by: -LAN- --- api/services/variable_truncator.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/services/variable_truncator.py b/api/services/variable_truncator.py index 4e13d2d964..6f8adb7536 100644 --- a/api/services/variable_truncator.py +++ b/api/services/variable_truncator.py @@ -283,7 +283,7 @@ class VariableTruncator: break remaining_budget = target_size - used_size - if item is None or isinstance(item, (str, list, dict, bool, int, float)): + if item is None or isinstance(item, (str, list, dict, bool, int, float, UpdatedVariable)): part_result = self._truncate_json_primitives(item, remaining_budget) else: raise UnknownTypeError(f"got unknown type {type(item)} in array truncation") @@ -373,6 +373,11 @@ class VariableTruncator: return _PartResult(truncated_obj, used_size, truncated) + @overload + def _truncate_json_primitives( + self, val: UpdatedVariable, target_size: int + ) -> _PartResult[Mapping[str, object]]: ... + @overload def _truncate_json_primitives(self, val: str, target_size: int) -> _PartResult[str]: ... From a4b38e7521bfd139e3c631115e625f221e836499 Mon Sep 17 00:00:00 2001 From: crazywoola <100913391+crazywoola@users.noreply.github.com> Date: Fri, 24 Oct 2025 10:40:41 +0800 Subject: [PATCH 05/10] Revert "Sync log detail drawer with conversation_id query parameter, so that we can share a specific conversation" (#27382) --- web/app/components/app/log/list.tsx | 120 ++++------------------------ 1 file changed, 17 insertions(+), 103 deletions(-) diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 258d06ac79..8b3370b678 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -14,7 +14,6 @@ import timezone from 'dayjs/plugin/timezone' import { createContext, useContext } from 'use-context-selector' import { useShallow } from 'zustand/react/shallow' import { useTranslation } from 'react-i18next' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' import type { ChatItemInTree } from '../../base/chat/types' import Indicator from '../../header/indicator' import VarPanel from './var-panel' @@ -43,10 +42,6 @@ import cn from '@/utils/classnames' import { noop } from 'lodash-es' import PromptLogModal from '../../base/prompt-log-modal' -type AppStoreState = ReturnType -type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail -type ConversationSelection = ConversationListItem | { id: string; isPlaceholder?: true } - dayjs.extend(utc) dayjs.extend(timezone) @@ -206,7 +201,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) { const { formatTime } = useTimestamp() const { onClose, appDetail } = useContext(DrawerContext) const { notify } = useContext(ToastContext) - const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({ + const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({ currentLogItem: state.currentLogItem, setCurrentLogItem: state.setCurrentLogItem, showMessageLogModal: state.showMessageLogModal, @@ -898,113 +893,20 @@ const ChatConversationDetailComp: FC<{ appId?: string; conversationId?: string } const ConversationList: FC = ({ logs, appDetail, onRefresh }) => { const { t } = useTranslation() const { formatTime } = useTimestamp() - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const conversationIdInUrl = searchParams.get('conversation_id') ?? undefined const media = useBreakpoints() const isMobile = media === MediaType.mobile const [showDrawer, setShowDrawer] = useState(false) // Whether to display the chat details drawer - const [currentConversation, setCurrentConversation] = useState() // Currently selected conversation - const closingConversationIdRef = useRef(null) - const pendingConversationIdRef = useRef(null) - const pendingConversationCacheRef = useRef(undefined) + const [currentConversation, setCurrentConversation] = useState() // Currently selected conversation const isChatMode = appDetail.mode !== 'completion' // Whether the app is a chat app const isChatflow = appDetail.mode === 'advanced-chat' // Whether the app is a chatflow app - const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow((state: AppStoreState) => ({ + const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow(state => ({ setShowPromptLogModal: state.setShowPromptLogModal, setShowAgentLogModal: state.setShowAgentLogModal, setShowMessageLogModal: state.setShowMessageLogModal, }))) - const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id - - const buildUrlWithConversation = useCallback((conversationId?: string) => { - const params = new URLSearchParams(searchParams.toString()) - if (conversationId) - params.set('conversation_id', conversationId) - else - params.delete('conversation_id') - - const queryString = params.toString() - return queryString ? `${pathname}?${queryString}` : pathname - }, [pathname, searchParams]) - - const handleRowClick = useCallback((log: ConversationListItem) => { - if (conversationIdInUrl === log.id) { - if (!showDrawer) - setShowDrawer(true) - - if (!currentConversation || currentConversation.id !== log.id) - setCurrentConversation(log) - return - } - - pendingConversationIdRef.current = log.id - pendingConversationCacheRef.current = log - if (!showDrawer) - setShowDrawer(true) - - if (currentConversation?.id !== log.id) - setCurrentConversation(undefined) - - router.push(buildUrlWithConversation(log.id), { scroll: false }) - }, [buildUrlWithConversation, conversationIdInUrl, currentConversation, router, showDrawer]) - - const currentConversationId = currentConversation?.id - - useEffect(() => { - if (!conversationIdInUrl) { - if (pendingConversationIdRef.current) - return - - if (showDrawer || currentConversationId) { - setShowDrawer(false) - setCurrentConversation(undefined) - } - closingConversationIdRef.current = null - pendingConversationCacheRef.current = undefined - return - } - - if (closingConversationIdRef.current === conversationIdInUrl) - return - - if (pendingConversationIdRef.current === conversationIdInUrl) - pendingConversationIdRef.current = null - - const matchedConversation = logs?.data?.find((item: ConversationListItem) => item.id === conversationIdInUrl) - const nextConversation: ConversationSelection = matchedConversation - ?? pendingConversationCacheRef.current - ?? { id: conversationIdInUrl, isPlaceholder: true } - - if (!showDrawer) - setShowDrawer(true) - - if (!currentConversation || currentConversation.id !== conversationIdInUrl || (matchedConversation && currentConversation !== matchedConversation)) - setCurrentConversation(nextConversation) - - if (pendingConversationCacheRef.current?.id === conversationIdInUrl || matchedConversation) - pendingConversationCacheRef.current = undefined - }, [conversationIdInUrl, currentConversation, isChatMode, logs?.data, showDrawer]) - - const onCloseDrawer = useCallback(() => { - onRefresh() - setShowDrawer(false) - setCurrentConversation(undefined) - setShowPromptLogModal(false) - setShowAgentLogModal(false) - setShowMessageLogModal(false) - pendingConversationIdRef.current = null - pendingConversationCacheRef.current = undefined - closingConversationIdRef.current = conversationIdInUrl ?? null - - if (conversationIdInUrl) - router.replace(buildUrlWithConversation(), { scroll: false }) - }, [buildUrlWithConversation, conversationIdInUrl, onRefresh, router, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal]) - // Annotated data needs to be highlighted const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => { return ( @@ -1023,6 +925,15 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) ) } + const onCloseDrawer = () => { + onRefresh() + setShowDrawer(false) + setCurrentConversation(undefined) + setShowPromptLogModal(false) + setShowAgentLogModal(false) + setShowMessageLogModal(false) + } + if (!logs) return @@ -1049,8 +960,11 @@ const ConversationList: FC = ({ logs, appDetail, onRefresh }) const rightValue = get(log, isChatMode ? 'message_count' : 'message.answer') return handleRowClick(log)}> + className={cn('cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover', currentConversation?.id !== log.id ? '' : 'bg-background-default-hover')} + onClick={() => { + setShowDrawer(true) + setCurrentConversation(log) + }}> {!log.read_at && (
From 634fb192efe2cabedf664497ca0e2f95f086f8d0 Mon Sep 17 00:00:00 2001 From: Novice Date: Fri, 24 Oct 2025 10:41:14 +0800 Subject: [PATCH 06/10] fix: remove unnecessary Flask context preservation to avoid circular import in audio service (#27380) --- api/services/audio_service.py | 81 +++++++++++++++++------------------ 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/api/services/audio_service.py b/api/services/audio_service.py index 1158fc5197..41ee9c88aa 100644 --- a/api/services/audio_service.py +++ b/api/services/audio_service.py @@ -82,54 +82,51 @@ class AudioService: message_id: str | None = None, is_draft: bool = False, ): - from app import app - def invoke_tts(text_content: str, app_model: App, voice: str | None = None, is_draft: bool = False): - with app.app_context(): - if voice is None: - if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - if is_draft: - workflow = WorkflowService().get_draft_workflow(app_model=app_model) - else: - workflow = app_model.workflow - if ( - workflow is None - or "text_to_speech" not in workflow.features_dict - or not workflow.features_dict["text_to_speech"].get("enabled") - ): + if voice is None: + if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: + if is_draft: + workflow = WorkflowService().get_draft_workflow(app_model=app_model) + else: + workflow = app_model.workflow + if ( + workflow is None + or "text_to_speech" not in workflow.features_dict + or not workflow.features_dict["text_to_speech"].get("enabled") + ): + raise ValueError("TTS is not enabled") + + voice = workflow.features_dict["text_to_speech"].get("voice") + else: + if not is_draft: + if app_model.app_model_config is None: + raise ValueError("AppModelConfig not found") + text_to_speech_dict = app_model.app_model_config.text_to_speech_dict + + if not text_to_speech_dict.get("enabled"): raise ValueError("TTS is not enabled") - voice = workflow.features_dict["text_to_speech"].get("voice") - else: - if not is_draft: - if app_model.app_model_config is None: - raise ValueError("AppModelConfig not found") - text_to_speech_dict = app_model.app_model_config.text_to_speech_dict + voice = text_to_speech_dict.get("voice") - if not text_to_speech_dict.get("enabled"): - raise ValueError("TTS is not enabled") - - voice = text_to_speech_dict.get("voice") - - model_manager = ModelManager() - model_instance = model_manager.get_default_model_instance( - tenant_id=app_model.tenant_id, model_type=ModelType.TTS - ) - try: - if not voice: - voices = model_instance.get_tts_voices() - if voices: - voice = voices[0].get("value") - if not voice: - raise ValueError("Sorry, no voice available.") - else: + model_manager = ModelManager() + model_instance = model_manager.get_default_model_instance( + tenant_id=app_model.tenant_id, model_type=ModelType.TTS + ) + try: + if not voice: + voices = model_instance.get_tts_voices() + if voices: + voice = voices[0].get("value") + if not voice: raise ValueError("Sorry, no voice available.") + else: + raise ValueError("Sorry, no voice available.") - return model_instance.invoke_tts( - content_text=text_content.strip(), user=end_user, tenant_id=app_model.tenant_id, voice=voice - ) - except Exception as e: - raise e + return model_instance.invoke_tts( + content_text=text_content.strip(), user=end_user, tenant_id=app_model.tenant_id, voice=voice + ) + except Exception as e: + raise e if message_id: try: From fa6d03c979b4c2c5403fe243bdca3feb577483db Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Fri, 24 Oct 2025 13:09:34 +0800 Subject: [PATCH 07/10] Fix/refresh token (#27381) --- api/controllers/console/auth/login.py | 3 ++- api/libs/token.py | 29 ++++----------------------- 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index c0a565b5da..77ecd5a5e4 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -29,6 +29,7 @@ from libs.token import ( clear_access_token_from_cookie, clear_csrf_token_from_cookie, clear_refresh_token_from_cookie, + extract_refresh_token, set_access_token_to_cookie, set_csrf_token_to_cookie, set_refresh_token_to_cookie, @@ -270,7 +271,7 @@ class EmailCodeLoginApi(Resource): class RefreshTokenApi(Resource): def post(self): # Get refresh token from cookie instead of request body - refresh_token = request.cookies.get("refresh_token") + refresh_token = extract_refresh_token(request) if not refresh_token: return {"result": "fail", "message": "No refresh token provided"}, 401 diff --git a/api/libs/token.py b/api/libs/token.py index 0b40f18143..b53663c89a 100644 --- a/api/libs/token.py +++ b/api/libs/token.py @@ -38,9 +38,6 @@ def _real_cookie_name(cookie_name: str) -> str: def _try_extract_from_header(request: Request) -> str | None: - """ - Try to extract access token from header - """ auth_header = request.headers.get("Authorization") if auth_header: if " " not in auth_header: @@ -55,27 +52,19 @@ def _try_extract_from_header(request: Request) -> str | None: return None +def extract_refresh_token(request: Request) -> str | None: + return request.cookies.get(_real_cookie_name(COOKIE_NAME_REFRESH_TOKEN)) + + def extract_csrf_token(request: Request) -> str | None: - """ - Try to extract CSRF token from header or cookie. - """ return request.headers.get(HEADER_NAME_CSRF_TOKEN) def extract_csrf_token_from_cookie(request: Request) -> str | None: - """ - Try to extract CSRF token from cookie. - """ return request.cookies.get(_real_cookie_name(COOKIE_NAME_CSRF_TOKEN)) def extract_access_token(request: Request) -> str | None: - """ - Try to extract access token from cookie, header or params. - - Access token is either for console session or webapp passport exchange. - """ - def _try_extract_from_cookie(request: Request) -> str | None: return request.cookies.get(_real_cookie_name(COOKIE_NAME_ACCESS_TOKEN)) @@ -83,20 +72,10 @@ def extract_access_token(request: Request) -> str | None: def extract_webapp_access_token(request: Request) -> str | None: - """ - Try to extract webapp access token from cookie, then header. - """ - return request.cookies.get(_real_cookie_name(COOKIE_NAME_WEBAPP_ACCESS_TOKEN)) or _try_extract_from_header(request) def extract_webapp_passport(app_code: str, request: Request) -> str | None: - """ - Try to extract app token from header or params. - - Webapp access token (part of passport) is only used for webapp session. - """ - def _try_extract_passport_token_from_cookie(request: Request) -> str | None: return request.cookies.get(_real_cookie_name(COOKIE_NAME_PASSPORT + "-" + app_code)) From eabdb09f8eb9fb233794b47dba140071953efd71 Mon Sep 17 00:00:00 2001 From: Novice Date: Fri, 24 Oct 2025 13:29:47 +0800 Subject: [PATCH 08/10] fix: support webapp passport token with end_user_id in web API auth (#27396) --- api/extensions/ext_login.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py index ed4fe332c1..74299956c0 100644 --- a/api/extensions/ext_login.py +++ b/api/extensions/ext_login.py @@ -6,10 +6,11 @@ from flask_login import user_loaded_from_request, user_logged_in from werkzeug.exceptions import NotFound, Unauthorized from configs import dify_config +from constants import HEADER_NAME_APP_CODE from dify_app import DifyApp from extensions.ext_database import db from libs.passport import PassportService -from libs.token import extract_access_token +from libs.token import extract_access_token, extract_webapp_passport from models import Account, Tenant, TenantAccountJoin from models.model import AppMCPServer, EndUser from services.account_service import AccountService @@ -61,14 +62,30 @@ def load_user_from_request(request_from_flask_login): logged_in_account = AccountService.load_logged_in_account(account_id=user_id) return logged_in_account elif request.blueprint == "web": - decoded = PassportService().verify(auth_token) - end_user_id = decoded.get("end_user_id") - if not end_user_id: - raise Unauthorized("Invalid Authorization token.") - end_user = db.session.query(EndUser).where(EndUser.id == decoded["end_user_id"]).first() - if not end_user: - raise NotFound("End user not found.") - return end_user + app_code = request.headers.get(HEADER_NAME_APP_CODE) + webapp_token = extract_webapp_passport(app_code, request) if app_code else None + + if webapp_token: + decoded = PassportService().verify(webapp_token) + end_user_id = decoded.get("end_user_id") + if not end_user_id: + raise Unauthorized("Invalid Authorization token.") + end_user = db.session.query(EndUser).where(EndUser.id == end_user_id).first() + if not end_user: + raise NotFound("End user not found.") + return end_user + else: + if not auth_token: + raise Unauthorized("Invalid Authorization token.") + decoded = PassportService().verify(auth_token) + end_user_id = decoded.get("end_user_id") + if end_user_id: + end_user = db.session.query(EndUser).where(EndUser.id == end_user_id).first() + if not end_user: + raise NotFound("End user not found.") + return end_user + else: + raise Unauthorized("Invalid Authorization token for web API.") elif request.blueprint == "mcp": server_code = request.view_args.get("server_code") if request.view_args else None if not server_code: From dc7ce125ad0f406bba445811c48e5aa0d22dd612 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Fri, 24 Oct 2025 13:46:36 +0800 Subject: [PATCH 09/10] chore: disable postgres timeouts for docker workflows (#27397) --- docker/.env.example | 10 ++++++---- docker/docker-compose-template.yaml | 4 ++-- docker/docker-compose.middleware.yaml | 4 ++-- docker/docker-compose.yaml | 8 ++++---- docker/middleware.env.example | 10 ++++++---- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index ca580dcb79..cbf9cbb912 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -260,16 +260,18 @@ POSTGRES_MAINTENANCE_WORK_MEM=64MB POSTGRES_EFFECTIVE_CACHE_SIZE=4096MB # Sets the maximum allowed duration of any statement before termination. -# Default is 60000 milliseconds. +# Default is 0 (no timeout). # # Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT -POSTGRES_STATEMENT_TIMEOUT=60000 +# A value of 0 prevents the server from timing out statements. +POSTGRES_STATEMENT_TIMEOUT=0 # Sets the maximum allowed duration of any idle in-transaction session before termination. -# Default is 60000 milliseconds. +# Default is 0 (no timeout). # # Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT -POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=60000 +# A value of 0 prevents the server from terminating idle sessions. +POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0 # ------------------------------ # Redis Configuration diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 9650be90db..886335a96b 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -115,8 +115,8 @@ services: -c 'work_mem=${POSTGRES_WORK_MEM:-4MB}' -c 'maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}' -c 'effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB}' - -c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-60000}' - -c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-60000}' + -c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-0}' + -c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-0}' volumes: - ./volumes/db/data:/var/lib/postgresql/data healthcheck: diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 9a1b9b53ba..0497e9d1f6 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -15,8 +15,8 @@ services: -c 'work_mem=${POSTGRES_WORK_MEM:-4MB}' -c 'maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}' -c 'effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB}' - -c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-60000}' - -c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-60000}' + -c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-0}' + -c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-0}' volumes: - ${PGDATA_HOST_VOLUME:-./volumes/db/data}:/var/lib/postgresql/data ports: diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index d2ca6b859e..a18138509c 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -68,8 +68,8 @@ x-shared-env: &shared-api-worker-env POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB} POSTGRES_MAINTENANCE_WORK_MEM: ${POSTGRES_MAINTENANCE_WORK_MEM:-64MB} POSTGRES_EFFECTIVE_CACHE_SIZE: ${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB} - POSTGRES_STATEMENT_TIMEOUT: ${POSTGRES_STATEMENT_TIMEOUT:-60000} - POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT: ${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-60000} + POSTGRES_STATEMENT_TIMEOUT: ${POSTGRES_STATEMENT_TIMEOUT:-0} + POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT: ${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-0} REDIS_HOST: ${REDIS_HOST:-redis} REDIS_PORT: ${REDIS_PORT:-6379} REDIS_USERNAME: ${REDIS_USERNAME:-} @@ -724,8 +724,8 @@ services: -c 'work_mem=${POSTGRES_WORK_MEM:-4MB}' -c 'maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}' -c 'effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB}' - -c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-60000}' - -c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-60000}' + -c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-0}' + -c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-0}' volumes: - ./volumes/db/data:/var/lib/postgresql/data healthcheck: diff --git a/docker/middleware.env.example b/docker/middleware.env.example index c9bb8c0528..24629c2d89 100644 --- a/docker/middleware.env.example +++ b/docker/middleware.env.example @@ -41,16 +41,18 @@ POSTGRES_MAINTENANCE_WORK_MEM=64MB POSTGRES_EFFECTIVE_CACHE_SIZE=4096MB # Sets the maximum allowed duration of any statement before termination. -# Default is 60000 milliseconds. +# Default is 0 (no timeout). # # Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT -POSTGRES_STATEMENT_TIMEOUT=60000 +# A value of 0 prevents the server from timing out statements. +POSTGRES_STATEMENT_TIMEOUT=0 # Sets the maximum allowed duration of any idle in-transaction session before termination. -# Default is 60000 milliseconds. +# Default is 0 (no timeout). # # Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT -POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=60000 +# A value of 0 prevents the server from terminating idle sessions. +POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0 # ----------------------------- # Environment Variables for redis Service From 2e0a7857f0562013302c01fbd55185d2b991b2b0 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 24 Oct 2025 14:47:29 +0800 Subject: [PATCH 10/10] feat: enhance candidate node interactions with iteration constraints and add block functionality --- .../components/workflow/candidate-node.tsx | 72 ++++++++++- .../workflow/hooks/use-nodes-interactions.ts | 8 +- .../panel-operator/panel-operator-popup.tsx | 7 +- .../nodes/iteration/panel-add-block.tsx | 116 ++++++++++++++++++ web/app/components/workflow/types.ts | 1 + 5 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 web/app/components/workflow/nodes/iteration/panel-add-block.tsx diff --git a/web/app/components/workflow/candidate-node.tsx b/web/app/components/workflow/candidate-node.tsx index 6f2389aad2..5a1fd825fb 100644 --- a/web/app/components/workflow/candidate-node.tsx +++ b/web/app/components/workflow/candidate-node.tsx @@ -13,7 +13,7 @@ import { useWorkflowStore, } from './store' import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks' -import { CUSTOM_NODE } from './constants' +import { CUSTOM_NODE, ITERATION_PADDING } from './constants' import { getIterationStartNode, getLoopStartNode } from './utils' import CustomNode from './nodes' import CustomNoteNode from './note-node' @@ -41,7 +41,33 @@ const CandidateNode = () => { } = store.getState() const { screenToFlowPosition } = reactflow const nodes = getNodes() - const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY }) + // Get mouse position in flow coordinates (this is where the top-left corner should be) + let { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY }) + + // If the node has a parent (e.g., inside iteration), apply constraints and convert to relative position + if (candidateNode.parentId) { + const parentNode = nodes.find(node => node.id === candidateNode.parentId) + if (parentNode && parentNode.position) { + // Apply boundary constraints for iteration nodes + if (candidateNode.data.isInIteration) { + const nodeWidth = candidateNode.width || 0 + const nodeHeight = candidateNode.height || 0 + const minX = parentNode.position.x + ITERATION_PADDING.left + const maxX = parentNode.position.x + (parentNode.width || 0) - ITERATION_PADDING.right - nodeWidth + const minY = parentNode.position.y + ITERATION_PADDING.top + const maxY = parentNode.position.y + (parentNode.height || 0) - ITERATION_PADDING.bottom - nodeHeight + + // Constrain position + x = Math.max(minX, Math.min(maxX, x)) + y = Math.max(minY, Math.min(maxY, y)) + } + + // Convert to relative position + x = x - parentNode.position.x + y = y - parentNode.position.y + } + } + const newNodes = produce(nodes, (draft) => { draft.push({ ...candidateNode, @@ -59,6 +85,20 @@ const CandidateNode = () => { if (candidateNode.data.type === BlockEnum.Loop) draft.push(getLoopStartNode(candidateNode.id)) + + // Update parent iteration node's _children array + if (candidateNode.parentId && candidateNode.data.isInIteration) { + const parentNode = draft.find(node => node.id === candidateNode.parentId) + if (parentNode && parentNode.data.type === BlockEnum.Iteration) { + if (!parentNode.data._children) + parentNode.data._children = [] + + parentNode.data._children.push({ + nodeId: candidateNode.id, + nodeType: candidateNode.data.type, + }) + } + } }) setNodes(newNodes) if (candidateNode.type === CUSTOM_NOTE_NODE) @@ -84,6 +124,34 @@ const CandidateNode = () => { if (!candidateNode) return null + // Apply boundary constraints if node is inside iteration + if (candidateNode.parentId && candidateNode.data.isInIteration) { + const { getNodes } = store.getState() + const nodes = getNodes() + const parentNode = nodes.find(node => node.id === candidateNode.parentId) + + if (parentNode && parentNode.position) { + const { screenToFlowPosition, flowToScreenPosition } = reactflow + // Get mouse position in flow coordinates + const flowPosition = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY }) + + // Calculate boundaries in flow coordinates + const nodeWidth = candidateNode.width || 0 + const nodeHeight = candidateNode.height || 0 + const minX = parentNode.position.x + ITERATION_PADDING.left + const maxX = parentNode.position.x + (parentNode.width || 0) - ITERATION_PADDING.right - nodeWidth + const minY = parentNode.position.y + ITERATION_PADDING.top + const maxY = parentNode.position.y + (parentNode.height || 0) - ITERATION_PADDING.bottom - nodeHeight + + // Constrain position + const constrainedX = Math.max(minX, Math.min(maxX, flowPosition.x)) + const constrainedY = Math.max(minY, Math.min(maxY, flowPosition.y)) + + // Convert back to screen coordinates + flowToScreenPosition({ x: constrainedX, y: constrainedY }) + } + } + return (
{ targetHandle = 'target', toolDefaultValue, }, - { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle }, + { prevNodeId, prevNodeSourceHandle, nextNodeId, nextNodeTargetHandle, skipAutoConnect }, ) => { if (getNodesReadOnly()) return @@ -830,7 +830,7 @@ export const useNodesInteractions = () => { } let newEdge = null - if (nodeType !== BlockEnum.DataSource) { + if (nodeType !== BlockEnum.DataSource && !skipAutoConnect) { newEdge = { id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, type: CUSTOM_EDGE, @@ -970,6 +970,7 @@ export const useNodesInteractions = () => { nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier && nodeType !== BlockEnum.LoopEnd + && !skipAutoConnect ) { newEdge = { id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, @@ -1119,7 +1120,7 @@ export const useNodesInteractions = () => { ) let newPrevEdge = null - if (nodeType !== BlockEnum.DataSource) { + if (nodeType !== BlockEnum.DataSource && !skipAutoConnect) { newPrevEdge = { id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`, type: CUSTOM_EDGE, @@ -1159,6 +1160,7 @@ export const useNodesInteractions = () => { nodeType !== BlockEnum.IfElse && nodeType !== BlockEnum.QuestionClassifier && nodeType !== BlockEnum.LoopEnd + && !skipAutoConnect ) { newNextEdge = { id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`, diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx index a871e60e3a..5fa3257a3f 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx @@ -15,7 +15,9 @@ import { useNodesSyncDraft, } from '@/app/components/workflow/hooks' import ShortcutsName from '@/app/components/workflow/shortcuts-name' -import type { Node } from '@/app/components/workflow/types' +import { BlockEnum, type Node } from '@/app/components/workflow/types' +import PanelAddBlock from '@/app/components/workflow/nodes/iteration/panel-add-block' +import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types' type PanelOperatorPopupProps = { id: string @@ -51,6 +53,9 @@ const PanelOperatorPopup = ({ (showChangeBlock || canRunBySingle(data.type, isChildNode)) && ( <>
+ {data.type === BlockEnum.Iteration && ( + + )} { canRunBySingle(data.type, isChildNode) && (
React.ReactNode + offset?: OffsetOptions + iterationNodeData: IterationNodeType + onClosePopup: () => void +} +const AddBlock = ({ + offset, + iterationNodeData, + onClosePopup, +}: AddBlockProps) => { + const { t } = useTranslation() + const store = useStoreApi() + const workflowStore = useWorkflowStore() + const { nodesReadOnly } = useNodesReadOnly() + const { handlePaneContextmenuCancel } = usePanelInteractions() + const [open, setOpen] = useState(false) + const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, false) + const { nodesMap: nodesMetaDataMap } = useNodesMetaData() + + const handleOpenChange = useCallback((open: boolean) => { + setOpen(open) + if (!open) + handlePaneContextmenuCancel() + }, [handlePaneContextmenuCancel]) + + const handleSelect = useCallback((type, toolDefaultValue) => { + const { getNodes } = store.getState() + const nodes = getNodes() + const nodesWithSameType = nodes.filter(node => node.data.type === type) + const { defaultValue } = nodesMetaDataMap![type] + + // Find the parent iteration node + const parentIterationNode = nodes.find(node => node.data.start_node_id === iterationNodeData.start_node_id) + + const { newNode } = generateNewNode({ + type: getNodeCustomTypeByNodeDataType(type), + data: { + ...(defaultValue as any), + title: nodesWithSameType.length > 0 ? `${defaultValue.title} ${nodesWithSameType.length + 1}` : defaultValue.title, + ...toolDefaultValue, + _isCandidate: true, + // Set iteration-specific properties + isInIteration: true, + iteration_id: parentIterationNode?.id, + }, + position: { + x: 0, + y: 0, + }, + }) + + // Set parent and z-index for iteration child + if (parentIterationNode) { + newNode.parentId = parentIterationNode.id + newNode.extent = 'parent' as any + newNode.zIndex = ITERATION_CHILDREN_Z_INDEX + } + + workflowStore.setState({ + candidateNode: newNode, + }) + onClosePopup() + }, [store, workflowStore, nodesMetaDataMap, iterationNodeData.start_node_id, onClosePopup]) + + const renderTrigger = () => { + return ( +
+ {t('workflow.common.addBlock')} +
+ ) + } + + return ( + + ) +} + +export default memo(AddBlock) diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index f6a706a982..55afde0bfd 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -379,6 +379,7 @@ export type OnNodeAdd = ( prevNodeSourceHandle?: string nextNodeId?: string nextNodeTargetHandle?: string + skipAutoConnect?: boolean }, ) => void