From f4e04fc8728628eb4463600081a6dc06ff99bc8c Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Fri, 10 Apr 2026 15:31:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20add=20Agent=20V2=20frontend=20?= =?UTF-8?q?=E2=80=94=20app=20creation,=20node=20editor,=20sandbox=20settin?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 — Agent App can be created and routed: - Add AppModeEnum.AGENT to types/app.ts - Add Agent card to create-app-modal (primary row, with RiRobot2Fill icon) - Route Agent apps to /workflow editor (same as workflow/advanced-chat) - Update layout-main.tsx mode guards P1 — Agent V2 workflow node: - Add BlockEnum.AgentV2 = 'agent-v2' to workflow types - Create agent-v2/node.tsx: displays model, strategy, tool count - Create agent-v2/panel.tsx: model selector, strategy picker, tool list, max iterations, memory config, vision toggle - Register in NodeComponentMap and PanelComponentMap P2 — Sandbox Provider settings: - Create sandbox-provider-page: list/configure/activate/delete providers (Docker, E2B, SSH, AWS CodeInterpreter) - Create service/sandbox.ts: API client for sandbox provider endpoints - Add "Sandbox Providers" to settings menu i18n: Add en-US and zh-Hans translations for agent V2 description. Made-with: Cursor --- .../(appDetailLayout)/[appId]/layout-main.tsx | 4 +- .../components/app/create-app-modal/index.tsx | 15 +- .../header/account-setting/constants.ts | 1 + .../header/account-setting/index.tsx | 8 + .../sandbox-provider-page/index.tsx | 192 ++++++++++++++++++ .../workflow/nodes/agent-v2/node.tsx | 61 ++++++ .../workflow/nodes/agent-v2/panel.tsx | 145 +++++++++++++ .../workflow/nodes/agent-v2/types.ts | 32 +++ .../components/workflow/nodes/components.ts | 4 + web/app/components/workflow/types.ts | 1 + web/i18n/en-US/app.json | 1 + web/i18n/zh-Hans/app.json | 1 + web/service/sandbox.ts | 40 ++++ web/types/app.ts | 3 +- web/utils/app-redirection.ts | 2 +- 15 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 web/app/components/header/account-setting/sandbox-provider-page/index.tsx create mode 100644 web/app/components/workflow/nodes/agent-v2/node.tsx create mode 100644 web/app/components/workflow/nodes/agent-v2/panel.tsx create mode 100644 web/app/components/workflow/nodes/agent-v2/types.ts create mode 100644 web/service/sandbox.ts diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index d3f15bdf46..03cc57e0d3 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -140,10 +140,10 @@ const AppDetailLayout: FC = (props) => { router.replace(`/app/${appId}/overview`) return } - if ((res.mode === AppModeEnum.WORKFLOW || res.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) { + if ((res.mode === AppModeEnum.WORKFLOW || res.mode === AppModeEnum.ADVANCED_CHAT || res.mode === AppModeEnum.AGENT) && (pathname).endsWith('configuration')) { router.replace(`/app/${appId}/workflow`) } - else if ((res.mode !== AppModeEnum.WORKFLOW && res.mode !== AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('workflow')) { + else if ((res.mode !== AppModeEnum.WORKFLOW && res.mode !== AppModeEnum.ADVANCED_CHAT && res.mode !== AppModeEnum.AGENT) && (pathname).endsWith('workflow')) { router.replace(`/app/${appId}/configuration`) } else { diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index f2ced9b6c0..20a60932ae 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { AppIconSelection } from '../../base/app-icon-picker' -import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' +import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill, RiRobot2Fill } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' import { useCallback, useEffect, useRef, useState } from 'react' @@ -145,6 +145,19 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: setAppMode(AppModeEnum.ADVANCED_CHAT) }} /> + + + + )} + onClick={() => { + setAppMode(AppModeEnum.AGENT) + }} + />
diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts index 2bf2f2eff5..c1fbb30637 100644 --- a/web/app/components/header/account-setting/constants.ts +++ b/web/app/components/header/account-setting/constants.ts @@ -8,6 +8,7 @@ export const ACCOUNT_SETTING_TAB = { API_BASED_EXTENSION: 'api-based-extension', CUSTOM: 'custom', LANGUAGE: 'language', + SANDBOX_PROVIDER: 'sandbox-provider', } as const export type AccountSettingTab = typeof ACCOUNT_SETTING_TAB[keyof typeof ACCOUNT_SETTING_TAB] diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 7ae1da8de3..a216eb659b 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -21,6 +21,7 @@ import DataSourcePage from './data-source-page-new' import LanguagePage from './language-page' import MembersPage from './members-page' import ModelProviderPage from './model-provider-page' +import SandboxProviderPage from './sandbox-provider-page' import { useResetModelProviderListExpanded } from './model-provider-page/atoms' const iconClassName = ` @@ -94,6 +95,12 @@ export default function AccountSetting({ icon: , activeIcon: , }, + { + key: ACCOUNT_SETTING_TAB.SANDBOX_PROVIDER, + name: 'Sandbox Providers', + icon: , + activeIcon: , + }, ) if (enableReplaceWebAppLogo || enableBilling) { @@ -233,6 +240,7 @@ export default function AccountSetting({ {activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && } {activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && } {activeMenu === ACCOUNT_SETTING_TAB.LANGUAGE && } + {activeMenu === ACCOUNT_SETTING_TAB.SANDBOX_PROVIDER && }
diff --git a/web/app/components/header/account-setting/sandbox-provider-page/index.tsx b/web/app/components/header/account-setting/sandbox-provider-page/index.tsx new file mode 100644 index 0000000000..c840eccb3f --- /dev/null +++ b/web/app/components/header/account-setting/sandbox-provider-page/index.tsx @@ -0,0 +1,192 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { RiCheckLine, RiDeleteBin7Line, RiSettings3Line } from '@remixicon/react' +import Button from '@/app/components/base/button' +import Input from '@/app/components/base/input' +import { toast } from '@/app/components/base/ui/toast' +import { + activateSandboxProvider, + deleteSandboxProviderConfig, + listSandboxProviders, + saveSandboxProviderConfig, +} from '@/service/sandbox' +import type { SandboxProvider } from '@/service/sandbox' + +const providerConfigs: Record }> = { + docker: { + label: 'Docker', + description: 'Run agent tools in Docker containers on the local machine.', + fields: [ + { key: 'docker_sock', label: 'Docker Socket Path' }, + { key: 'docker_image', label: 'Docker Image' }, + ], + }, + e2b: { + label: 'E2B Cloud', + description: 'Run agent tools in E2B cloud sandboxes.', + fields: [ + { key: 'api_key', label: 'E2B API Key', secret: true }, + { key: 'e2b_default_template', label: 'Template' }, + ], + }, + ssh: { + label: 'SSH Remote', + description: 'Run agent tools on a remote server via SSH.', + fields: [ + { key: 'ssh_host', label: 'Host' }, + { key: 'ssh_port', label: 'Port' }, + { key: 'ssh_username', label: 'Username' }, + { key: 'ssh_password', label: 'Password / Private Key', secret: true }, + ], + }, + aws_code_interpreter: { + label: 'AWS CodeInterpreter', + description: 'Run agent tools in Amazon Bedrock AgentCore Code Interpreter.', + fields: [ + { key: 'aws_access_key_id', label: 'Access Key ID', secret: true }, + { key: 'aws_secret_access_key', label: 'Secret Access Key', secret: true }, + { key: 'aws_region', label: 'Region' }, + { key: 'code_interpreter_id', label: 'Code Interpreter ID' }, + ], + }, +} + +export default function SandboxProviderPage() { + const { t } = useTranslation() + const [providers, setProviders] = useState([]) + const [editingType, setEditingType] = useState(null) + const [editConfig, setEditConfig] = useState>({}) + const [loading, setLoading] = useState(false) + + const fetchProviders = useCallback(async () => { + try { + const data = await listSandboxProviders() + setProviders(Array.isArray(data) ? data : []) + } + catch { + setProviders([]) + } + }, []) + + useEffect(() => { fetchProviders() }, [fetchProviders]) + + const handleSave = async (providerType: string) => { + setLoading(true) + try { + await saveSandboxProviderConfig(providerType, editConfig, true) + toast({ type: 'success', message: 'Saved and activated' }) + setEditingType(null) + fetchProviders() + } + catch (e: any) { + toast({ type: 'error', message: e.message || 'Failed to save' }) + } + finally { setLoading(false) } + } + + const handleDelete = async (providerType: string) => { + try { + await deleteSandboxProviderConfig(providerType) + toast({ type: 'success', message: 'Deleted' }) + fetchProviders() + } + catch (e: any) { + toast({ type: 'error', message: e.message || 'Failed to delete' }) + } + } + + const handleActivate = async (providerType: string) => { + try { + await activateSandboxProvider(providerType) + toast({ type: 'success', message: 'Activated' }) + fetchProviders() + } + catch (e: any) { + toast({ type: 'error', message: e.message || 'Failed to activate' }) + } + } + + const activeProvider = providers.find(p => p.is_active) + + return ( +
+
+ +

Sandbox Providers

+
+

+ Configure where agent tools execute in isolated environments. + {activeProvider && ( + + Active: {providerConfigs[activeProvider.provider_type]?.label || activeProvider.provider_type} + + )} +

+ +
+ {Object.entries(providerConfigs).map(([type, cfg]) => { + const existing = providers.find(p => p.provider_type === type) + const isActive = existing?.is_active + const isEditing = editingType === type + + return ( +
+
+
+

{cfg.label}

+

{cfg.description}

+
+
+ {isActive && ( + + Active + + )} + {existing && !isActive && ( + + )} + + {existing && ( + + )} +
+
+ + {isEditing && ( +
+ {cfg.fields.map(field => ( +
+ + setEditConfig(prev => ({ ...prev, [field.key]: e.target.value }))} + placeholder={field.label} + /> +
+ ))} + +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/web/app/components/workflow/nodes/agent-v2/node.tsx b/web/app/components/workflow/nodes/agent-v2/node.tsx new file mode 100644 index 0000000000..2c3210ab4d --- /dev/null +++ b/web/app/components/workflow/nodes/agent-v2/node.tsx @@ -0,0 +1,61 @@ +import type { FC } from 'react' +import type { NodeProps } from '../../types' +import type { AgentV2NodeType } from './types' +import { memo, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { RiRobot2Line, RiToolsFill } from '@remixicon/react' +import { Group, GroupLabel } from '../_base/components/group' +import { SettingItem } from '../_base/components/setting-item' + +const strategyLabels: Record = { + auto: 'Auto', + 'function-calling': 'Function Calling', + 'chain-of-thought': 'ReAct (Chain of Thought)', +} + +const AgentV2Node: FC> = ({ id, data }) => { + const { t } = useTranslation() + + const modelName = data.model?.name || '' + const modelProvider = data.model?.provider || '' + const strategy = data.agent_strategy || 'auto' + const enabledTools = useMemo(() => (data.tools || []).filter(t => t.enabled), [data.tools]) + const maxIter = data.max_iterations || 10 + + return ( +
+ + + {modelName || 'Not configured'} + + + + + {strategyLabels[strategy] || strategy} + + + {enabledTools.length > 0 && ( + Tools ({enabledTools.length})}> +
+ {enabledTools.slice(0, 6).map((tool, i) => ( + + {tool.tool_name} + + ))} + {enabledTools.length > 6 && ( + +{enabledTools.length - 6} + )} +
+
+ )} + {maxIter !== 10 && ( + + {maxIter} + + )} +
+ ) +} + +AgentV2Node.displayName = 'AgentV2Node' +export default memo(AgentV2Node) diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx new file mode 100644 index 0000000000..ff3e24d605 --- /dev/null +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -0,0 +1,145 @@ +import type { FC } from 'react' +import type { AgentV2NodeType } from './types' +import type { NodePanelProps } from '@/app/components/workflow/types' +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { RiAddLine, RiDeleteBin7Line } from '@remixicon/react' +import Button from '@/app/components/base/button' +import Select from '@/app/components/base/select' +import Switch from '@/app/components/base/switch' +import Field from '@/app/components/workflow/nodes/_base/components/field' +import Split from '@/app/components/workflow/nodes/_base/components/split' +import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' +import ConfigVision from '../_base/components/config-vision' +import MemoryConfig from '../_base/components/memory-config' +import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor' +import ConfigPrompt from '../llm/components/config-prompt' +import { useProviderContextSelector } from '@/context/provider-context' +import { useNodeDataUpdate } from '../../hooks/use-node-data-update' + +const strategyOptions = [ + { value: 'auto', name: 'Auto (based on model capability)' }, + { value: 'function-calling', name: 'Function Calling' }, + { value: 'chain-of-thought', name: 'ReAct (Chain of Thought)' }, +] + +const Panel: FC> = ({ id, data }) => { + const { t } = useTranslation() + const { handleNodeDataUpdate } = useNodeDataUpdate() + + const updateData = useCallback((patch: Partial) => { + handleNodeDataUpdate({ id, data: patch as any }) + }, [id, handleNodeDataUpdate]) + + const inputs = data as AgentV2NodeType + + return ( +
+ {/* Model Selection */} + + { + updateData({ + model: { + ...inputs.model, + provider: model.provider, + name: model.modelId, + mode: model.mode || 'chat', + completion_params: model.completionParams || {}, + }, + }) + }} + onCompletionParamsChange={(params) => { + updateData({ + model: { ...inputs.model, completion_params: params }, + }) + }} + /> + + + + + {/* Agent Strategy */} + + updateData({ max_iterations: parseInt(e.target.value) || 10 })} + /> + + + + + {/* Tools */} + t.enabled).length})`}> +
+ {(inputs.tools || []).map((tool, idx) => ( +
+
+ { + const tools = [...(inputs.tools || [])] + tools[idx] = { ...tools[idx], enabled: v } + updateData({ tools }) + }} + /> + {tool.tool_name} +
+ {tool.provider_name?.split('/').pop()} +
+ ))} + {(inputs.tools || []).length === 0 && ( +
+ No tools configured. Add tools from the workflow toolbar. +
+ )} +
+
+ + + + {/* Memory */} + + updateData({ memory })} + /> + + + + + {/* Vision */} + + updateData({ vision })} + /> + +
+ ) +} + +Panel.displayName = 'AgentV2Panel' +export default memo(Panel) diff --git a/web/app/components/workflow/nodes/agent-v2/types.ts b/web/app/components/workflow/nodes/agent-v2/types.ts new file mode 100644 index 0000000000..e22f74a4d7 --- /dev/null +++ b/web/app/components/workflow/nodes/agent-v2/types.ts @@ -0,0 +1,32 @@ +import type { CommonNodeType, Memory, ModelConfig, PromptItem, ValueSelector, VisionSetting } from '@/app/components/workflow/types' + +export type ToolMetadata = { + enabled: boolean + type: string + provider_name: string + tool_name: string + plugin_unique_identifier?: string + credential_id?: string + parameters: Record + settings: Record + extra: Record +} + +export type AgentV2NodeType = CommonNodeType & { + model: ModelConfig + prompt_template: PromptItem[] | PromptItem + tools: ToolMetadata[] + max_iterations: number + agent_strategy: 'auto' | 'function-calling' | 'chain-of-thought' + memory?: Memory + context: { + enabled: boolean + variable_selector?: ValueSelector + } + vision: { + enabled: boolean + configs?: VisionSetting + } + structured_output_enabled?: boolean + structured_output?: Record +} diff --git a/web/app/components/workflow/nodes/components.ts b/web/app/components/workflow/nodes/components.ts index ec1e4422bf..c29164e4b5 100644 --- a/web/app/components/workflow/nodes/components.ts +++ b/web/app/components/workflow/nodes/components.ts @@ -2,6 +2,8 @@ import type { ComponentType } from 'react' import { BlockEnum } from '../types' import AgentNode from './agent/node' import AgentPanel from './agent/panel' +import AgentV2Node from './agent-v2/node' +import AgentV2Panel from './agent-v2/panel' import AnswerNode from './answer/node' import AnswerPanel from './answer/panel' import AssignerNode from './assigner/node' @@ -72,6 +74,7 @@ export const NodeComponentMap: Record> = { [BlockEnum.DocExtractor]: DocExtractorNode, [BlockEnum.ListFilter]: ListFilterNode, [BlockEnum.Agent]: AgentNode, + [BlockEnum.AgentV2]: AgentV2Node, [BlockEnum.DataSource]: DataSourceNode, [BlockEnum.KnowledgeBase]: KnowledgeBaseNode, [BlockEnum.HumanInput]: HumanInputNode, @@ -101,6 +104,7 @@ export const PanelComponentMap: Record> = { [BlockEnum.DocExtractor]: DocExtractorPanel, [BlockEnum.ListFilter]: ListFilterPanel, [BlockEnum.Agent]: AgentPanel, + [BlockEnum.AgentV2]: AgentV2Panel, [BlockEnum.DataSource]: DataSourcePanel, [BlockEnum.KnowledgeBase]: KnowledgeBasePanel, [BlockEnum.HumanInput]: HumanInputPanel, diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index a770616d5d..e2d68cd9de 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -46,6 +46,7 @@ export enum BlockEnum { IterationStart = 'iteration-start', Assigner = 'assigner', // is now named as VariableAssigner Agent = 'agent', + AgentV2 = 'agent-v2', Loop = 'loop', LoopStart = 'loop-start', LoopEnd = 'loop-end', diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index 0c3b35ba14..75030af569 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -135,6 +135,7 @@ "newApp.advancedUserDescription": "Workflow with additional memory features and a chatbot interface.", "newApp.agentAssistant": "New Agent Assistant", "newApp.agentShortDescription": "Intelligent agent with reasoning and autonomous tool use", + "newApp.agentV2ShortDescription": "Next-gen agent with tools, sandbox, and workflow integration", "newApp.agentUserDescription": "An intelligent agent capable of iterative reasoning and autonomous tool use to achieve task goals.", "newApp.appCreateDSLErrorPart1": "A significant difference in DSL versions has been detected. Forcing the import may cause the application to malfunction.", "newApp.appCreateDSLErrorPart2": "Do you want to continue?", diff --git a/web/i18n/zh-Hans/app.json b/web/i18n/zh-Hans/app.json index 2eb0f231b8..3b9b682e91 100644 --- a/web/i18n/zh-Hans/app.json +++ b/web/i18n/zh-Hans/app.json @@ -135,6 +135,7 @@ "newApp.advancedUserDescription": "基于工作流编排,适用于定义等复杂流程的多轮对话场景,具有记忆功能。", "newApp.agentAssistant": "新的智能助手", "newApp.agentShortDescription": "具备推理与自主工具调用的智能助手", + "newApp.agentV2ShortDescription": "新一代 Agent,支持工具调用、沙箱执行和 Workflow 集成", "newApp.agentUserDescription": "能够迭代式的规划推理、自主工具调用,直至完成任务目标的智能助手。", "newApp.appCreateDSLErrorPart1": "检测到 DSL 版本差异较大,强制导入应用可能无法正常运行。", "newApp.appCreateDSLErrorPart2": "是否继续?", diff --git a/web/service/sandbox.ts b/web/service/sandbox.ts new file mode 100644 index 0000000000..ee7c2a9969 --- /dev/null +++ b/web/service/sandbox.ts @@ -0,0 +1,40 @@ +import { del, get, post } from './base' + +export type SandboxProvider = { + provider_type: string + is_active: boolean + config?: Record + config_schema?: Array<{ + name: string + type: string + }> +} + +export const listSandboxProviders = (): Promise => { + return get('workspaces/current/sandbox-providers') +} + +export const saveSandboxProviderConfig = ( + providerType: string, + config: Record, + activate = false, +): Promise<{ result: string }> => { + return post<{ result: string }>(`workspaces/current/sandbox-provider/${providerType}/config`, { + body: { config, activate }, + }) +} + +export const activateSandboxProvider = ( + providerType: string, + type = 'user', +): Promise<{ result: string }> => { + return post<{ result: string }>(`workspaces/current/sandbox-provider/${providerType}/activate`, { + body: { type }, + }) +} + +export const deleteSandboxProviderConfig = ( + providerType: string, +): Promise<{ result: string }> => { + return del<{ result: string }>(`workspaces/current/sandbox-provider/${providerType}/config`) +} diff --git a/web/types/app.ts b/web/types/app.ts index b782a78730..c4125f26ca 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -44,8 +44,9 @@ export enum AppModeEnum { CHAT = 'chat', ADVANCED_CHAT = 'advanced-chat', AGENT_CHAT = 'agent-chat', + AGENT = 'agent', } -export const AppModes = [AppModeEnum.COMPLETION, AppModeEnum.WORKFLOW, AppModeEnum.CHAT, AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT] as const +export const AppModes = [AppModeEnum.COMPLETION, AppModeEnum.WORKFLOW, AppModeEnum.CHAT, AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.AGENT] as const /** * Variable type diff --git a/web/utils/app-redirection.ts b/web/utils/app-redirection.ts index 5ed8419e05..f7a420479c 100644 --- a/web/utils/app-redirection.ts +++ b/web/utils/app-redirection.ts @@ -8,7 +8,7 @@ export const getRedirectionPath = ( return `/app/${app.id}/overview` } else { - if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT) + if (app.mode === AppModeEnum.WORKFLOW || app.mode === AppModeEnum.ADVANCED_CHAT || app.mode === AppModeEnum.AGENT) return `/app/${app.id}/workflow` else return `/app/${app.id}/configuration`