From dd6fde26d01f781bd182dd3e15674fdc919755d3 Mon Sep 17 00:00:00 2001 From: Novice Date: Tue, 24 Mar 2026 14:26:38 +0800 Subject: [PATCH] feat: add runtime upgrade handling and UI components for LLM nodes --- api/services/app_runtime_upgrade_service.py | 100 +++++++++++++----- web/app/components/app-sidebar/index.tsx | 38 ++++++- web/app/components/app/store.ts | 4 + .../workflow-app/hooks/use-workflow-init.ts | 6 +- web/app/components/workflow-app/index.tsx | 19 ++-- 5 files changed, 127 insertions(+), 40 deletions(-) diff --git a/api/services/app_runtime_upgrade_service.py b/api/services/app_runtime_upgrade_service.py index db804579c6..ff3a3814d2 100644 --- a/api/services/app_runtime_upgrade_service.py +++ b/api/services/app_runtime_upgrade_service.py @@ -75,6 +75,7 @@ class AppRuntimeUpgradeService: nodes = graph.get("nodes", []) converted, skipped = _convert_agent_nodes(nodes) + _enable_computer_use_for_existing_llm_nodes(nodes) llm_node_ids = {n["id"] for n in nodes if n.get("data", {}).get("type") == "llm"} _rewrite_variable_references(nodes, llm_node_ids) @@ -124,7 +125,6 @@ class AppRuntimeUpgradeService: def _convert_agent_nodes(nodes: list[dict[str, Any]]) -> tuple[int, int]: """Convert Agent nodes to LLM nodes in-place. Returns (converted_count, skipped_count).""" converted = 0 - skipped = 0 for node in nodes: data = node.get("data", {}) @@ -132,38 +132,33 @@ def _convert_agent_nodes(nodes: list[dict[str, Any]]) -> tuple[int, int]: continue node_id = node.get("id", "?") - llm_data = _agent_data_to_llm_data(data) - if llm_data is None: - logger.warning("Skipped agent node %s: cannot extract model config", node_id) - skipped += 1 - continue - - node["data"] = llm_data + node["data"] = _agent_data_to_llm_data(data) logger.info("Converted agent node %s to LLM", node_id) converted += 1 - return converted, skipped + return converted, 0 -def _agent_data_to_llm_data(agent_data: dict[str, Any]) -> dict[str, Any] | None: +def _agent_data_to_llm_data(agent_data: dict[str, Any]) -> dict[str, Any]: """Map an Agent node's data dict to an LLM node's data dict. - Returns None if the conversion cannot be performed (e.g. missing model config). + Always returns a valid LLM data dict. If the agent has no model selected, + produces an empty LLM node with agent mode (computer_use) enabled. """ - params = agent_data.get("agent_parameters", {}) + params = agent_data.get("agent_parameters") or {} - model_param = params.get("model", {}) + model_param = params.get("model", {}) if isinstance(params, dict) else {} model_value = model_param.get("value") if isinstance(model_param, dict) else None - if not isinstance(model_value, dict) or not model_value.get("provider") or not model_value.get("model"): - return None - - model_config = { - "provider": model_value["provider"], - "name": model_value["model"], - "mode": model_value.get("mode", "chat"), - "completion_params": model_value.get("completion_params", {}), - } + if isinstance(model_value, dict) and model_value.get("provider") and model_value.get("model"): + model_config = { + "provider": model_value["provider"], + "name": model_value["model"], + "mode": model_value.get("mode", "chat"), + "completion_params": model_value.get("completion_params", {}), + } + else: + model_config = {"provider": "", "name": "", "mode": "chat", "completion_params": {}} tools_param = params.get("tools", {}) tools_value = tools_param.get("value", []) if isinstance(tools_param, dict) else [] @@ -186,6 +181,9 @@ def _agent_data_to_llm_data(agent_data: dict[str, Any]) -> dict[str, Any] | None max_iter_param = params.get("maximum_iterations", {}) max_iterations = max_iter_param.get("value", 100) if isinstance(max_iter_param, dict) else 100 + context_config = _extract_context(params) + vision_config = _extract_vision(params) + llm_data: dict[str, Any] = { "type": "llm", "title": agent_data.get("title", "LLM"), @@ -194,9 +192,9 @@ def _agent_data_to_llm_data(agent_data: dict[str, Any]) -> dict[str, Any] | None "prompt_template": prompt_template, "prompt_config": {"jinja2_variables": []}, "memory": agent_data.get("memory"), - "context": {"enabled": False}, - "vision": {"enabled": False}, - "computer_use": bool(tools_meta), + "context": context_config, + "vision": vision_config, + "computer_use": True, "structured_output_switch_on": False, "reasoning_format": "separated", "tools": tools_meta, @@ -211,6 +209,58 @@ def _agent_data_to_llm_data(agent_data: dict[str, Any]) -> dict[str, Any] | None return llm_data +def _extract_context(params: dict[str, Any]) -> dict[str, Any]: + """Extract context config from agent_parameters for LLM node format. + + Agent stores context as a variable selector in agent_parameters.context.value, + e.g. ["knowledge_retrieval_node_id", "result"]. Maps to LLM ContextConfig. + """ + if not isinstance(params, dict): + return {"enabled": False} + + ctx_param = params.get("context", {}) + ctx_value = ctx_param.get("value") if isinstance(ctx_param, dict) else None + + if isinstance(ctx_value, list) and len(ctx_value) >= 2 and all(isinstance(s, str) for s in ctx_value): + return {"enabled": True, "variable_selector": ctx_value} + + return {"enabled": False} + + +def _extract_vision(params: dict[str, Any]) -> dict[str, Any]: + """Extract vision config from agent_parameters for LLM node format.""" + if not isinstance(params, dict): + return {"enabled": False} + + vision_param = params.get("vision", {}) + vision_value = vision_param.get("value") if isinstance(vision_param, dict) else None + + if isinstance(vision_value, dict) and vision_value.get("enabled"): + return vision_value + + if isinstance(vision_value, bool) and vision_value: + return {"enabled": True} + + return {"enabled": False} + + +def _enable_computer_use_for_existing_llm_nodes(nodes: list[dict[str, Any]]) -> None: + """Enable computer_use for existing LLM nodes that have tools configured. + + After upgrade, the sandbox runtime requires computer_use=true for tool calling. + Existing LLM nodes from classic mode may have tools but computer_use=false. + """ + for node in nodes: + data = node.get("data", {}) + if data.get("type") != "llm": + continue + + tools = data.get("tools", []) + if tools and not data.get("computer_use"): + data["computer_use"] = True + logger.info("Enabled computer_use for LLM node %s with %d tools", node.get("id", "?"), len(tools)) + + def _convert_tools( tools_input: list[dict[str, Any]], ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index afc6bd0f13..686c0da463 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -3,12 +3,14 @@ import { useHover, useKeyPress } from 'ahooks' import { usePathname } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import { useEventEmitterContextContext } from '@/context/event-emitter' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { cn } from '@/utils/classnames' import Divider from '../base/divider' +import Tooltip from '../base/tooltip' import { getKeyboardKeyCodeBySystem } from '../workflow/utils' import AppInfo from './app-info' import AppSidebarDropdown from './app-sidebar-dropdown' @@ -34,9 +36,11 @@ const AppDetailNav = ({ extraInfo, iconType = 'app', }: IAppDetailNavProps) => { - const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({ + const { t } = useTranslation() + const { appSidebarExpand, setAppSidebarExpand, needsRuntimeUpgrade } = useAppStore(useShallow(state => ({ appSidebarExpand: state.appSidebarExpand, setAppSidebarExpand: state.setAppSidebarExpand, + needsRuntimeUpgrade: state.needsRuntimeUpgrade, }))) const sidebarRef = React.useRef(null) const media = useBreakpoints() @@ -49,6 +53,8 @@ const AppDetailNav = ({ const isHoveringSidebar = useHover(sidebarRef) + const showUpgradeButton = iconType === 'app' && needsRuntimeUpgrade + // Check if the current path is a workflow canvas & fullscreen const pathname = usePathname() const inWorkflowCanvas = pathname.endsWith('/workflow') @@ -57,9 +63,9 @@ const AppDetailNav = ({ const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize) const { eventEmitter } = useEventEmitterContextContext() - eventEmitter?.useSubscription((v: any) => { + eventEmitter?.useSubscription((v: { type: string; payload?: boolean }) => { if (v?.type === 'workflow-canvas-maximize') - setHideHeader(v.payload) + setHideHeader(v.payload ?? false) }) useEffect(() => { @@ -136,10 +142,10 @@ const AppDetailNav = ({ expand ? 'px-3 py-2' : 'p-3', )} > - {navigation.map((item, index) => { + {navigation.map((item) => { return ( {iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)} + {iconType === 'app' && showUpgradeButton && ( +
+ + + +
+ )} ) } diff --git a/web/app/components/app/store.ts b/web/app/components/app/store.ts index 020f2b4e6f..ee96d9d160 100644 --- a/web/app/components/app/store.ts +++ b/web/app/components/app/store.ts @@ -11,6 +11,7 @@ type State = { showAgentLogModal: boolean showMessageLogModal: boolean showAppConfigureFeaturesModal: boolean + needsRuntimeUpgrade: boolean } type Action = { @@ -22,6 +23,7 @@ type Action = { setShowAgentLogModal: (showAgentLogModal: boolean) => void setShowMessageLogModal: (showMessageLogModal: boolean) => void setShowAppConfigureFeaturesModal: (showAppConfigureFeaturesModal: boolean) => void + setNeedsRuntimeUpgrade: (needsRuntimeUpgrade: boolean) => void } export const useStore = create(set => ({ @@ -51,4 +53,6 @@ export const useStore = create(set => ({ }), showAppConfigureFeaturesModal: false, setShowAppConfigureFeaturesModal: showAppConfigureFeaturesModal => set(() => ({ showAppConfigureFeaturesModal })), + needsRuntimeUpgrade: false, + setNeedsRuntimeUpgrade: needsRuntimeUpgrade => set(() => ({ needsRuntimeUpgrade })), })) diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index 3fed7b1a60..820d6ba1ab 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -22,6 +22,7 @@ import { } from '@/service/workflow' import { AppModeEnum } from '@/types/app' import { storage } from '@/utils/storage' +import { setSandboxMigrationDismissed } from '../utils/sandbox-migration-storage' import { useWorkflowTemplate } from './use-workflow-template' const hasConnectedUserInput = (nodes: Node[] = [], edges: Edge[] = []): boolean => { @@ -46,7 +47,7 @@ export const useWorkflowInit = () => { const [isLoading, setIsLoading] = useState(true) useEffect(() => { workflowStore.setState({ appId: appDetail.id, appName: appDetail.name }) - }, [appDetail.id, workflowStore]) + }, [appDetail.id, appDetail.name, workflowStore]) const handleUpdateWorkflowFileUploadConfig = useCallback((config: FileUploadConfigResponse) => { const { setFileUploadConfig } = workflowStore.getState() @@ -92,6 +93,9 @@ export const useWorkflowInit = () => { if (enableSandboxRuntime) storage.remove(runtimeStorageKey) + if (!enableSandboxRuntime) + setSandboxMigrationDismissed(appDetail.id) + syncWorkflowDraft({ url: `/apps/${appDetail.id}/workflows/draft`, params: { diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 4785583eab..f292758bef 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -37,6 +37,7 @@ import { initialNodes, } from '@/app/components/workflow/utils' import { useAppContext } from '@/context/app-context' +import { useEventEmitterContextContext } from '@/context/event-emitter' import { upgradeAppRuntime } from '@/service/apps' import { fetchRunDetail } from '@/service/log' import { useAppTriggers } from '@/service/use-tools' @@ -44,7 +45,6 @@ import { AppModeEnum } from '@/types/app' import { useFeatures } from '../base/features/hooks' import ViewPicker from '../workflow/view-picker' import SandboxMigrationModal from './components/sandbox-migration-modal' -import UpgradeRuntimeBanner from './components/upgrade-runtime-banner' import UpgradedFromBanner from './components/upgraded-from-banner' import WorkflowAppMain from './components/workflow-main' import { useGetRunAndTraceUrl } from './hooks/use-get-run-and-trace-url' @@ -193,9 +193,11 @@ const WorkflowAppWithAdditionalContext = () => { setShowMigrationModal(false) }, [appId]) - const handleOpenMigrationModal = useCallback(() => { - setShowMigrationModal(true) - }, []) + const { eventEmitter } = useEventEmitterContextContext() + eventEmitter?.useSubscription((v: { type: string }) => { + if (v?.type === 'upgrade-runtime-click') + setShowMigrationModal(true) + }) const showUpgradeRuntimeModal = useStore(s => s.showUpgradeRuntimeModal) const setShowUpgradeRuntimeModal = useStore(s => s.setShowUpgradeRuntimeModal) @@ -255,7 +257,7 @@ const WorkflowAppWithAdditionalContext = () => { const { debouncedSyncWorkflowDraft } = workflowStore.getState() // The debounced function from lodash has a cancel method if (debouncedSyncWorkflowDraft && 'cancel' in debouncedSyncWorkflowDraft) - (debouncedSyncWorkflowDraft as any).cancel() + (debouncedSyncWorkflowDraft as { cancel: () => void }).cancel() } }, [workflowStore]) @@ -364,16 +366,18 @@ const WorkflowAppWithAdditionalContext = () => { const isDataReady = !(!data || isLoading || isLoadingCurrentWorkspace || !currentWorkspace.id) const sandboxEnabled = isSandboxFeatureEnabled + const setNeedsRuntimeUpgrade = useAppStore(s => s.setNeedsRuntimeUpgrade) useEffect(() => { if (!isDataReady || !appId) return + setNeedsRuntimeUpgrade(!sandboxEnabled) if (lastCheckedAppIdRef.current !== appId) { lastCheckedAppIdRef.current = appId const dismissed = getSandboxMigrationDismissed(appId) // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect setShowMigrationModal(!sandboxEnabled && !dismissed) } - }, [appId, isDataReady, sandboxEnabled]) + }, [appId, isDataReady, sandboxEnabled, setNeedsRuntimeUpgrade]) const renderGraph = useCallback((headerLeftSlot: ReactNode) => { if (!isDataReady) return null @@ -432,9 +436,6 @@ const WorkflowAppWithAdditionalContext = () => { onClose={handleCloseMigrationModal} onUpgrade={handleUpgradeRuntime} /> - {!sandboxEnabled && !upgradedFromId && ( - - )} {upgradedFromId && (