Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions packages/global/core/app/variableIdentifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
export const VARIABLE_IDENTIFIER_REGEX = /^[A-Za-z][A-Za-z0-9_]{1,49}$/;

export const workflowVariableReservedKeys = [
'userId',
'appId',
'chatId',
'responseChatItemId',
'histories',
'cTime'
] as const;

export type VariableIdentifierValidationReason =
| 'required'
| 'invalid_format'
| 'duplicate'
| 'system_conflict';

export const buildDefaultVariableIdentifier = (label: string) => {
const normalized = label
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_+/g, '_');

if (!normalized) return 'var';
if (/^[a-z]/.test(normalized)) return normalized.slice(0, 50);

return `var_${normalized}`.slice(0, 50);
};

export const validateVariableIdentifier = (
key: string,
props?: {
existingKeys?: string[];
reservedKeys?: readonly string[];
currentKey?: string;
}
):
| {
valid: true;
}
| {
valid: false;
reason: VariableIdentifierValidationReason;
} => {
const trimmedKey = key.trim();
const { existingKeys = [], reservedKeys = [], currentKey } = props || {};

if (!trimmedKey) {
return {
valid: false,
reason: 'required'
};
}

if (!VARIABLE_IDENTIFIER_REGEX.test(trimmedKey)) {
return {
valid: false,
reason: 'invalid_format'
};
}

if (
existingKeys.some((existingKey) => existingKey !== currentKey && existingKey === trimmedKey)
) {
return {
valid: false,
reason: 'duplicate'
};
}

if (reservedKeys.includes(trimmedKey)) {
return {
valid: false,
reason: 'system_conflict'
};
}

return {
valid: true
};
};
32 changes: 31 additions & 1 deletion packages/service/core/app/controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { type AppSchemaType } from '@fastgpt/global/core/app/type';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import {
validateVariableIdentifier,
workflowVariableReservedKeys
} from '@fastgpt/global/core/app/variableIdentifier';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
Expand Down Expand Up @@ -31,10 +35,36 @@ import { MongoAppRecord } from './record/schema';
import { mongoSessionRun } from '../../common/mongo/sessionRun';
import { getLogger, LogCategories } from '../../common/logger';
import { deleteSandboxesByAppId } from '../ai/sandbox/controller';
import { CommonErrEnum } from '@fastgpt/global/common/error/code/common';

const logger = getLogger(LogCategories.MODULE.APP.FOLDER);

export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] }) => {
export const validateAppChatConfigVariables = (chatConfig?: AppSchemaType['chatConfig']) => {
const variables = chatConfig?.variables || [];
const existingKeys = new Set<string>();

variables.forEach((variable) => {
const result = validateVariableIdentifier(variable.key, {
reservedKeys: workflowVariableReservedKeys
});

if (!result.valid || existingKeys.has(variable.key)) {
throw CommonErrEnum.invalidParams;
}

existingKeys.add(variable.key);
});
};

export const beforeUpdateAppFormat = ({
nodes,
chatConfig
}: {
nodes?: StoreNodeItemType[];
chatConfig?: AppSchemaType['chatConfig'];
}) => {
validateAppChatConfigVariables(chatConfig);

if (!nodes) return;

nodes.forEach((node) => {
Expand Down
7 changes: 7 additions & 0 deletions packages/web/i18n/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,13 @@
"use_computer_desc": "After being turned on, AI will get a virtual machine environment where it can execute commands, operate files, and run code. \nEach session shares a virtual machine environment.",
"used_points": "Used point: ",
"variable.internal_type_desc": "Use only inside the workflow and will not appear in the dialog box",
"variable_key": "Variable identifier",
"variable_key_description": "Used for workflow references and API variables. It can only be set when the variable is created.",
"variable_key_invalid": "The identifier must start with a letter and only contain letters, numbers, and underscores",
"variable_key_placeholder": "For example: customer_name",
"variable_key_readonly": "This identifier is already in use by the workflow and cannot be changed",
"variable_key_required": "Required variable identifier",
"variable_key_system_conflict": "The identifier conflicts with a system variable. Please use a different identifier",
"variable.select type_desc": "The input box will be displayed in the site conversation and run preview, and this variable will not be displayed in the sharing link.",
"variable.textarea_type_desc": "Allows users to input up to 4000 characters in the dialogue box.",
"variable_name_required": "Required variable name",
Expand Down
7 changes: 7 additions & 0 deletions packages/web/i18n/zh-CN/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,13 @@
"use_computer_desc": "开启后,AI 将获得一个虚拟机环境,可执行命令、操作文件、运行代码。每个会话共享一个虚拟机环境。",
"used_points": "积分用量:",
"variable.internal_type_desc": "仅在工作流内部使用,不会出现在对话框中",
"variable_key": "变量标识符",
"variable_key_description": "用于工作流引用和 API 变量传参,仅可在创建变量时设置。",
"variable_key_invalid": "标识符必须以字母开头,且只能包含字母、数字和下划线",
"variable_key_placeholder": "例如:customer_name",
"variable_key_readonly": "该标识符已被工作流使用,创建后不可修改",
"variable_key_required": "变量标识符必填",
"variable_key_system_conflict": "变量标识符与系统变量冲突,请使用其他标识符",
"variable.select type_desc": "会在站内对话和运行预览中显示输入框,在分享链接中不会显示此变量",
"variable.textarea_type_desc": "允许用户最多输入4000字的对话框。",
"variable_name_required": "变量名必填",
Expand Down
7 changes: 7 additions & 0 deletions packages/web/i18n/zh-Hant/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,13 @@
"use_computer_desc": "開啟後,AI 將獲得一個虛擬機器環境,執行指令、操作檔案、執行程式碼。\n每个会话共享一个虚拟机环境。",
"used_points": "積分用量:",
"variable.internal_type_desc": "僅在工作流內部使用,不會出現在對話框中",
"variable_key": "變量標識符",
"variable_key_description": "用於工作流引用和 API 變量傳參,僅可在建立變量時設定。",
"variable_key_invalid": "標識符必須以字母開頭,且只能包含字母、數字和底線",
"variable_key_placeholder": "例如:customer_name",
"variable_key_readonly": "該標識符已被工作流使用,建立後不可修改",
"variable_key_required": "變量標識符必填",
"variable_key_system_conflict": "變量標識符與系統變量衝突,請使用其他標識符",
"variable.select type_desc": "會在站內對話和運行預覽中顯示輸入框,在分享鏈接中不會顯示此變量",
"variable.textarea_type_desc": "允許使用者最多輸入 4000 字的對話框。",
"variable_name_required": "變量名必填",
Expand Down
22 changes: 21 additions & 1 deletion projects/app/src/components/core/app/VariableEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import DndDrag, {
type DraggableStateSnapshot
} from '@fastgpt/web/components/common/DndDrag';
import VariableEditModal from './VariableEditModal';
import { useCopyData } from '@fastgpt/web/hooks/useCopyData';

export const defaultVariable: VariableItemType = {
key: '',
Expand Down Expand Up @@ -73,6 +74,7 @@ const VariableEdit = ({
zoom?: number;
}) => {
const { t } = useTranslation();
const { copyData } = useCopyData();

const [editingVariable, setEditingVariable] = useState<VariableItemType | null>(null);

Expand Down Expand Up @@ -116,6 +118,7 @@ const VariableEdit = ({
<Thead>
<Tr>
<Th>{t('workflow:Variable_name')}</Th>
<Th>{t('app:variable_key')}</Th>
<Th>{t('common:Required_input')}</Th>
<Th>{t('common:Operation')}</Th>
</Tr>
Expand All @@ -133,6 +136,7 @@ const VariableEdit = ({
onEdit={setEditingVariable}
onChange={onChange}
variables={variables}
copyData={copyData}
/>
)}
zoom={zoom}
Expand All @@ -149,6 +153,7 @@ const VariableEdit = ({
onEdit={setEditingVariable}
onChange={onChange}
variables={variables}
copyData={copyData}
key={item.key}
/>
)}
Expand Down Expand Up @@ -180,7 +185,8 @@ const TableItem = ({
item,
onEdit,
onChange,
variables
variables,
copyData
}: {
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
Expand All @@ -190,6 +196,7 @@ const TableItem = ({
onEdit: (variable: VariableItemType) => void;
onChange: (data: VariableItemType[]) => void;
variables: VariableItemType[];
copyData: (data: string, title?: string | null | undefined, duration?: number) => Promise<void>;
}) => {
return (
<Tr
Expand All @@ -207,6 +214,19 @@ const TableItem = ({
{item.label}
</Flex>
</Td>
<Td>
<Flex alignItems={'center'} gap={1}>
<Box fontSize={'sm'} color={'myGray.700'}>
{item.key}
</Box>
<MyIconButton
icon={'copy'}
onClick={() => {
copyData(item.key);
}}
/>
</Flex>
</Td>
<Td>
<Flex alignItems={'center'}>
{item.required ? <MyIcon name={'check'} w={'16px'} color={'myGray.900'} mr={2} /> : ''}
Expand Down
46 changes: 37 additions & 9 deletions projects/app/src/components/core/app/VariableEditModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { getNanoid } from '@fastgpt/global/common/string/tools';
import InputTypeSelector from '@fastgpt/web/components/common/InputTypeSelector';
import { getVariableInputTypeList } from '@fastgpt/web/components/common/InputTypeSelector/configs';
import { addVariable } from '../VariableEdit';
import { useValidateFieldName, useSubmitErrorHandler } from '../utils/formValidation';
import {
useValidateFieldKey,
useValidateFieldName,
useSubmitErrorHandler
} from '../utils/formValidation';
import { shouldLockVariableIdentifier } from '../utils/variableEditor';

const VariableEditModal = ({
onClose,
Expand All @@ -29,13 +34,16 @@ const VariableEditModal = ({
const { t } = useTranslation();
const { toast } = useToast();
const validateFieldName = useValidateFieldName();
const validateFieldKey = useValidateFieldKey();
const onSubmitError = useSubmitErrorHandler();

const form = useForm<VariableItemType>({
defaultValues: variable
});
const { setValue, reset, watch, getValues } = form;
const type = watch('type');
const isIdentifierReadonly = useMemo(() => shouldLockVariableIdentifier(variable), [variable]);

useEffect(() => {
reset(variable);
}, [variable, reset]);
Expand Down Expand Up @@ -76,18 +84,27 @@ const VariableEditModal = ({
const onSubmitSuccess = useCallback(
(data: VariableItemType, action: 'confirm' | 'continue') => {
data.label = data?.label?.trim();
data.key = data?.key?.trim();

const otherVariables = variables.filter((v) => v.key !== data.key);
const isValid = validateFieldName(data.label, {
existingKeys: otherVariables.flatMap((v) => [v.key, v.label]),
systemVariables: workflowSystemVariables,
currentKey: data.key
const otherVariables = variables.filter((v) => v.key !== variable.key);
const isValidLabel = validateFieldName(data.label, {
existingKeys: otherVariables.map((v) => v.label),
systemVariables: workflowSystemVariables
});

if (!isValid) {
if (!isValidLabel) {
return;
}

if (data.key) {
const isValidKey = validateFieldKey(data.key, {
existingKeys: otherVariables.map((v) => v.key),
reservedKeys: workflowSystemVariables.map((item) => item.key)
});
if (!isValidKey) {
return;
}
}

// For custom and internal types, user can select valueType manually, so don't override it
// For other types, set valueType from defaultValueType
if (
Expand Down Expand Up @@ -137,7 +154,17 @@ const VariableEditModal = ({
});
}
},
[variables, inputTypeList, onChange, reset, onClose, validateFieldName, toast, t]
[
variables,
inputTypeList,
onChange,
reset,
onClose,
validateFieldName,
validateFieldKey,
toast,
t
]
);

return (
Expand Down Expand Up @@ -167,6 +194,7 @@ const VariableEditModal = ({
isEdit={!!variable?.key}
inputType={type}
defaultValueType={defaultValueType}
identifierReadonly={isIdentifierReadonly}
onClose={onClose}
onSubmitSuccess={onSubmitSuccess}
onSubmitError={onSubmitError}
Expand Down
Loading