feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
.wrapper {
display: flex;
.stop {
width: 40px;
margin-right: 16px;
&.hidden {
display: none;
}
}
.generate {
:global {
.semi-button-content-right {
background-image: linear-gradient(90deg, #4D53E8, #4DCCE8);
background-clip: text;
-webkit-text-fill-color: transparent;
}
}
}
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useRef, type CSSProperties } from 'react';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Tooltip, UIButton } from '@coze-arch/bot-semi';
import { IconEffects, IconStopOutlined } from '@coze-arch/bot-icons';
import { useFlags } from '@coze-arch/bot-flags';
import { debuggerApi } from '@coze-arch/bot-api';
import { type NodeFormSchema } from '../../types';
import { useInnerStore } from '../../store';
import { useTestsetManageStore } from '../../hooks';
import { TestsetManageEventName } from '../../events';
import { toNodeFormSchemas } from './utils';
import s from './auto-fill.module.less';
interface AutoFillButtonProps {
className?: string;
style?: CSSProperties;
onAutoFill?: (schemas: NodeFormSchema[]) => void;
}
/** AI生成节点数据的按钮 */
export function AutoFillButton({
className,
style,
onAutoFill,
}: AutoFillButtonProps) {
const [FLAGS] = useFlags();
const { generating, patch } = useInnerStore();
const abortRef = useRef<AbortController | null>(null);
const { bizComponentSubject, bizCtx, reportEvent } = useTestsetManageStore(
store => store,
);
const onClick = async () => {
// report event
reportEvent?.(TestsetManageEventName.AIGC_PARAMS_CLICK, {
path: 'testset',
});
patch({ generating: true });
try {
abortRef.current = new AbortController();
const { genCaseData } = await debuggerApi.AutoGenerateCaseData(
{ bizComponentSubject, bizCtx, count: 1 },
{ signal: abortRef.current.signal },
);
if (!genCaseData?.length) {
return;
}
// fill form values
const autoSchemas = toNodeFormSchemas(genCaseData[0].input);
onAutoFill?.(autoSchemas);
} finally {
patch({ generating: false });
}
};
const onStop = () => {
abortRef.current?.abort();
};
// 社区版暂不支持该功能
if (!FLAGS['bot.devops.testset_auto_gen'] || !(IS_OVERSEA || IS_BOE)) {
return null;
}
return (
<div className={cls(s.wrapper, className)} style={style}>
<Tooltip content={I18n.t('workflow_testset_stopgen')}>
<UIButton
icon={<IconStopOutlined />}
className={cls(s.stop, generating || s.hidden)}
onClick={onStop}
/>
</Tooltip>
<UIButton
loading={generating}
icon={<IconEffects />}
className={s.generate}
style={style}
onClick={onClick}
>
{generating
? I18n.t('workflow_testset_generating')
: I18n.t('workflow_testset_aigenerate')}
</UIButton>
</div>
);
}

View File

@@ -0,0 +1,25 @@
.wrapper {
font-size: 12px;
font-weight: 500;
font-style: normal;
line-height: 16px;
color: var(--light-usage-text-color-text-0, #1D1C23);
.label {
display: inline-block;
font-weight: 600;
color: var(--semi-color-text-0);
&.required::after {
content: "*";
font-weight: 600;
color: var(--semi-color-danger);
}
}
.type-label {
display: inline-block;
margin-left: 8px;
color: var(--light-usage-text-color-text-3, rgb(29 28 35 / 35%));
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type CSSProperties } from 'react';
import cls from 'classnames';
import s from './form-label.module.less';
interface FormLabelProps {
label: string;
typeLabel?: string;
required?: boolean;
className?: string;
style?: CSSProperties;
}
// 内置的FormLabel样式不支持 typeLabel所以简单自定义
export function FormLabel({
label,
typeLabel,
required,
className,
style,
}: FormLabelProps) {
return (
<div className={cls(s.wrapper, className)} style={style}>
<div className={cls(s.label, required && s.required)}>{label}</div>
{typeLabel ? <div className={s['type-label']}>{typeLabel}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 25 25" fill="none">
<path d="M10.6777 4.83398L4.01387 11.9738C3.8246 12.1766 3.8246 12.4913 4.01387 12.6941L10.6777 19.834"
stroke="#1D1C23" stroke-width="2" stroke-linecap="round" />
<path d="M20 12.334H4" stroke="#1D1C23" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 358 B

View File

@@ -0,0 +1,60 @@
.sidesheet {
:global {
.semi-sidesheet-title {
width: 100%;
}
.semi-sidesheet-content {
background-color: var(--light-color-grey-grey-0, #F7F7FA);
}
.semi-form-field-label-text {
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
line-height: 16px;
&::after {
margin-left: 0;
}
}
// 不要红色感叹号 与workflow对齐
.semi-form-field-error-message .semi-form-field-validate-status-icon {
display: none;
}
}
}
.testset-desc {
:global {
.semi-input-textarea-counter {
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 20px;
color: var(--light-usage-text-color-text-3, rgb(29 28 35 / 35%));
}
}
}
.node-data-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 12px 0 16px;
>span {
font-size: 18px;
font-weight: 600;
font-style: normal;
line-height: 24px;
color: var(--light-usage-text-color-text-0, #1D1C23);
}
.auto-btn {
padding: 4px 8px;
font-size: 12px;
border-radius: 6px;
}
}

View File

@@ -0,0 +1,382 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useRef, useState } from 'react';
import { cloneDeep } from 'lodash-es';
import { useRequest } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { type FormApi } from '@coze-arch/bot-semi/Form';
import {
UIButton,
Form,
UIFormTextArea,
SideSheet,
Spin,
Tooltip,
} from '@coze-arch/bot-semi';
import { debuggerApi } from '@coze-arch/bot-api';
import { SideSheetTitle } from '../sidesheet-title';
import {
type FormItemSchema,
type TestsetData,
type TestsetDatabase,
FormItemSchemaType,
type NodeFormSchema,
} from '../../types';
import { useInnerStore } from '../../store';
import { useTestsetManageStore } from '../../hooks';
import { TestsetManageEventName } from '../../events';
import {
toNodeFormSchemas,
isNil,
traverseNodeFormSchemas,
getTestsetNameRules,
getSubFieldName,
isSameType,
transBoolSelect2Bool,
type ValuesForBoolSelect,
transFormItemSchema2Form,
} from './utils';
import { TestsetNameInput } from './testset-name-input';
import { NodeFormSection } from './node-form-section';
import { ReactComponent as IconBack } from './icon-back.svg';
import { AutoFillButton } from './auto-fill';
import s from './index.module.less';
export interface TestsetEditState {
visible?: boolean;
mode?: 'edit' | 'create';
testset?: TestsetData;
}
interface TestsetEditSideSheetProps extends TestsetEditState {
mask?: boolean;
onClose?: () => void;
onSuccess?: (testset?: TestsetData) => void;
onCancel?: () => void;
/** 是否为多人协作模式 */
isExpertMode?: boolean;
}
const TESTSET_NAME_FIELD = '__TESTSET_NAME__';
const TESTSET_DESC_FIELD = '__TESTSET_DESC__';
/**
* 特化逻辑:表单项赋默认值
* - Boolean类型`false` 因为undefined的表现上和false一样容易引发用户误解
* - Object类型`{}`
* - Array类型 `[]`
*/
function assignDefaultValue(ipt: FormItemSchema) {
if (!isNil(ipt.value)) {
return;
}
switch (ipt.type) {
case FormItemSchemaType.BOOLEAN:
// ipt.value = true;
break;
case FormItemSchemaType.OBJECT:
ipt.value = '{}';
break;
case FormItemSchemaType.LIST:
ipt.value = '[]';
break;
default:
break;
}
}
interface TestsetFormValue {
[key: string]: any;
}
// eslint-disable-next-line @coze-arch/max-line-per-function -- form function component
export function TestsetEditSideSheet({
visible,
mode,
testset,
mask,
onClose,
onSuccess,
isExpertMode,
}: TestsetEditSideSheetProps) {
const { generating: autoGenerating } = useInnerStore();
const { bizComponentSubject, bizCtx, reportEvent } = useTestsetManageStore(
store => store,
);
const testsetFormApi = useRef<FormApi<TestsetFormValue>>();
const [validating, setValidating] = useState(false);
const [submitting, setSubmitting] = useState(false);
const { data: nodeSchemas, loading: loadingSchema } = useRequest(
async () => {
if (!visible) {
return [];
}
const localSchemas = toNodeFormSchemas(testset?.caseBase?.input);
const res = await debuggerApi.GetSchemaByID({
bizComponentSubject,
bizCtx,
});
const remoteSchemas = toNodeFormSchemas(res.schemaJson);
if (localSchemas.length) {
// 编辑模式比对本地和远程schema并尝试赋值
const localSchemaMap: Record<string, FormItemSchema | undefined> = {};
traverseNodeFormSchemas(
localSchemas,
(schema, ipt) => (localSchemaMap[getSubFieldName(schema, ipt)] = ipt),
);
traverseNodeFormSchemas(remoteSchemas, (schema, ipt) => {
const subName = getSubFieldName(schema, ipt);
const field = localSchemaMap[subName];
if (isSameType(ipt.type, field?.type) && !isNil(field?.value)) {
ipt.value = field?.value;
}
});
} else {
// 创建模式:赋默认值
traverseNodeFormSchemas(remoteSchemas, (schema, ipt) => {
assignDefaultValue(ipt);
});
}
return remoteSchemas;
},
{ refreshDeps: [testset, visible] },
);
useEffect(() => {
if (!visible) {
return;
}
testsetFormApi.current?.setValues({
[TESTSET_NAME_FIELD]: testset?.caseBase?.name ?? '',
[TESTSET_DESC_FIELD]: testset?.caseBase?.description,
});
}, [visible, testset]);
// 给节点表单设置值
useEffect(() => {
if (typeof nodeSchemas === 'undefined') {
return;
}
const values = testsetFormApi.current?.getValues() ?? {};
traverseNodeFormSchemas(
nodeSchemas,
(schema, ipt) =>
(values[getSubFieldName(schema, ipt)] =
transFormItemSchema2Form(ipt)?.value),
);
testsetFormApi.current?.setValues(values);
}, [nodeSchemas]);
const renderTitle = () => (
<SideSheetTitle
icon={<IconBack />}
title={
mode
? mode === 'create'
? I18n.t('workflow_testset_create_title')
: I18n.t('workflow_testset_edit_title')
: ''
}
onClose={onClose}
/>
);
const renderFooter = () => (
<div className="text-right">
<Tooltip
trigger={isExpertMode ? 'hover' : 'custom'}
content={I18n.t('workflow_testset_submit_tooltip_for_expert_mode')}
>
<UIButton
theme="solid"
disabled={autoGenerating}
loading={validating || submitting}
onClick={onConfirm}
>
{I18n.t('workflow_testset_edit_confirm')}
</UIButton>
</Tooltip>
</div>
);
const renderNodeForm = () => {
if (loadingSchema) {
return <Spin />;
}
if (!nodeSchemas?.length) {
return null;
}
return nodeSchemas.map(schema => (
<NodeFormSection
key={schema.component_id}
schema={schema}
autoGenerating={autoGenerating}
/>
));
};
const onConfirm = async () => {
setValidating(true);
try {
await testsetFormApi.current?.validate();
const errors = testsetFormApi.current?.getFormState().errors;
if (Object.keys(errors ?? {}).length) {
return;
}
onSubmit();
} finally {
setValidating(false);
}
};
// 提交表单
const onSubmit = async () => {
setSubmitting(true);
try {
const testsetFormValues = testsetFormApi.current?.getValues();
if (!testsetFormValues) {
return;
}
const inputSchemas = cloneDeep(nodeSchemas ?? []);
traverseNodeFormSchemas(inputSchemas, (schema, ipt) => {
const val = testsetFormValues[getSubFieldName(schema, ipt)];
if (!isNil(val)) {
ipt.value = val;
}
// 清除 object/array的空值包括空字符串
if (
!val &&
(ipt.type === FormItemSchemaType.LIST ||
ipt.type === FormItemSchemaType.OBJECT)
) {
ipt.value = undefined;
}
// bool 类型 需要将枚举转为布尔值
if (ipt.type === FormItemSchemaType.BOOLEAN) {
ipt.value = transBoolSelect2Bool(ipt.value as ValuesForBoolSelect);
}
});
const caseBase: TestsetDatabase = {
name: testsetFormValues[TESTSET_NAME_FIELD],
caseID: testset?.caseBase?.caseID,
description: testsetFormValues[TESTSET_DESC_FIELD],
input: JSON.stringify(inputSchemas),
};
const saveResp = await debuggerApi.SaveCaseData({
bizComponentSubject,
bizCtx,
caseBase,
});
if (mode === 'create') {
reportEvent?.(TestsetManageEventName.CREATE_TESTSET_SUCCESS);
}
onSuccess?.(saveResp.caseDetail);
} finally {
setSubmitting(false);
}
};
/** auto fill form values */
const onAutoFill = (autoSchemas: NodeFormSchema[]) => {
const formValues = testsetFormApi.current?.getValues() || {};
const validateFields: string[] = [];
traverseNodeFormSchemas(autoSchemas, (schema, ipt) => {
const fieldName = getSubFieldName(schema, ipt);
const value = transFormItemSchema2Form(ipt)?.value;
if (!isNil(value)) {
formValues[fieldName] = value;
validateFields.push(fieldName);
}
});
testsetFormApi.current?.setValues(formValues);
// 设置值之后再校验一次
testsetFormApi.current?.validate(validateFields);
};
return (
<SideSheet
title={renderTitle()}
footer={renderFooter()}
visible={visible}
mask={mask}
className={s.sidesheet}
width={600}
closable={false}
onCancel={onClose}
>
<Form<TestsetFormValue>
getFormApi={api => (testsetFormApi.current = api)}
>
<TestsetNameInput
field={TESTSET_NAME_FIELD}
trigger="blur"
stopValidateWithError={true}
label={I18n.t('workflow_testset_name')}
placeholder={I18n.t('workflow_testset_name_placeholder')}
rules={getTestsetNameRules({
bizCtx,
bizComponentSubject,
originVal: testset?.caseBase?.name,
isOversea: IS_OVERSEA,
})}
/>
<UIFormTextArea
field={TESTSET_DESC_FIELD}
className={s['testset-desc']}
label={I18n.t('workflow_testset_desc')}
placeholder={I18n.t('workflow_testset_desc_placeholder')}
autosize={true}
maxCount={200}
maxLength={200}
rows={2}
/>
<div className={s['node-data-title']}>
<span>{I18n.t('workflow_testset_node_data')}</span>
<AutoFillButton className={s['auto-btn']} onAutoFill={onAutoFill} />
</div>
{renderNodeForm()}
</Form>
</SideSheet>
);
}

View File

@@ -0,0 +1,41 @@
.section {
:global .semi-form-section-text {
border-bottom: unset;
}
}
.title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
font-style: normal;
line-height: 22px;
color: var(--light-usage-text-color-text-0, #1D1C23);
}
.icon {
overflow: hidden;
display: inline-block;
width: 22px;
height: 22px;
margin-right: 8px;
font-size: 0;
img {
width: 100%;
height: auto;
}
}
.label {
margin-bottom: -4px;
}
.select-container {
width: 100%;
}

View File

@@ -0,0 +1,191 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type CSSProperties, Fragment } from 'react';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Form, UIFormSelect, UIFormTextArea } from '@coze-arch/bot-semi';
import { ComponentType } from '@coze-arch/bot-api/debugger_api';
import {
type NodeFormSchema,
type FormItemSchema,
FormItemSchemaType,
} from '../../types';
import { useTestsetManageStore } from '../../hooks';
import {
getCustomProps,
getLabel,
getPlaceholder,
getSubFieldName,
getTypeLabel,
optionsForBoolSelect,
} from './utils';
import { FormLabel } from './form-label';
import s from './node-form-section.module.less';
interface NodeFormSectionProps {
schema: NodeFormSchema;
/** AI生成中 */
autoGenerating?: boolean;
className?: string;
style?: CSSProperties;
}
const { Section, InputNumber } = Form;
/** 整数类型表单精度 */
const INTEGER_PRECISION = 0.1;
export function NodeFormSection({
schema,
autoGenerating,
className,
style,
}: NodeFormSectionProps) {
const formRenders = useTestsetManageStore(store => store.formRenders);
const renderSectionTitle = () => {
let sectionName = schema.component_name;
// 目前只有start和variable两种节点
switch (schema.component_type) {
case ComponentType.CozeStartNode:
sectionName = I18n.t('workflow_testset_start_node');
break;
case ComponentType.CozeVariableBot:
sectionName = I18n.t('workflow_testset_vardatabase_node');
break;
default:
break;
}
return (
<div className={s.title}>
{schema.component_icon ? (
<div className={s.icon}>
<img src={schema.component_icon} />
</div>
) : null}
{sectionName}
</div>
);
};
const renderFormItem = (formItemSchema: FormItemSchema) => {
const { type, name, required } = formItemSchema;
const CustomFormItem = formRenders?.[type];
const fieldName = getSubFieldName(schema, formItemSchema);
const placeholder = getPlaceholder(formItemSchema);
const requiredMsg = I18n.t('workflow_testset_required_tip', {
param_name: formItemSchema.type === FormItemSchemaType.BOT ? '' : name,
});
if (typeof CustomFormItem !== 'undefined') {
return (
<CustomFormItem
field={fieldName}
disabled={autoGenerating}
rules={[{ required, message: requiredMsg }]}
noLabel={true}
placeholder={placeholder}
{...getCustomProps(formItemSchema)}
/>
);
}
switch (type) {
case FormItemSchemaType.BOOLEAN:
return (
<UIFormSelect
className={s['select-container']}
field={fieldName}
disabled={autoGenerating}
rules={[{ required, message: requiredMsg }]}
optionList={optionsForBoolSelect}
noLabel={true}
placeholder={placeholder}
showClear={!required}
/>
);
case FormItemSchemaType.INTEGER:
case FormItemSchemaType.FLOAT:
case FormItemSchemaType.NUMBER:
return (
<InputNumber
field={fieldName}
trigger={['change', 'blur']}
precision={
type === FormItemSchemaType.INTEGER
? INTEGER_PRECISION
: undefined
}
rules={[{ required, message: requiredMsg }]}
disabled={autoGenerating}
noLabel={true}
style={{ width: '100%' }}
placeholder={placeholder}
/>
);
case FormItemSchemaType.OBJECT:
case FormItemSchemaType.LIST:
return (
<UIFormTextArea
field={fieldName}
trigger={['change', 'blur']}
rules={[{ required, message: requiredMsg }]}
disabled={autoGenerating}
noLabel={true}
placeholder={placeholder}
/>
);
case FormItemSchemaType.STRING:
default:
return (
<UIFormTextArea
field={fieldName}
autosize={{ minRows: 2, maxRows: 5 }}
trigger={['change', 'blur']}
rules={[{ required, message: requiredMsg }]}
disabled={autoGenerating}
noLabel={true}
placeholder={placeholder}
/>
);
}
};
return (
<Section
className={cls(s.section, className)}
style={style}
text={renderSectionTitle()}
>
{schema.inputs.map((formItemSchema, i) => (
<Fragment key={i}>
<FormLabel
className={s.label}
label={getLabel(formItemSchema)}
typeLabel={getTypeLabel(formItemSchema)}
required={formItemSchema.required}
/>
{renderFormItem(formItemSchema)}
</Fragment>
))}
</Section>
);
}

View File

@@ -0,0 +1,10 @@
.suffix {
margin-right: 12px;
margin-left: 8px;
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 20px;
color: var(--light-usage-text-color-text-3, rgb(29 28 35 / 35%));
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type ChangeEvent, type FocusEvent } from 'react';
import { type InputProps } from '@coze-arch/bot-semi/Input';
import { type CommonFieldProps } from '@coze-arch/bot-semi/Form';
import { withField, UIInput } from '@coze-arch/bot-semi';
import s from './testset-name-input.module.less';
const TESTSET_NAME_MAX_LEN = 50;
function count(val: unknown) {
return val ? `${val}`.length : 0;
}
/** 需要后缀 & blur trim扩展下原始的input */
function InnerInput(props: InputProps) {
const onBlur = (evt: FocusEvent<HTMLInputElement>) => {
props.onChange?.(
`${props.value ?? ''}`.trim(),
{} as unknown as ChangeEvent<HTMLInputElement>,
);
props.onBlur?.(evt);
};
return (
<UIInput
{...props}
maxLength={props.maxLength ?? TESTSET_NAME_MAX_LEN}
autoComplete="off"
onBlur={onBlur}
suffix={
<div className={s.suffix}>
{count(props.value)}/{props.maxLength ?? TESTSET_NAME_MAX_LEN}
</div>
}
/>
);
}
export const TestsetNameInput = withField(InnerInput, {}) as (
props: CommonFieldProps & InputProps,
) => JSX.Element;

View File

@@ -0,0 +1,414 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/naming-convention -- copy */
import Ajv from 'ajv';
import { logger } from '@coze-arch/logger';
import { I18n } from '@coze-arch/i18n';
import { type RuleItem } from '@coze-arch/bot-semi/Form';
import {
type ComponentSubject,
type BizCtx,
} from '@coze-arch/bot-api/debugger_api';
import { debuggerApi } from '@coze-arch/bot-api';
import {
type NodeFormSchema,
type FormItemSchema,
type ArrayFieldSchema,
FormItemSchemaType,
} from '../../types';
let ajv: Ajv | undefined;
/** jsonStr转为节点表单schema简单的`JSON.parse` */
export function toNodeFormSchemas(jsonStr?: string): NodeFormSchema[] {
if (!jsonStr) {
return [];
}
try {
const schemas = JSON.parse(jsonStr) as NodeFormSchema[];
return schemas;
} catch (e: any) {
logger.error(e);
return [];
}
}
/** 空值判断null/undefined/NaN */
export function isNil(val: unknown) {
return (
typeof val === 'undefined' ||
val === null ||
(typeof val === 'number' && isNaN(val))
);
}
function isNumberType(t: string) {
return t === FormItemSchemaType.NUMBER || t === FormItemSchemaType.FLOAT;
}
/** 判断类型一致,**特化:**`number`和`float`视为同一类型 */
export function isSameType(t1?: string, t2?: string) {
if (typeof t1 === 'undefined' || typeof t2 === 'undefined') {
return false;
}
return isNumberType(t1) ? isNumberType(t2) : t1 === t2;
}
/** 两层for遍历schema (经常需要遍历,单独抽一个的函数) */
export function traverseNodeFormSchemas(
schemas: NodeFormSchema[],
cb: (s: NodeFormSchema, ip: FormItemSchema) => any,
) {
for (const schema of schemas) {
for (const ipt of schema.inputs) {
cb(schema, ipt);
}
}
}
/**
* 校验名称格式(参考插件名称)
* - 海外:仅支持输入字母、数字、下划线或空格
* - 国内:仅支持输入中文、字母、数字、下划线或空格
*/
function validateNamePattern(
name: string,
isOversea?: boolean,
): string | undefined {
try {
const pattern = isOversea ? /^[\w\s]+$/ : /^[\w\s\u4e00-\u9fa5]+$/u;
const msg = isOversea
? I18n.t('create_plugin_modal_nameerror')
: I18n.t('create_plugin_modal_nameerror_cn');
return pattern.test(name) ? undefined : msg;
} catch (e: any) {
logger.error(e);
return undefined;
}
}
interface GetTestsetNameRulesProps {
/** bizCtx */
bizCtx?: BizCtx;
/** bizComponentSubject */
bizComponentSubject?: ComponentSubject;
/** 原始值 */
originVal?: string;
/** 是否为海外(海外不允许输入中文 与PluginName校验规则对齐 */
isOversea?: boolean;
}
/**
* Testset名称表单校验规则
*
* @param param.bizCtx - bizCtx
* @param param.bizComponentSubject - bizComponentSubject
* @param param.originVal - 原始值
* @param param.isOversea - 是否为海外(海外不允许输入中文 与PluginName校验规则对齐
*/
export function getTestsetNameRules({
bizCtx,
bizComponentSubject,
originVal,
isOversea,
}: GetTestsetNameRulesProps): RuleItem[] {
const requiredMsg = I18n.t('workflow_testset_required_tip', {
param_name: I18n.t('workflow_testset_name'),
});
return [
{ required: true, message: requiredMsg },
{
asyncValidator: async (_rules, value: string, cb) => {
// required
if (!value) {
cb(requiredMsg);
return;
}
// 编辑模式下,名称与原名相同时跳过
if (originVal && value === originVal) {
return;
}
// 中文、字母等等等等
const formatMsg = validateNamePattern(value, isOversea);
if (formatMsg) {
cb(formatMsg);
return;
}
// 检查重复
try {
const { isPass } = await debuggerApi.CheckCaseDuplicate({
bizCtx,
bizComponentSubject,
caseName: value,
});
if (isPass) {
cb();
return;
}
cb(I18n.t('workflow_testset_name_duplicated'));
// eslint-disable-next-line @coze-arch/use-error-in-catch -- no catch
} catch {
cb();
}
},
},
];
}
/**
* 表单label
* - bot选择你需要的Bot
* - 其他:字段名
*/
export function getLabel(formSchema: FormItemSchema) {
return formSchema.type === FormItemSchemaType.BOT
? I18n.t('workflow_testset_vardatabase_tip')
: formSchema.name;
}
function getSubType(type: string) {
switch (type) {
case FormItemSchemaType.STRING:
return 'String';
case FormItemSchemaType.FLOAT:
case FormItemSchemaType.NUMBER:
return 'Number';
case FormItemSchemaType.OBJECT:
return 'Object';
case FormItemSchemaType.BOOLEAN:
return 'Boolean';
case FormItemSchemaType.INTEGER:
return 'Integer';
default:
return `${type.charAt(0).toUpperCase()}${type.slice(1)}`;
}
}
/** 类型标签 */
export function getTypeLabel(formSchema: FormItemSchema) {
switch (formSchema.type) {
case FormItemSchemaType.STRING:
case FormItemSchemaType.FLOAT:
case FormItemSchemaType.NUMBER:
case FormItemSchemaType.OBJECT:
case FormItemSchemaType.BOOLEAN:
case FormItemSchemaType.INTEGER:
return getSubType(formSchema.type);
case FormItemSchemaType.LIST: {
const subType = (formSchema.schema as ArrayFieldSchema).type;
return subType ? `Array<${getSubType(subType)}>` : 'Array';
}
case FormItemSchemaType.BOT:
return '';
default:
return formSchema.type;
}
}
/**
* placeholder
* - bot请选择bot
* - 其他xx必填
*/
export function getPlaceholder({ name, type }: FormItemSchema) {
if (type === FormItemSchemaType.BOT) {
return I18n.t('workflow_testset_vardatabase_placeholder');
} else if (type === FormItemSchemaType.BOOLEAN) {
return I18n.t('workflow_testset_please_select');
}
return I18n.t('workflow_detail_title_testrun_error_input', {
a: name || '',
});
}
/** 字段在表单中的唯一字段名 */
export function getSubFieldName(
formSchema: NodeFormSchema,
itemSchema: FormItemSchema,
) {
return `${itemSchema.name}_${formSchema.component_id}`;
}
enum VariableTypeDTO {
object = 'object',
list = 'list',
string = 'string',
integer = 'integer',
float = 'float',
number = 'number',
boolean = 'boolean',
}
const VariableType2JsonSchemaProps = {
[VariableTypeDTO.object]: { type: 'object' },
[VariableTypeDTO.list]: { type: 'array' },
[VariableTypeDTO.float]: { type: 'number' },
[VariableTypeDTO.number]: { type: 'number' },
[VariableTypeDTO.integer]: { type: 'integer' },
[VariableTypeDTO.boolean]: { type: 'boolean' },
[VariableTypeDTO.string]: { type: 'string' },
};
function workflowJsonToJsonSchema(workflowJson: any) {
const { type, description } = workflowJson;
const props = VariableType2JsonSchemaProps[type];
if (type === VariableTypeDTO.object) {
const properties = {};
const required: string[] = [];
for (const field of workflowJson.schema) {
properties[field.name] = workflowJsonToJsonSchema(field);
if (field.required) {
required.push(field.name);
}
}
return {
...props,
description,
required,
properties,
};
} else if (type === VariableTypeDTO.list) {
return {
...props,
description,
items: workflowJsonToJsonSchema(workflowJson.schema),
};
}
return { ...props, description };
}
function validateByJsonSchema(val: any, jsonSchema: any) {
if (!jsonSchema || !val) {
return true;
}
if (!ajv) {
ajv = new Ajv();
}
try {
const validate = ajv.compile(jsonSchema);
const valid = validate(JSON.parse(val));
return valid;
// eslint-disable-next-line @coze-arch/use-error-in-catch -- no-catch
} catch {
return false;
}
}
/**
* 自定义表单的额外参数
* 目前只对array和object表单加jsonSchema校验
*/
export function getCustomProps(formItemSchema: FormItemSchema) {
switch (formItemSchema.type) {
case FormItemSchemaType.LIST:
case FormItemSchemaType.OBJECT: {
const jsonSchema = workflowJsonToJsonSchema(formItemSchema);
return {
trigger: ['blur'],
jsonSchema,
rules: [
{
validator: (_rules, v, cb) => {
if (formItemSchema.required && !v) {
cb(
I18n.t('workflow_testset_required_tip', {
param_name: formItemSchema.name,
}),
);
return false;
}
if (!validateByJsonSchema(v, jsonSchema)) {
cb(I18n.t('workflow_debug_wrong_json'));
return false;
}
return true;
},
},
] as RuleItem[],
};
}
default:
return {};
}
}
export enum ValuesForBoolSelect {
TRUE = 'true',
FALSE = 'false',
UNDEFINED = 'undefined',
}
/** 布尔类型选项 */
export const optionsForBoolSelect = [
{
value: ValuesForBoolSelect.TRUE,
label: 'true',
},
{
value: ValuesForBoolSelect.FALSE,
label: 'false',
},
];
export function transBoolSelect2Bool(val?: ValuesForBoolSelect) {
switch (val) {
case ValuesForBoolSelect.TRUE:
return true;
case ValuesForBoolSelect.FALSE:
return false;
default:
return undefined;
}
}
export function transBool2BoolSelect(val?: boolean) {
switch (val) {
case true:
return ValuesForBoolSelect.TRUE;
case false:
return ValuesForBoolSelect.FALSE;
default:
return undefined;
}
}
export function transFormItemSchema2Form(ipt?: FormItemSchema) {
if (ipt?.type === FormItemSchemaType.BOOLEAN) {
return {
...ipt,
value: transBool2BoolSelect(ipt.value as boolean | undefined),
};
}
return ipt;
}