Merge remote-tracking branch 'origin/feat/workflow' into feat/workflow

This commit is contained in:
jyong 2024-03-29 19:29:42 +08:00
commit bab88efda9
21 changed files with 601 additions and 603 deletions

View File

@ -8,7 +8,7 @@ api = ExternalApi(bp)
from . import admin, apikey, extension, feature, setup, version, ping
# Import app controllers
from .app import (advanced_prompt_template, annotation, app, audio, completion, conversation, generator, message,
model_config, site, statistic, workflow, workflow_run, workflow_app_log, workflow_statistic)
model_config, site, statistic, workflow, workflow_run, workflow_app_log, workflow_statistic, agent)
# Import auth controllers
from .auth import activate, data_source_oauth, login, oauth
# Import billing controllers

View File

@ -0,0 +1,32 @@
from flask_restful import Resource, reqparse
from controllers.console import api
from controllers.console.app.wraps import get_app_model
from controllers.console.setup import setup_required
from controllers.console.wraps import account_initialization_required
from libs.helper import uuid_value
from libs.login import login_required
from models.model import AppMode
from services.agent_service import AgentService
class AgentLogApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT_CHAT])
def get(self, app_model):
"""Get agent logs"""
parser = reqparse.RequestParser()
parser.add_argument('message_id', type=uuid_value, required=True, location='args')
parser.add_argument('conversation_id', type=uuid_value, required=True, location='args')
args = parser.parse_args()
return AgentService.get_agent_logs(
app_model,
args['conversation_id'],
args['message_id']
)
api.add_resource(AgentLogApi, '/apps/<uuid:app_id>/agent/logs')

View File

@ -11,6 +11,7 @@ class ToolProviderType(Enum):
Enum class for tool provider
"""
BUILT_IN = "built-in"
DATASET_RETRIEVAL = "dataset-retrieval"
APP_BASED = "app-based"
API_BASED = "api-based"
@ -161,6 +162,8 @@ class ToolIdentity(BaseModel):
author: str = Field(..., description="The author of the tool")
name: str = Field(..., description="The name of the tool")
label: I18nObject = Field(..., description="The label of the tool")
provider: str = Field(..., description="The provider of the tool")
icon: Optional[str] = None
class ToolCredentialsOption(BaseModel):
value: str = Field(..., description="The value of the option")
@ -334,23 +337,25 @@ class ToolInvokeMeta(BaseModel):
"""
time_cost: float = Field(..., description="The time cost of the tool invoke")
error: Optional[str] = None
tool_config: Optional[dict] = None
@classmethod
def empty(cls) -> 'ToolInvokeMeta':
"""
Get an empty instance of ToolInvokeMeta
"""
return cls(time_cost=0.0, error=None)
return cls(time_cost=0.0, error=None, tool_config={})
@classmethod
def error_instance(cls, error: str) -> 'ToolInvokeMeta':
"""
Get an instance of ToolInvokeMeta with error
"""
return cls(time_cost=0.0, error=error)
return cls(time_cost=0.0, error=error, tool_config={})
def to_dict(self) -> dict:
return {
'time_cost': self.time_cost,
'error': self.error,
'tool_config': self.tool_config,
}

View File

@ -16,6 +16,8 @@ from models.tools import ApiToolProvider
class ApiBasedToolProviderController(ToolProviderController):
provider_id: str
@staticmethod
def from_db(db_provider: ApiToolProvider, auth_type: ApiProviderAuthType) -> 'ApiBasedToolProviderController':
credentials_schema = {
@ -89,9 +91,10 @@ class ApiBasedToolProviderController(ToolProviderController):
'en_US': db_provider.description,
'zh_Hans': db_provider.description
},
'icon': db_provider.icon
'icon': db_provider.icon,
},
'credentials_schema': credentials_schema
'credentials_schema': credentials_schema,
'provider_id': db_provider.id,
})
@property
@ -120,7 +123,8 @@ class ApiBasedToolProviderController(ToolProviderController):
'en_US': tool_bundle.operation_id,
'zh_Hans': tool_bundle.operation_id
},
'icon': tool_bundle.icon if tool_bundle.icon else ''
'icon': self.identity.icon,
'provider': self.provider_id,
},
'description': {
'human': {

View File

@ -68,6 +68,7 @@ class BuiltinToolProviderController(ToolProviderController):
script_path=path.join(path.dirname(path.realpath(__file__)),
'builtin', provider, 'tools', f'{tool_name}.py'),
parent_type=BuiltinTool)
tool["identity"]["provider"] = provider
tools.append(assistant_tool_class(**tool))
self.tools = tools

View File

@ -8,7 +8,8 @@ import requests
import core.helper.ssrf_proxy as ssrf_proxy
from core.tools.entities.tool_bundle import ApiBasedToolBundle
from core.tools.entities.tool_entities import ToolInvokeMessage
from core.tools.entities.tool_entities import ToolInvokeMessage, ToolProviderType
from core.tools.entities.user_entities import UserToolProvider
from core.tools.errors import ToolInvokeError, ToolParameterValidationError, ToolProviderCredentialValidationError
from core.tools.tool.tool import Tool
@ -34,7 +35,7 @@ class ApiTool(Tool):
api_bundle=self.api_bundle.copy() if self.api_bundle else None,
runtime=Tool.Runtime(**meta)
)
def validate_credentials(self, credentials: dict[str, Any], parameters: dict[str, Any], format_only: bool = False) -> str:
"""
validate the credentials for Api tool
@ -49,6 +50,9 @@ class ApiTool(Tool):
# validate response
return self.validate_and_parse_response(response)
def tool_provider_type(self) -> ToolProviderType:
return UserToolProvider.ProviderType.API
def assembling_request(self, parameters: dict[str, Any]) -> dict[str, Any]:
headers = {}
credentials = self.runtime.credentials or {}

View File

@ -1,6 +1,8 @@
from core.model_runtime.entities.llm_entities import LLMResult
from core.model_runtime.entities.message_entities import PromptMessage, SystemPromptMessage, UserPromptMessage
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.entities.user_entities import UserToolProvider
from core.tools.model.tool_model_manager import ToolModelManager
from core.tools.tool.tool import Tool
from core.tools.utils.web_reader_tool import get_url
@ -40,6 +42,9 @@ class BuiltinTool(Tool):
prompt_messages=prompt_messages,
)
def tool_provider_type(self) -> ToolProviderType:
return UserToolProvider.ProviderType.BUILTIN
def get_max_tokens(self) -> int:
"""
get max tokens

View File

@ -7,7 +7,13 @@ from core.app.entities.app_invoke_entities import InvokeFrom
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolDescription, ToolIdentity, ToolInvokeMessage, ToolParameter
from core.tools.entities.tool_entities import (
ToolDescription,
ToolIdentity,
ToolInvokeMessage,
ToolParameter,
ToolProviderType,
)
from core.tools.tool.tool import Tool
@ -53,7 +59,7 @@ class DatasetRetrieverTool(Tool):
for langchain_tool in langchain_tools:
tool = DatasetRetrieverTool(
langchain_tool=langchain_tool,
identity=ToolIdentity(author='', name=langchain_tool.name, label=I18nObject(en_US='', zh_Hans='')),
identity=ToolIdentity(provider='', author='', name=langchain_tool.name, label=I18nObject(en_US='', zh_Hans='')),
parameters=[],
is_team_authorization=True,
description=ToolDescription(
@ -77,6 +83,9 @@ class DatasetRetrieverTool(Tool):
required=True,
default=''),
]
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.DATASET_RETRIEVAL
def _invoke(self, user_id: str, tool_parameters: dict[str, Any]) -> ToolInvokeMessage | list[ToolInvokeMessage]:
"""

View File

@ -11,7 +11,7 @@ from core.model_runtime.entities.message_entities import (
UserPromptMessage,
)
from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
from core.tools.entities.tool_entities import ModelToolPropertyKey, ToolInvokeMessage
from core.tools.entities.tool_entities import ModelToolPropertyKey, ToolInvokeMessage, ToolProviderType
from core.tools.tool.tool import Tool
VISION_PROMPT = """## Image Recognition Task
@ -79,6 +79,9 @@ class ModelTool(Tool):
"""
pass
def tool_provider_type(self) -> ToolProviderType:
return ToolProviderType.BUILT_IN
def _invoke(self, user_id: str, tool_parameters: dict[str, Any]) -> ToolInvokeMessage | list[ToolInvokeMessage]:
"""
"""

View File

@ -9,6 +9,7 @@ from core.tools.entities.tool_entities import (
ToolIdentity,
ToolInvokeMessage,
ToolParameter,
ToolProviderType,
ToolRuntimeImageVariable,
ToolRuntimeVariable,
ToolRuntimeVariablePool,
@ -59,6 +60,14 @@ class Tool(BaseModel, ABC):
runtime=Tool.Runtime(**meta),
)
@abstractmethod
def tool_provider_type(self) -> ToolProviderType:
"""
get the tool provider type
:return: the tool provider type
"""
def load_variables(self, variables: ToolRuntimeVariablePool):
"""
load variables from database

View File

@ -1,3 +1,4 @@
from copy import deepcopy
from datetime import datetime, timezone
from typing import Union
@ -55,11 +56,7 @@ class ToolEngine:
tool_inputs=tool_parameters
)
try:
meta, response = ToolEngine._invoke(tool, tool_parameters, user_id)
except ToolEngineInvokeError as e:
meta = e.meta
meta, response = ToolEngine._invoke(tool, tool_parameters, user_id)
response = ToolFileMessageTransformer.transform_tool_invoke_messages(
messages=response,
user_id=user_id,
@ -104,11 +101,16 @@ class ToolEngine:
except ToolInvokeError as e:
error_response = f"tool invoke error: {e}"
agent_tool_callback.on_tool_error(e)
except ToolEngineInvokeError as e:
meta = e.args[0]
error_response = f"tool invoke error: {meta.error}"
agent_tool_callback.on_tool_error(e)
return error_response, [], meta
except Exception as e:
error_response = f"unknown error: {e}"
agent_tool_callback.on_tool_error(e)
return error_response, [], meta
return error_response, [], ToolInvokeMeta.error_instance(error_response)
@staticmethod
def workflow_invoke(tool: Tool, tool_parameters: dict,
@ -146,12 +148,18 @@ class ToolEngine:
Invoke the tool with the given arguments.
"""
started_at = datetime.now(timezone.utc)
meta = ToolInvokeMeta(time_cost=0.0, error=None)
meta = ToolInvokeMeta(time_cost=0.0, error=None, tool_config={
'tool_name': tool.identity.name,
'tool_provider': tool.identity.provider,
'tool_provider_type': tool.tool_provider_type().value,
'tool_parameters': deepcopy(tool.runtime.runtime_parameters),
'tool_icon': tool.identity.icon
})
try:
response = tool.invoke(user_id, tool_parameters)
except Exception as e:
meta.error = str(e)
raise ToolEngineInvokeError(meta=meta)
raise ToolEngineInvokeError(meta)
finally:
ended_at = datetime.now(timezone.utc)
meta.time_cost = (ended_at - started_at).total_seconds()

View File

@ -1163,6 +1163,10 @@ class MessageAgentThought(db.Model):
else:
return []
@property
def tools(self) -> list[str]:
return self.tool.split(";") if self.tool else []
@property
def tool_labels(self) -> dict:
try:
@ -1182,6 +1186,55 @@ class MessageAgentThought(db.Model):
return {}
except Exception as e:
return {}
@property
def tool_inputs_dict(self) -> dict:
tools = self.tools
try:
if self.tool_input:
data = json.loads(self.tool_input)
result = {}
for tool in tools:
if tool in data:
result[tool] = data[tool]
else:
if len(tools) == 1:
result[tool] = data
else:
result[tool] = {}
return result
else:
return {
tool: {} for tool in tools
}
except Exception as e:
return {}
@property
def tool_outputs_dict(self) -> dict:
tools = self.tools
try:
if self.observation:
data = json.loads(self.observation)
result = {}
for tool in tools:
if tool in data:
result[tool] = data[tool]
else:
if len(tools) == 1:
result[tool] = data
else:
result[tool] = {}
return result
else:
return {
tool: {} for tool in tools
}
except Exception as e:
if self.observation:
return {
tool: self.observation for tool in tools
}
class DatasetRetrieverResource(db.Model):
__tablename__ = 'dataset_retriever_resources'

View File

@ -0,0 +1,123 @@
from core.app.app_config.easy_ui_based_app.agent.manager import AgentConfigManager
from extensions.ext_database import db
from models.account import Account
from models.model import App, Conversation, EndUser, Message, MessageAgentThought
from services.tools_transform_service import ToolTransformService
class AgentService:
@classmethod
def get_agent_logs(cls, app_model: App,
conversation_id: str,
message_id: str) -> dict:
"""
Service to get agent logs
"""
conversation: Conversation = db.session.query(Conversation).filter(
Conversation.id == conversation_id,
Conversation.app_id == app_model.id,
).first()
if not conversation:
raise ValueError(f"Conversation not found: {conversation_id}")
message: Message = db.session.query(Message).filter(
Message.id == message_id,
Message.conversation_id == conversation_id,
).first()
if not message:
raise ValueError(f"Message not found: {message_id}")
agent_thoughts: list[MessageAgentThought] = message.agent_thoughts
if conversation.from_end_user_id:
# only select name field
executor = db.session.query(EndUser, EndUser.name).filter(
EndUser.id == conversation.from_end_user_id
).first()
else:
executor = db.session.query(Account, Account.name).filter(
Account.id == conversation.from_account_id
).first()
if executor:
executor = executor.name
else:
executor = 'Unknown'
result = {
'meta': {
'status': 'success',
'executor': executor,
'start_time': message.created_at.isoformat(),
'elapsed_time': message.provider_response_latency,
'total_tokens': message.answer_tokens + message.message_tokens,
'agent_mode': app_model.app_model_config.agent_mode_dict.get('strategy', 'react'),
'iterations': len(agent_thoughts),
},
'iterations': [],
'files': message.files,
}
agent_config = AgentConfigManager.convert(app_model.app_model_config.to_dict())
agent_tools = agent_config.tools
def find_agent_tool(tool_name: str):
for agent_tool in agent_tools:
if agent_tool.tool_name == tool_name:
return agent_tool
for agent_thought in agent_thoughts:
tools = agent_thought.tools
tool_labels = agent_thought.tool_labels
tool_meta = agent_thought.tool_meta
tool_inputs = agent_thought.tool_inputs_dict
tool_outputs = agent_thought.tool_outputs_dict
tool_calls = []
for tool in tools:
tool_name = tool
tool_label = tool_labels.get(tool_name, tool_name)
tool_input = tool_inputs.get(tool_name, {})
tool_output = tool_outputs.get(tool_name, {})
tool_meta_data = tool_meta.get(tool_name, {})
tool_config = tool_meta_data.get('tool_config', {})
tool_icon = ToolTransformService.get_tool_provider_icon_url(
provider_type=tool_config.get('tool_provider_type', ''),
provider_name=tool_config.get('tool_provider', ''),
icon=tool_config.get('tool_icon', '')
)
if not tool_icon:
tool_entity = find_agent_tool(tool_name)
if tool_entity:
tool_icon = ToolTransformService.get_tool_provider_icon_url(
provider_type=tool_entity.provider_type,
provider_name=tool_entity.provider_id,
icon=''
)
tool_calls.append({
'status': 'success' if not tool_meta_data.get('error') else 'error',
'error': tool_meta_data.get('error'),
'time_cost': tool_meta_data.get('time_cost', 0),
'tool_name': tool_name,
'tool_label': tool_label,
'tool_input': tool_input,
'tool_output': tool_output,
'tool_parameters': tool_meta_data.get('tool_parameters', {}),
'tool_icon': tool_icon,
})
result['iterations'].append({
'tokens': agent_thought.tokens,
'tool_calls': tool_calls,
'tool_raw': {
'inputs': agent_thought.tool_input,
'outputs': agent_thought.observation,
},
'thought': agent_thought.thought,
'created_at': agent_thought.created_at.isoformat(),
'files': agent_thought.files,
})
return result

View File

@ -7,7 +7,6 @@ import type { Body } from '../../types'
import { BodyType } from '../../types'
import useKeyValueList from '../../hooks/use-key-value-list'
import KeyValue from '../key-value'
import TextEditor from '../../../_base/components/editor/text-editor'
import useAvailableVarList from '../../../_base/hooks/use-available-var-list'
import InputWithVar from '@/app/components/workflow/nodes/_base/components/prompt/editor'
import type { Var } from '@/app/components/workflow/types'
@ -127,22 +126,17 @@ const EditBody: FC<Props> = ({
)}
{type === BodyType.rawText && (
<TextEditor
<InputWithVar
title={<div className='uppercase'>Raw text</div>}
onChange={handleBodyValueChange}
value={payload.data}
minHeight={150}
readonly={readonly}
justVar
nodesOutputVars={availableVarList}
readOnly={readonly}
/>
)}
{type === BodyType.json && (
// <CodeEditor
// readOnly={readonly}
// title={<div className='uppercase'>JSON</div>}
// value={payload.data} onChange={handleBodyValueChange}
// language={CodeLanguage.json}
// />
<InputWithVar
title='JSON'
value={payload.data}

View File

@ -1,22 +1,39 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import type { HttpNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
import SupportVarInput from '@/app/components/workflow/nodes/_base/components/support-var-input'
import type { NodeProps, Var } from '@/app/components/workflow/types'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import { VarType } from '@/app/components/workflow/types'
import Input from '@/app/components/workflow/nodes/_base/components/input-support-select-var'
const Node: FC<NodeProps<HttpNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()
const { method, url } = data
const availableVarList = useAvailableVarList(id, {
onlyLeafNodeVar: false,
filterVar: (varPayload: Var) => {
return [VarType.string, VarType.number].includes(varPayload.type)
},
})
return (
<div className='mb-1 px-3 py-1'>
<div className='flex items-center p-1 rounded-md bg-gray-100'>
<div className='shrink-0 px-1 h-7 leading-7 rounded bg-gray-25 text-xs font-semibold text-gray-700 uppercase'>{method}</div>
<SupportVarInput
wrapClassName='w-0 grow truncate flex items-center'
textClassName='ml-1 text-xs font-normal text-gray-700'
<Input
className={cn('bg-gray-100 border-gray-100', 'w-0 grow rounded-lg px-3 py-[6px] border')}
value={url}
readonly
onChange={() => { }}
readOnly
nodesOutputVars={availableVarList}
onFocusChange={() => { }}
placeholder={t('workflow.nodes.http.apiPlaceholder')!}
placeholderClassName='!leading-[21px]'
/>
</div>
</div>

View File

@ -15,32 +15,39 @@ const IfElseNode: FC<NodeProps<IfElseNodeType>> = (props) => {
return (
<div className='px-3'>
<div className='flex items-center h-6 relative px-1'>
<div className='w-full text-right text-gray-700 text-xs font-semibold'>IF</div>
<div className='relative flex items-center h-6 px-1'>
<div className='w-full text-xs font-semibold text-right text-gray-700'>IF</div>
<NodeSourceHandle
{...props}
handleId='true'
handleClassName='!top-1/2 !-right-[21px] !-translate-y-1/2'
/>
</div>
<div className='mb-0.5 leading-4 text-[10px] font-medium text-gray-500 uppercase'>{t(`${i18nPrefix}.conditions`)}</div>
<div className='space-y-0.5'>
{conditions.filter(item => (item.variable_selector && item.variable_selector.length > 0 && item.comparison_operator && (isEmptyRelatedOperator(item.comparison_operator!) ? true : !!item.value))).map((condition, i) => (
{conditions.map((condition, i) => (
<div key={condition.id} className='relative'>
<div className='flex items-center h-6 bg-gray-100 rounded-md px-1 space-x-1 text-xs font-normal text-gray-700'>
<Variable02 className='w-3.5 h-3.5 text-primary-500' />
<span>{condition.variable_selector.slice(-1)[0]}</span>
<span className='text-gray-500'>{isComparisonOperatorNeedTranslate(condition.comparison_operator) ? t(`${i18nPrefix}.comparisonOperator.${condition.comparison_operator}`) : condition.comparison_operator}</span>
{!isEmptyRelatedOperator(condition.comparison_operator!) && <span>{condition.value}</span>}
</div>
{(condition.variable_selector?.length > 0 && condition.comparison_operator && (isEmptyRelatedOperator(condition.comparison_operator!) ? true : !!condition.value))
? (
<div className='flex items-center h-6 px-1 space-x-1 text-xs font-normal text-gray-700 bg-gray-100 rounded-md'>
<Variable02 className='w-3.5 h-3.5 text-primary-500' />
<span>{condition.variable_selector.slice(-1)[0]}</span>
<span className='text-gray-500'>{isComparisonOperatorNeedTranslate(condition.comparison_operator) ? t(`${i18nPrefix}.comparisonOperator.${condition.comparison_operator}`) : condition.comparison_operator}</span>
{!isEmptyRelatedOperator(condition.comparison_operator!) && <span>{condition.value}</span>}
</div>
)
: (
<div className='flex items-center h-6 px-1 space-x-1 text-xs font-normal text-gray-500 bg-gray-100 rounded-md'>
{t(`${i18nPrefix}.conditionNotSetup`)}
</div>
)}
{i !== conditions.length - 1 && (
<div className='absolute z-10 right-0 bottom-[-10px] leading-4 text-[10px] font-medium text-primary-600 uppercase'>{t(`${i18nPrefix}.${logical_operator}`)}</div>
)}
</div>
))}
</div>
<div className='flex items-center h-6 relative px-1'>
<div className='w-full text-right text-gray-700 text-xs font-semibold'>ELSE</div>
<div className='relative flex items-center h-6 px-1'>
<div className='w-full text-xs font-semibold text-right text-gray-700'>ELSE</div>
<NodeSourceHandle
{...props}
handleId='false'

View File

@ -38,7 +38,6 @@ const ConfigPrompt: FC<Props> = ({
isChatModel,
isChatApp,
payload,
variables,
onChange,
isShowContext,
hasSetBlockStatus,
@ -137,7 +136,6 @@ const ConfigPrompt: FC<Props> = ({
}
value={item.text}
onChange={handleChatModePromptChange(index)}
variables={variables}
readOnly={readOnly}
showRemove={(payload as PromptItem[]).length > 1}
onRemove={handleRemove(index)}
@ -165,7 +163,6 @@ const ConfigPrompt: FC<Props> = ({
title={<span className='capitalize'>{t(`${i18nPrefix}.prompt`)}</span>}
value={(payload as PromptItem).text}
onChange={handleCompletionPromptChange}
variables={variables}
readOnly={readOnly}
isChatModel={isChatModel}
isChatApp={isChatApp}

View File

@ -193,7 +193,6 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
</div>}
value={QUERY_PLACEHOLDER_TEXT}
onChange={() => { }}
variables={[]}
readOnly
isShowContext={false}
isChatApp
@ -243,7 +242,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
<OutputVars>
<>
<VarItem
name='output'
name='text'
type='string'
description={t(`${i18nPrefix}.outputVars.output`)}
/>

View File

@ -247,7 +247,6 @@ const translation = {
},
},
ifElse: {
conditions: 'Conditions',
if: 'If',
else: 'Else',
elseDescription: 'Used to define the logic that should be executed when the if condition is not met.',
@ -269,6 +268,7 @@ const translation = {
},
enterValue: 'Enter value',
addCondition: 'Add Condition',
conditionNotSetup: 'Condition NOT setup',
},
variableAssigner: {
title: 'Assign variables',

View File

@ -247,7 +247,6 @@ const translation = {
},
},
ifElse: {
conditions: '条件',
if: 'If',
else: 'Else',
elseDescription: '用于定义当 if 条件不满足时应执行的逻辑。',
@ -269,6 +268,7 @@ const translation = {
},
enterValue: '输入值',
addCondition: '添加条件',
conditionNotSetup: '条件未设置',
},
variableAssigner: {
title: '变量赋值',

File diff suppressed because it is too large Load Diff