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,32 @@
.container {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
}
.spin {
width: 16px;
height: 16px;
:global {
.semi-spin-wrapper {
line-height: 16px;
svg {
width: 16px;
height: 16px;
}
}
}
}
.text {
margin-left: 8px;
font-size: 12px;
font-weight: 500;
font-style: normal;
line-height: 16px;
color: var(--light-usage-primary-color-primary, #4D53E8);
}

View File

@@ -0,0 +1,48 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import { Spin } from '@coze-arch/bot-semi';
import s from './auto-load-more.module.less';
interface LoadMoreProps {
loadingMore?: boolean;
noMore?: boolean;
className?: string;
style?: CSSProperties;
}
export function AutoLoadMore({
loadingMore,
noMore,
className,
style,
}: LoadMoreProps) {
if (noMore || !loadingMore) {
return null;
}
return (
<div className={cls(s.container, className)} style={style}>
<Spin spinning={true} wrapperClassName={s.spin} />
<div className={s.text}>{I18n.t('loading')}</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
.container {
display: flex;
align-items: center;
}
.icon {
cursor: pointer;
flex-shrink: 0;
padding: 4px;
line-height: 0;
&:hover {
background-color: var(--semi-color-fill-0);
border-radius: 8px;
}
svg {
width: 24px;
height: 24px;
font-size: 0;
}
}
.title {
overflow: hidden;
flex-grow: 1;
min-width: 0;
padding: 0 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.action {
flex-shrink: 0;
}

View File

@@ -0,0 +1,47 @@
/*
* 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 { ReactNode } from 'react';
import { IconCloseNoCycle } from '@coze-arch/bot-icons';
import s from './sidesheet-title.module.less';
interface SideSheetTitleProps {
icon?: ReactNode;
title?: ReactNode;
action?: ReactNode;
onClose?: () => void;
}
export function SideSheetTitle({
icon = <IconCloseNoCycle />,
title,
action,
onClose,
}: SideSheetTitleProps) {
return (
<div className={s.container}>
{icon ? (
<div className={s.icon} onClick={onClose}>
{icon}
</div>
) : null}
<div className={s.title}>{title}</div>
<div className={s.action}>{action}</div>
</div>
);
}

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;
}

View File

@@ -0,0 +1,72 @@
// override bad semi-select style
/* stylelint-disable-next-line plugin/disallow-first-level-global */
:global(.semi-popover-wrapper) {
&:has(.dropdown) {
border-radius: 6px;
}
}
.select {
width: 154px;
padding: 0 12px;
&:hover {
background-color: var(--light-usage-fill-color-fill-0, rgb(46 46 56 / 4%));
border-color: var(--light-usage-primary-color-primary, #4D53E8);
}
:global {
.semi-select-option {
padding: 8px 16px;
}
}
}
.incompatible-option {
cursor: not-allowed;
color: var(--semi-color-disabled-text);
}
.prefix {
margin-right: 8px;
}
.dropdown {
overflow: hidden;
width: 280px;
padding: 4px;
:global {
.semi-select-option-selected .semi-select-option-icon {
color: #4D53E8;
}
.semi-select-option-list {
/* stylelint-disable-next-line declaration-no-important -- semi-select-option-list的max-height写在了style上所以要important覆盖 */
max-height: 208px !important;
&::-webkit-scrollbar-track {
background-color: transparent;
border: none;
border-radius: 10px;
}
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 10px;
}
.semi-select-option {
padding: 7px 16px;
}
&:hover::-webkit-scrollbar-thumb {
background-color: rgb(0 0 0 / 40%);
}
}
}
}

View File

@@ -0,0 +1,283 @@
/*
* 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, useState, useRef, useEffect } from 'react';
import { debounce } from 'lodash-es';
import cls from 'classnames';
import { useInViewport } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import {
type RenderSingleSelectedItemFn,
type SelectProps,
} from '@coze-arch/bot-semi/Select';
import { Select, UIModal } from '@coze-arch/bot-semi';
import { IconSearchInput } from '@coze-arch/bot-icons';
import { debuggerApi } from '@coze-arch/bot-api';
import {
TestsetEditSideSheet,
type TestsetEditState,
} from '../testset-edit-sidesheet';
import { AutoLoadMore } from '../auto-load-more';
import { type TestsetData } from '../../types';
import { useTestsetManageStore, useTestsetOptions } from '../../hooks';
import {
SelectedTestsetOptionItem,
TestsetOptionItem,
} from './testset-option-item';
import s from './index.module.less';
export interface TestsetSelectProps {
/** 当前testset */
testset: TestsetData | undefined;
placeholder?: string;
/** 是否有workflow编辑权限也挂放在外层的 TestsetManageProvider上组件上的editable优先级更高 */
editable?: boolean;
/** 编辑面板mask */
editSideSheetMask?: boolean;
onSelect: (v?: TestsetData) => void;
className?: string;
style?: CSSProperties;
}
const DEBOUNCE_DELAY = 200;
/** option key, 更新 name、incompatible、input时都要重新渲染 */
function getOptionKey({ caseBase, schemaIncompatible }: TestsetData) {
return `${caseBase?.caseID}_${caseBase?.name}_${caseBase?.input}_${
schemaIncompatible ? 0 : 1
}`;
}
/**
* Testset下拉选择组件
* 需配合`TestsetManageProvider`一起使用
* @example
* ``` tsx
* <TestsetManageProvider
* // 一些必填参数 bizCtx bizComponentSubject editable formRenders
* >
* <TestsetSideSheet visible={visible} onClose={() => setVisible(false)} />
* </TestsetManageProvider>
* ```
*/
// eslint-disable-next-line @coze-arch/max-line-per-function -- complicated!
export function TestsetSelect({
testset,
placeholder = I18n.t('workflow_debug_testset_placeholder'),
editable,
editSideSheetMask = false,
className,
style,
onSelect,
}: TestsetSelectProps) {
const { editable: editableInStore, bizCtx } = useTestsetManageStore(
store => store,
);
const innerEditable = editable ?? editableInStore;
const selectRef = useRef<Select>(null);
const editRef = useRef<boolean>(false);
const [pending, setPending] = useState(false);
const loadMoreRef = useRef<HTMLDivElement>(null);
const [loadMoreInView] = useInViewport(loadMoreRef.current);
const {
loading,
loadOptions,
loadingMore,
loadMoreOptions,
optionsData,
updateOption,
} = useTestsetOptions();
const [testsetEditState, setTestsetEditState] = useState<TestsetEditState>(
{},
);
// 首次加载
useEffect(() => {
(async () => {
setPending(true);
const list = await loadOptions();
if (list.length) {
setPending(false);
}
})();
}, []);
useEffect(() => {
if (!optionsData.hasNext || !loadMoreInView || loading || loadingMore) {
return;
}
loadMoreOptions();
}, [loadMoreInView]);
const onSearch = (input: string) => {
loadOptions(input);
};
const onEditTestset = (data: TestsetData) => {
editRef.current = true;
if (!innerEditable) {
return;
}
selectRef.current?.close();
setTestsetEditState({ visible: true, testset: data, mode: 'edit' });
};
const onEditTestsetSuccess = (val?: TestsetData) => {
// 1. check if selected
if (val?.caseBase?.caseID === testset?.caseBase?.caseID) {
// onChange new one
onSelect(val);
}
// 2. refresh list
updateOption(val);
// 3. close edit side sheet
closeTestsetEdit();
};
// 选中Testset
const onSelectTestset = (val: SelectProps['value']) => {
if (typeof val !== 'string' || editRef.current) {
return;
}
const selectedTestset = optionsData.list.find(
op => op.caseBase?.caseID === val,
);
// 不兼容的不可选中
if (!selectedTestset || selectedTestset.schemaIncompatible) {
return;
}
onSelect(selectedTestset);
};
const onDeleteTestset = (data: TestsetData) => {
editRef.current = true;
if (!innerEditable || !data.caseBase?.caseID) {
return;
}
selectRef.current?.close();
const deleteId = data.caseBase?.caseID;
const deleteTestset = async () => {
editRef.current = false;
// 1. request to delete
await debuggerApi.DeleteCaseData({
bizCtx,
caseIDs: [deleteId],
});
// 2. check if selected
if (deleteId === testset?.caseBase?.caseID) {
onSelect(undefined);
}
// 3. reload list
await loadOptions();
};
const cancelDelete = () => {
editRef.current = false;
};
UIModal.error({
title: I18n.t('workflow_testset_delete_title'),
content: I18n.t('workflow_testset_delete_tip'),
cancelText: I18n.t('workflow_testset_delete_cancel'),
okText: I18n.t('workflow_testset_delete_confirm'),
onOk: deleteTestset,
onCancel: cancelDelete,
});
};
const closeTestsetEdit = () => {
editRef.current = false;
setTestsetEditState({});
};
const onDropdownVisibleChange = (visible: boolean) => {
if (visible) {
loadOptions();
}
};
// 自定义选中选项
const renderSelectedItem: RenderSingleSelectedItemFn = () =>
testset ? <SelectedTestsetOptionItem data={testset} /> : null;
// testset为空的时候不展示下拉选项对大部分来说可能不需要看到这个下拉
if (pending) {
return null;
}
return (
<>
<Select
className={cls(s.select, className)}
dropdownClassName={s.dropdown}
style={style}
prefix={<IconSearchInput className={s.prefix} />}
filter={true}
value={testset?.caseBase?.caseID}
remote={true}
onDropdownVisibleChange={onDropdownVisibleChange}
onSelect={onSelectTestset}
emptyContent={I18n.t('workflow_testset_search_empty')}
placeholder={placeholder}
ref={selectRef}
onSearch={debounce(onSearch, DEBOUNCE_DELAY)}
innerBottomSlot={
<div
ref={loadMoreRef}
className={cls({ hidden: !optionsData.hasNext })}
>
<AutoLoadMore noMore={false} loadingMore={true} />
</div>
}
renderSelectedItem={renderSelectedItem}
>
{optionsData.list.map(data => (
<Select.Option
value={data.caseBase?.caseID}
// disabled的option编辑/删除唤起其他浮层后select不会自动失焦
// 用样式模拟disabled并修改onSelect选中不兼容testset的逻辑
className={cls(data.schemaIncompatible && s['incompatible-option'])}
key={getOptionKey(data)}
>
<TestsetOptionItem
data={data}
editable={innerEditable}
onEdit={() => onEditTestset(data)}
onDelete={() => onDeleteTestset(data)}
/>
</Select.Option>
))}
</Select>
<TestsetEditSideSheet
{...testsetEditState}
mask={editSideSheetMask}
onSuccess={onEditTestsetSuccess}
onClose={closeTestsetEdit}
/>
</>
);
}

View File

@@ -0,0 +1,75 @@
.container {
display: flex;
align-items: center;
width: 220px;
max-width: 220px;
.action {
display: none;
flex-shrink: 0;
margin-left: 12px;
line-height: 0;
}
&:hover .action {
display: inline-block;
}
}
.warning {
flex-shrink: 0;
margin-right: 4px;
svg {
width: 16px;
height: 16px;
}
}
.text {
flex-grow: 1;
min-width: 0;
line-height: 16px;
}
.name {
font-size: 12px;
font-style: normal;
line-height: 18px;
color: inherit;
&.incompatible {
color: var(--light-usage-text-color-text-3, rgb(29 28 35 / 35%));
}
}
.action-btn {
cursor: pointer;
display: inline-block;
width: 18px;
height: 18px;
padding: 2px;
&:hover {
background: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
border-radius: 4px;
}
&:not(:first-of-type) {
margin-left: 4px;
}
svg {
width: 14px;
height: 14px;
}
}
.selected {
display: flex;
align-items: center;
width: 100%;
max-width: 100%;
}

View File

@@ -0,0 +1,171 @@
/*
* 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 MouseEvent, type CSSProperties } from 'react';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Tooltip, Typography } from '@coze-arch/bot-semi';
import {
IconDeleteOutline,
IconEdit,
IconWarningInfo,
} from '@coze-arch/bot-icons';
import { type TestsetData } from '../../types';
import s from './testset-option-item.module.less';
interface TestsetOptionItemProps {
className?: string;
data: TestsetData;
/** 有编辑权限 */
editable?: boolean;
onEdit?: (data: TestsetData) => void;
onDelete?: (data: TestsetData) => void;
}
const { Text } = Typography;
/** 多行文本展示优化 */
const MULTILINE_TOOLTIP_STYLE: CSSProperties = { wordBreak: 'break-word' };
export function TestsetOptionItem({
data,
editable,
className,
onEdit,
onDelete,
}: TestsetOptionItemProps) {
const incompatible = data.schemaIncompatible;
const testsetName = data.caseBase?.name ?? '-';
const onOptionClick = (evt: MouseEvent<HTMLDivElement>) => {
// 非兼容时需要阻止冒泡
if (incompatible) {
evt.preventDefault();
evt.stopPropagation();
}
};
const onEditAction = () => {
onEdit?.(data);
};
const onDeleteAction = () => {
onDelete?.(data);
};
const renderContent = () => (
<>
<div className={s.text} onClick={onOptionClick}>
<Text
className={cls(s.name, incompatible && s.incompatible)}
ellipsis={{
showTooltip: incompatible
? false
: {
opts: {
position: 'left',
spacing: 48,
content: testsetName,
style: MULTILINE_TOOLTIP_STYLE,
},
},
}}
>
{testsetName}
</Text>
</div>
{editable ? (
<div className={s.action}>
<div role="button" className={s['action-btn']} onClick={onEditAction}>
<IconEdit />
</div>
<div
role="button"
className={s['action-btn']}
onClick={onDeleteAction}
>
<IconDeleteOutline />
</div>
</div>
) : null}
</>
);
return incompatible ? (
<Tooltip
position="left"
spacing={48}
content={I18n.t('workflow_testset_invalid_tip', { testsetName })}
>
<div className={cls(s.container, className)}>
<IconWarningInfo className={s.warning} />
{renderContent()}
</div>
</Tooltip>
) : (
<div className={cls(s.container, className)}>{renderContent()}</div>
);
}
/** 选中的回填项 */
export function SelectedTestsetOptionItem({
data,
className,
}: Pick<TestsetOptionItemProps, 'data' | 'className'>) {
const testsetName = data.caseBase?.name || '';
const incompatible = data.schemaIncompatible;
const invalidTip = incompatible
? I18n.t('workflow_testset_invaild_tip', { testset_name: testsetName })
: undefined;
const renderContent = () => (
<div className={cls(s.selected, className)}>
{incompatible ? <IconWarningInfo className={s.warning} /> : null}
<div className={s.text}>
<Text
className={cls(s.name, incompatible && s.incompatible)}
ellipsis={{
showTooltip: incompatible
? false
: {
opts: {
content: data.caseBase?.name,
style: MULTILINE_TOOLTIP_STYLE,
},
},
}}
>
{testsetName}
</Text>
</div>
</div>
);
return incompatible ? (
<Tooltip
content={invalidTip}
style={MULTILINE_TOOLTIP_STYLE}
clickToHide={true}
>
{renderContent()}
</Tooltip>
) : (
renderContent()
);
}

View File

@@ -0,0 +1,43 @@
.sidesheet {
.loading-wrapper {
width: 100%;
height: 99%;
}
:global {
.semi-sidesheet-body {
overflow: hidden;
min-height: 0;
padding: 0;
}
.semi-sidesheet-content {
background-color: var(--light-color-grey-grey-0, #F7F7FA);
}
}
}
.empty-container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.empty {
transform: translateY(-15%);
}
.container {
overflow: auto;
overscroll-behavior: none;
width: 100%;
height: 100%;
max-height: 100%;
padding: 0 24px 12px;
}

View File

@@ -0,0 +1,281 @@
/*
* 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 { useInfiniteScroll } from 'ahooks';
import { logger } from '@coze-arch/logger';
import { I18n } from '@coze-arch/i18n';
import {
Empty,
UIButton,
Spin,
SideSheet,
UIToast,
} from '@coze-arch/bot-semi';
import { debuggerApi } from '@coze-arch/bot-api';
import {
IllustrationNoContent,
IllustrationNoContentDark,
} from '@douyinfe/semi-illustrations';
import {
TestsetEditSideSheet,
type TestsetEditState,
} from '../testset-edit-sidesheet';
import { SideSheetTitle } from '../sidesheet-title';
import { AutoLoadMore } from '../auto-load-more';
import type { TestsetData } from '../../types';
import {
SchemaError,
useCheckSchema,
useTestsetManageStore,
} from '../../hooks';
import { TestsetListItem } from './testset-list-item';
import s from './index.module.less';
export interface TestsetSideSheetProps {
visible: boolean;
editable?: boolean;
onClose: () => void;
/** 是否为多人协作模式 */
isExpertMode?: boolean;
}
interface EmptyContentProps {
onCreateTestset?: () => void;
}
function EmptyContent({ onCreateTestset }: EmptyContentProps) {
return (
<div className={s['empty-container']}>
<Empty
title={I18n.t('workflow_testset_empty')}
className={s.empty}
description={I18n.t('workflow_testset_create_tip')}
image={<IllustrationNoContent />}
darkModeImage={<IllustrationNoContentDark />}
>
<div className="text-center">
<UIButton theme="solid" onClick={onCreateTestset}>
{I18n.t('workflow_testset_create_btn')}
</UIButton>
</div>
</Empty>
</div>
);
}
interface TestsetQueryResult {
list: TestsetData[];
hasNext?: boolean;
nextToken?: string;
}
const DEFAULT_PAGE_SIZE = 30;
/**
* Testset管理侧边面板
* 需配合`TestsetManageProvider`一起使用
*
* @example
* ``` tsx
* <TestsetManageProvider
* // 一些必填参数 bizCtx bizComponentSubject editable formRenders
* >
* <TestsetSideSheet visible={visible} onClose={() => setVisible(false)} />
* </TestsetManageProvider>
* ```
*/
// eslint-disable-next-line @coze-arch/max-line-per-function -- 大组件>150行只超了不到5行哈
export function TestsetSideSheet({
visible,
onClose,
isExpertMode,
}: TestsetSideSheetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { schemaError, checking, checkSchema } = useCheckSchema();
const { bizComponentSubject, bizCtx } = useTestsetManageStore(store => store);
const {
data: testsetResp,
loading,
loadingMore,
reload: reloadTestsetList,
mutate: patchTestsetResp,
noMore,
} = useInfiniteScroll<TestsetQueryResult>(
async d => {
const res = await debuggerApi.MGetCaseData({
bizCtx,
bizComponentSubject,
pageLimit: DEFAULT_PAGE_SIZE,
nextToken: d?.nextToken,
});
return {
list: res.cases ?? [],
hasNext: res.hasNext,
nextToken: res.nextToken,
};
},
{ target: containerRef, isNoMore: d => !d?.hasNext, manual: true },
);
const [testsetEditState, setTestsetEditState] = useState<TestsetEditState>(
{},
);
useEffect(() => {
if (visible) {
patchTestsetResp({ list: [] });
reloadTestsetList();
// 检查schema
checkSchema();
}
}, [visible]);
const onCreateTestset = () => {
if (checking) {
return;
}
if (schemaError) {
UIToast.error({
content:
schemaError === SchemaError.EMPTY
? I18n.t('workflow_testset_paramempty')
: I18n.t('workflow_test_nodeerror'),
showClose: false,
});
return;
}
setTestsetEditState({ visible: true, mode: 'create' });
};
const onEditTestset = (data: TestsetData) => {
if (checking) {
return;
}
if (schemaError) {
UIToast.error({
content:
schemaError === SchemaError.EMPTY
? I18n.t('workflow_testset_peedit')
: I18n.t('workflow_test_nodeerror'),
showClose: false,
});
return;
}
setTestsetEditState({ visible: true, testset: data, mode: 'edit' });
};
const onDeleteTestset = async (data: TestsetData) => {
if (!data.caseBase?.caseID) {
return;
}
try {
await debuggerApi.DeleteCaseData({
bizCtx,
caseIDs: [data.caseBase?.caseID],
});
patchTestsetResp({ list: [] });
reloadTestsetList();
} catch (e: any) {
logger.error(e);
}
};
const closeTestsetEdit = () => {
setTestsetEditState({});
};
const onEditSuccess = () => {
patchTestsetResp({ list: [] });
reloadTestsetList();
closeTestsetEdit();
};
const renderContent = () => {
if (loading) {
return (
<Spin
spinning={loading}
tip={I18n.t('loading')}
wrapperClassName={s['loading-wrapper']}
/>
);
}
if (!testsetResp?.list.length) {
return <EmptyContent onCreateTestset={onCreateTestset} />;
}
return (
<>
{testsetResp.list.map((data, i) => (
<TestsetListItem
key={data.caseBase?.caseID ?? i}
data={data}
onEdit={onEditTestset}
onDelete={onDeleteTestset}
/>
))}
</>
);
};
return (
<>
{/* Testset管理侧边面板 */}
<SideSheet
className={s.sidesheet}
title={
<SideSheetTitle
title={I18n.t('workflow_testset_tilte')}
action={
loading || !testsetResp?.list.length ? null : (
<UIButton theme="solid" onClick={onCreateTestset}>
{I18n.t('workflow_testset_create_btn')}
</UIButton>
)
}
onClose={onClose}
/>
}
visible={visible}
width={600}
maskClosable={false}
closable={false}
onCancel={onClose}
>
<div className={s.container} ref={containerRef}>
{renderContent()}
<AutoLoadMore noMore={noMore} loadingMore={loadingMore} />
</div>
</SideSheet>
{/* Testset创建/编辑侧边面板 */}
<TestsetEditSideSheet
{...testsetEditState}
mask={false}
onSuccess={onEditSuccess}
onClose={closeTestsetEdit}
onCancel={closeTestsetEdit}
isExpertMode={isExpertMode}
/>
</>
);
}

View File

@@ -0,0 +1,89 @@
.container {
display: flex;
align-items: center;
padding: 16px 20px;
border-top: 1px solid var(--light-usage-border-color-border, rgb(29 28 35 / 8%));
&:hover {
background-color: var(--light-usage-fill-color-fill-1, rgb(46 46 56 / 8%));
border-top-color: transparent;
border-radius: 8px;
}
&.pressing {
background-color: var(--light-usage-fill-color-fill-2, rgb(46 46 56 / 12%));
border-top-color: transparent;
border-radius: 8px;
}
&:hover+&,
&.pressing+& {
border-top-color: transparent;
}
}
.warning {
flex-shrink: 0;
margin-right: 6px;
svg {
width: 16px;
height: 16px;
}
}
.title {
display: flex;
align-items: center;
color: var(--light-usage-text-color-text-0, #1C1D23);
}
.action {
&:global(.semi-button-borderless:not(.semi-button-disabled):hover) {
background-color: var(--semi-color-fill-1);
}
}
.desc {
margin: 2px 0;
font-weight: 400;
color: rgb(29 28 35 / 80%);
}
.editor-info {
overflow: hidden;
display: flex;
flex-direction: row;
align-items: center;
white-space: nowrap;
}
.editor-info>span,
.editor-info .editor-info-name {
cursor: default;
font-size: 12px;
font-weight: 400;
color: var(--light-usage-text-color-text-3, rgb(28 29 35 / 35%));
}
.editor-info-avatar {
width: 14px;
height: 14px;
margin-right: 4px;
border-radius: 14px;
}
.editor-info-separator {
display: inline-block;
width: 1px;
height: 10px;
margin: 0 8px;
background-color: var(--semi-color-border);
}
.popconfirm {
width: 315px;
}

View File

@@ -0,0 +1,180 @@
/*
* 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 MouseEvent, useState } from 'react';
import dayjs from 'dayjs';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import {
Popconfirm,
Tooltip,
Typography,
UIButton,
} from '@coze-arch/bot-semi';
import {
IconDeleteOutline,
IconEdit,
IconWarningInfo,
IconWaringRed,
} from '@coze-arch/bot-icons';
import { type TestsetData } from '../../types';
import s from './testset-list-item.module.less';
interface TestsetListItemProps {
data: TestsetData;
onEdit?: (data: TestsetData) => void;
/** 点击了删除 */
onClickDelete?: () => void;
/** 确认删除 */
onDelete?: (data: TestsetData) => Promise<void>;
}
function formatTime(time: unknown) {
const x = Number(time);
return isNaN(x) ? '-' : dayjs.unix(x).format('YYYY.MM.DD HH:mm');
}
const { Text, Title, Paragraph } = Typography;
export function TestsetListItem({
data,
onEdit,
onDelete,
}: TestsetListItemProps) {
const testsetName = data.caseBase?.name ?? '-';
const [deleting, setDeleting] = useState(false);
const [pressing, setPressing] = useState(false);
const onMouseDown = () => {
setPressing(true);
};
const onMouseUp = () => {
setPressing(false);
};
const onContainerClick = () => {
onEdit?.(data);
};
const onClickEdit = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onEdit?.(data);
};
const onClickDelete = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
};
const onDeleteTestset = async () => {
if (deleting) {
return;
}
setDeleting(true);
try {
await onDelete?.(data);
} finally {
setDeleting(false);
}
};
return (
<div
className={cls(s.container, pressing && s.pressing)}
onClick={onContainerClick}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
>
<div className="min-w-0 grow">
<div className={s.title}>
{data.schemaIncompatible ? (
<Tooltip
position="left"
content={I18n.t('workflow_testset_invalid_tip', { testsetName })}
>
<IconWarningInfo className={s.warning} />
</Tooltip>
) : null}
<Title
heading={6}
ellipsis={{ showTooltip: true }}
className="min-w-0 grow"
>
{testsetName}
</Title>
</div>
<Paragraph
size="small"
ellipsis={{ showTooltip: true }}
className={s.desc}
>
{data.caseBase?.description}
</Paragraph>
<Text size="small" className={s['editor-info']}>
<img
className={s['editor-info-avatar']}
src={data.updater?.avatarUrl}
/>
<Typography.Text
className={s['editor-info-name']}
ellipsis={{ rows: 1, showTooltip: true }}
>
{data.updater?.name}
</Typography.Text>
<span className={s['editor-info-separator']} />
<span>
{`${I18n.t('workflow_testset_edited')} ${formatTime(
data.updateTimeInSec || data.createTimeInSec,
)}`}
</span>
</Text>
</div>
<div className="ml-4 shrink-0">
<UIButton
theme="borderless"
className={s.action}
icon={<IconEdit />}
onClick={onClickEdit}
/>
<Popconfirm
trigger="click"
className={s.popconfirm}
icon={<IconWaringRed />}
title={I18n.t('workflow_testset_delete_title')}
content={I18n.t('workflow_testset_delete_tip')}
okText={I18n.t('workflow_testset_delete_confirm')}
cancelText={I18n.t('workflow_testset_delete_cancel')}
okType="danger"
okButtonProps={{ loading: deleting }}
onConfirm={onDeleteTestset}
>
<UIButton
theme="borderless"
className={s.action}
icon={<IconDeleteOutline />}
onClick={onClickDelete}
/>
</Popconfirm>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
/*
* 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 PropsWithChildren, createContext } from 'react';
import { createTestsetManageStore, type TestsetManageState } from './store';
type TestsetManageStore = ReturnType<typeof createTestsetManageStore>;
export const TestsetManageContext = createContext<TestsetManageStore | null>(
null,
);
export function TestsetManageProvider({
children,
...props
}: PropsWithChildren<TestsetManageState>) {
const storeRef = useRef<TestsetManageStore>();
if (!storeRef.current) {
storeRef.current = createTestsetManageStore(props);
}
return (
<TestsetManageContext.Provider value={storeRef.current}>
{children}
</TestsetManageContext.Provider>
);
}

View File

@@ -0,0 +1,22 @@
/*
* 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.
*/
export enum TestsetManageEventName {
/** 创建测试集成功 */
CREATE_TESTSET_SUCCESS = 'create_testset_success',
/** 点击AI生成节点入参 */
AIGC_PARAMS_CLICK = 'aigc_params_click',
}

View File

@@ -0,0 +1,19 @@
/*
* 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.
*/
export { useTestsetOptions } from './use-testset-options';
export { useTestsetManageStore } from './use-testset-manage-store';
export { useCheckSchema, SchemaError } from './use-check-schema';

View File

@@ -0,0 +1,196 @@
/*
* 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 } from 'react';
import { useState } from 'react';
import { useMemoizedFn } from 'ahooks';
import { logger } from '@coze-arch/logger';
import { ComponentType } from '@coze-arch/bot-api/debugger_api';
import { debuggerApi } from '@coze-arch/bot-api';
import {
type ArrayFieldSchema,
type FormItemSchema,
FormItemSchemaType,
type ObjectFieldSchema,
type NodeFormSchema,
} from '../types';
import { useTestsetManageStore } from './use-testset-manage-store';
export enum SchemaError {
OK = '',
EMPTY = 'empty',
INVALID = 'invalid',
}
/** 变量命名校验规则对齐workflow得参数名校验 */
const PARAM_NAME_VALIDATION_RULE =
/^(?!.*\b(true|false|and|AND|or|OR|not|NOT|null|nil|If|Switch)\b)[a-zA-Z_][a-zA-Z_$0-9]*$/;
function validateParamName(name?: string) {
return Boolean(name && PARAM_NAME_VALIDATION_RULE.test(name));
}
function isArrayOrObjectField(field: FormItemSchema) {
return (
field.type === FormItemSchemaType.LIST ||
field.type === FormItemSchemaType.OBJECT
);
}
function validateArrayOrObjectSchema(
schema?: ObjectFieldSchema | ArrayFieldSchema,
) {
if (!schema) {
return false;
}
if (Array.isArray(schema)) {
const nameSet = new Set<string>();
for (const sub of schema) {
if (!validateParamName(sub.name) || nameSet.has(sub.name)) {
return false;
}
nameSet.add(sub.name);
if (
isArrayOrObjectField(sub) &&
!validateArrayOrObjectSchema(sub.schema)
) {
return false;
}
}
return true;
}
return Boolean(schema.type);
}
function checkArrayOrObjectField(field: FormItemSchema) {
if (!isArrayOrObjectField(field)) {
return true;
}
if (!field.schema) {
return false;
}
if (Array.isArray(field.schema)) {
const nameSet = new Set<string>();
for (const item of field.schema) {
if (!validateParamName(item.name) || nameSet.has(item.name)) {
return false;
}
nameSet.add(item.name);
if (
isArrayOrObjectField(item) &&
!validateArrayOrObjectSchema(item.schema)
) {
return false;
}
}
}
return true;
}
function checkNodeFormSchema(schema: NodeFormSchema) {
// 节点参数为空
if (!schema.inputs.length) {
return false;
}
const nameSet = new Set<string>();
for (const ipt of schema.inputs) {
// 名称非法 or 重复
if (!validateParamName(ipt.name) || nameSet.has(ipt.name)) {
return false;
}
nameSet.add(ipt.name);
// 单独检测复杂类型
if (!checkArrayOrObjectField(ipt)) {
return false;
}
}
return true;
}
function validateSchema(json?: string) {
if (!json) {
return SchemaError.INVALID;
}
try {
const schemas = JSON.parse(json) as NodeFormSchema[];
// schema为空 or start节点的inputs为空
const isEmpty =
schemas.length === 0 ||
(schemas[0].component_type === ComponentType.CozeStartNode &&
schemas[0].inputs.length === 0);
if (isEmpty) {
return SchemaError.EMPTY;
}
for (const schema of schemas) {
if (!checkNodeFormSchema(schema)) {
return SchemaError.INVALID;
}
}
return SchemaError.OK;
} catch (e: any) {
logger.error(e);
return SchemaError.OK;
}
}
/** 检查workflow节点表单是否为空(schema为空 or start节点的inputs为空) */
export function useCheckSchema() {
const { bizComponentSubject, bizCtx } = useTestsetManageStore(store => store);
const [schemaError, setSchemaError] = useState(SchemaError.OK);
const [checking, setChecking] = useState(false);
const checkSchema = useMemoizedFn(async () => {
setChecking(true);
try {
const resp = await debuggerApi.GetSchemaByID({
bizComponentSubject,
bizCtx,
});
const err = validateSchema(resp.schemaJson);
setSchemaError(err);
return err;
} catch (e: any) {
logger.error(e);
setSchemaError(SchemaError.OK);
return SchemaError.OK;
} finally {
setChecking(false);
}
});
return { schemaError, checkSchema, checking };
}

View File

@@ -0,0 +1,38 @@
/*
* 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 { useContext } from 'react';
import { useStore } from 'zustand';
import { CustomError } from '@coze-arch/bot-error';
import { type TestsetManageProps } from '../store';
import { TestsetManageContext } from '../context';
export function useTestsetManageStore<T>(
selector: (s: TestsetManageProps) => T,
): T {
const store = useContext(TestsetManageContext);
if (!store) {
throw new CustomError(
'normal_error',
'Missing TestsetManageProvider in the tree',
);
}
return useStore(store, selector);
}

View File

@@ -0,0 +1,112 @@
/*
* 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 { useState } from 'react';
import { useMemoizedFn } from 'ahooks';
import { debuggerApi } from '@coze-arch/bot-api';
import { type TestsetData } from '../types';
import { useTestsetManageStore } from './use-testset-manage-store';
const DEFAULT_PAGE_SIZE = 30;
export interface OptionsData {
list: TestsetData[];
hasNext?: boolean;
nextToken?: string;
}
export function useTestsetOptions() {
const { bizComponentSubject, bizCtx } = useTestsetManageStore(store => store);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [optionsData, setOptionsData] = useState<OptionsData>({ list: [] });
const updateOption = useMemoizedFn((testset?: TestsetData) => {
if (!testset) {
return;
}
const index = optionsData.list.findIndex(
v => v.caseBase?.caseID === testset.caseBase?.caseID,
);
if (index > -1) {
const newList = [...optionsData.list];
newList[index] = testset;
setOptionsData(prev => ({ ...prev, list: newList }));
}
});
const loadOptions = useMemoizedFn(
async (q?: string, limit = DEFAULT_PAGE_SIZE) => {
setLoading(true);
try {
const {
cases = [],
hasNext,
nextToken,
} = await debuggerApi.MGetCaseData({
bizCtx,
bizComponentSubject,
caseName: q,
pageLimit: limit,
});
setOptionsData({ list: cases, hasNext, nextToken });
return cases;
} finally {
setLoading(false);
}
},
);
const loadMoreOptions = useMemoizedFn(
async (q?: string, limit = DEFAULT_PAGE_SIZE) => {
setLoadingMore(true);
try {
const {
cases = [],
hasNext,
nextToken,
} = await debuggerApi.MGetCaseData({
bizCtx,
bizComponentSubject,
caseName: q,
pageLimit: limit,
nextToken: optionsData.nextToken,
});
setOptionsData(prev => ({
list: [...prev.list, ...cases],
hasNext,
nextToken,
}));
return cases;
} finally {
setLoadingMore(false);
}
},
);
return {
loading,
loadOptions,
loadingMore,
loadMoreOptions,
optionsData,
updateOption,
};
}

View File

@@ -0,0 +1,35 @@
/*
* 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.
*/
export {
TestsetSideSheet,
type TestsetSideSheetProps,
} from './components/testset-sidesheet';
export {
TestsetSelect,
type TestsetSelectProps,
} from './components/testset-select';
export { getTestsetNameRules } from './components/testset-edit-sidesheet/utils';
export { TestsetManageProvider } from './context';
export { useTestsetManageStore, useCheckSchema } from './hooks';
export {
FormItemSchemaType,
type NodeFormItem,
type NodeFormSchema,
type TestsetData,
type TestsetDatabase,
} from './types';
export { TestsetManageEventName } from './events';

View File

@@ -0,0 +1,71 @@
/*
* 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 { create } from 'zustand';
import {
type BizCtx,
type ComponentSubject,
} from '@coze-arch/bot-api/debugger_api';
import { type NodeFormItem, type FormItemSchemaType } from './types';
import { type TestsetManageEventName } from './events';
export interface TestsetManageState {
bizCtx?: BizCtx;
bizComponentSubject?: ComponentSubject;
/** 编辑权限 */
editable?: boolean;
/** 表单渲染组件 */
formRenders?: Partial<Record<FormItemSchemaType, NodeFormItem>>;
/** 埋点事件上报 */
reportEvent?: (
name: TestsetManageEventName,
params?: Record<string, unknown>,
) => void;
}
export interface TestsetManageAction {
/** 更新状态 */
patch: (s: Partial<TestsetManageState>) => void;
}
export type TestsetManageProps = TestsetManageState & TestsetManageAction;
export function createTestsetManageStore(
initState: Partial<TestsetManageState>,
) {
return create<TestsetManageProps>((set, get) => ({
...initState,
patch: s => {
set(prev => ({ ...prev, ...s }));
},
}));
}
interface InnerState {
generating: boolean;
}
interface InnerAction {
patch: (s: Partial<InnerState>) => void;
}
export const useInnerStore = create<InnerState & InnerAction>((set, get) => ({
generating: false,
patch: s => {
set({ ...s });
},
}));

View File

@@ -0,0 +1,70 @@
/*
* 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 ReactNode } from 'react';
import { type CommonFieldProps } from '@coze-arch/bot-semi/Form';
import {
type CaseDataDetail,
type CaseDataBase,
} from '@coze-arch/bot-api/debugger_api';
export type TestsetData = CaseDataDetail;
export type TestsetDatabase = CaseDataBase;
export enum FormItemSchemaType {
STRING = 'string',
BOT = 'bot',
NUMBER = 'number',
OBJECT = 'object',
BOOLEAN = 'boolean',
INTEGER = 'integer',
FLOAT = 'float',
LIST = 'list',
}
export interface ArrayFieldSchema {
type: string;
}
export type ObjectFieldSchema = {
name: string;
type: string;
schema?: ArrayFieldSchema | ObjectFieldSchema;
}[];
export interface FormItemSchema {
// 扩展为枚举
type: string;
name: string;
description?: string;
required?: boolean;
value?: string | number | boolean;
/** object/array复杂类型有schema定义 */
schema?: ArrayFieldSchema | ObjectFieldSchema;
}
export interface NodeFormSchema {
component_id: string;
component_type: number;
component_name: string;
component_icon?: string;
inputs: FormItemSchema[];
}
export interface NodeFormItem {
(props: CommonFieldProps): ReactNode;
}

View File

@@ -0,0 +1,33 @@
/*
* 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.
*/
/// <reference types='@coze-arch/bot-typings' />
declare module '*.less' {
const classes: { readonly [key: string]: string };
export default classes;
}
declare module '*.svg' {
export const ReactComponent: React.FunctionComponent<
React.SVGProps<SVGSVGElement>
>;
const content: any;
export default content;
}
// declare const IS_OVERSEA: boolean;