feat(web): add Agent V2 frontend — app creation, node editor, sandbox settings

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
This commit is contained in:
Yansong Zhang 2026-04-10 15:31:48 +08:00
parent 59b9221501
commit f4e04fc872
15 changed files with 505 additions and 5 deletions

View File

@ -140,10 +140,10 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (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 {

View File

@ -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)
}}
/>
<AppTypeCard
active={appMode === AppModeEnum.AGENT}
title={t('types.agent', { ns: 'app' })}
description={t('newApp.agentV2ShortDescription', { ns: 'app' })}
icon={(
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-violet-solid">
<RiRobot2Fill className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
</div>
)}
onClick={() => {
setAppMode(AppModeEnum.AGENT)
}}
/>
</div>
</div>
<div>

View File

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

View File

@ -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: <span className={cn('i-ri-puzzle-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-puzzle-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.SANDBOX_PROVIDER,
name: 'Sandbox Providers',
icon: <span className={cn('i-ri-shield-keyhole-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-shield-keyhole-fill', iconClassName)} />,
},
)
if (enableReplaceWebAppLogo || enableBilling) {
@ -233,6 +240,7 @@ export default function AccountSetting({
{activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && <ApiBasedExtensionPage />}
{activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && <CustomPage />}
{activeMenu === ACCOUNT_SETTING_TAB.LANGUAGE && <LanguagePage />}
{activeMenu === ACCOUNT_SETTING_TAB.SANDBOX_PROVIDER && <SandboxProviderPage />}
</div>
</ScrollArea>
</div>

View File

@ -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<string, { label: string, description: string, fields: Array<{ key: string, label: string, secret?: boolean }> }> = {
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<SandboxProvider[]>([])
const [editingType, setEditingType] = useState<string | null>(null)
const [editConfig, setEditConfig] = useState<Record<string, string>>({})
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 (
<div className="pt-2 pb-7">
<div className="mb-4 flex items-center gap-2">
<RiSettings3Line className="h-5 w-5 text-text-secondary" />
<h2 className="text-text-primary title-xl-semi-bold">Sandbox Providers</h2>
</div>
<p className="mb-6 text-text-tertiary system-sm-regular">
Configure where agent tools execute in isolated environments.
{activeProvider && (
<span className="ml-2 text-text-accent">
Active: <strong>{providerConfigs[activeProvider.provider_type]?.label || activeProvider.provider_type}</strong>
</span>
)}
</p>
<div className="grid gap-4">
{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 (
<div key={type} className={`rounded-xl border ${isActive ? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg' : 'border-divider-subtle'} p-4`}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-text-primary system-md-semibold">{cfg.label}</h3>
<p className="text-text-tertiary system-xs-regular mt-0.5">{cfg.description}</p>
</div>
<div className="flex items-center gap-2">
{isActive && (
<span className="flex items-center gap-1 rounded-full bg-util-colors-green-green-50 px-2 py-0.5 text-[11px] text-util-colors-green-green-600">
<RiCheckLine className="h-3 w-3" /> Active
</span>
)}
{existing && !isActive && (
<Button size="small" onClick={() => handleActivate(type)}>Activate</Button>
)}
<Button size="small" variant="secondary" onClick={() => {
setEditingType(isEditing ? null : type)
setEditConfig(existing?.config || {})
}}>
{isEditing ? 'Cancel' : 'Configure'}
</Button>
{existing && (
<Button size="small" variant="ghost" onClick={() => handleDelete(type)}>
<RiDeleteBin7Line className="h-4 w-4 text-text-tertiary" />
</Button>
)}
</div>
</div>
{isEditing && (
<div className="mt-4 space-y-3 border-t border-divider-subtle pt-4">
{cfg.fields.map(field => (
<div key={field.key}>
<label className="mb-1 block text-text-secondary system-xs-semibold">{field.label}</label>
<Input
type={field.secret ? 'password' : 'text'}
value={editConfig[field.key] || ''}
onChange={e => setEditConfig(prev => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.label}
/>
</div>
))}
<Button
variant="primary"
disabled={loading}
onClick={() => handleSave(type)}
>
{loading ? 'Saving...' : 'Save & Activate'}
</Button>
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@ -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<string, string> = {
auto: 'Auto',
'function-calling': 'Function Calling',
'chain-of-thought': 'ReAct (Chain of Thought)',
}
const AgentV2Node: FC<NodeProps<AgentV2NodeType>> = ({ 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 (
<div className="mb-1 space-y-1 px-3">
<SettingItem label={t('workflow.nodes.llm.model')}>
<span className="system-xs-medium text-text-secondary truncate">
{modelName || 'Not configured'}
</span>
</SettingItem>
<SettingItem label="Strategy">
<span className="system-xs-medium text-text-secondary">
{strategyLabels[strategy] || strategy}
</span>
</SettingItem>
{enabledTools.length > 0 && (
<Group label={<GroupLabel className="mt-1"><RiToolsFill className="mr-1 inline h-3 w-3" />Tools ({enabledTools.length})</GroupLabel>}>
<div className="flex flex-wrap gap-1">
{enabledTools.slice(0, 6).map((tool, i) => (
<span key={i} className="inline-flex items-center rounded bg-components-badge-bg-gray px-1.5 py-0.5 text-[11px] text-text-tertiary">
{tool.tool_name}
</span>
))}
{enabledTools.length > 6 && (
<span className="text-[11px] text-text-quaternary">+{enabledTools.length - 6}</span>
)}
</div>
</Group>
)}
{maxIter !== 10 && (
<SettingItem label="Max Iterations">
<span className="system-xs-medium text-text-secondary">{maxIter}</span>
</SettingItem>
)}
</div>
)
}
AgentV2Node.displayName = 'AgentV2Node'
export default memo(AgentV2Node)

View File

@ -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<NodePanelProps<AgentV2NodeType>> = ({ id, data }) => {
const { t } = useTranslation()
const { handleNodeDataUpdate } = useNodeDataUpdate()
const updateData = useCallback((patch: Partial<AgentV2NodeType>) => {
handleNodeDataUpdate({ id, data: patch as any })
}, [id, handleNodeDataUpdate])
const inputs = data as AgentV2NodeType
return (
<div className="space-y-4 px-4 pb-4 pt-2">
{/* Model Selection */}
<Field title={t('workflow.nodes.llm.model')}>
<ModelParameterModal
popupProps={{ disabled: false }}
isInWorkflow
isAdvancedMode
mode={inputs.model?.mode || 'chat'}
provider={inputs.model?.provider || ''}
completionParams={inputs.model?.completion_params || {}}
modelId={inputs.model?.name || ''}
setModel={(model) => {
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 },
})
}}
/>
</Field>
<Split />
{/* Agent Strategy */}
<Field title="Agent Strategy">
<Select
items={strategyOptions}
defaultValue={inputs.agent_strategy || 'auto'}
onSelect={(item) => updateData({ agent_strategy: item.value as any })}
/>
</Field>
{/* Max Iterations */}
<Field title="Max Iterations">
<input
type="number"
min={1}
max={99}
className="w-full rounded-lg border border-components-input-border-active px-3 py-1.5 text-[13px]"
value={inputs.max_iterations || 10}
onChange={(e) => updateData({ max_iterations: parseInt(e.target.value) || 10 })}
/>
</Field>
<Split />
{/* Tools */}
<Field title={`Tools (${(inputs.tools || []).filter(t => t.enabled).length})`}>
<div className="space-y-2">
{(inputs.tools || []).map((tool, idx) => (
<div key={idx} className="flex items-center justify-between rounded-lg border border-divider-subtle px-3 py-2">
<div className="flex items-center gap-2">
<Switch
size="sm"
defaultValue={tool.enabled}
onChange={(v) => {
const tools = [...(inputs.tools || [])]
tools[idx] = { ...tools[idx], enabled: v }
updateData({ tools })
}}
/>
<span className="text-[13px] text-text-secondary">{tool.tool_name}</span>
</div>
<span className="text-[11px] text-text-quaternary">{tool.provider_name?.split('/').pop()}</span>
</div>
))}
{(inputs.tools || []).length === 0 && (
<div className="py-3 text-center text-[13px] text-text-quaternary">
No tools configured. Add tools from the workflow toolbar.
</div>
)}
</div>
</Field>
<Split />
{/* Memory */}
<Field title="Memory">
<MemoryConfig
readonly={false}
config={inputs.memory || { window: { enabled: true, size: 50 } }}
onChange={(memory) => updateData({ memory })}
/>
</Field>
<Split />
{/* Vision */}
<Field title="Vision">
<ConfigVision
payload={inputs.vision}
onChange={(vision) => updateData({ vision })}
/>
</Field>
</div>
)
}
Panel.displayName = 'AgentV2Panel'
export default memo(Panel)

View File

@ -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<string, any>
settings: Record<string, any>
extra: Record<string, any>
}
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<string, any>
}

View File

@ -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<string, ComponentType<any>> = {
[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<string, ComponentType<any>> = {
[BlockEnum.DocExtractor]: DocExtractorPanel,
[BlockEnum.ListFilter]: ListFilterPanel,
[BlockEnum.Agent]: AgentPanel,
[BlockEnum.AgentV2]: AgentV2Panel,
[BlockEnum.DataSource]: DataSourcePanel,
[BlockEnum.KnowledgeBase]: KnowledgeBasePanel,
[BlockEnum.HumanInput]: HumanInputPanel,

View File

@ -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',

View File

@ -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?",

View File

@ -135,6 +135,7 @@
"newApp.advancedUserDescription": "基于工作流编排,适用于定义等复杂流程的多轮对话场景,具有记忆功能。",
"newApp.agentAssistant": "新的智能助手",
"newApp.agentShortDescription": "具备推理与自主工具调用的智能助手",
"newApp.agentV2ShortDescription": "新一代 Agent支持工具调用、沙箱执行和 Workflow 集成",
"newApp.agentUserDescription": "能够迭代式的规划推理、自主工具调用,直至完成任务目标的智能助手。",
"newApp.appCreateDSLErrorPart1": "检测到 DSL 版本差异较大,强制导入应用可能无法正常运行。",
"newApp.appCreateDSLErrorPart2": "是否继续?",

40
web/service/sandbox.ts Normal file
View File

@ -0,0 +1,40 @@
import { del, get, post } from './base'
export type SandboxProvider = {
provider_type: string
is_active: boolean
config?: Record<string, any>
config_schema?: Array<{
name: string
type: string
}>
}
export const listSandboxProviders = (): Promise<SandboxProvider[]> => {
return get<SandboxProvider[]>('workspaces/current/sandbox-providers')
}
export const saveSandboxProviderConfig = (
providerType: string,
config: Record<string, any>,
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`)
}

View File

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

View File

@ -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`