From 40b0d7c8986f163236d67f34dcbbca97cd893b8f Mon Sep 17 00:00:00 2001 From: Novice Date: Tue, 17 Mar 2026 13:53:12 +0800 Subject: [PATCH] feat: implement app runtime upgrade feature to clone and convert classic runtime apps to sandboxed mode --- api/controllers/console/app/app.py | 25 +- api/services/app_runtime_upgrade_service.py | 393 ++++++++++++++++++ web/app/components/apps/app-card.tsx | 66 ++- .../components/upgrade-runtime-banner.tsx | 48 +++ .../components/upgraded-from-banner.tsx | 53 +++ web/app/components/workflow-app/index.tsx | 53 ++- .../components/workflow/panel-contextmenu.tsx | 19 + .../workflow/store/workflow/workflow-slice.ts | 4 + web/i18n/en-US/app.json | 3 +- web/i18n/en-US/workflow.json | 3 + web/i18n/zh-Hans/app.json | 3 +- web/i18n/zh-Hans/workflow.json | 3 + web/service/apps.ts | 4 + 13 files changed, 656 insertions(+), 21 deletions(-) create mode 100644 api/services/app_runtime_upgrade_service.py create mode 100644 web/app/components/workflow-app/components/upgrade-runtime-banner.tsx create mode 100644 web/app/components/workflow-app/components/upgraded-from-banner.tsx diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 8ab6033fb6..8f2c824d0b 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -31,7 +31,7 @@ from core.workflow.enums import NodeType, WorkflowExecutionStatus from extensions.ext_database import db from libs.login import current_account_with_tenant, login_required from models import App, DatasetPermissionEnum, Workflow -from models.model import IconType +from models.model import AppMode, IconType from models.workflow_features import WorkflowFeatures from services.app_dsl_service import AppDslService, ImportMode from services.app_service import AppService @@ -919,3 +919,26 @@ class AppTraceApi(Resource): ) return {"result": "success"} + + +@console_ns.route("/apps//upgrade-runtime") +class AppRuntimeUpgradeApi(Resource): + @console_ns.doc("upgrade_app_runtime") + @console_ns.doc(description="Clone the app and upgrade the clone to sandboxed runtime") + @console_ns.doc(params={"app_id": "Application ID to upgrade"}) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + @edit_permission_required + def post(self, app_model): + """Upgrade app runtime by cloning to sandboxed mode""" + current_user, _ = current_account_with_tenant() + + with Session(db.engine) as session: + from services.app_runtime_upgrade_service import AppRuntimeUpgradeService + + result = AppRuntimeUpgradeService(session).upgrade(app_model, current_user) + session.commit() + + return result, 200 diff --git a/api/services/app_runtime_upgrade_service.py b/api/services/app_runtime_upgrade_service.py new file mode 100644 index 0000000000..db804579c6 --- /dev/null +++ b/api/services/app_runtime_upgrade_service.py @@ -0,0 +1,393 @@ +"""Service for upgrading Classic runtime apps to Sandboxed runtime via clone-and-convert. + +The upgrade flow: +1. Clone the source app via DSL export/import +2. On the cloned app's draft workflow, convert Agent nodes to LLM nodes +3. Rewrite variable references for all LLM nodes (old output names → new generation-based names) +4. Enable sandbox feature flag + +The original app is never modified; the user gets a new sandboxed copy. +""" + +import json +import logging +import re +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models import App, Workflow +from models.workflow_features import WorkflowFeatures +from services.app_dsl_service import AppDslService, ImportMode + +logger = logging.getLogger(__name__) + +_VAR_REWRITES: dict[str, list[str]] = { + "text": ["generation", "content"], + "reasoning_content": ["generation", "reasoning_content"], +} + +_PASSTHROUGH_KEYS = ( + "version", + "error_strategy", + "default_value", + "retry_config", + "parent_node_id", + "isInLoop", + "loop_id", + "isInIteration", + "iteration_id", +) + + +class AppRuntimeUpgradeService: + """Upgrades a Classic-runtime app to Sandboxed runtime by cloning and converting. + + Holds an active SQLAlchemy session; the caller is responsible for commit/rollback. + """ + + session: Session + + def __init__(self, session: Session) -> None: + self.session = session + + def upgrade(self, app_model: App, account: Any) -> dict[str, Any]: + """Clone *app_model* and upgrade the clone to sandboxed runtime. + + Returns: + dict with keys: result, new_app_id, converted_agents, skipped_agents. + """ + workflow = self._get_draft_workflow(app_model) + if not workflow: + return {"result": "no_draft"} + + if workflow.get_feature(WorkflowFeatures.SANDBOX).enabled: + return {"result": "already_sandboxed"} + + new_app = self._clone_app(app_model, account) + new_workflow = self._get_draft_workflow(new_app) + if not new_workflow: + return {"result": "no_draft"} + + graph = json.loads(new_workflow.graph) if new_workflow.graph else {} + nodes = graph.get("nodes", []) + + converted, skipped = _convert_agent_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) + + new_workflow.graph = json.dumps(graph) + + features = json.loads(new_workflow.features) if new_workflow.features else {} + features.setdefault("sandbox", {})["enabled"] = True + new_workflow.features = json.dumps(features) + + return { + "result": "success", + "new_app_id": str(new_app.id), + "converted_agents": converted, + "skipped_agents": skipped, + } + + def _get_draft_workflow(self, app_model: App) -> Workflow | None: + stmt = select(Workflow).where( + Workflow.tenant_id == app_model.tenant_id, + Workflow.app_id == app_model.id, + Workflow.version == "draft", + ) + return self.session.scalar(stmt) + + def _clone_app(self, app_model: App, account: Any) -> App: + dsl_service = AppDslService(self.session) + yaml_content = dsl_service.export_dsl(app_model=app_model, include_secret=True) + result = dsl_service.import_app( + account=account, + import_mode=ImportMode.YAML_CONTENT, + yaml_content=yaml_content, + name=f"{app_model.name} (Sandboxed)", + ) + stmt = select(App).where(App.id == result.app_id) + new_app = self.session.scalar(stmt) + if not new_app: + raise RuntimeError(f"Cloned app not found: {result.app_id}") + return new_app + + +# --------------------------------------------------------------------------- +# Pure conversion functions (no DB access) +# --------------------------------------------------------------------------- + + +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", {}) + if data.get("type") != "agent": + 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 + logger.info("Converted agent node %s to LLM", node_id) + converted += 1 + + return converted, skipped + + +def _agent_data_to_llm_data(agent_data: dict[str, Any]) -> dict[str, Any] | None: + """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). + """ + params = agent_data.get("agent_parameters", {}) + + model_param = params.get("model", {}) + 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", {}), + } + + tools_param = params.get("tools", {}) + tools_value = tools_param.get("value", []) if isinstance(tools_param, dict) else [] + tools_meta, tool_settings = _convert_tools(tools_value if isinstance(tools_value, list) else []) + + instruction_param = params.get("instruction", {}) + instruction = instruction_param.get("value", "") if isinstance(instruction_param, dict) else "" + + query_param = params.get("query", {}) + query_value = query_param.get("value", "") if isinstance(query_param, dict) else "" + + has_tools = bool(tools_meta) + prompt_template = _build_prompt_template( + instruction, + query_value, + skill=has_tools, + tools=tools_value if has_tools else None, + ) + + max_iter_param = params.get("maximum_iterations", {}) + max_iterations = max_iter_param.get("value", 100) if isinstance(max_iter_param, dict) else 100 + + llm_data: dict[str, Any] = { + "type": "llm", + "title": agent_data.get("title", "LLM"), + "desc": agent_data.get("desc", ""), + "model": model_config, + "prompt_template": prompt_template, + "prompt_config": {"jinja2_variables": []}, + "memory": agent_data.get("memory"), + "context": {"enabled": False}, + "vision": {"enabled": False}, + "computer_use": bool(tools_meta), + "structured_output_switch_on": False, + "reasoning_format": "separated", + "tools": tools_meta, + "tool_settings": tool_settings, + "max_iterations": max_iterations, + } + + for key in _PASSTHROUGH_KEYS: + if key in agent_data: + llm_data[key] = agent_data[key] + + return llm_data + + +def _convert_tools( + tools_input: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """Convert agent tool dicts to (ToolMetadata[], ToolSetting[]). + + Agent tools in graph JSON already use provider_name/settings/parameters — + the same field names as LLM ToolMetadata. We pass them through with defaults + for any missing fields. + """ + tools_meta: list[dict[str, Any]] = [] + tool_settings: list[dict[str, Any]] = [] + + for ts in tools_input: + if not isinstance(ts, dict): + continue + + provider_name = ts.get("provider_name", "") + tool_name = ts.get("tool_name", "") + tool_type = ts.get("type", "builtin") + + tools_meta.append( + { + "enabled": True, + "type": tool_type, + "provider_name": provider_name, + "tool_name": tool_name, + "plugin_unique_identifier": ts.get("plugin_unique_identifier"), + "credential_id": ts.get("credential_id"), + "parameters": ts.get("parameters", {}), + "settings": ts.get("settings", {}) or ts.get("tool_configuration", {}), + "extra": ts.get("extra", {}), + } + ) + + tool_settings.append( + { + "type": tool_type, + "provider": provider_name, + "tool_name": tool_name, + "enabled": True, + } + ) + + return tools_meta, tool_settings + + +def _build_prompt_template( + instruction: Any, + query: Any, + *, + skill: bool = False, + tools: list[dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """Build LLM prompt_template from Agent instruction and query values. + + When *skill* is True each message gets ``"skill": True`` so the sandbox + engine treats the prompt as a skill document. + + When *tools* is provided, tool reference placeholders + (``§[tool].[provider].[name].[uuid]§``) are appended to the system + message and the corresponding ``ToolReference`` entries are placed in the + message's ``metadata.tools`` dict so the skill assembler can resolve them. + Tools from the same provider are grouped into a single token list. + """ + messages: list[dict[str, Any]] = [] + + system_text = instruction if isinstance(instruction, str) else (str(instruction) if instruction else "") + metadata: dict[str, Any] | None = None + + if tools: + tool_refs: dict[str, dict[str, Any]] = {} + provider_groups: dict[str, list[str]] = {} + for ts in tools: + if not isinstance(ts, dict): + continue + tool_uuid = str(uuid.uuid4()) + provider_id = ts.get("provider_name", "") + tool_name = ts.get("tool_name", "") + tool_type = ts.get("type", "builtin") + + token = f"§[tool].[{provider_id}].[{tool_name}].[{tool_uuid}]§" + provider_groups.setdefault(provider_id, []).append(token) + tool_refs[tool_uuid] = { + "type": tool_type, + "configuration": {"fields": []}, + "enabled": True, + **({"credential_id": ts.get("credential_id")} if ts.get("credential_id") else {}), + } + + if provider_groups: + group_texts: list[str] = [] + for tokens in provider_groups.values(): + if len(tokens) == 1: + group_texts.append(tokens[0]) + else: + group_texts.append("[" + ",".join(tokens) + "]") + all_tools_text = " ".join(group_texts) + system_text = f"{system_text}\n\n{all_tools_text}" if system_text else all_tools_text + metadata = {"tools": tool_refs, "files": []} + + if system_text: + msg: dict[str, Any] = {"role": "system", "text": system_text, "skill": skill} + if metadata: + msg["metadata"] = metadata + messages.append(msg) + + if isinstance(query, list) and len(query) >= 2: + template_ref = "{{#" + ".".join(str(s) for s in query) + "#}}" + messages.append({"role": "user", "text": template_ref, "skill": skill}) + elif query: + messages.append({"role": "user", "text": str(query), "skill": skill}) + + if not messages: + messages.append({"role": "user", "text": "", "skill": skill}) + + return messages + + +def _rewrite_variable_references(nodes: list[dict[str, Any]], llm_ids: set[str]) -> None: + """Recursively walk all node data and rewrite variable references for LLM nodes. + + Handles two forms: + - Structured selectors: [node_id, "text"] → [node_id, "generation", "content"] + - Template strings: {{#node_id.text#}} → {{#node_id.generation.content#}} + """ + if not llm_ids: + return + + escaped_ids = [re.escape(nid) for nid in llm_ids] + patterns: list[tuple[re.Pattern[str], str]] = [] + for old_name, new_path in _VAR_REWRITES.items(): + pattern = re.compile(r"\{\{#(" + "|".join(escaped_ids) + r")\." + re.escape(old_name) + r"#\}\}") + replacement = r"{{#\1." + ".".join(new_path) + r"#}}" + patterns.append((pattern, replacement)) + + for node in nodes: + data = node.get("data", {}) + _walk_and_rewrite(data, llm_ids, patterns) + + +def _walk_and_rewrite( + obj: Any, + llm_ids: set[str], + template_patterns: list[tuple[re.Pattern[str], str]], +) -> Any: + """Recursively rewrite variable references in a nested data structure.""" + if isinstance(obj, dict): + for key, value in obj.items(): + obj[key] = _walk_and_rewrite(value, llm_ids, template_patterns) + return obj + + if isinstance(obj, list): + if _is_variable_selector(obj, llm_ids): + return _rewrite_selector(obj) + for i, item in enumerate(obj): + obj[i] = _walk_and_rewrite(item, llm_ids, template_patterns) + return obj + + if isinstance(obj, str): + for pattern, replacement in template_patterns: + obj = pattern.sub(replacement, obj) + return obj + + return obj + + +def _is_variable_selector(lst: list, llm_ids: set[str]) -> bool: + """Check if a list is a structured variable selector pointing to an LLM node output.""" + if len(lst) < 2: + return False + if not all(isinstance(s, str) for s in lst): + return False + return lst[0] in llm_ids and lst[1] in _VAR_REWRITES + + +def _rewrite_selector(selector: list[str]) -> list[str]: + """Rewrite [node_id, "text"] → [node_id, "generation", "content"].""" + old_field = selector[1] + new_path = _VAR_REWRITES[old_field] + return [selector[0]] + new_path + selector[2:] diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 973d464fc2..a41ead0240 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -7,7 +7,6 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo import type { EnvironmentVariable } from '@/app/components/workflow/types' import type { WorkflowOnlineUser } from '@/models/app' import type { App } from '@/types/app' -import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' import dynamic from 'next/dynamic' import { useRouter } from 'next/navigation' import * as React from 'react' @@ -29,7 +28,7 @@ import { useProviderContext } from '@/context/provider-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { AccessMode } from '@/models/access-control' import { useGetUserCanAccessApp } from '@/service/access-control' -import { copyApp, deleteApp, exportAppBundle, exportAppConfig, updateAppInfo } from '@/service/apps' +import { copyApp, deleteApp, exportAppBundle, exportAppConfig, updateAppInfo, upgradeAppRuntime } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' @@ -89,10 +88,10 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => { onRefresh() onPlanInfoChanged() } - catch (e: any) { + catch (e: unknown) { notify({ type: 'error', - message: `${t('appDeleteFailed', { ns: 'app' })}${'message' in e ? `: ${e.message}` : ''}`, + message: `${t('appDeleteFailed', { ns: 'app' })}${e instanceof Error ? `: ${e.message}` : ''}`, }) } setShowConfirmDelete(false) @@ -126,10 +125,10 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => { if (onRefresh) onRefresh() } - catch (e: any) { + catch (e: unknown) { notify({ type: 'error', - message: e.message || t('editFailed', { ns: 'app' }), + message: (e instanceof Error ? e.message : '') || t('editFailed', { ns: 'app' }), }) } }, [app.id, notify, onRefresh, t]) @@ -213,6 +212,10 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => { setShowSwitchModal(false) } + const [isUpgradingRuntime, startUpgradeRuntime] = useTransition() + const isClassicWorkflowApp = app.runtime_type !== 'sandboxed' + && (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) + const onUpdateAccessControl = useCallback(() => { if (onRefresh) onRefresh() @@ -268,8 +271,8 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => { e.preventDefault() try { await openAsyncWindow(async () => { - const { installed_apps }: any = await fetchInstalledAppList(app.id) || {} - if (installed_apps?.length > 0) + const { installed_apps } = (await fetchInstalledAppList(app.id) || {}) as { installed_apps?: { id: string }[] } + if (installed_apps && installed_apps.length > 0) return `${basePath}/explore/installed/${installed_apps[0].id}` throw new Error('No app found in Explore') }, { @@ -278,10 +281,31 @@ const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => { }, }) } - catch (e: any) { - Toast.notify({ type: 'error', message: `${e.message || e}` }) + catch (e: unknown) { + Toast.notify({ type: 'error', message: e instanceof Error ? e.message : String(e) }) } } + const onClickUpgradeRuntime = (e: React.MouseEvent) => { + e.stopPropagation() + props.onClick?.() + e.preventDefault() + startUpgradeRuntime(async () => { + try { + const res = await upgradeAppRuntime(app.id) + if (res.result === 'success' && res.new_app_id) { + notify({ type: 'success', message: t('sandboxMigrationModal.upgrade', { ns: 'workflow' }) }) + const params = new URLSearchParams({ + upgraded_from: app.id, + upgraded_from_name: app.name, + }) + push(`/app/${res.new_app_id}/workflow?${params.toString()}`) + } + } + catch (e: unknown) { + notify({ type: 'error', message: (e instanceof Error ? e.message : '') || 'Upgrade failed' }) + } + }) + } return (
+ )}
-
+
{
- +
)} btnClassName={open => diff --git a/web/app/components/workflow-app/components/upgrade-runtime-banner.tsx b/web/app/components/workflow-app/components/upgrade-runtime-banner.tsx new file mode 100644 index 0000000000..793ef90218 --- /dev/null +++ b/web/app/components/workflow-app/components/upgrade-runtime-banner.tsx @@ -0,0 +1,48 @@ +'use client' + +import type { FC } from 'react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type Props = { + onUpgrade: () => void +} + +const UpgradeRuntimeBanner: FC = ({ onUpgrade }) => { + const { t } = useTranslation('workflow') + const [visible, setVisible] = useState(true) + + const handleClose = useCallback(() => { + setVisible(false) + }, []) + + if (!visible) + return null + + return ( +
+ + {t('sandboxMigrationModal.bannerHint')} + +
+ + +
+
+ ) +} + +export default UpgradeRuntimeBanner diff --git a/web/app/components/workflow-app/components/upgraded-from-banner.tsx b/web/app/components/workflow-app/components/upgraded-from-banner.tsx new file mode 100644 index 0000000000..b7b7a873ea --- /dev/null +++ b/web/app/components/workflow-app/components/upgraded-from-banner.tsx @@ -0,0 +1,53 @@ +'use client' + +import type { FC } from 'react' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +type Props = { + fromAppName: string + fromAppId: string +} + +const UpgradedFromBanner: FC = ({ fromAppName, fromAppId }) => { + const { t } = useTranslation('workflow') + const [visible, setVisible] = useState(true) + + const handleGoBack = useCallback(() => { + window.location.href = `/app/${fromAppId}/workflow` + }, [fromAppId]) + + const handleClose = useCallback(() => { + setVisible(false) + }, []) + + if (!visible) + return null + + return ( +
+ + {t('sandboxMigrationModal.upgradedFrom', { name: fromAppName })} + +
+ + +
+
+ ) +} + +export default UpgradedFromBanner diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 6f3c68aa9e..4785583eab 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -37,12 +37,15 @@ import { initialNodes, } from '@/app/components/workflow/utils' import { useAppContext } from '@/context/app-context' +import { upgradeAppRuntime } from '@/service/apps' import { fetchRunDetail } from '@/service/log' import { useAppTriggers } from '@/service/use-tools' 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' import { useNodesSyncDraft } from './hooks/use-nodes-sync-draft' @@ -177,8 +180,8 @@ const WorkflowAppWithAdditionalContext = () => { } = useWorkflowInit() const workflowStore = useWorkflowStore() const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext() - const notSupportMigration = true // wait for backend support const [showMigrationModal, setShowMigrationModal] = useState(false) + const [isUpgrading, setIsUpgrading] = useState(false) const lastCheckedAppIdRef = useRef(null) // Initialize trigger status at application level @@ -189,6 +192,40 @@ const WorkflowAppWithAdditionalContext = () => { setSandboxMigrationDismissed(appId) setShowMigrationModal(false) }, [appId]) + + const handleOpenMigrationModal = useCallback(() => { + setShowMigrationModal(true) + }, []) + + const showUpgradeRuntimeModal = useStore(s => s.showUpgradeRuntimeModal) + const setShowUpgradeRuntimeModal = useStore(s => s.setShowUpgradeRuntimeModal) + useEffect(() => { + if (showUpgradeRuntimeModal) { + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setShowMigrationModal(true) + setShowUpgradeRuntimeModal(false) + } + }, [showUpgradeRuntimeModal, setShowUpgradeRuntimeModal]) + + const handleUpgradeRuntime = useCallback(async () => { + if (!appId || isUpgrading) + return + setIsUpgrading(true) + try { + const res = await upgradeAppRuntime(appId) + if (res.result === 'success' && res.new_app_id) { + const appName = appDetail?.name || '' + const params = new URLSearchParams({ + upgraded_from: appId, + upgraded_from_name: appName, + }) + window.location.href = `/app/${res.new_app_id}/workflow?${params.toString()}` + } + } + finally { + setIsUpgrading(false) + } + }, [appId, appDetail?.name, isUpgrading]) const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId : undefined, { staleTime: 5 * 60 * 1000, // 5 minutes cache @@ -261,6 +298,8 @@ const WorkflowAppWithAdditionalContext = () => { const searchParams = useSearchParams() const { getWorkflowRunAndTraceUrl } = useGetRunAndTraceUrl() const replayRunId = searchParams.get('replayRunId') + const upgradedFromId = searchParams.get('upgraded_from') + const upgradedFromName = searchParams.get('upgraded_from_name') useEffect(() => { if (!replayRunId) @@ -389,9 +428,19 @@ const WorkflowAppWithAdditionalContext = () => { <> + {!sandboxEnabled && !upgradedFromId && ( + + )} + {upgradedFromId && ( + + )} { const panelMenu = useStore(s => s.panelMenu) const clipboardElements = useStore(s => s.clipboardElements) const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal) + const setShowUpgradeRuntimeModal = useStore(s => s.setShowUpgradeRuntimeModal) const pendingComment = useStore(s => s.pendingComment) const setCommentPlacing = useStore(s => s.setCommentPlacing) const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd) + const sandboxEnabled = !!useFeatures(s => s.features.sandbox?.enabled) const { handleNodesPaste } = useNodesInteractions() const { handlePaneContextmenuCancel, handleNodeContextmenuCancel } = usePanelInteractions() const { handleStartWorkflowRun } = useWorkflowStartRun() @@ -147,6 +150,22 @@ const PanelContextmenu = () => { {!pipelineId ? t('importApp', { ns: 'app' }) : t('common.importDSL', { ns: 'workflow' })}
+ {!sandboxEnabled && ( + <> + +
+
{ + setShowUpgradeRuntimeModal(true) + handlePaneContextmenuCancel() + }} + > + {t('sandboxMigrationModal.upgrade', { ns: 'workflow' })} +
+
+ + )}
) } diff --git a/web/app/components/workflow/store/workflow/workflow-slice.ts b/web/app/components/workflow/store/workflow/workflow-slice.ts index 4cc56e4a80..cf647d2ae3 100644 --- a/web/app/components/workflow/store/workflow/workflow-slice.ts +++ b/web/app/components/workflow/store/workflow/workflow-slice.ts @@ -59,6 +59,8 @@ export type WorkflowSliceShape = { setControlPromptEditorRerenderKey: (controlPromptEditorRerenderKey: number) => void showImportDSLModal: boolean setShowImportDSLModal: (showImportDSLModal: boolean) => void + showUpgradeRuntimeModal: boolean + setShowUpgradeRuntimeModal: (showUpgradeRuntimeModal: boolean) => void fileUploadConfig?: FileUploadConfigResponse setFileUploadConfig: (fileUploadConfig: FileUploadConfigResponse) => void } @@ -109,6 +111,8 @@ export const createWorkflowSlice: StateCreator = set => ({ setControlPromptEditorRerenderKey: controlPromptEditorRerenderKey => set(() => ({ controlPromptEditorRerenderKey })), showImportDSLModal: false, setShowImportDSLModal: showImportDSLModal => set(() => ({ showImportDSLModal })), + showUpgradeRuntimeModal: false, + setShowUpgradeRuntimeModal: showUpgradeRuntimeModal => set(() => ({ showUpgradeRuntimeModal })), fileUploadConfig: undefined, setFileUploadConfig: fileUploadConfig => set(() => ({ fileUploadConfig })), }) diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index 8f70e1102a..5987e624a1 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -305,5 +305,6 @@ "types.basic": "Basic", "types.chatbot": "Chatbot", "types.completion": "Completion", - "types.workflow": "Workflow" + "types.workflow": "Workflow", + "upgradeRuntime": "Clone & Upgrade Runtime" } diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index bc4d22155a..0ecf25fb87 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1180,10 +1180,13 @@ "publishLimit.startNodeDesc": "You’ve reached the limit of 2 triggers per workflow for this plan. Upgrade to publish this workflow.", "publishLimit.startNodeTitlePrefix": "Upgrade to", "publishLimit.startNodeTitleSuffix": "unlock unlimited triggers per workflow", + "sandboxMigrationModal.bannerHint": "This app uses classic runtime. Upgrade to sandboxed runtime for enhanced agent capabilities.", "sandboxMigrationModal.description": "This will create a separate copy of your app, without affecting the original.", "sandboxMigrationModal.dismiss": "Dismiss", "sandboxMigrationModal.title": "Upgrade to filesystem-based agents", "sandboxMigrationModal.upgrade": "Clone & Upgrade", + "sandboxMigrationModal.upgradedFrom": "This app was upgraded from \"{{name}}\"", + "sandboxMigrationModal.viewOriginal": "View original app", "sidebar.exportWarning": "Export Current Saved Version", "sidebar.exportWarningDesc": "This will export the current saved version of your workflow. If you have unsaved changes in the editor, please save them first by using the export option in the workflow canvas.", "singleRun.back": "Back", diff --git a/web/i18n/zh-Hans/app.json b/web/i18n/zh-Hans/app.json index 16eecda9a1..f65a346bba 100644 --- a/web/i18n/zh-Hans/app.json +++ b/web/i18n/zh-Hans/app.json @@ -305,5 +305,6 @@ "types.basic": "基础编排", "types.chatbot": "聊天助手", "types.completion": "文本生成", - "types.workflow": "工作流" + "types.workflow": "工作流", + "upgradeRuntime": "复制并升级运行时" } diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 373c6553dd..cd2c4721d5 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1180,10 +1180,13 @@ "publishLimit.startNodeDesc": "您已达到此计划上每个工作流最多 2 个触发器的限制。请升级后再发布此工作流。", "publishLimit.startNodeTitlePrefix": "升级以", "publishLimit.startNodeTitleSuffix": "解锁每个工作流无限制的触发器", + "sandboxMigrationModal.bannerHint": "当前应用使用经典运行时,升级到沙箱运行时可获得增强的智能体能力。", "sandboxMigrationModal.description": "这将创建你的应用的一个独立副本,不会影响原应用。", "sandboxMigrationModal.dismiss": "暂不升级", "sandboxMigrationModal.title": "升级到基于文件系统的智能体", "sandboxMigrationModal.upgrade": "复制并升级", + "sandboxMigrationModal.upgradedFrom": "此应用由「{{name}}」升级而来", + "sandboxMigrationModal.viewOriginal": "查看原应用", "sidebar.exportWarning": "导出当前已保存版本", "sidebar.exportWarningDesc": "这将导出您工作流的当前已保存版本。如果您在编辑器中有未保存的更改,请先使用工作流画布中的导出选项保存它们。", "singleRun.back": "返回", diff --git a/web/service/apps.ts b/web/service/apps.ts index 2722b16eb4..a848ee6b1c 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -111,6 +111,10 @@ export const copyApp = ({ return post(`apps/${appID}/copy`, { body: { name, icon_type, icon, icon_background, mode, description } }) } +export const upgradeAppRuntime = (appID: string): Promise<{ result: string, new_app_id?: string, converted_agents?: number, skipped_agents?: number }> => { + return post(`apps/${appID}/upgrade-runtime`) +} + export const exportAppConfig = ({ appID, include = false, workflowID }: { appID: string, include?: boolean, workflowID?: string }): Promise<{ data: string }> => { const params = new URLSearchParams({ include_secret: include.toString(),