feat: add runtime upgrade handling and UI components for LLM nodes

This commit is contained in:
Novice
2026-03-24 14:26:38 +08:00
parent 2cbc8da9cb
commit dd6fde26d0
5 changed files with 127 additions and 40 deletions

View File

@ -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]]]:

View File

@ -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<HTMLDivElement>(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 (
<NavLink
key={index}
key={item.href}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
@ -150,6 +156,28 @@ const AppDetailNav = ({
})}
</nav>
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
{iconType === 'app' && showUpgradeButton && (
<div className={cn('shrink-0 border-t border-divider-subtle', expand ? 'p-3' : 'p-2')}>
<Tooltip popupContent={!expand ? t('sandboxMigrationModal.title', { ns: 'workflow' }) : undefined}>
<button
type="button"
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded-lg text-components-menu-item-text',
'hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
expand ? 'px-2 py-1.5' : 'justify-center p-2',
)}
onClick={() => eventEmitter?.emit({ type: 'upgrade-runtime-click' })}
>
<div className="flex shrink-0 items-center justify-center rounded-xl bg-[#296dff] p-1.5 shadow-sm">
<span className="i-custom-vender-workflow-thinking h-4 w-4 text-white/90" />
</div>
{expand && (
<span className="system-xs-medium">{t('sandboxMigrationModal.title', { ns: 'workflow' })}</span>
)}
</button>
</Tooltip>
</div>
)}
</div>
)
}

View File

@ -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<State & Action>(set => ({
@ -51,4 +53,6 @@ export const useStore = create<State & Action>(set => ({
}),
showAppConfigureFeaturesModal: false,
setShowAppConfigureFeaturesModal: showAppConfigureFeaturesModal => set(() => ({ showAppConfigureFeaturesModal })),
needsRuntimeUpgrade: false,
setNeedsRuntimeUpgrade: needsRuntimeUpgrade => set(() => ({ needsRuntimeUpgrade })),
}))

View File

@ -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: {

View File

@ -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 && (
<UpgradeRuntimeBanner onUpgrade={handleOpenMigrationModal} />
)}
{upgradedFromId && (
<UpgradedFromBanner
fromAppId={upgradedFromId}