mirror of
https://github.com/langgenius/dify.git
synced 2026-03-24 15:57:55 +08:00
feat: add runtime upgrade handling and UI components for LLM nodes
This commit is contained in:
@ -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]]]:
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 })),
|
||||
}))
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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}
|
||||
|
||||
Reference in New Issue
Block a user