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,31 @@
import { mergeConfig } from 'vite';
import svgr from 'vite-plugin-svgr';
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-onboarding',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal: config =>
mergeConfig(config, {
plugins: [
svgr({
svgrOptions: {
native: false,
},
}),
],
}),
};
export default config;

View File

@@ -0,0 +1,14 @@
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,5 @@
const { defineConfig } = require('@coze-arch/stylelint-config');
module.exports = defineConfig({
extends: [],
});

View File

@@ -0,0 +1,16 @@
# @coze-data/variable
> Project template for react component with storybook.
## Features
- [x] eslint & ts
- [x] esm bundle
- [x] umd bundle
- [x] storybook
## Commands
- init: `rush update`
- dev: `npm run dev`
- build: `npm run build`

View File

@@ -0,0 +1,12 @@
{
"operationSettings": [
{
"operationName": "test:cov",
"outputFolderNames": ["coverage"]
},
{
"operationName": "ts-check",
"outputFolderNames": ["dist"]
}
]
}

View File

@@ -0,0 +1,7 @@
const { defineConfig } = require('@coze-arch/eslint-config');
module.exports = defineConfig({
packageRoot: __dirname,
preset: 'web',
rules: {},
});

View File

@@ -0,0 +1,81 @@
{
"name": "@coze-data/variable",
"version": "0.0.1",
"description": "memory-variable",
"license": "Apache-2.0",
"author": "haozhenfei@bytedance.com",
"maintainers": [],
"main": "src/index.tsx",
"scripts": {
"build": "exit 0",
"lint": "eslint ./ --cache",
"test": "vitest --run --passWithNoTests",
"test:cov": "npm run test -- --coverage"
},
"dependencies": {
"@coze-arch/bot-api": "workspace:*",
"@coze-arch/bot-error": "workspace:*",
"@coze-arch/bot-flags": "workspace:*",
"@coze-arch/bot-hooks": "workspace:*",
"@coze-arch/bot-icons": "workspace:*",
"@coze-arch/bot-monaco-editor": "workspace:*",
"@coze-arch/bot-semi": "workspace:*",
"@coze-arch/bot-studio-store": "workspace:*",
"@coze-arch/bot-tea": "workspace:*",
"@coze-arch/bot-utils": "workspace:*",
"@coze-arch/coze-design": "0.0.6-alpha.346d77",
"@coze-arch/i18n": "workspace:*",
"@coze-arch/logger": "workspace:*",
"@coze-arch/report-events": "workspace:*",
"@coze-arch/report-tti": "workspace:*",
"@coze-common/chat-area-utils": "workspace:*",
"@coze-data/e2e": "workspace:*",
"@coze-data/knowledge-common-components": "workspace:*",
"@coze-data/knowledge-ide-base": "workspace:*",
"@coze-data/knowledge-modal-adapter": "workspace:*",
"@coze-data/knowledge-modal-base": "workspace:*",
"@coze-data/knowledge-resource-processor-adapter": "workspace:*",
"@coze-data/knowledge-resource-processor-base": "workspace:*",
"@coze-data/knowledge-resource-processor-core": "workspace:*",
"@coze-data/knowledge-stores": "workspace:*",
"@coze-data/reporter": "workspace:*",
"@coze-data/utils": "workspace:*",
"@coze-foundation/local-storage": "workspace:*",
"@coze-studio/components": "workspace:*",
"@coze-studio/premium-components-adapter": "workspace:*",
"@coze-studio/premium-store-adapter": "workspace:*",
"@douyinfe/semi-illustrations": "^2.36.0",
"ahooks": "^3.7.8",
"classnames": "^2.3.2",
"dayjs": "^1.11.7",
"dompurify": "3.0.8",
"immer": "^10.0.3",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.2",
"react-router-dom": "^6.22.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@coze-arch/bot-typings": "workspace:*",
"@coze-arch/eslint-config": "workspace:*",
"@coze-arch/stylelint-config": "workspace:*",
"@coze-arch/ts-config": "workspace:*",
"@coze-arch/vitest-config": "workspace:*",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "18.2.37",
"@types/react-dom": "18.2.15",
"@vitest/coverage-v8": "~3.0.5",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"stylelint": "^15.11.0",
"vite-plugin-svgr": "~3.3.0",
"vitest": "~3.0.5"
},
"peerDependencies": {
"react": ">=18.2.0",
"react-dom": ">=18.2.0"
}
}

View File

@@ -0,0 +1,73 @@
/*
* 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 FC, type PropsWithChildren, useState } from 'react';
import cls from 'classnames';
import { IconCozArrowRight } from '@coze-arch/coze-design/icons';
import { Collapsible } from '@coze-arch/coze-design';
import { type VariableGroup } from '@/store';
export const GroupCollapsibleWrapper: FC<
PropsWithChildren<{
groupInfo: VariableGroup;
level?: number;
}>
> = props => {
const { groupInfo, children, level = 0 } = props;
const [isOpen, setIsOpen] = useState(true);
const isTopLevel = level === 0;
return (
<>
<div
className={cls(
'flex w-full flex-col cursor-pointer px-1 py-2',
isTopLevel ? 'hover:coz-mg-secondary-hovered hover:rounded-lg' : '',
)}
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex items-center">
<div className="w-[22px] h-full flex items-center">
<IconCozArrowRight
className={cls('w-[14px] h-[14px]', isOpen ? 'rotate-90' : '')}
/>
</div>
<div className="w-[370px] h-full flex items-center">
<div
className={cls(
'coz-stroke-primary text-xxl font-medium',
!isTopLevel ? '!text-sm my-[10px]' : '',
)}
>
{groupInfo.groupName}
</div>
</div>
</div>
{isTopLevel ? (
<div className="text-sm coz-fg-secondary pl-[22px]">
{groupInfo.groupDesc}
</div>
) : null}
</div>
<Collapsible keepDOM isOpen={isOpen}>
<div className={cls('w-full h-full', !isTopLevel ? 'pl-[18px]' : '')}>
{children}
</div>
</Collapsible>
</>
);
};

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 { VariableChannel } from '@coze-arch/bot-api/memory';
import { type VariableGroup } from '@/store';
import { flatGroupVariableMeta } from '../../../variable-tree/utils';
export const useGetHideKeys = (variableGroup: VariableGroup) => {
const hideKeys: string[] = [];
const hideChannel =
flatGroupVariableMeta([variableGroup]).filter(
item => (item?.effectiveChannelList?.length ?? 0) > 0,
).length <= 0;
const hideTypeChange = variableGroup.channel === VariableChannel.Custom;
if (hideChannel) {
hideKeys.push('channel');
}
if (hideTypeChange) {
hideKeys.push('type');
}
return hideKeys;
};

View File

@@ -0,0 +1,18 @@
/*
* 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 { VariableGroupParamHeader } from './render';
export { useGetHideKeys } from './hooks/use-get-hide-keys';

View File

@@ -0,0 +1,67 @@
/*
* 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 cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
export const VariableGroupParamHeader = ({
hideHeaderKeys,
}: {
hideHeaderKeys?: string[];
}) => (
<div
className={cls(
'flex w-full h-[28px] py-[6px] pl-8 items-center gap-x-4 justify-start',
'border border-solid coz-stroke-primary border-t-0 border-x-0',
)}
>
<div className="flex-1 h-full flex items-center">
<div className="coz-fg-secondary text-[12px] font-[500] leading-[16px]">
{I18n.t('bot_edit_memory_title_filed')}
<span className="coz-fg-hglt-red">*</span>
</div>
</div>
<div className="flex-1 h-full flex items-center">
<div className="coz-fg-secondary text-[12px] font-[500] leading-[16px]">
{I18n.t('bot_edit_memory_title_description')}
</div>
</div>
{!hideHeaderKeys?.includes('type') ? (
<div className="flex-none w-[166px] basis-[166px] h-full flex items-center box-content">
<div className="coz-fg-secondary text-[12px] font-[500] leading-[16px]">
{I18n.t('variable_Table_Title_type')}
</div>
</div>
) : null}
<div className="flex-none w-[164px] basis-[164px] h-full flex items-center box-content">
<div className="coz-fg-secondary text-[12px] font-[500] leading-[16px]">
{I18n.t('bot_edit_memory_title_default')}
</div>
</div>
{!hideHeaderKeys?.includes('channel') ? (
<div className="flex-none w-[164px] basis-[164px] h-full flex items-center box-content">
<div className="coz-fg-secondary text-[12px] font-[500] leading-[16px]">
{I18n.t('variable_Table_Title_support_channels')}
</div>
</div>
) : null}
<div className="flex-none w-[130px] basis-[130px] h-full flex items-center box-content">
<div className="coz-fg-secondary text-[12px] font-[500] leading-[16px]">
{I18n.t('bot_edit_memory_title_action')}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,72 @@
/*
* 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 VariableGroup as VariableGroupType } from '@/store';
import { type TreeNodeCustomData } from '../variable-tree/type';
import { VariableTree } from '../variable-tree';
import { VariableGroupParamHeader, useGetHideKeys } from './group-header';
import { GroupCollapsibleWrapper } from './group-collapsible-wraper';
interface IVariableGroupProps {
groupInfo: VariableGroupType;
readonly?: boolean;
validateExistKeyword?: boolean;
onVariableChange: (changeValue: TreeNodeCustomData) => void;
}
export const VariableGroup = (props: IVariableGroupProps) => {
const {
groupInfo,
readonly = true,
validateExistKeyword = false,
onVariableChange,
} = props;
const hideHeaderKeys = useGetHideKeys(groupInfo);
return (
<>
<GroupCollapsibleWrapper groupInfo={groupInfo}>
<VariableGroupParamHeader hideHeaderKeys={hideHeaderKeys} />
<div className="pl-6">
{groupInfo.subGroupList?.map(subGroup => (
<GroupCollapsibleWrapper groupInfo={subGroup} level={1}>
<VariableTree
hideHeaderKeys={hideHeaderKeys}
groupId={groupInfo.groupId}
value={subGroup.varInfoList ?? []}
readonly={readonly}
channel={subGroup.channel}
validateExistKeyword={validateExistKeyword}
onChange={onVariableChange}
/>
</GroupCollapsibleWrapper>
))}
</div>
<div className="flex flex-col pl-6">
<VariableTree
hideHeaderKeys={hideHeaderKeys}
groupId={groupInfo.groupId}
value={groupInfo.varInfoList ?? []}
readonly={readonly}
channel={groupInfo.channel}
validateExistKeyword={validateExistKeyword}
onChange={onVariableChange}
/>
</div>
</GroupCollapsibleWrapper>
</>
);
};

View File

@@ -0,0 +1,67 @@
/*
* 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 React from 'react';
import { IconAdd } from '@coze-arch/bot-icons';
import { IconCozAddNode } from '@coze-arch/coze-design/icons';
import { IconButton, type ButtonProps } from '@coze-arch/coze-design';
type AddOperationProps = React.PropsWithChildren<{
readonly?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
subitem?: boolean;
size?: ButtonProps['size'];
color?: ButtonProps['color'];
}>;
export default function AddOperation({
readonly,
onClick,
className,
style,
disabled,
subitem = false,
size,
color,
...restProps
}: AddOperationProps) {
if (readonly) {
return null;
}
return (
<IconButton
data-testid={restProps['data-testid']}
onClick={onClick}
className={`${disabled ? 'disabled:text-[rgb(28,31,35,0.35)]' : 'text-[#4d53e8]'} ${className}`}
style={style}
icon={
subitem ? (
<IconCozAddNode />
) : (
<IconAdd className="text-[#4d53e8] disabled:text-[rgb(28,31,35,0.35)]" />
)
}
disabled={disabled}
size={size}
color={color}
/>
);
}

View File

@@ -0,0 +1,26 @@
/*
* 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 TreeNodeCustomData } from '@/components/variable-tree/type';
export const ParamChannel = (props: { value: TreeNodeCustomData }) => {
const { value } = props;
return value.effectiveChannelList?.length ? (
<div className="coz-stroke-primary text-[14px] font-[500] leading-[20px]">
{value.effectiveChannelList?.join(',') ?? '--'}
</div>
) : null;
};

View File

@@ -0,0 +1,141 @@
/*
* 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 cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozEdit } from '@coze-arch/coze-design/icons';
import { CozInputNumber, Switch, Input } from '@coze-arch/coze-design';
import { ViewVariableType } from '@/store';
import { type TreeNodeCustomData } from '@/components/variable-tree/type';
import { ReadonlyText } from '../readonly-text';
import { JSONLikeTypes } from '../../constants';
import { JSONImport } from '../../../json-import';
interface ParamDefaultProps {
data: TreeNodeCustomData;
onDefaultChange: (value: string | number | boolean) => void;
onImportChange: (value: TreeNodeCustomData) => void;
readonly?: boolean;
}
export const ParamDefault = (props: ParamDefaultProps) => {
const { data, onDefaultChange, onImportChange, readonly } = props;
const [jsonModalVisible, setJsonModalVisible] = useState(false);
const isRoot = data.meta.level === 0;
const isString = isRoot && data.type === ViewVariableType.String;
const isNumber =
isRoot &&
(data.type === ViewVariableType.Number ||
data.type === ViewVariableType.Integer);
const isBoolean = isRoot && data.type === ViewVariableType.Boolean;
const isShowJsonImport = JSONLikeTypes.includes(data.type) && isRoot;
return (
<div className="w-[144px] h-full flex items-center [&_.semi-input-number-suffix-btns]:!h-auto">
<div className="flex flex-col w-full relative">
{readonly && !isShowJsonImport ? (
<ReadonlyText
className="w-[144px]"
value={data.defaultValue || '-'}
/>
) : (
<>
{isString ? (
<Input
value={data.defaultValue}
onChange={value => onDefaultChange(value)}
placeholder={I18n.t(
'workflow_detail_title_testrun_error_input',
{
a: data.name,
},
)}
maxLength={1000}
disabled={readonly}
/>
) : null}
{isNumber ? (
<CozInputNumber
className={cls('h-full', {
'[&_.semi-input-wrapper]:!coz-stroke-plus': true,
})}
value={data.defaultValue}
onChange={value => onDefaultChange(value)}
placeholder={I18n.t(
'workflow_detail_title_testrun_error_input',
{
a: data.name,
},
)}
disabled={readonly}
/>
) : null}
{isBoolean ? (
<Switch
checked={Boolean(data.defaultValue === 'true')}
size="small"
onChange={value => onDefaultChange(value)}
disabled={readonly}
/>
) : null}
{isShowJsonImport ? (
<>
<div
onClick={() => setJsonModalVisible(true)}
className={cls(
'coz-mg-primary rounded cursor-pointer flex items-center justify-center h-[32px] gap-x-1',
{
'coz-fg-primary': !readonly,
'coz-fg-dim': readonly,
},
)}
>
<IconCozEdit />
<span className="text-sm font-medium">
{readonly
? I18n.t('variables_json_input_readonly_button')
: I18n.t('variable_button_input_json')}
</span>
</div>
<JSONImport
visible={jsonModalVisible}
treeData={data}
rules={{
jsonImport: true,
readonly: Boolean(readonly),
}}
onOk={value => {
onImportChange(value);
setJsonModalVisible(false);
}}
onCancel={() => setJsonModalVisible(false)}
/>
</>
) : null}
</>
)}
</div>
</div>
);
};

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 { I18n } from '@coze-arch/i18n';
import { Input } from '@coze-arch/coze-design';
import { type Variable } from '@/store';
import { ReadonlyText } from '../readonly-text';
export const ParamDescription = (props: {
data: Variable;
onChange: (value: string) => void;
readonly: boolean;
}) => {
const { data, onChange, readonly } = props;
return !readonly ? (
<div className="flex flex-col w-full relative overflow-hidden">
<Input
value={data.description}
placeholder={I18n.t('workflow_detail_llm_output_decription')}
maxLength={200}
onChange={value => {
onChange(value);
}}
className="w-full"
/>
</div>
) : (
<ReadonlyText value={data.description ?? ''} />
);
};

View File

@@ -0,0 +1,36 @@
/*
* 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 } from 'react';
import { useFormApi } from '@coze-arch/coze-design';
import { type Variable } from '@/store';
export const useCacheField = (data: Variable) => {
const formApi = useFormApi();
const lastValidValueRef = useRef(data.name);
useEffect(() => {
const currentValue = formApi.getValue(`${data.variableId}.name`);
if (currentValue) {
lastValidValueRef.current = currentValue;
} else if (lastValidValueRef.current) {
formApi.setValue(`${data.variableId}.name`, lastValidValueRef.current);
}
}, [data.variableId]);
};

View File

@@ -0,0 +1,106 @@
/*
* 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 cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { FormInput, useFormApi } from '@coze-arch/coze-design';
import { type Variable } from '@/store';
import { useVariableContext } from '@/context';
import { ReadonlyText } from '../readonly-text';
import {
requiredRules,
duplicateRules,
existKeywordRules,
} from './services/check-rules';
import { useCacheField } from './hooks/use-cache-field';
export const ParamName = (props: {
data: Variable;
readonly: boolean;
onChange: (value: string) => void;
validateExistKeyword?: boolean;
}) => {
const { data, onChange, readonly, validateExistKeyword = false } = props;
const { groups } = useVariableContext();
const formApi = useFormApi();
// 使用 ref 缓存最后一次的有效值, Tree组件隐藏的时候会销毁组件Form表单的Field字段会删除所以需要缓存
useCacheField(data);
return (
<div
className={cls(
'w-full overflow-hidden',
'[&_.semi-form-field-error-message]:absolute',
'[&_.semi-form-field-error-message]:text-[12px]',
'[&_.semi-form-field-error-message]:font-[400]',
'[&_.semi-form-field-error-message]:leading-[16px]',
)}
>
{!readonly ? (
<>
<FormInput
field={`${data.variableId}.name`}
placeholder={I18n.t('variable_name_placeholder')}
maxLength={50}
autoFocus={!data.name}
noLabel
rules={[
{
validator: (_, value) =>
requiredRules.validate({
...data,
name: value,
}),
message: requiredRules.message,
},
{
validator: (_, value) =>
validateExistKeyword
? existKeywordRules.validate({
...data,
name: value,
})
: true,
message: existKeywordRules.message,
},
{
validator: (_, value) =>
duplicateRules.validate(
{
...data,
name: value,
},
groups,
),
message: duplicateRules.message,
},
]}
onChange={value => {
onChange(value);
formApi.setValue(`${data.variableId}.name`, value);
}}
className="w-full truncate"
/>
</>
) : (
<ReadonlyText value={data.name} />
)}
</div>
);
};

View File

@@ -0,0 +1,144 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import { VariableChannel } from '@coze-arch/bot-api/memory';
import { type Variable, type VariableGroup } from '@/store';
export const requiredRules = {
validate: (value: Variable) => !!value.name,
message: I18n.t('bot_edit_variable_field_required_error'),
};
/**
* 检查变量名称是否重复
* 1、检查变量名称在同组&同层级是否重复
* 2、检查变量名称在不同组的Root节点名称是否重复
*/
export const duplicateRules = {
validate: (value: Variable, groups: VariableGroup[]): boolean => {
if (!value.name) {
return true;
} // 如果名称为空则跳过检查
// 1. 检查同组同层级是否重复
const currentGroup = groups.find(group => group.groupId === value.groupId);
if (!currentGroup) {
return true;
}
// 获取当前节点所在的所有同层级节点包括嵌套在其他节点children中的
const findSiblings = (
variables: Variable[],
targetParentId: string | null,
): Variable[] => {
let result: Variable[] = [];
for (const variable of variables) {
// 如果当前变量的parentId与目标parentId相同且不是自身则添加到结果中
if (
variable.parentId === targetParentId &&
variable.variableId !== value.variableId
) {
result.push(variable);
}
// 递归检查children
if (variable.children?.length) {
result = result.concat(
findSiblings(variable.children, targetParentId),
);
}
}
return result;
};
const siblings = findSiblings(currentGroup.varInfoList, value.parentId);
if (siblings.some(sibling => sibling.name === value.name)) {
return false;
}
// 2. 检查是否与其他组的根节点重名
// 只有当前节点是根节点时才需要检查
if (!value.parentId) {
const otherGroupsRootNodes = groups
.filter(group => group.groupId !== value.groupId)
.flatMap(group => {
const rootVariableList = group.varInfoList;
const subGroupVarInfoList = group.subGroupList.flatMap(
subGroup => subGroup.varInfoList,
);
return rootVariableList.concat(subGroupVarInfoList);
});
if (otherGroupsRootNodes.some(node => node.name === value.name)) {
return false;
}
}
return true;
},
message: I18n.t('workflow_detail_node_error_variablename_duplicated'),
};
export const existKeywordRules = {
validate: (value: Variable) =>
/^(?!.*\b(true|false|and|AND|or|OR|not|NOT|null|nil|If|Switch)\b)[a-zA-Z_][a-zA-Z_$0-9]*$/.test(
value.name,
),
message: I18n.t('variables_app_name_limit'),
};
export const checkParamNameRules = (
value: Variable,
groups: VariableGroup[],
validateExistKeyword: boolean,
):
| {
valid: boolean;
message: string;
}
| undefined => {
if (!requiredRules.validate(value)) {
return {
valid: false,
message: requiredRules.message,
};
}
if (!duplicateRules.validate(value, groups)) {
return {
valid: false,
message: duplicateRules.message,
};
}
if (
validateExistKeyword &&
!existKeywordRules.validate(value) &&
value.channel === VariableChannel.APP
) {
return {
valid: false,
message: existKeywordRules.message,
};
}
return {
valid: true,
message: '',
};
};

View File

@@ -0,0 +1,102 @@
/*
* 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 @coze-arch/no-deep-relative-import */
import { VariableE2e } from '@coze-data/e2e';
import { I18n } from '@coze-arch/i18n';
import { IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { Tooltip, IconButton, Switch } from '@coze-arch/coze-design';
import { ObjectLikeTypes } from '@/store/variable-groups/types';
import { useVariableContext } from '@/context';
import AddOperation from '../add-operation';
import { type TreeNodeCustomData } from '../../../../type';
interface ParamOperatorProps {
data: TreeNodeCustomData;
level: number;
onAppend: () => void;
onDelete: () => void;
onEnabledChange: (enabled: boolean) => void;
hasObjectLike?: boolean;
needRenderAppendChild?: boolean;
readonly?: boolean;
}
export default function ParamOperator({
level,
data,
hasObjectLike,
readonly,
needRenderAppendChild = true,
onEnabledChange,
onDelete,
onAppend,
}: ParamOperatorProps) {
const isLimited = level >= 3;
// 是否可以添加子项
const canAddChild = !readonly && ObjectLikeTypes.includes(data.type);
// 子项按钮是否可用
const enableAddChildButton =
!readonly && hasObjectLike && canAddChild && needRenderAppendChild;
// 是否显示删除按钮
const showDeleteButton = !readonly;
// 是否显示开启/关闭按钮
const enabledSwitch = level === 0;
const { variablePageCanEdit } = useVariableContext();
return (
<div className="flex items-center h-[24px] flex-shrink-0 justify-start gap-x-2 w-[130px]">
{/* 开启/关闭 */}
<Switch
size="small"
disabled={!variablePageCanEdit || !enabledSwitch}
checked={data.enabled}
onChange={onEnabledChange}
/>
{/* 添加子项 */}
{needRenderAppendChild ? (
<div className="flex items-center justify-center">
<Tooltip
content={I18n.t('workflow_detail_node_output_add_subitem')}
theme="dark"
>
<div>
<AddOperation
color="secondary"
disabled={isLimited || !enableAddChildButton}
className="cursor-pointer"
onClick={onAppend}
subitem={true}
/>
</div>
</Tooltip>
</div>
) : null}
{/* 删除 */}
<IconButton
data-testid={VariableE2e.VariableTreeDeleteBtn}
color="secondary"
onClick={onDelete}
disabled={!showDeleteButton}
icon={<IconCozTrashCan />}
/>
</div>
);
}

View File

@@ -0,0 +1,43 @@
/*
* 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 {
IconCozNumber,
IconCozNumberBracket,
IconCozString,
IconCozStringBracket,
IconCozBoolean,
IconCozBooleanBracket,
IconCozBrace,
IconCozBraceBracket,
} from '@coze-arch/coze-design/icons';
import { ViewVariableType } from '@/store';
export const VARIABLE_TYPE_ICONS_MAP: Record<ViewVariableType, ReactNode> = {
[ViewVariableType.String]: <IconCozString />,
[ViewVariableType.Integer]: <IconCozNumber />,
[ViewVariableType.Boolean]: <IconCozBoolean />,
[ViewVariableType.Number]: <IconCozNumber />,
[ViewVariableType.Object]: <IconCozBrace />,
[ViewVariableType.ArrayString]: <IconCozStringBracket />,
[ViewVariableType.ArrayInteger]: <IconCozNumberBracket />,
[ViewVariableType.ArrayBoolean]: <IconCozBooleanBracket />,
[ViewVariableType.ArrayNumber]: <IconCozNumberBracket />,
[ViewVariableType.ArrayObject]: <IconCozBraceBracket />,
};

View File

@@ -0,0 +1,91 @@
/*
* 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 @coze-arch/no-deep-relative-import */
import React, { useMemo } from 'react';
import { I18n } from '@coze-arch/i18n';
import { type SelectProps } from '@coze-arch/bot-semi/Select';
import { Cascader } from '@coze-arch/coze-design';
import { VARIABLE_TYPE_ALIAS_MAP } from '@/types/view-variable-tree';
import { ReadonlyText } from '../readonly-text';
import { type TreeNodeCustomData } from '../../../../type';
import {
getVariableTypeList,
getCascaderVal,
allVariableTypeList,
} from './utils';
import { VARIABLE_TYPE_ICONS_MAP } from './constants';
interface ParamTypeProps {
data: TreeNodeCustomData;
level: number;
onSelectChange?: SelectProps['onChange'];
readonly?: boolean;
}
export default function ParamType({
data,
onSelectChange,
level,
readonly,
}: ParamTypeProps) {
const optionList = useMemo(() => getVariableTypeList({ level }), [level]);
const cascaderVal = useMemo(
() => getCascaderVal(data.type, allVariableTypeList),
[data.type],
);
return readonly ? (
<ReadonlyText
className="w-full"
value={VARIABLE_TYPE_ALIAS_MAP[data.type]}
/>
) : (
<Cascader
placeholder={I18n.t('workflow_detail_start_variable_type')}
disabled={readonly}
onChange={val => {
let newVal = val;
if (Array.isArray(val)) {
newVal = val[val.length - 1];
}
onSelectChange?.(newVal);
}}
className="w-full coz-stroke-plus"
displayProp="value"
displayRender={selected => {
if (!Array.isArray(selected)) {
return null;
}
return (
<div className="flex items-center gap-1 text-xs">
{VARIABLE_TYPE_ICONS_MAP[selected[selected.length - 1]]}
<div className="truncate">
{VARIABLE_TYPE_ALIAS_MAP[selected[selected.length - 1]]}
</div>
</div>
);
}}
treeData={optionList}
value={cascaderVal}
/>
);
}

View File

@@ -0,0 +1,126 @@
/*
* 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 { VARIABLE_TYPE_ALIAS_MAP } from '@/types/view-variable-tree';
import { ObjectLikeTypes } from '@/store/variable-groups/types';
import { ViewVariableType } from '@/store';
const LEVEL_LIMIT = 3;
export const generateVariableOption = (
type: ViewVariableType,
label?: string,
display?: string,
) => ({
value: Number(type),
label: label || VARIABLE_TYPE_ALIAS_MAP[type],
display: display || label || VARIABLE_TYPE_ALIAS_MAP[type],
});
export interface VariableTypeOption {
// 类型的值, 非叶子节点时可能为空
value: number | string;
// 选项的展示名称
label: ReactNode;
// 回显的展示名称
display?: string;
// 类型是否禁用
disabled?: boolean;
// 子类型
children?: VariableTypeOption[];
}
export const allVariableTypeList: Array<VariableTypeOption> = [
generateVariableOption(ViewVariableType.String),
generateVariableOption(ViewVariableType.Integer),
generateVariableOption(ViewVariableType.Boolean),
generateVariableOption(ViewVariableType.Number),
generateVariableOption(ViewVariableType.Object),
generateVariableOption(ViewVariableType.ArrayString),
generateVariableOption(ViewVariableType.ArrayInteger),
generateVariableOption(ViewVariableType.ArrayBoolean),
generateVariableOption(ViewVariableType.ArrayNumber),
generateVariableOption(ViewVariableType.ArrayObject),
];
const filterTypes = (
list: Array<VariableTypeOption>,
options?: VariableListOptions,
): Array<VariableTypeOption> => {
const { level } = options || {};
return list.reduce((pre, cur) => {
const newOption = { ...cur };
if (newOption.children) {
newOption.children = filterTypes(newOption.children, options);
}
/**
* 1. 到达层级限制时禁用 ObjectLike 类型,避免嵌套过深
*/
const disabled = Boolean(
level &&
level >= LEVEL_LIMIT &&
ObjectLikeTypes.includes(Number(newOption.value)),
);
return [
...pre,
{
...newOption,
disabled,
},
];
}, [] as Array<VariableTypeOption>);
};
interface VariableListOptions {
level?: number;
}
export const getVariableTypeList = options =>
filterTypes(allVariableTypeList, options);
/**
* 获取类型在选项列表中的路径,作为 cascader 的 value
*/
export const getCascaderVal = (
originalVal: ViewVariableType,
list: Array<VariableTypeOption>,
path: Array<string | number> = [],
) => {
let valuePath = [...path];
list.forEach(item => {
if (item.children) {
const childPath = getCascaderVal(originalVal, item.children, [
...valuePath,
item.value,
]);
if (childPath[childPath.length - 1] === originalVal) {
valuePath = childPath;
return;
}
} else if (item.value === originalVal) {
valuePath.push(originalVal);
return;
}
});
return valuePath;
};

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.
*/
import classNames from 'classnames';
import { Typography } from '@coze-arch/coze-design';
const { Text } = Typography;
export const ReadonlyText = (props: { value: string; className?: string }) => {
const { value, className } = props;
return (
<Text
className={classNames(
'w-full coz-fg-primary text-sm !font-medium',
className,
)}
ellipsis
>
{value}
</Text>
);
};

View File

@@ -0,0 +1,36 @@
/*
* 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 { ViewVariableType } from '@/store';
export enum ChangeMode {
Update,
Delete,
Append,
UpdateEnabled,
Replace,
}
// JSON类型
// eslint-disable-next-line @typescript-eslint/naming-convention
export const JSONLikeTypes = [
ViewVariableType.Object,
ViewVariableType.ArrayObject,
ViewVariableType.ArrayBoolean,
ViewVariableType.ArrayNumber,
ViewVariableType.ArrayString,
ViewVariableType.ArrayInteger,
];

View File

@@ -0,0 +1,202 @@
/*
* 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 @coze-arch/max-line-per-function */
import React, { useCallback, useRef } from 'react';
import isNumber from 'lodash-es/isNumber';
import { cloneDeep } from 'lodash-es';
import classNames from 'classnames';
import { type RenderFullLabelProps } from '@coze-arch/bot-semi/Tree';
import { IconCozArrowRight } from '@coze-arch/coze-design/icons';
import { type Variable, type ViewVariableType } from '@/store';
import { type TreeNodeCustomData } from '../../type';
import { TreeIndentWidth } from '../../constants';
import { ChangeMode } from './constants';
import ParamType from './components/param-type';
import ParamOperator from './components/param-operator';
import { ParamName } from './components/param-name';
import { ParamDescription } from './components/param-description';
import { ParamDefault } from './components/param-default';
import { ParamChannel } from './components/param-channel';
export interface CustomTreeNodeProps extends RenderFullLabelProps {
level: number;
readonly?: boolean;
variablePageCanEdit?: boolean;
needRenderAppendChild?: boolean;
onChange: (mode: ChangeMode, param: TreeNodeCustomData) => void;
hasObjectLike?: boolean;
disableDelete?: boolean;
couldCollapse?: boolean;
hideHeaderKeys?: string[];
collapsed?: boolean;
validateExistKeyword?: boolean;
onCollapse?: (collapsed: boolean) => void;
}
export default function CustomTreeNode(props: CustomTreeNodeProps) {
const {
data,
className,
level,
readonly = false,
onChange,
hasObjectLike,
couldCollapse = true,
hideHeaderKeys,
collapsed = false,
onCollapse,
validateExistKeyword = false,
} = props;
// 当前值
const value = cloneDeep(data) as Variable;
const treeNodeRef = useRef<HTMLDivElement>(null);
// 删除时
const onDelete = () => {
onChange(ChangeMode.Delete, value);
};
// 新增子项时
const onAppend = () => {
onChange(ChangeMode.Append, value);
};
// 类型切换时
const onSelectChange = (
val?: string | number | Array<unknown> | Record<string, unknown>,
) => {
if (val === undefined) {
return;
}
if (!isNumber(val)) {
return;
}
// 清除默认值
value.defaultValue = '';
value.children = [];
onChange(ChangeMode.Update, { ...value, type: val as ViewVariableType });
};
const onDefaultChange = (
val: string | number | boolean | TreeNodeCustomData,
) => {
onChange(ChangeMode.Update, { ...value, defaultValue: val.toString() });
};
const onImportChange = (val: TreeNodeCustomData) => {
onChange(ChangeMode.Replace, val);
};
const onNameChange = (name: string) => {
if (value.name === name) {
return;
}
onChange(ChangeMode.Update, { ...value, name });
};
const onDescriptionChange = useCallback(
(description: string) => {
if (value.description === description) {
return;
}
onChange(ChangeMode.Update, { ...value, description });
},
[onChange, value],
);
const onEnabledChange = useCallback(
(enabled: boolean) => {
onChange(ChangeMode.UpdateEnabled, { ...value, enabled });
},
[onChange, value],
);
return (
<div
className={classNames('flex items-center', {
[className]: Boolean(className),
})}
ref={treeNodeRef}
>
<div className="flex flex-1 my-3 gap-x-4 items-center w-full relative h-[32px]">
<div className="flex flex-1 items-center flex-nowrap overflow-x-hidden overflow-y-visible">
<div
className="flex items-center justify-end"
style={{ width: level * TreeIndentWidth }}
></div>
<IconCozArrowRight
className={classNames(
'flex-none mr-2 w-[16px] h-[16px]',
collapsed ? 'rotate-90' : '',
couldCollapse ? '' : 'invisible',
'cursor-pointer',
level === 0 && !couldCollapse ? 'hidden' : '',
)}
onClick={() => {
onCollapse?.(!collapsed);
}}
/>
<ParamName
readonly={readonly}
data={value}
onChange={onNameChange}
validateExistKeyword={validateExistKeyword}
/>
</div>
<div className="flex-1 overflow-hidden">
<ParamDescription
data={value}
onChange={onDescriptionChange}
readonly={readonly}
/>
</div>
{!hideHeaderKeys?.includes('type') ? (
<div className="flex-none w-[166px] basis-[166px]">
<ParamType
level={level}
readonly={readonly}
data={value}
onSelectChange={onSelectChange}
/>
</div>
) : null}
<div className="flex-none w-[164px] basis-[164px]">
<ParamDefault
readonly={readonly}
data={value}
onDefaultChange={onDefaultChange}
onImportChange={onImportChange}
/>
</div>
<div className="flex-none w-[164px] basis-[164px] empty:hidden">
<ParamChannel value={value} />
</div>
<div className="flex-none w-[130px] basis-[130px]">
<ParamOperator
data={value}
readonly={readonly}
level={level}
onDelete={onDelete}
onAppend={onAppend}
hasObjectLike={hasObjectLike}
needRenderAppendChild={!hideHeaderKeys?.includes('type')}
onEnabledChange={onEnabledChange}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,227 @@
/*
* 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 {
useCallback,
useEffect,
useState,
Suspense,
lazy,
type FC,
useMemo,
} from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconCozBroom } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip, Modal } from '@coze-arch/coze-design';
import { MAX_JSON_LENGTH } from '../../constants';
import { formatJson } from './utils/format-json';
import {
convertSchemaService,
type SchemaNode,
} from './service/convert-schema-service';
import lightStyles from './light.module.less';
const LazyBizIDEMonacoEditor = lazy(async () => {
const { Editor } = await import('@coze-arch/bot-monaco-editor');
return { default: Editor };
});
const BizIDEMonacoEditor = props => (
<Suspense>
<LazyBizIDEMonacoEditor {...props} />
</Suspense>
);
interface JSONEditorProps {
id: string;
value: string;
groupId: string;
setValue: (value: string) => void;
visible: boolean;
readonly?: boolean;
onCancel: () => void;
onOk: (value: SchemaNode[]) => void;
}
const ValidateRules = {
jsonValid: {
message: I18n.t('variables_json_input_error'),
validator: (value: string) => {
try {
const rs = JSON.parse(value);
const isJson = typeof rs === 'object';
return isJson;
// eslint-disable-next-line @coze-arch/use-error-in-catch
} catch (error) {
return false;
}
},
},
jsonLength: {
message: I18n.t('variables_json_input_limit'),
validator: (value: string) => {
if (value.length > MAX_JSON_LENGTH) {
return false;
}
return true;
},
},
};
export const JSONEditor: FC<JSONEditorProps> = props => {
const { id, value, setValue, visible, onCancel, onOk, readonly } = props;
const [schema, setSchema] = useState<SchemaNode[] | undefined>();
const [error, setError] = useState<string | undefined>();
const change = useCallback(async () => {
if (!schema) {
return;
}
setError(undefined);
return new Promise(resolve => {
Modal.warning({
title: I18n.t('workflow_json_node_update_tips_title'),
content: I18n.t('workflow_json_node_update_tips_content'),
okType: 'warning',
okText: I18n.t('Confirm'),
cancelText: I18n.t('Cancel'),
onOk: () => {
const outputValue = convert(value) || [];
onOk(outputValue);
resolve(true);
},
onCancel: () => resolve(false),
});
});
}, [schema]);
const convert = (jsonString: string): SchemaNode[] | undefined => {
if (!jsonString) {
return;
}
try {
const json = JSON.parse(jsonString);
const outputValue = convertSchemaService(json);
if (
!outputValue ||
!Array.isArray(outputValue) ||
outputValue.length === 0
) {
return;
}
return outputValue;
} catch (e) {
return;
}
};
const validate = (newValue: string) => {
const rules = Object.values(ValidateRules);
for (const rule of rules) {
if (!rule.validator(newValue)) {
setError(rule.message);
return false;
}
}
setError(undefined);
return true;
};
const isValid = useMemo(() => validate(value), [value]);
// 同步 value 和 schema
useEffect(() => {
const _schema = convert(value);
setSchema(_schema);
}, [value]);
return (
<Modal
visible={visible}
title={
readonly
? I18n.t('variables_json_input_readonly_title')
: I18n.t('workflow_json_windows_title')
}
okText={I18n.t('Confirm')}
cancelText={I18n.t('Cancel')}
onOk={change}
onCancel={onCancel}
height={530}
okButtonProps={{
disabled: !isValid || readonly,
}}
>
<div key={id} className="w-full relative">
<div className="w-full h-[48px] coz-bg-primary rounded-t-lg coz-fg-primary font-medium text-sm flex items-center justify-between px-4">
<div className="coz-fg-primary">JSON</div>
<Tooltip content={I18n.t('workflow_exception_ignore_format')}>
<IconButton
className="bg-transparent"
disabled={readonly}
icon={<IconCozBroom />}
onClick={() => {
setValue(formatJson(value));
}}
/>
</Tooltip>
</div>
<div className="w-full h-[320px]">
<BizIDEMonacoEditor
key={id}
value={value}
defaultLanguage="json"
/** 通过 css 样式覆盖 icube-dark 主题 */
className={lightStyles.light}
options={{
fontSize: 13,
minimap: {
enabled: false,
},
contextmenu: false,
scrollbar: {
verticalScrollbarSize: 10,
alwaysConsumeMouseWheel: false,
},
lineNumbers: 'on',
lineNumbersMinChars: 3,
folding: false,
lineDecorationsWidth: 2,
renderLineHighlight: 'none',
glyphMargin: false,
scrollBeyondLastLine: false,
overviewRulerBorder: false,
wordWrap: 'on',
fixedOverflowWidgets: true,
readOnly: readonly,
}}
onChange={stringValue => {
setValue(stringValue || '');
}}
/>
</div>
{error ? (
<div className="absolute top-full">
<span className="coz-fg-hglt-red text-[12px] font-[400] leading-[16px] whitespace-nowrap">
{error}
</span>
</div>
) : null}
</div>
</Modal>
);
};

View File

@@ -0,0 +1,399 @@
// fork from biz-ide-component vs-light-theme.module.less
// 样式与 ide 保持一致
/* stylelint-disable declaration-no-important */
.light {
:global {
.monaco-editor {
--vscode-foreground: #616161;
--vscode-disabledForeground: rgba(97, 97, 97, 50%);
--vscode-errorForeground: #a1260d;
--vscode-descriptionForeground: #717171;
--vscode-icon-foreground: #424242;
--vscode-focusBorder: #0090f1;
--vscode-textSeparator-foreground: rgba(0, 0, 0, 18%);
--vscode-textLink-foreground: #006ab1;
--vscode-textLink-activeForeground: #006ab1;
--vscode-textPreformat-foreground: #a31515;
--vscode-textBlockQuote-background: rgba(127, 127, 127, 10%);
--vscode-textBlockQuote-border: rgba(0, 122, 204, 50%);
--vscode-textCodeBlock-background: rgba(220, 220, 220, 40%);
--vscode-widget-shadow: rgba(0, 0, 0, 16%);
--vscode-input-background: #fff;
--vscode-input-foreground: #616161;
--vscode-inputOption-activeBorder: #007acc;
--vscode-inputOption-hoverBackground: rgba(184, 184, 184, 31%);
--vscode-inputOption-activeBackground: rgba(0, 144, 241, 20%);
--vscode-inputOption-activeForeground: #000;
--vscode-input-placeholderForeground: rgba(97, 97, 97, 50%);
--vscode-inputValidation-infoBackground: #d6ecf2;
--vscode-inputValidation-infoBorder: #007acc;
--vscode-inputValidation-warningBackground: #f6f5d2;
--vscode-inputValidation-warningBorder: #b89500;
--vscode-inputValidation-errorBackground: #f2dede;
--vscode-inputValidation-errorBorder: #be1100;
--vscode-dropdown-background: #fff;
--vscode-dropdown-foreground: #616161;
--vscode-dropdown-border: #cecece;
--vscode-button-foreground: #fff;
--vscode-button-separator: rgba(255, 255, 255, 40%);
--vscode-button-background: #007acc;
--vscode-button-hoverBackground: #0062a3;
--vscode-button-secondaryForeground: #fff;
--vscode-button-secondaryBackground: #5f6a79;
--vscode-button-secondaryHoverBackground: #4c5561;
--vscode-badge-background: #c4c4c4;
--vscode-badge-foreground: #333;
--vscode-scrollbar-shadow: #ddd;
--vscode-scrollbarSlider-background: rgba(100, 100, 100, 40%);
--vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 70%);
--vscode-scrollbarSlider-activeBackground: rgba(0, 0, 0, 60%);
--vscode-progressBar-background: #0e70c0;
--vscode-editorError-foreground: #e51400;
--vscode-editorWarning-foreground: #bf8803;
--vscode-editorInfo-foreground: #1a85ff;
--vscode-editorHint-foreground: #6c6c6c;
--vscode-sash-hoverBorder: #0090f1;
--vscode-editor-background: #fffffe;
--vscode-editor-foreground: #000;
--vscode-editorStickyScroll-background: #fffffe;
--vscode-editorStickyScrollHover-background: #f0f0f0;
--vscode-editorWidget-background: #f3f3f3;
--vscode-editorWidget-foreground: #616161;
--vscode-editorWidget-border: #c8c8c8;
--vscode-quickInput-background: #f3f3f3;
--vscode-quickInput-foreground: #616161;
--vscode-quickInputTitle-background: rgba(0, 0, 0, 6%);
--vscode-pickerGroup-foreground: #0066bf;
--vscode-pickerGroup-border: #cccedb;
--vscode-keybindingLabel-background: rgba(221, 221, 221, 40%);
--vscode-keybindingLabel-foreground: #555;
--vscode-keybindingLabel-border: rgba(204, 204, 204, 40%);
--vscode-keybindingLabel-bottomBorder: rgba(187, 187, 187, 40%);
--vscode-editor-selectionBackground: #add6ff;
--vscode-editor-inactiveSelectionBackground: #e5ebf1;
--vscode-editor-selectionHighlightBackground: rgba(173, 214, 255, 30%);
--vscode-editor-findMatchBackground: #a8ac94;
--vscode-editor-findMatchHighlightBackground: rgba(234, 92, 0, 33%);
--vscode-editor-findRangeHighlightBackground: rgba(180, 180, 180, 30%);
--vscode-searchEditor-findMatchBackground: rgba(234, 92, 0, 22%);
--vscode-search-resultsInfoForeground: #616161;
--vscode-editor-hoverHighlightBackground: rgba(173, 214, 255, 15%);
--vscode-editorHoverWidget-background: #f3f3f3;
--vscode-editorHoverWidget-foreground: #616161;
--vscode-editorHoverWidget-border: #c8c8c8;
--vscode-editorHoverWidget-statusBarBackground: #e7e7e7;
--vscode-editorLink-activeForeground: #00f;
--vscode-editorInlayHint-foreground: #969696;
--vscode-editorInlayHint-background: rgba(196, 196, 196, 10%);
--vscode-editorInlayHint-typeForeground: #969696;
--vscode-editorInlayHint-typeBackground: rgba(196, 196, 196, 10%);
--vscode-editorInlayHint-parameterForeground: #969696;
--vscode-editorInlayHint-parameterBackground: rgba(196, 196, 196, 10%);
--vscode-editorLightBulb-foreground: #ddb100;
--vscode-editorLightBulbAutoFix-foreground: #007acc;
--vscode-diffEditor-insertedTextBackground: rgba(156, 204, 44, 25%);
--vscode-diffEditor-removedTextBackground: rgba(255, 0, 0, 20%);
--vscode-diffEditor-insertedLineBackground: rgba(155, 185, 85, 20%);
--vscode-diffEditor-removedLineBackground: rgba(255, 0, 0, 20%);
--vscode-diffEditor-diagonalFill: rgba(34, 34, 34, 20%);
--vscode-diffEditor-unchangedRegionBackground: #e4e4e4;
--vscode-diffEditor-unchangedRegionForeground: #4d4c4c;
--vscode-diffEditor-unchangedCodeBackground: rgba(184, 184, 184, 16%);
--vscode-list-focusOutline: #0090f1;
--vscode-list-activeSelectionBackground: #0060c0;
--vscode-list-activeSelectionForeground: #fff;
--vscode-list-inactiveSelectionBackground: #e4e6f1;
--vscode-list-hoverBackground: #f0f0f0;
--vscode-list-dropBackground: #d6ebff;
--vscode-list-highlightForeground: #0066bf;
--vscode-list-focusHighlightForeground: #bbe7ff;
--vscode-list-invalidItemForeground: #b89500;
--vscode-list-errorForeground: #b01011;
--vscode-list-warningForeground: #855f00;
--vscode-listFilterWidget-background: #f3f3f3;
--vscode-listFilterWidget-outline: rgba(0, 0, 0, 0%);
--vscode-listFilterWidget-noMatchesOutline: #be1100;
--vscode-listFilterWidget-shadow: rgba(0, 0, 0, 16%);
--vscode-list-filterMatchBackground: rgba(234, 92, 0, 33%);
--vscode-tree-indentGuidesStroke: #a9a9a9;
--vscode-tree-inactiveIndentGuidesStroke: rgba(169, 169, 169, 40%);
--vscode-tree-tableColumnsBorder: rgba(97, 97, 97, 13%);
--vscode-tree-tableOddRowsBackground: rgba(97, 97, 97, 4%);
--vscode-list-deemphasizedForeground: #8e8e90;
--vscode-checkbox-background: #fff;
--vscode-checkbox-selectBackground: #f3f3f3;
--vscode-checkbox-foreground: #616161;
--vscode-checkbox-border: #cecece;
--vscode-checkbox-selectBorder: #424242;
--vscode-quickInputList-focusForeground: #fff;
--vscode-quickInputList-focusBackground: #0060c0;
--vscode-menu-foreground: #616161;
--vscode-menu-background: #fff;
--vscode-menu-selectionForeground: #fff;
--vscode-menu-selectionBackground: #0060c0;
--vscode-menu-separatorBackground: #d4d4d4;
--vscode-toolbar-hoverBackground: rgba(184, 184, 184, 31%);
--vscode-toolbar-activeBackground: rgba(166, 166, 166, 31%);
--vscode-editor-snippetTabstopHighlightBackground: rgba(10, 50, 100, 20%);
--vscode-editor-snippetFinalTabstopHighlightBorder: rgba(10,
50,
100,
50%);
--vscode-breadcrumb-foreground: rgba(97, 97, 97, 80%);
--vscode-breadcrumb-background: #fffffe;
--vscode-breadcrumb-focusForeground: #4e4e4e;
--vscode-breadcrumb-activeSelectionForeground: #4e4e4e;
--vscode-breadcrumbPicker-background: #f3f3f3;
--vscode-merge-currentHeaderBackground: rgba(64, 200, 174, 50%);
--vscode-merge-currentContentBackground: rgba(64, 200, 174, 20%);
--vscode-merge-incomingHeaderBackground: rgba(64, 166, 255, 50%);
--vscode-merge-incomingContentBackground: rgba(64, 166, 255, 20%);
--vscode-merge-commonHeaderBackground: rgba(96, 96, 96, 40%);
--vscode-merge-commonContentBackground: rgba(96, 96, 96, 16%);
--vscode-editorOverviewRuler-currentContentForeground: rgba(64,
200,
174,
50%);
--vscode-editorOverviewRuler-incomingContentForeground: rgba(64,
166,
255,
50%);
--vscode-editorOverviewRuler-commonContentForeground: rgba(96,
96,
96,
40%);
--vscode-editorOverviewRuler-findMatchForeground: rgba(209,
134,
22,
49%);
--vscode-editorOverviewRuler-selectionHighlightForeground: rgba(160,
160,
160,
80%);
--vscode-minimap-findMatchHighlight: #d18616;
--vscode-minimap-selectionOccurrenceHighlight: #c9c9c9;
--vscode-minimap-selectionHighlight: #add6ff;
--vscode-minimap-errorHighlight: rgba(255, 18, 18, 70%);
--vscode-minimap-warningHighlight: #bf8803;
--vscode-minimap-foregroundOpacity: #000;
--vscode-minimapSlider-background: rgba(100, 100, 100, 20%);
--vscode-minimapSlider-hoverBackground: rgba(100, 100, 100, 35%);
--vscode-minimapSlider-activeBackground: rgba(0, 0, 0, 30%);
--vscode-problemsErrorIcon-foreground: #e51400;
--vscode-problemsWarningIcon-foreground: #bf8803;
--vscode-problemsInfoIcon-foreground: #1a85ff;
--vscode-charts-foreground: #616161;
--vscode-charts-lines: rgba(97, 97, 97, 50%);
--vscode-charts-red: #e51400;
--vscode-charts-blue: #1a85ff;
--vscode-charts-yellow: #bf8803;
--vscode-charts-orange: #d18616;
--vscode-charts-green: #388a34;
--vscode-charts-purple: #652d90;
--vscode-diffEditor-move-border: rgba(139, 139, 139, 61%);
--vscode-diffEditor-moveActive-border: #ffa500;
--vscode-symbolIcon-arrayForeground: #616161;
--vscode-symbolIcon-booleanForeground: #616161;
--vscode-symbolIcon-classForeground: #d67e00;
--vscode-symbolIcon-colorForeground: #616161;
--vscode-symbolIcon-constantForeground: #616161;
--vscode-symbolIcon-constructorForeground: #652d90;
--vscode-symbolIcon-enumeratorForeground: #d67e00;
--vscode-symbolIcon-enumeratorMemberForeground: #007acc;
--vscode-symbolIcon-eventForeground: #d67e00;
--vscode-symbolIcon-fieldForeground: #007acc;
--vscode-symbolIcon-fileForeground: #616161;
--vscode-symbolIcon-folderForeground: #616161;
--vscode-symbolIcon-functionForeground: #652d90;
--vscode-symbolIcon-interfaceForeground: #007acc;
--vscode-symbolIcon-keyForeground: #616161;
--vscode-symbolIcon-keywordForeground: #616161;
--vscode-symbolIcon-methodForeground: #652d90;
--vscode-symbolIcon-moduleForeground: #616161;
--vscode-symbolIcon-namespaceForeground: #616161;
--vscode-symbolIcon-nullForeground: #616161;
--vscode-symbolIcon-numberForeground: #616161;
--vscode-symbolIcon-objectForeground: #616161;
--vscode-symbolIcon-operatorForeground: #616161;
--vscode-symbolIcon-packageForeground: #616161;
--vscode-symbolIcon-propertyForeground: #616161;
--vscode-symbolIcon-referenceForeground: #616161;
--vscode-symbolIcon-snippetForeground: #616161;
--vscode-symbolIcon-stringForeground: #616161;
--vscode-symbolIcon-structForeground: #616161;
--vscode-symbolIcon-textForeground: #616161;
--vscode-symbolIcon-typeParameterForeground: #616161;
--vscode-symbolIcon-unitForeground: #616161;
--vscode-symbolIcon-variableForeground: #007acc;
--vscode-actionBar-toggledBackground: rgba(0, 144, 241, 20%);
--vscode-editor-lineHighlightBorder: #eee;
--vscode-editor-rangeHighlightBackground: rgba(253, 255, 0, 20%);
--vscode-editor-symbolHighlightBackground: rgba(234, 92, 0, 33%);
--vscode-editorCursor-foreground: #000;
--vscode-editorWhitespace-foreground: rgba(51, 51, 51, 20%);
--vscode-editorLineNumber-foreground: #237893;
--vscode-editorIndentGuide-background: rgba(51, 51, 51, 20%);
--vscode-editorIndentGuide-activeBackground: rgba(51, 51, 51, 20%);
--vscode-editorIndentGuide-background1: #d3d3d3;
--vscode-editorIndentGuide-background2: rgba(0, 0, 0, 0%);
--vscode-editorIndentGuide-background3: rgba(0, 0, 0, 0%);
--vscode-editorIndentGuide-background4: rgba(0, 0, 0, 0%);
--vscode-editorIndentGuide-background5: rgba(0, 0, 0, 0%);
--vscode-editorIndentGuide-background6: rgba(0, 0, 0, 0%);
--vscode-editorIndentGuide-activeBackground1: #939393;
--vscode-editorIndentGuide-activeBackground2: rgba(0, 0, 0, 0%);
--vscode-editorIndentGuide-activeBackground3: rgba(0, 0, 0, 0%);
--vscode-editorIndentGuide-activeBackground4: rgba(0, 0, 0, 0%);
--vscode-editorIndentGuide-activeBackground5: rgba(0, 0, 0, 0%);
--vscode-editorIndentGuide-activeBackground6: rgba(0, 0, 0, 0%);
--vscode-editorActiveLineNumber-foreground: #0b216f;
--vscode-editorLineNumber-activeForeground: #0b216f;
--vscode-editorRuler-foreground: #d3d3d3;
--vscode-editorCodeLens-foreground: #919191;
--vscode-editorBracketMatch-background: rgba(0, 100, 0, 10%);
--vscode-editorBracketMatch-border: #b9b9b9;
--vscode-editorOverviewRuler-border: rgba(127, 127, 127, 30%);
--vscode-editorGutter-background: #fffffe;
--vscode-editorUnnecessaryCode-opacity: rgba(0, 0, 0, 47%);
--vscode-editorGhostText-foreground: rgba(0, 0, 0, 47%);
--vscode-editorOverviewRuler-rangeHighlightForeground: rgba(0,
122,
204,
60%);
--vscode-editorOverviewRuler-errorForeground: rgba(255, 18, 18, 70%);
--vscode-editorOverviewRuler-warningForeground: #bf8803;
--vscode-editorOverviewRuler-infoForeground: #1a85ff;
--vscode-editorBracketHighlight-foreground1: #0431fa;
--vscode-editorBracketHighlight-foreground2: #319331;
--vscode-editorBracketHighlight-foreground3: #7b3814;
--vscode-editorBracketHighlight-foreground4: rgba(0, 0, 0, 0%);
--vscode-editorBracketHighlight-foreground5: rgba(0, 0, 0, 0%);
--vscode-editorBracketHighlight-foreground6: rgba(0, 0, 0, 0%);
--vscode-editorBracketHighlight-unexpectedBracket-foreground: rgba(255,
18,
18,
80%);
--vscode-editorBracketPairGuide-background1: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-background2: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-background3: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-background4: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-background5: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-background6: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-activeBackground1: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-activeBackground2: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-activeBackground3: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-activeBackground4: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-activeBackground5: rgba(0, 0, 0, 0%);
--vscode-editorBracketPairGuide-activeBackground6: rgba(0, 0, 0, 0%);
--vscode-editorUnicodeHighlight-border: #cea33d;
--vscode-editorUnicodeHighlight-background: rgba(206, 163, 61, 8%);
--vscode-editorOverviewRuler-bracketMatchForeground: #a0a0a0;
--vscode-editor-linkedEditingBackground: rgba(255, 0, 0, 30%);
--vscode-editor-wordHighlightBackground: rgba(87, 87, 87, 25%);
--vscode-editor-wordHighlightStrongBackground: rgba(14, 99, 156, 25%);
--vscode-editor-wordHighlightTextBackground: rgba(87, 87, 87, 25%);
--vscode-editorOverviewRuler-wordHighlightForeground: rgba(160,
160,
160,
80%);
--vscode-editorOverviewRuler-wordHighlightStrongForeground: rgba(192,
160,
192,
80%);
--vscode-editorOverviewRuler-wordHighlightTextForeground: rgba(160,
160,
160,
80%);
--vscode-peekViewTitle-background: #f3f3f3;
--vscode-peekViewTitleLabel-foreground: #000;
--vscode-peekViewTitleDescription-foreground: #616161;
--vscode-peekView-border: #1a85ff;
--vscode-peekViewResult-background: #f3f3f3;
--vscode-peekViewResult-lineForeground: #646465;
--vscode-peekViewResult-fileForeground: #1e1e1e;
--vscode-peekViewResult-selectionBackground: rgba(51, 153, 255, 20%);
--vscode-peekViewResult-selectionForeground: #6c6c6c;
--vscode-peekViewEditor-background: #f2f8fc;
--vscode-peekViewEditorGutter-background: #f2f8fc;
--vscode-peekViewEditorStickyScroll-background: #f2f8fc;
--vscode-peekViewResult-matchHighlightBackground: rgba(234, 92, 0, 30%);
--vscode-peekViewEditor-matchHighlightBackground: rgba(245, 216, 2, 87%);
--vscode-editorMarkerNavigationError-background: #e51400;
--vscode-editorMarkerNavigationError-headerBackground: rgba(229,
20,
0,
10%);
--vscode-editorMarkerNavigationWarning-background: #bf8803;
--vscode-editorMarkerNavigationWarning-headerBackground: rgba(191,
136,
3,
10%);
--vscode-editorMarkerNavigationInfo-background: #1a85ff;
--vscode-editorMarkerNavigationInfo-headerBackground: rgba(26,
133,
255,
10%);
--vscode-editorMarkerNavigation-background: #fffffe;
--vscode-editorHoverWidget-highlightForeground: #0066bf;
--vscode-editorSuggestWidget-background: #f3f3f3;
--vscode-editorSuggestWidget-border: #c8c8c8;
--vscode-editorSuggestWidget-foreground: #000;
--vscode-editorSuggestWidget-selectedForeground: #fff;
--vscode-editorSuggestWidget-selectedBackground: #0060c0;
--vscode-editorSuggestWidget-highlightForeground: #0066bf;
--vscode-editorSuggestWidget-focusHighlightForeground: #bbe7ff;
--vscode-editorSuggestWidgetStatus-foreground: rgba(0, 0, 0, 50%);
--vscode-editor-foldBackground: rgba(173, 214, 255, 30%);
--vscode-editorGutter-foldingControlForeground: #424242;
background-color: #fff;
.bracket-highlighting-0 {
color: #d5a00d; // { }
}
.bracket-highlighting-1 {
color: #8140e3; // [ ]
}
.mtk1 {
color: #000;
}
.lines-content .core-guide-indent {
box-shadow: 1px 0 0 0 transparent !important;
}
.lines-content .core-guide-indent.indent-active {
box-shadow: 1px 0 0 0 transparent !important;
}
.line-numbers {
color: #A7A7B0;
}
.marker-widget {
background-color: #fff !important;
}
.find-widget {
// 搜索框
color: #000;
background-color: #fff;
.monaco-sash {
background-color: #fff;
}
.button:not(.disabled):hover,
.monaco-editor .find-widget .codicon-find-selection:hover {
background-color: #f8f8f8 !important;
}
.monaco-inputbox {
border: 1px solid #e4e3e4 !important;
}
}
}
}
}

View File

@@ -0,0 +1,143 @@
/*
* 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 interface SchemaNode {
name: string;
type: number;
children?: SchemaNode[];
defaultValue: string;
}
// modify from @byted/biz-ide-component
export const convertSchemaService = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: any,
maxDepth = 20,
currentDepth = 1,
): SchemaNode[] => {
if (currentDepth > maxDepth) {
return [];
}
const paramSchema: SchemaNode[] = [];
Object.keys(object).forEach(key => {
const value = object[key];
switch (typeof value) {
case 'string':
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 1 /* String */,
});
break;
case 'number':
if (Number.isInteger(value)) {
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 2 /* Integer */,
});
} else {
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 4 /* Number */,
});
}
break;
case 'boolean':
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 3 /* Boolean */,
});
break;
case 'object':
if (value === null) {
break;
}
if (Array.isArray(value)) {
if (value.length > 0) {
switch (typeof value[0]) {
case 'string':
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 99 /* ArrayString */,
});
break;
case 'number':
if (Number.isInteger(value[0])) {
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 100 /* ArrayInteger */,
});
} else {
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 102 /* ArrayNumber */,
});
}
break;
case 'boolean':
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 101 /* ArrayBoolean */,
});
break;
case 'object':
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 103 /* ArrayObject */,
children: convertSchemaService(
value[0],
maxDepth,
currentDepth + 1,
),
});
break;
default:
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 99 /* ArrayString */,
});
}
} else {
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 99 /* ArrayString */,
});
}
} else {
paramSchema.push({
name: key,
defaultValue: JSON.stringify(value),
type: 6 /* Object */,
children: convertSchemaService(value, maxDepth, currentDepth + 1),
});
}
break;
default:
throw new Error('ContainsInvalidValue');
}
});
return paramSchema;
};

View File

@@ -0,0 +1,24 @@
/*
* 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.
*/
const INDENT = 4;
export const formatJson = (json: string) => {
try {
return JSON.stringify(JSON.parse(json), null, INDENT);
} catch (e) {
return json;
}
};

View File

@@ -0,0 +1,101 @@
/*
* 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, useState, type FC } from 'react';
import { merge, cloneDeep } from 'lodash-es';
import { type SchemaNode } from '../json-editor/service/convert-schema-service';
import { JSONEditor } from '../json-editor';
import type { TreeNodeCustomData } from '../../type';
import {
MAX_LEVEL,
MAX_NAME_LENGTH,
MAX_JSON_VARIABLE_COUNT,
} from '../../constants';
import { cutOffInvalidData } from './utils/cut-off';
import { exportVariableService } from './services/use-case-service/export-variable-service';
import { getEditorViewVariableJson } from './services/life-cycle-service/init-service';
interface JSONImportProps {
visible: boolean;
onCancel: () => void;
treeData: TreeNodeCustomData;
rules: {
jsonImport: boolean;
readonly: boolean;
};
onOk: (value: TreeNodeCustomData) => void;
}
export const JSONImport: FC<JSONImportProps> = props => {
const { treeData, rules, visible, onCancel, onOk } = props;
const { jsonImport, readonly } = rules;
const [jsonString, setJsonString] = useState('');
const handleImport = (data: SchemaNode[]) => {
const allowDepth = MAX_LEVEL; // 最大深度限制
const allowNameLength = MAX_NAME_LENGTH; // 名称长度限制
const maxVariableCount = MAX_JSON_VARIABLE_COUNT; // 最大变量数量限制
const variables = exportVariableService(
data,
{
groupId: treeData.groupId,
channel: treeData.channel,
},
treeData, // 传入原始变量以保持variableId
);
// 裁切非法数据
const dataCutoff = cutOffInvalidData({
data: variables,
allowDepth,
allowNameLength,
maxVariableCount,
});
// 先深拷贝原始数据
const clonedTreeData = cloneDeep(treeData);
// 合并新旧数据
const mergedData = merge(clonedTreeData, dataCutoff[0]);
// 更新数据
return onOk(mergedData);
};
useEffect(() => {
setJsonString(getEditorViewVariableJson(treeData));
}, [treeData]);
if (!jsonImport) {
return <></>;
}
return (
<JSONEditor
id={treeData.variableId}
groupId={treeData.groupId}
value={jsonString}
readonly={readonly}
setValue={(value: string) => {
setJsonString(value);
}}
visible={visible}
onOk={handleImport}
onCancel={onCancel}
/>
);
};

View File

@@ -0,0 +1,126 @@
/*
* 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 { ViewVariableType } from '@/store/variable-groups/types';
import { type TreeNodeCustomData } from '@/components/variable-tree/type';
import { formatJson } from '@/components/variable-tree/components/json-editor/utils/format-json';
const getDefaultValueByType = (type: ViewVariableType) => {
switch (type) {
case ViewVariableType.String:
return '';
case ViewVariableType.Integer:
case ViewVariableType.Number:
return 0;
case ViewVariableType.Boolean:
return false;
case ViewVariableType.Object:
return {};
case ViewVariableType.ArrayString:
return [''];
case ViewVariableType.ArrayInteger:
return [0];
case ViewVariableType.ArrayBoolean:
return [true];
case ViewVariableType.ArrayNumber:
return [0];
case ViewVariableType.ArrayObject:
return [{}];
default:
return {};
}
};
const isArrayType = (type: ViewVariableType) =>
[
ViewVariableType.ArrayString,
ViewVariableType.ArrayInteger,
ViewVariableType.ArrayBoolean,
ViewVariableType.ArrayNumber,
ViewVariableType.ArrayObject,
].includes(type);
export const getEditorViewVariableJson = (treeData: TreeNodeCustomData) => {
const { defaultValue, type, name, children } = treeData;
if (defaultValue) {
const json = JSON.parse(defaultValue);
return formatJson(
JSON.stringify({
[name]: json,
}),
);
}
// 如果没有name,返回空对象
if (!name) {
return '{}';
}
const isArray = isArrayType(type);
// 递归处理children
const processChildren = (
nodes?: TreeNodeCustomData[],
parentType?: ViewVariableType,
) => {
if (!nodes || nodes.length === 0) {
return getDefaultValueByType(parentType || type);
}
if (isArray && !parentType) {
const firstChild = nodes[0];
if (!firstChild) {
return [];
}
// 如果是数组类型,根据第一个子元素的类型生成默认值
const result = {};
if (firstChild.children && firstChild.children.length > 0) {
result[firstChild.name] = processChildren(
firstChild.children,
firstChild.type,
);
} else {
result[firstChild.name] = getDefaultValueByType(firstChild.type);
}
return [result];
}
return nodes.reduce(
(acc, node) => {
if (!node.name) {
return acc;
}
if (node.children && node.children.length > 0) {
const value = processChildren(node.children, node.type);
acc[node.name] = isArrayType(node.type) ? [value] : value;
} else {
acc[node.name] = getDefaultValueByType(node.type);
}
return acc;
},
{} satisfies Record<string, unknown>,
);
};
// 生成最终的JSON结构
const result = {
[name]: processChildren(children),
};
return formatJson(JSON.stringify(result));
};

View File

@@ -0,0 +1,82 @@
/*
* 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 VariableChannel } from '@coze-arch/bot-api/memory';
import { type ViewVariableType } from '@/store/variable-groups/types';
import { useVariableGroupsStore } from '@/store/variable-groups/store';
import { type Variable } from '@/store';
import { type SchemaNode } from '../../../json-editor/service/convert-schema-service';
/**
* 将转换后的数据转换为Variable
* @param data 转换后的数据
* @param baseInfo 基础信息
* @param originalVariable 原始变量用于保持variableId
* @returns Variable[]
*/
export const exportVariableService = (
data: SchemaNode[],
baseInfo: {
groupId: string;
channel: VariableChannel;
},
originalVariable?: Variable,
): Variable[] => {
const store = useVariableGroupsStore.getState();
const convertNode = (
node: SchemaNode,
parentId = '',
originalNode?: Variable,
): Variable => {
// 使用store中的createVariable方法创建基础变量
const baseVariable = store.createVariable({
variableType: node.type as ViewVariableType,
groupId: baseInfo.groupId,
parentId,
channel: baseInfo.channel,
});
// 如果存在原始节点保持其variableId
if (originalNode) {
baseVariable.variableId = originalNode.variableId;
baseVariable.description = originalNode.description;
}
// 更新变量的基本信息
baseVariable.name = node.name;
baseVariable.defaultValue = node.defaultValue;
// 递归处理子节点,尝试匹配原始子节点
if (node.children?.length) {
baseVariable.children = node.children.map((child, index) => {
const originalChild = originalNode?.children?.[index];
return convertNode(child, baseVariable.variableId, originalChild);
});
}
return baseVariable;
};
const variables = data.map(node => convertNode(node, '', originalVariable));
// 使用store中的updateMeta方法更新meta信息
store.updateMeta({ variables });
return variables;
};

View File

@@ -0,0 +1,74 @@
/*
* 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 Variable, ViewVariableType } from '@/store';
import {
traverse,
type TraverseContext,
type TraverseHandler,
} from './traverse';
const isOutputValueContext = (context: TraverseContext): boolean => {
if (
typeof context.node.value !== 'object' ||
typeof context.node.value.type === 'undefined'
) {
return false;
} else {
return true;
}
};
const cutOffNameLength =
(length: number): TraverseHandler =>
(context: TraverseContext): void => {
if (!isOutputValueContext(context)) {
return;
}
if (context.node.value.name.length > length) {
context.node.value.name = context.node.value.name.slice(0, length);
}
};
const cutOffDepth =
(depth: number): TraverseHandler =>
(context: TraverseContext): void => {
if (
!isOutputValueContext(context) ||
context.node.value.level !== depth ||
![ViewVariableType.Object, ViewVariableType.ArrayObject].includes(
context.node.value.type,
)
) {
return;
}
context.deleteSelf();
};
export const cutOffInvalidData = (params: {
data: Variable[];
allowDepth: number;
allowNameLength: number;
maxVariableCount: number;
}): Variable[] => {
const { data, allowDepth, allowNameLength, maxVariableCount } = params;
const cutOffVariableCountData = data.slice(0, maxVariableCount);
return traverse<Variable[]>(cutOffVariableCountData, [
cutOffNameLength(allowNameLength),
cutOffDepth(allowDepth),
]);
};

View File

@@ -0,0 +1,79 @@
/*
* 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 { nanoid } from 'nanoid';
import type { TreeNodeCustomData } from '../../../type';
import { traverse, type TraverseContext } from './traverse';
/** 计算路径 */
const getTreePath = (context: TraverseContext): string => {
const parents = context
.getParents()
.filter(
node =>
typeof node.value === 'object' &&
typeof node.value.name !== 'undefined' &&
typeof node.value.type !== 'undefined',
);
return parents.map(node => node.value.name).join('/');
};
/** 新旧数据保留 key 防止变量系统引用失效 */
export const mergeData = (params: {
newData: TreeNodeCustomData;
oldData: TreeNodeCustomData;
}): TreeNodeCustomData => {
const { newData, oldData } = params;
// 计算旧数据中路径与key的映射
const treeDataPathKeyMap = new Map<
string,
{
key: string;
}
>();
traverse(oldData, context => {
if (
typeof context.node.value !== 'object' ||
typeof context.node.value.key === 'undefined' ||
typeof context.node.value.type === 'undefined'
) {
return;
}
const stringifyPath = getTreePath(context);
treeDataPathKeyMap.set(stringifyPath, {
key: context.node.value.key,
});
});
// 新数据复用旧数据的key失败则重新生成
const newDataWithKey = traverse(newData, context => {
if (
typeof context.node.value !== 'object' ||
typeof context.node.value.type === 'undefined'
) {
return;
}
const stringifyPath = getTreePath(context);
const { key } = treeDataPathKeyMap.get(stringifyPath) || {
key: nanoid(),
};
context.node.value.key = key;
});
return newDataWithKey;
};

View File

@@ -0,0 +1,182 @@
/*
* 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/no-namespace */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type TraverseValue = any;
export interface TraverseNode {
value: TraverseValue;
container?: TraverseValue;
parent?: TraverseNode;
key?: string;
index?: number;
}
export interface TraverseContext {
node: TraverseNode;
setValue: (value: TraverseValue) => void;
getParents: () => TraverseNode[];
getPath: () => Array<string | number>;
getStringifyPath: () => string;
deleteSelf: () => void;
}
export type TraverseHandler = (context: TraverseContext) => void;
/**
* 深度遍历对象,对每个值做处理
* @param value 遍历对象
* @param handle 处理函数
*/
export const traverse = <T extends TraverseValue = TraverseValue>(
value: T,
handler: TraverseHandler | TraverseHandler[],
): T => {
const traverseHandler: TraverseHandler = Array.isArray(handler)
? (context: TraverseContext) => {
handler.forEach(handlerFn => handlerFn(context));
}
: handler;
TraverseUtils.traverseNodes({ value }, traverseHandler);
return value;
};
namespace TraverseUtils {
/**
* 深度遍历对象,对每个值做处理
* @param node 遍历节点
* @param handle 处理函数
*/
export const traverseNodes = (
node: TraverseNode,
handle: TraverseHandler,
): void => {
const { value } = node;
if (!value) {
// 异常处理
return;
}
if (Object.prototype.toString.call(value) === '[object Object]') {
// 对象,遍历对象的每个属性
Object.entries(value).forEach(([key, item]) =>
traverseNodes(
{
value: item,
container: value,
key,
parent: node,
},
handle,
),
);
} else if (Array.isArray(value)) {
// 数组,遍历数组的每个元素
// 从数组的末尾开始遍历,这样即使中途移除了某个元素,也不会影响到未处理的元素的索引
for (let index = value.length - 1; index >= 0; index--) {
const item: string = value[index];
traverseNodes(
{
value: item,
container: value,
index,
parent: node,
},
handle,
);
}
}
const context: TraverseContext = createContext({ node });
handle(context);
};
const createContext = ({
node,
}: {
node: TraverseNode;
}): TraverseContext => ({
node,
setValue: (value: unknown) => setValue(node, value),
getParents: () => getParents(node),
getPath: () => getPath(node),
getStringifyPath: () => getStringifyPath(node),
deleteSelf: () => deleteSelf(node),
});
const setValue = (node: TraverseNode, value: unknown) => {
// 设置值函数
// 引用类型,需要借助父元素修改值
// 由于是递归遍历所以需要根据node来判断是给对象的哪个属性赋值还是给数组的哪个元素赋值
if (!value || !node) {
return;
}
node.value = value;
// 从上级作用域node中取出containerkeyindex
const { container, key, index } = node;
if (key && container) {
container[key] = value;
} else if (typeof index === 'number') {
container[index] = value;
}
};
const getParents = (node: TraverseNode): TraverseNode[] => {
const parents: TraverseNode[] = [];
let currentNode: TraverseNode | undefined = node;
while (currentNode) {
parents.unshift(currentNode);
currentNode = currentNode.parent;
}
return parents;
};
const getPath = (node: TraverseNode): Array<string | number> => {
const path: Array<string | number> = [];
const parents = getParents(node);
parents.forEach(parent => {
if (parent.key) {
path.unshift(parent.key);
} else if (parent.index) {
path.unshift(parent.index);
}
});
return path;
};
const getStringifyPath = (node: TraverseNode): string => {
const path = getPath(node);
return path.reduce((stringifyPath: string, pathItem: string | number) => {
if (typeof pathItem === 'string') {
const re = /\W/g;
if (re.test(pathItem)) {
// 包含特殊字符
return `${stringifyPath}["${pathItem}"]`;
}
return `${stringifyPath}.${pathItem}`;
} else {
return `${stringifyPath}[${pathItem}]`;
}
}, '');
};
const deleteSelf = (node: TraverseNode): void => {
const { container, key, index } = node;
if (key && container) {
delete container[key];
} else if (typeof index === 'number') {
container.splice(index, 1);
}
};
}

View File

@@ -0,0 +1,32 @@
/*
* 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 */
/** 每一级树缩进宽度 */
export const TreeIndentWidth = 30;
/** 树节点展开收起按钮宽度 */
export const TreeCollapseWidth = 24;
// 名称最长50字符
export const MAX_NAME_LENGTH = 50;
// 最大深度限制
export const MAX_LEVEL = 3;
// 最大变量数量限制
export const MAX_JSON_VARIABLE_COUNT = 1;
// 最大JSON长度限制30kb
export const MAX_JSON_LENGTH = 30 * 1024;

View File

@@ -0,0 +1,375 @@
/*
* 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 @coze-arch/max-line-per-function */
import { useParams } from 'react-router-dom';
import React, {
useCallback,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import { useShallow } from 'zustand/react/shallow';
import { nanoid } from 'nanoid';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { type DynamicParams } from '@coze-arch/bot-typings/teamspace';
import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea';
import { type TreeProps } from '@coze-arch/bot-semi/Tree';
import { type VariableChannel } from '@coze-arch/bot-api/memory';
import { IconCozPlus } from '@coze-arch/coze-design/icons';
import { IconButton, Toast, Tree, useFormApi } from '@coze-arch/coze-design';
import { traverse } from '@/utils/traverse';
import { useVariableGroupsStore, ViewVariableType } from '@/store';
import { VariableTreeContext } from '@/context/variable-tree-context';
import { flatVariableTreeData } from './utils';
import { type TreeNodeCustomData } from './type';
import { ChangeMode } from './components/custom-tree-node/constants';
import CustomTreeNode from './components/custom-tree-node';
export interface VariableTreeProps {
groupId: string;
value: Array<TreeNodeCustomData>;
treeProps?: TreeProps;
readonly?: boolean;
className?: string;
style?: React.CSSProperties;
showAddButton?: boolean;
/** 默认变量类型 */
defaultVariableType?: ViewVariableType;
defaultCollapse?: boolean;
children?: React.ReactNode;
maxLimit?: number;
hideHeaderKeys?: string[];
channel: VariableChannel;
validateExistKeyword?: boolean;
onChange?: (changeValue: TreeNodeCustomData) => void;
}
export interface VariableTreeRef {
validate: () => void;
}
function useExpandedKeys(keys: string[], defaultCollapse: boolean) {
const [expandedKeys, setExpandedKeys] = useState(defaultCollapse ? [] : keys);
const expandTreeNode = useCallback((key: string) => {
setExpandedKeys(prev => [...new Set([...prev, key])]);
}, []);
const collapseTreeNode = useCallback((key: string) => {
setExpandedKeys(prev => prev.filter(expandedKey => expandedKey !== key));
}, []);
return { expandedKeys, expandTreeNode, collapseTreeNode };
}
export function Index(
props: VariableTreeProps,
ref: React.Ref<VariableTreeRef>,
) {
const {
readonly = false,
treeProps,
className,
style,
value,
defaultVariableType = ViewVariableType.String,
defaultCollapse = false,
maxLimit,
groupId,
channel,
hideHeaderKeys,
validateExistKeyword = false,
onChange,
} = props;
const {
createVariable,
addRootVariable,
addChildVariable,
updateVariable,
deleteVariable,
findAndModifyVariable,
} = useVariableGroupsStore(
useShallow(state => ({
createVariable: state.createVariable,
addRootVariable: state.addRootVariable,
addChildVariable: state.addChildVariable,
updateVariable: state.updateVariable,
deleteVariable: state.deleteVariable,
findAndModifyVariable: state.findAndModifyVariable,
})),
);
const formApi = useFormApi();
const isValueEmpty = !value || value.length === 0;
const itemKeysWithChildren = useMemo(() => {
const keys: string[] = [];
traverse(value, item => {
if (item.children?.length > 0) {
keys.push(item.variableId);
}
});
return keys;
}, [value]);
const flatTreeData = useMemo(() => flatVariableTreeData(value), [value]);
const { expandedKeys, expandTreeNode, collapseTreeNode } = useExpandedKeys(
itemKeysWithChildren,
defaultCollapse,
);
const params = useParams<DynamicParams>();
useImperativeHandle(ref, () => ({
validate: () => formApi.validate(),
}));
const disableAdd = useMemo(() => {
if (maxLimit === undefined) {
return false;
}
return (value?.length ?? 0) >= maxLimit;
}, [value, maxLimit]);
const showAddButton = !readonly && !disableAdd;
const onAdd = () => {
const newVariable = createVariable({
groupId,
parentId: '',
variableType: defaultVariableType,
channel,
});
addRootVariable(newVariable);
onChange?.(newVariable);
sendTeaEvent(EVENT_NAMES.memory_click_front, {
project_id: params?.project_id || '',
resource_type: 'variable',
action: 'add',
source: 'app_detail_page',
source_detail: 'memory_manage',
});
};
// 树节点的 change 方法
const onTreeNodeChange = (mode: ChangeMode, param: TreeNodeCustomData) => {
const findResult = findAndModifyVariable(
groupId,
item => item.variableId === param.variableId,
);
if (!findResult) {
Toast.error(I18n.t('workflow_detail_node_output_parsingfailed'));
return;
}
switch (mode) {
case ChangeMode.Append: {
const { variableId: parentId, channel: parentChannel } = findResult;
const childVariable = createVariable({
groupId,
parentId,
variableType: defaultVariableType,
channel: parentChannel,
});
addChildVariable(childVariable);
// 当前节点下新增节点 展开当前节点
if (findResult?.variableId) {
expandTreeNode(findResult.variableId);
}
sendTeaEvent(EVENT_NAMES.memory_click_front, {
project_id: params?.project_id || '',
resource_type: 'variable',
action: 'add',
source: 'app_detail_page',
source_detail: 'memory_manage',
});
break;
}
case ChangeMode.Update: {
updateVariable(param);
sendTeaEvent(EVENT_NAMES.memory_click_front, {
project_id: params?.project_id || '',
resource_type: 'variable',
action: 'edit',
source: 'app_detail_page',
source_detail: 'memory_manage',
});
break;
}
case ChangeMode.Delete: {
deleteVariable(param);
sendTeaEvent(EVENT_NAMES.memory_click_front, {
project_id: params?.project_id || '',
resource_type: 'variable',
action: 'delete',
source: 'app_detail_page',
source_detail: 'memory_manage',
});
break;
}
case ChangeMode.UpdateEnabled: {
findResult.enabled = param.enabled;
// 一键关闭所有子节点
traverse<TreeNodeCustomData>(findResult, node => {
if (!param.enabled) {
node.enabled = param.enabled;
}
});
// 子点开启,父节点也开启
if (findResult.parentId && findResult.enabled) {
const parentData = findAndModifyVariable(
groupId,
item => item.variableId === findResult.parentId,
);
if (parentData) {
parentData.enabled = findResult.enabled;
updateVariable(parentData);
}
}
updateVariable(findResult);
sendTeaEvent(EVENT_NAMES.memory_click_front, {
project_id: params?.project_id || '',
resource_type: 'variable',
action: param.enabled ? 'turn_on' : 'turn_off',
source: 'app_detail_page',
source_detail: 'memory_manage',
});
break;
}
case ChangeMode.Replace: {
updateVariable(param);
expandTreeNode(findResult.variableId);
sendTeaEvent(EVENT_NAMES.memory_click_front, {
project_id: params?.project_id || '',
resource_type: 'variable',
action: 'edit',
source: 'app_detail_page',
source_detail: 'memory_manage',
});
break;
}
default:
}
onChange?.(param);
};
if (readonly && isValueEmpty) {
return null;
}
return (
<VariableTreeContext.Provider value={{ groupId, variables: flatTreeData }}>
<div
className={classNames(
// 基础容器样式
'relative h-full',
// 交互状态
!readonly && 'cursor-default',
// 自定义类名
className,
)}
style={style}
>
<Tree
style={readonly ? {} : { overflow: 'inherit' }}
motion={false}
keyMaps={{
key: 'variableId',
}}
disabled={readonly}
className={classNames(
// 基础滚动行为
'overflow-x-auto',
// Tree 列表基础样式
[
// 列表容器样式
'[&_.semi-tree-option-list]:overflow-visible',
'[&_.semi-tree-option-list]:p-0',
'[&_.semi-tree-option-list>div:first-child]:mt-0',
// 选项样式
'[&_.semi-tree-option]:!pl-2',
].join(' '),
// 交互状态样式
readonly
? '[&_.semi-tree-option-list-block_.semi-tree-option:hover]:bg-inherit'
: [
'[&_.semi-tree-option-list-block_.semi-tree-option:hover]:bg-transparent',
'[&_.semi-tree-option-list-block_.semi-tree-option:active]:bg-transparent',
].join(' '),
)}
renderFullLabel={renderFullLabelProps => {
const { data } = renderFullLabelProps;
const currentLevelReadOnly = readonly || data.IsReadOnly;
const onCollapse = (collapsed: boolean) => {
const { variableId } = renderFullLabelProps.data;
if (!variableId) {
return;
}
if (collapsed) {
expandTreeNode(variableId);
} else {
collapseTreeNode(variableId);
}
};
return (
<CustomTreeNode
{...renderFullLabelProps}
hideHeaderKeys={hideHeaderKeys}
validateExistKeyword={validateExistKeyword}
onChange={onTreeNodeChange}
hasObjectLike={data.meta.hasObjectLike}
readonly={currentLevelReadOnly}
couldCollapse={(data.children?.length ?? 0) > 0}
collapsed={renderFullLabelProps.expandStatus.expanded}
onCollapse={onCollapse}
/>
);
}}
emptyContent={<></>}
expandedKeys={[...expandedKeys, nanoid()]}
treeData={value}
{...treeProps}
/>
{showAddButton ? (
<div className="flex items-center my-3">
<IconButton icon={<IconCozPlus />} onClick={onAdd}>
{I18n.t('workflow_detail_node_output_add_subitem')}
</IconButton>
</div>
) : null}
</div>
</VariableTreeContext.Provider>
);
}
// 导出可调用ref方法的组件
export const VariableTree = React.forwardRef(Index);

View File

@@ -0,0 +1,54 @@
/*
* 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 { type Variable, type ViewVariableType } from '@/store';
import { type ChangeMode } from './components/custom-tree-node/constants';
export interface RecursedParamDefinition {
name?: string;
/** Tree 组件要求每一个节点都有 key而 key 不适合用名称(前后缀)等任何方式赋值,最终确定由接口转换层一次性提供随机 key */
fieldRandomKey?: string;
desc?: string;
type: ViewVariableType;
children?: RecursedParamDefinition[];
}
export type TreeNodeCustomData = Variable;
export interface CustomTreeNodeFuncRef {
data: TreeNodeCustomData;
level: number;
readonly: boolean;
// 通用change方法
onChange: (mode: ChangeMode, param: TreeNodeCustomData) => void;
// 定制的类型改变的change方法主要用于自定义render使用
// 添加子项
onAppend: () => void;
// 删除该项
onDelete: () => void;
// 类型改变时内部的调用方法主要用于从类Object类型转为其他类型时需要删除所有子项
onSelectChange: (
val?: string | number | Array<unknown> | Record<string, unknown>,
) => void;
}
export type WithCustomStyle<T = object> = {
className?: string;
style?: CSSProperties;
} & T;

View File

@@ -0,0 +1,110 @@
/*
* 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 { traverse } from '@/utils/traverse';
import { type Variable, type VariableGroup } from '@/store';
import { type TreeNodeCustomData } from './type';
interface RootFindResult {
isRoot: true;
data: TreeNodeCustomData;
parentData: null;
}
interface ChildrenFindResult {
isRoot: false;
parentData: TreeNodeCustomData;
data: TreeNodeCustomData;
}
export type FindDataResult = RootFindResult | ChildrenFindResult | null;
/**
* 根据target数组找到key在该项的值和位置主要是获取位置方便操作parent的children
*/
export function findCustomTreeNodeDataResult(
target: Array<TreeNodeCustomData>,
variableId: string,
): FindDataResult {
const dataInRoot = target.find(item => item.variableId === variableId);
if (dataInRoot) {
// 如果是根节点
return {
isRoot: true,
parentData: null,
data: dataInRoot,
};
}
function findDataInChildrenLoop(
customChildren: Array<TreeNodeCustomData>,
parentData?: TreeNodeCustomData,
): FindDataResult {
function findDataLoop(
customData: TreeNodeCustomData,
_parentData: TreeNodeCustomData,
): FindDataResult {
if (customData.variableId === variableId) {
return {
isRoot: false,
parentData: _parentData,
data: customData,
};
}
if (customData.children && customData.children.length > 0) {
return findDataInChildrenLoop(
customData.children as Array<TreeNodeCustomData>,
customData,
);
}
return null;
}
for (const child of customChildren) {
const childResult = findDataLoop(child, parentData || child);
if (childResult) {
return childResult;
}
}
return null;
}
return findDataInChildrenLoop(target);
}
// 将groupVariableMeta打平为viewVariableTreeNode[]
export function flatGroupVariableMeta(
groupVariableMeta: VariableGroup[],
maxDepth = Infinity,
) {
const res: Variable[] = [];
traverse(
groupVariableMeta,
item => {
res.push(...item.varInfoList);
},
'subGroupList',
maxDepth,
);
return res;
}
export const flatVariableTreeData = (treeData: Variable[]) => {
const res: Variable[] = [];
traverse(
treeData,
item => {
res.push(item);
},
'children',
);
return res;
};

View File

@@ -0,0 +1,31 @@
/*
* 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 { createContext, useContext } from 'react';
import { type VariableGroup } from '../store';
interface VariableContextType {
variablePageCanEdit?: boolean;
groups: VariableGroup[];
}
// eslint-disable-next-line @typescript-eslint/naming-convention
export const VariableContext = createContext<VariableContextType>({
groups: [],
});
export const useVariableContext = () => useContext(VariableContext);

View File

@@ -0,0 +1,30 @@
/*
* 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 { createContext, useContext } from 'react';
import { type Variable } from '../store';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const VariableTreeContext = createContext<{
groupId: string;
variables: Variable[];
}>({
groupId: '',
variables: [],
});
export const useVariableTreeContext = () => useContext(VariableTreeContext);

View File

@@ -0,0 +1,32 @@
/*
* 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 { useVariableGroupsStore } from '../../store';
export const useDestory = () => {
const { clear } = useVariableGroupsStore();
useEffect(
() => () => {
clear();
},
[clear],
);
return {
clear,
};
};

View File

@@ -0,0 +1,124 @@
/*
* 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 { useRequest } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { CustomError } from '@coze-arch/bot-error';
import { type project_memory as ProjectMemory } from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import { Toast } from '@coze-arch/coze-design';
import { useVariableGroupsStore } from '../../store';
export const useInit = (projectID?: string, version?: string) => {
const { data: reqData, loading } = useGetVariableList(projectID, version);
const { initStore } = useVariableGroupsStore();
useEffect(() => {
if (loading) {
return;
}
const { variableGroups, canEdit } = reqData;
initStore({
variableGroups,
canEdit: canEdit && !version,
});
}, [loading]);
return {
loading,
};
};
const useGetVariableList = (
projectID?: string,
version?: string,
): {
data: {
variableGroups: ProjectMemory.GroupVariableInfo[];
canEdit: boolean;
};
loading: boolean;
error: string;
} => {
const {
data: reqData,
loading,
error,
} = useRequest(
async () => {
if (!projectID) {
throw new CustomError(
'useListDataSetReq_error',
'projectID cannot be empty',
);
}
const res = await MemoryApi.GetProjectVariableList({
ProjectID: projectID,
version: version || undefined,
});
const { GroupConf, code, CanEdit: canEdit, msg } = res;
if (code !== 0) {
return {
error: msg,
data: {
variableGroups: [],
canEdit: false,
},
loading: false,
};
}
if (!GroupConf) {
return {
data: {
variableGroups: [],
canEdit,
},
loading: false,
};
}
return {
variableGroups: GroupConf,
canEdit,
};
},
{
manual: false,
onError: () => {
Toast.error({
content: I18n.t('Network_error'),
showClose: false,
});
},
},
);
return {
data: {
variableGroups: reqData?.variableGroups ?? [],
canEdit: reqData?.canEdit ?? false,
},
loading,
error: error?.message ?? '',
};
};

View File

@@ -0,0 +1,46 @@
/*
* 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 { useHiddenSession } from '@/hooks/use-case/use-hidden-session';
export const useChangeWarning = () => {
const [isShowBanner, setIsShowBanner] = useState(false);
const { isSessionHidden, hideSession } = useHiddenSession(
'variable_config_change_banner_remind',
);
const showBanner = () => {
setIsShowBanner(true);
};
const hideBanner = () => {
setIsShowBanner(false);
};
const hideBannerForever = () => {
hideSession();
setIsShowBanner(false);
};
return {
isShowBanner: isShowBanner && !isSessionHidden,
showBanner,
hideBanner,
hideBannerForever,
};
};

View File

@@ -0,0 +1,44 @@
/*
* 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 { localStorageService } from '@coze-foundation/local-storage';
const SESSION_HIDDEN_KEY = 'coze-home-session-area-hidden-key';
export const useHiddenSession = (key: string) => {
const [isSessionHidden, setIsSessionHidden] = useState(isKeyExist(key));
return {
isSessionHidden,
hideSession: () => {
if (isKeyExist(key)) {
return;
}
const oldValue = localStorageService.getValue(SESSION_HIDDEN_KEY) || '';
localStorageService.setValue(
SESSION_HIDDEN_KEY,
oldValue ? `${oldValue},${key}` : key,
);
setIsSessionHidden(true);
},
};
};
const isKeyExist = (key: string) => {
const oldValue = localStorageService.getValue(SESSION_HIDDEN_KEY);
return oldValue?.includes(key);
};

View File

@@ -0,0 +1,79 @@
/*
* 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 { useLocation } from 'react-router-dom';
import { useEffect, useRef, useState } from 'react';
import { useDataNavigate } from '@coze-data/knowledge-stores';
import { I18n } from '@coze-arch/i18n';
import { Button, Toast } from '@coze-arch/coze-design';
export const useLeaveWarning = () => {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const location = useLocation();
const prevPathRef = useRef(location.pathname);
const resourceNavigate = useDataNavigate();
useEffect(() => {
const currentPath = location.pathname;
const wasInVariablePage = prevPathRef.current.includes('/variables');
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
}
};
if (
wasInVariablePage &&
!currentPath.includes('/variables') &&
hasUnsavedChanges
) {
Toast.warning({
content: (
<div>
<span className="text-sm font-medium coz-fg-plus mr-2">
{I18n.t('variable_config_toast_savetips')}
</span>
<Button
color="primary"
onClick={() => {
resourceNavigate.navigateTo?.('/variables');
}}
>
{I18n.t('variable_config_toast_return_button')}
</Button>
</div>
),
});
}
if (currentPath.includes('/variables') && hasUnsavedChanges) {
window.addEventListener('beforeunload', handleBeforeUnload);
}
prevPathRef.current = currentPath;
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [location, hasUnsavedChanges]);
return {
hasUnsavedChanges,
setHasUnsavedChanges,
};
};

View File

@@ -0,0 +1,23 @@
.container {
display: flex;
flex-direction: column;
height: 100%;
.content {
overflow-y: auto;
flex: 1;
padding-bottom: 16px; // 给底部留出间距
}
.footer {
flex-shrink: 0;
padding: 16px 24px;
background: #fff;
border-top: 1px solid var(--semi-color-border);
// 如果需要阴影效果
box-shadow: 0 -2px 8px rgba(0, 0, 0, 6%);
}
}

View File

@@ -0,0 +1,56 @@
/*
* 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 classNames from 'classnames';
import { useKnowledgeParams } from '@coze-data/knowledge-stores';
import { I18n } from '@coze-arch/i18n';
import { TabBar, TabPane } from '@coze-arch/coze-design';
import { VariablesValue } from './variables-value';
import { VariablesConfig } from './variables-config';
export const VariablesPage = () => {
const params = useKnowledgeParams();
const { projectID = '', version } = params;
return (
<div
className={classNames(
'h-full w-full overflow-hidden',
'border border-solid coz-stroke-primary coz-bg-max',
)}
>
<TabBar
lazyRender
type="text"
className={classNames(
'h-full flex flex-col',
// 滚动条位置调整到 tab 内容中
'[&_.semi-tabs-content]:p-0 [&_.semi-tabs-content]:grow [&_.semi-tabs-content]:overflow-hidden',
'[&_.semi-tabs-pane-active]:h-full',
'[&_.semi-tabs-pane-motion-overlay]:h-full [&_.semi-tabs-pane-motion-overlay]:overflow-auto',
)}
tabBarClassName="flex items-center h-[56px] mx-[16px]"
>
<TabPane tab={I18n.t('db_optimize_033')} itemKey="config">
<VariablesConfig projectID={projectID} version={version} />
</TabPane>
<TabPane tab={I18n.t('variable_Tabname_test_data')} itemKey="values">
<VariablesValue projectID={projectID} version={version} />
</TabPane>
</TabBar>
</div>
);
};

View File

@@ -0,0 +1,46 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import { MemoryApi } from '@coze-arch/bot-api';
import { Toast } from '@coze-arch/coze-design';
import { useVariableGroupsStore } from '../../store';
/**
* 提交变量
* @param projectID
* @returns
*/
export async function submit(projectID: string) {
const { getAllRootVariables, getDtoVariable } =
useVariableGroupsStore.getState();
const res = await MemoryApi.UpdateProjectVariable({
ProjectID: projectID,
VariableList: getAllRootVariables().map(item => getDtoVariable(item)),
});
if (res.code === 0) {
Toast.success(I18n.t('Update_success'));
}
}
/**
* 检查并确保 projectID 是非空字符串
* @param projectID 可能为空的项目ID
* @returns projectID 是否为非空字符串
*/
export const checkProjectID = (projectID: unknown): projectID is string =>
typeof projectID === 'string' && projectID.length > 0;

View File

@@ -0,0 +1,25 @@
/*
* 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 {
useVariableGroupsStore,
type VariableGroup,
type Variable,
type VariableMeta,
VariableTypeDTO,
VariableSchemaDTO,
ViewVariableType,
} from './variable-groups';

View File

@@ -0,0 +1,23 @@
/*
* 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 {
useVariableGroupsStore,
type VariableGroup,
type Variable,
type VariableMeta,
} from './store';
export { ViewVariableType, VariableTypeDTO, VariableSchemaDTO } from './types';

View File

@@ -0,0 +1,359 @@
/*
* 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 @coze-arch/max-line-per-function */
import { devtools, subscribeWithSelector } from 'zustand/middleware';
import { create } from 'zustand';
import { nanoid } from 'nanoid';
import { cloneDeep } from 'lodash-es';
import { produce } from 'immer';
import {
type project_memory as ProjectMemory,
type VariableChannel,
VariableType,
} from '@coze-arch/bot-api/memory';
import { traverse } from '../../utils/traverse';
import { type ViewVariableType, ObjectLikeTypes } from './types';
import { getDtoVariable } from './transform/vo2dto';
import { getGroupListByDto } from './transform/dto2vo';
export interface Variable {
variableId: string;
type: ViewVariableType;
name: string;
children: Variable[];
defaultValue: string;
description: string;
enabled: boolean;
channel: VariableChannel;
effectiveChannelList: string[];
variableType: VariableType;
readonly: boolean;
groupId: string;
parentId: string;
meta: VariableMeta;
}
export interface VariableMeta {
isHistory: boolean;
level?: number;
hasObjectLike?: boolean;
field?: string;
}
export interface VariableGroup {
groupId: string;
groupName: string;
groupDesc: string;
groupExtDesc: string;
isReadOnly: boolean;
channel: VariableChannel;
subGroupList: VariableGroup[];
varInfoList: Variable[];
raw: ProjectMemory.GroupVariableInfo;
}
export interface VariableGroupsStore {
variableGroups: VariableGroup[];
canEdit: boolean;
}
export const getDefaultVariableGroupStore = (): VariableGroupsStore => ({
canEdit: false,
variableGroups: [],
});
export interface VariableGroupsAction {
setVariableGroups: (variableGroups: VariableGroup[]) => void;
createVariable: (variableInfo: {
variableType: ViewVariableType;
groupId: string;
parentId: string;
channel: VariableChannel;
}) => Variable;
// 更新变量, 根据groupId和variableId更新
updateVariable: (newVariable: Variable) => void;
// 更新变量的meta信息
updateMeta: (params: {
variables: Variable[];
level?: number;
parentId?: string;
}) => void;
// 新增根节点变量
addRootVariable: (variable: Omit<Variable, 'channel'>) => void;
// 新增子节点变量
addChildVariable: (variable: Variable) => void;
// 删除变量
deleteVariable: (variable: Variable) => void;
// 保存后作为历史变量对待
saveHistory: () => void;
// 获取DTO variable
getDtoVariable: (variable: Variable) => ProjectMemory.Variable;
// 获取groups下所有的变量
getAllRootVariables: () => Variable[];
// 获取groups下所有的变量
getAllVariables: () => Variable[];
transformDto2Vo: (data: ProjectMemory.GroupVariableInfo[]) => VariableGroup[];
initStore: (data: {
variableGroups: ProjectMemory.GroupVariableInfo[];
canEdit: boolean;
}) => void;
clear: () => void;
// 在变量树中查找变量,并可选地修改或删除
findAndModifyVariable: (
groupId: string,
predicate: (variable: Variable) => boolean,
options?: {
modifyVariable?: (variable: Variable) => void;
removeVariable?: boolean;
mark?: string;
},
) => Variable | null;
}
export const useVariableGroupsStore = create<
VariableGroupsStore & VariableGroupsAction
>()(
devtools(
subscribeWithSelector((set, get) => ({
...getDefaultVariableGroupStore(),
setVariableGroups: variableGroups =>
set({ variableGroups }, false, 'setVariableGroups'),
createVariable: baseInfo => ({
variableId: nanoid(),
type: baseInfo.variableType,
name: '',
enabled: true,
children: [],
defaultValue: '',
description: '',
channel: baseInfo.channel,
effectiveChannelList: [],
variableType: VariableType.KVVariable,
readonly: false,
groupId: baseInfo.groupId,
parentId: baseInfo.parentId,
meta: {
isHistory: false,
},
}),
addRootVariable: variable => {
set(
produce<VariableGroupsStore>(state => {
const findGroup = state.variableGroups.find(
item => item.groupId === variable.groupId,
);
if (!findGroup) {
return;
}
findGroup.varInfoList.push({
...variable,
channel: findGroup.channel,
});
get().updateMeta({
variables: findGroup.varInfoList,
level: 0,
parentId: '',
});
}),
false,
'addRootVariable',
);
},
addChildVariable: variable => {
get().findAndModifyVariable(
variable.groupId,
item => item.variableId === variable.parentId,
{
modifyVariable: parentNode => {
parentNode.children.push(variable);
get().updateMeta({
variables: parentNode.children,
level: (parentNode.meta.level || 0) + 1,
parentId: parentNode.variableId,
});
},
mark: 'addChildVariable',
},
);
},
deleteVariable: variable => {
get().findAndModifyVariable(
variable.groupId,
item => item.variableId === variable.variableId,
{ removeVariable: true, mark: 'deleteVariable' },
);
},
findAndModifyVariable: (groupId, predicate, options) => {
let foundVariable: Variable | null = null;
set(
produce<VariableGroupsStore>(state => {
const findInGroups = (groups: VariableGroup[]): boolean => {
for (const group of groups) {
if (group.groupId === groupId) {
if (findInTree(group.varInfoList, predicate, options)) {
return true;
}
}
if (group.subGroupList?.length) {
if (findInGroups(group.subGroupList)) {
return true;
}
}
}
return false;
};
const findInTree = (
variables: Variable[],
predicateIn: (variable: Variable) => boolean,
optionsIn?: {
modifyVariable?: (variable: Variable) => void;
removeVariable?: boolean;
},
): boolean => {
for (let i = 0; i < variables.length; i++) {
if (predicateIn(variables[i])) {
foundVariable = cloneDeep(variables[i]);
if (optionsIn?.removeVariable) {
variables.splice(i, 1);
}
if (optionsIn?.modifyVariable) {
optionsIn.modifyVariable(variables[i]);
}
return true;
}
if (variables[i].children?.length) {
if (
findInTree(variables[i].children, predicateIn, optionsIn)
) {
return true;
}
}
}
return false;
};
findInGroups(state.variableGroups);
}),
false,
options?.mark || 'findVariableInTree',
);
return foundVariable;
},
updateVariable: newVariable => {
get().findAndModifyVariable(
newVariable.groupId,
variable => variable.variableId === newVariable.variableId,
{
mark: 'updateVariable',
modifyVariable: variable => {
Object.assign(variable, newVariable);
get().updateMeta({
variables: [variable],
level: variable.meta.level,
parentId: variable.parentId,
});
},
},
);
},
updateMeta: ({ variables, level = 0, parentId = '' }) => {
variables.forEach(item => {
item.meta.level = level;
item.meta.hasObjectLike = ObjectLikeTypes.includes(item.type);
item.parentId = parentId;
if (item.children?.length) {
get().updateMeta({
variables: item.children,
level: level + 1,
parentId: item.variableId,
});
}
});
},
saveHistory: () => {
set(
produce<VariableGroupsStore>(state => {
state.variableGroups.forEach(item => {
traverse(item.varInfoList, itemIn => {
itemIn.meta.isHistory = true;
});
});
}),
false,
'saveHistory',
);
},
getAllRootVariables: () => {
const { variableGroups } = get();
const res: Variable[] = [];
traverse(
variableGroups,
item => {
res.push(...item.varInfoList);
},
'subGroupList',
);
return res;
},
getAllVariables: () => {
const { variableGroups } = get();
const variables = variableGroups.map(item => item.varInfoList).flat();
const res: Variable[] = [];
traverse(
variables,
item => {
res.push(item);
},
'children',
);
return res;
},
transformDto2Vo: data => {
const transformedData = getGroupListByDto(data);
// 在数据转换完成后立即更新meta信息
transformedData.forEach(group => {
get().updateMeta({ variables: group.varInfoList });
});
return transformedData;
},
getDtoVariable: (variable: Variable) => getDtoVariable(variable),
initStore: data => {
const { transformDto2Vo } = get();
const transformedData = transformDto2Vo(data.variableGroups);
set(
{
variableGroups: transformedData,
canEdit: data.canEdit,
},
false,
'initStore',
);
},
clear: () => {
set({ ...getDefaultVariableGroupStore() }, false, 'clear');
},
})),
{
enabled: IS_DEV_MODE,
name: 'knowledge.variableGroups',
},
),
);

View File

@@ -0,0 +1,353 @@
/*
* 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 { nanoid } from 'nanoid';
import {
exhaustiveCheckSimple,
safeAsyncThrow,
} from '@coze-common/chat-area-utils';
import { typeSafeJSONParse } from '@coze-arch/bot-utils';
import {
type project_memory as ProjectMemory,
VariableChannel,
VariableType,
} from '@coze-arch/bot-api/memory';
import {
VariableTypeDTO,
type VariableSchemaDTO,
ViewVariableType,
} from '../types';
import { type VariableGroup, type Variable } from '../store';
export const getGroupListByDto = (
dtoGroups: ProjectMemory.GroupVariableInfo[],
): VariableGroup[] => {
const groups = dtoGroups?.map(group => {
const baseGroupInfo = getBaseGroupInfoByDto(group);
const { groupId } = baseGroupInfo;
const varInfoList = getGroupVariableListByDto({
group,
groupId,
});
return {
...baseGroupInfo,
varInfoList,
subGroupList: getSubGroupListByDto({
group,
groupId,
}),
};
});
return groups || [];
};
const getBaseGroupInfoByDto = (
group: Partial<ProjectMemory.GroupVariableInfo>,
): Omit<VariableGroup, 'subGroupList' | 'varInfoList'> => {
const {
GroupName: groupName = '',
GroupDesc: groupDesc = '',
GroupExtDesc: groupExtDesc = '',
IsReadOnly: isReadOnly = false,
DefaultChannel: channel = VariableChannel.Custom,
} = group;
const groupId = nanoid();
return {
groupId,
groupName,
groupDesc,
groupExtDesc,
channel,
isReadOnly,
raw: group,
};
};
const getGroupVariableListByDto = ({
group,
groupId,
}: {
group: Partial<ProjectMemory.GroupVariableInfo>;
groupId: string;
}): Variable[] => {
const { VarInfoList: varInfoList = [] } = group;
return (
varInfoList?.map(dtoVariable =>
getViewVariableByDto(dtoVariable, groupId),
) ?? []
);
};
const getSubGroupListByDto = ({
group,
groupId,
}: {
group: Partial<ProjectMemory.GroupVariableInfo>;
groupId: string;
}): VariableGroup[] => {
const { SubGroupList: subGroupList = [] } = group;
return (
subGroupList?.map(subGroup => ({
...getBaseGroupInfoByDto({
...subGroup,
DefaultChannel: group.DefaultChannel, // 服务端返回的 subGroup 没有 DefaultChannel需要手动设置
}),
groupId,
varInfoList: getGroupVariableListByDto({
group: subGroup,
groupId,
}),
subGroupList: [],
})) ?? []
);
};
export function getViewVariableByDto(
dtoVariable: ProjectMemory.Variable,
groupId: string,
): Variable {
const variableSchema = typeSafeJSONParse(
dtoVariable.Schema || '{}',
) as VariableSchemaDTO;
const { type } = variableSchema;
const baseVariable = createBaseVariable({
dtoVariable,
groupId,
});
if (type === VariableTypeDTO.List) {
return convertListVariable(baseVariable, variableSchema);
}
if (type === VariableTypeDTO.Object) {
return convertObjectVariable(baseVariable, variableSchema);
}
return {
...baseVariable,
type: dTOTypeToViewType(variableSchema.type),
children: [],
};
}
export function dTOTypeToViewType(
type: VariableTypeDTO,
{
arrayItemType,
}: {
arrayItemType?: VariableTypeDTO;
} = {},
): ViewVariableType {
switch (type) {
case VariableTypeDTO.Boolean:
return ViewVariableType.Boolean;
case VariableTypeDTO.Integer:
return ViewVariableType.Integer;
case VariableTypeDTO.Float:
return ViewVariableType.Number;
case VariableTypeDTO.String:
return ViewVariableType.String;
case VariableTypeDTO.Object:
return ViewVariableType.Object;
case VariableTypeDTO.List:
if (!arrayItemType) {
throw new Error(
`Unkown variable DTO list need sub type but get ${arrayItemType}`,
);
}
switch (arrayItemType) {
case VariableTypeDTO.Boolean:
return ViewVariableType.ArrayBoolean;
case VariableTypeDTO.Integer:
return ViewVariableType.ArrayInteger;
case VariableTypeDTO.Float:
return ViewVariableType.ArrayNumber;
case VariableTypeDTO.String:
return ViewVariableType.ArrayString;
case VariableTypeDTO.Object:
return ViewVariableType.ArrayObject;
case VariableTypeDTO.List:
safeAsyncThrow(
`List type variable can't have sub list type: ${type}:${arrayItemType}`,
);
return ViewVariableType.String;
default:
exhaustiveCheckSimple(arrayItemType);
safeAsyncThrow(`Unknown variable DTO Type: ${type}:${arrayItemType}`);
return ViewVariableType.String;
}
default:
exhaustiveCheckSimple(type);
safeAsyncThrow(`Unknown variable DTO Type: ${type}:${arrayItemType}`);
return ViewVariableType.String;
}
}
function createBaseVariable({
dtoVariable,
groupId,
}: {
dtoVariable: ProjectMemory.Variable;
groupId: string;
}): Omit<Variable, 'type' | 'children'> {
return {
variableId: nanoid(),
name: dtoVariable.Keyword ?? '',
description: dtoVariable.Description ?? '',
enabled: dtoVariable.Enable ?? true,
defaultValue: dtoVariable.DefaultValue ?? '',
channel: dtoVariable.Channel ?? VariableChannel.Custom,
effectiveChannelList: dtoVariable.EffectiveChannelList ?? [],
variableType: dtoVariable.VariableType ?? VariableType.KVVariable,
readonly: dtoVariable.IsReadOnly ?? false,
groupId,
parentId: '',
meta: {
isHistory: true,
},
};
}
function convertListVariable(
baseVariable: Omit<Variable, 'type' | 'children'>,
variableSchema: VariableSchemaDTO,
): Variable {
const subVariableSchema = variableSchema.schema as VariableSchemaDTO;
const { type: subVariableType } = subVariableSchema;
if (subVariableType === VariableTypeDTO.Object) {
return convertListObjectVariable(baseVariable, variableSchema);
}
return {
...baseVariable,
type: dTOTypeToViewType(variableSchema.type, {
arrayItemType: subVariableType,
}),
children: [],
} as unknown as Variable;
}
/**
*@example schema: array<object>
{
"type": "list",
"name": "arr_obj",
"schema": {
"type": "object",
"schema": [{
"type": "string",
"name": "name",
"required": false
}, {
"type": "integer",
"name": "age",
"required": false
}]
},
}
*/
function convertListObjectVariable(
baseVariable: Omit<Variable, 'type' | 'children'>,
variableSchema: VariableSchemaDTO,
): Variable {
const subVariableSchema = variableSchema.schema;
if (!subVariableSchema) {
throw new Error('List object variable schema is invalid');
}
const { type: subVariableType } = subVariableSchema;
return {
...baseVariable,
type: dTOTypeToViewType(VariableTypeDTO.List, {
arrayItemType: subVariableType,
}),
children: Array.isArray(subVariableSchema.schema)
? subVariableSchema.schema.map(schema =>
createVariableBySchema(schema, {
groupId: baseVariable.groupId,
parentId: baseVariable.variableId,
}),
)
: [],
};
}
/**
* @example schema: object
* object
{
"type": "object",
"name": "obj",
"schema": [{
"type": "string",
"name": "name",
"required": false
}, {
"type": "integer",
"name": "age",
"required": false
}],
}
* @returns
*/
function convertObjectVariable(
baseVariable: Omit<Variable, 'type' | 'children'>,
variableSchema: VariableSchemaDTO,
): Variable {
const schema = variableSchema.schema || [];
return {
...baseVariable,
type: dTOTypeToViewType(variableSchema.type),
children: Array.isArray(schema)
? schema.map(subMeta =>
createVariableBySchema(subMeta, {
groupId: baseVariable.groupId,
parentId: baseVariable.variableId,
}),
)
: [],
};
}
function createVariableBySchema(
subMeta: VariableSchemaDTO,
{
groupId,
parentId,
}: {
groupId: string;
parentId: string;
},
): Variable {
return getViewVariableByDto(
{
Keyword: subMeta.name,
Description: subMeta.description,
Schema: JSON.stringify(subMeta),
Enable: true,
IsReadOnly: subMeta.readonly,
},
groupId,
);
}

View File

@@ -0,0 +1,140 @@
/*
* 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 {
exhaustiveCheckSimple,
safeAsyncThrow,
} from '@coze-common/chat-area-utils';
import { type project_memory as ProjectMemory } from '@coze-arch/bot-api/memory';
import { type VariableSchemaDTO, VariableTypeDTO } from '../types';
import { type Variable } from '../store';
/**
* 前端变量类型
*/
export enum ViewVariableType {
String = 1,
Integer,
Boolean,
Number,
Object = 6,
// 上面是 api 中定义的 InputType。下面是整合后的。从 99 开始,避免和后端定义撞车
ArrayString = 99,
ArrayInteger,
ArrayBoolean,
ArrayNumber,
ArrayObject,
}
export function viewTypeToDTO(type: ViewVariableType): {
type: VariableTypeDTO;
arrayItemType?: VariableTypeDTO;
} {
switch (type) {
case ViewVariableType.Boolean:
return { type: VariableTypeDTO.Boolean };
case ViewVariableType.Integer:
return { type: VariableTypeDTO.Integer };
case ViewVariableType.Number:
return { type: VariableTypeDTO.Float };
case ViewVariableType.String:
return { type: VariableTypeDTO.String };
case ViewVariableType.Object:
return { type: VariableTypeDTO.Object };
case ViewVariableType.ArrayBoolean:
return {
type: VariableTypeDTO.List,
arrayItemType: VariableTypeDTO.Boolean,
};
case ViewVariableType.ArrayInteger:
return {
type: VariableTypeDTO.List,
arrayItemType: VariableTypeDTO.Integer,
};
case ViewVariableType.ArrayNumber:
return {
type: VariableTypeDTO.List,
arrayItemType: VariableTypeDTO.Float,
};
case ViewVariableType.ArrayString:
return {
type: VariableTypeDTO.List,
arrayItemType: VariableTypeDTO.String,
};
case ViewVariableType.ArrayObject:
return {
type: VariableTypeDTO.List,
arrayItemType: VariableTypeDTO.Object,
};
default:
exhaustiveCheckSimple(type);
safeAsyncThrow(`Unknown view variable type: ${type}`);
return { type: VariableTypeDTO.String };
}
}
export const getDtoVariable = (
viewVariable: Variable,
): ProjectMemory.Variable => {
const { type, arrayItemType } = viewTypeToDTO(viewVariable.type);
const schema: VariableSchemaDTO = {
name: viewVariable.name,
enable: viewVariable.enabled,
description: viewVariable.description || '',
type,
readonly: Boolean(viewVariable.readonly),
schema: '',
};
// 处理数组类型
if (type === VariableTypeDTO.List && arrayItemType) {
if (arrayItemType === VariableTypeDTO.Object) {
schema.schema = {
type: VariableTypeDTO.Object,
schema: viewVariable.children?.map(child => {
const childDTO = getDtoVariable(child);
return JSON.parse(childDTO.Schema || '{}');
}),
};
} else {
schema.schema = {
type: arrayItemType,
};
}
}
// 处理对象类型
if (type === VariableTypeDTO.Object) {
schema.schema = viewVariable.children?.map(child => {
const childDTO = getDtoVariable(child);
return JSON.parse(childDTO.Schema || '{}');
});
}
return {
Keyword: viewVariable.name,
Channel: viewVariable.channel,
VariableType: viewVariable.variableType ?? 1,
DefaultValue: viewVariable.defaultValue,
Description: viewVariable.description,
EffectiveChannelList: viewVariable.effectiveChannelList,
Enable: Boolean(viewVariable.enabled),
IsReadOnly: Boolean(viewVariable.readonly),
Schema: JSON.stringify(schema, null, 0),
};
};

View File

@@ -0,0 +1,97 @@
/*
* 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 VariableTypeDTO {
Object = 'object',
List = 'list',
String = 'string',
Integer = 'integer',
Boolean = 'boolean',
Float = 'float',
}
export interface VariableSchemaDTO {
type: VariableTypeDTO;
name: string;
enable: boolean;
description: string;
readonly: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema?: any;
}
/**
* 前端变量类型
*/
export enum ViewVariableType {
String = 1,
Integer,
Boolean,
Number,
Object = 6,
// 上面是 api 中定义的 InputType。下面是整合后的。从 99 开始,避免和后端定义撞车
ArrayString = 99,
ArrayInteger,
ArrayBoolean,
ArrayNumber,
ArrayObject,
}
export const BASE_ARRAY_PAIR: [ViewVariableType, ViewVariableType][] = [
[ViewVariableType.String, ViewVariableType.ArrayString],
[ViewVariableType.Integer, ViewVariableType.ArrayInteger],
[ViewVariableType.Boolean, ViewVariableType.ArrayBoolean],
[ViewVariableType.Number, ViewVariableType.ArrayNumber],
[ViewVariableType.Object, ViewVariableType.ArrayObject],
];
export const VARIABLE_TYPE_ALIAS_MAP: Record<ViewVariableType, string> = {
[ViewVariableType.String]: 'String',
[ViewVariableType.Integer]: 'Integer',
[ViewVariableType.Boolean]: 'Boolean',
[ViewVariableType.Number]: 'Number',
[ViewVariableType.Object]: 'Object',
[ViewVariableType.ArrayString]: 'Array<String>',
[ViewVariableType.ArrayInteger]: 'Array<Integer>',
[ViewVariableType.ArrayBoolean]: 'Array<Boolean>',
[ViewVariableType.ArrayNumber]: 'Array<Number>',
[ViewVariableType.ArrayObject]: 'Array<Object>',
};
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace ViewVariableType {
/**
* 获取所有变量类型的补集
* @param inputTypes
*/
export function getComplement(inputTypes: ViewVariableType[]) {
const allTypes: ViewVariableType[] = [
...BASE_ARRAY_PAIR.map(_pair => _pair[0]),
...BASE_ARRAY_PAIR.map(_pair => _pair[1]),
];
return allTypes.filter(type => !inputTypes.includes(type));
}
export function isArrayType(type: ViewVariableType): boolean {
const arrayTypes = BASE_ARRAY_PAIR.map(_pair => _pair[1]);
return arrayTypes.includes(type);
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention
export const ObjectLikeTypes = [
ViewVariableType.Object,
ViewVariableType.ArrayObject,
];

View File

@@ -0,0 +1,37 @@
/*
* 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 { ViewVariableType } from '../store';
export type WithCustomStyle<T = object> = {
className?: string;
style?: CSSProperties;
} & T;
export const VARIABLE_TYPE_ALIAS_MAP: Record<ViewVariableType, string> = {
[ViewVariableType.String]: 'String',
[ViewVariableType.Integer]: 'Integer',
[ViewVariableType.Boolean]: 'Boolean',
[ViewVariableType.Number]: 'Number',
[ViewVariableType.Object]: 'Object',
[ViewVariableType.ArrayString]: 'Array<String>',
[ViewVariableType.ArrayInteger]: 'Array<Integer>',
[ViewVariableType.ArrayBoolean]: 'Array<Boolean>',
[ViewVariableType.ArrayNumber]: 'Array<Number>',
[ViewVariableType.ArrayObject]: 'Array<Object>',
};

View File

@@ -0,0 +1,17 @@
/*
* 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' />

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.
*/
// eslint-disable-next-line max-params
export function traverse<
T extends { [key in K]?: T[] },
K extends string = 'children',
>(
nodeOrNodes: T | T[],
action: (node: T) => void,
traverseKey: K = 'children' as K,
maxDepth = Infinity,
currentDepth = 0,
) {
const nodes = Array.isArray(nodeOrNodes) ? nodeOrNodes : [nodeOrNodes];
nodes.forEach(node => {
action(node);
if (currentDepth < maxDepth) {
const children = node[traverseKey] ?? [];
if (children?.length > 0) {
traverse(children, action, traverseKey, maxDepth, currentDepth + 1);
}
}
});
}

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 React, { useEffect, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import cls from 'classnames';
import { IllustrationNoContent } from '@douyinfe/semi-illustrations';
import { I18n } from '@coze-arch/i18n';
import { VariableChannel } from '@coze-arch/bot-api/memory';
import { IconCozCross } from '@coze-arch/coze-design/icons';
import {
Button,
Empty,
Form,
type FormApi,
IconButton,
Spin,
} from '@coze-arch/coze-design';
import { useVariableGroupsStore, type Variable } from '@/store';
import { useLeaveWarning } from '@/hooks/use-case/use-leave-waring';
import { useInit } from '@/hooks/life-cycle/use-init';
import { useDestory } from '@/hooks/life-cycle/use-destory';
import { VariableContext } from '@/context';
import { VariableGroup as VariableGroupComponent } from '@/components/variable-group';
import {
checkProjectID,
submit,
} from './service/use-case-service/submit-service';
import { useChangeWarning } from './hooks/use-case/use-change-warning';
export interface VariableConfigProps {
projectID: string;
version?: string;
}
export const VariablesConfig = ({
projectID,
version,
}: VariableConfigProps) => {
const formApiRef = useRef<FormApi | null>(null);
const { loading } = useInit(projectID, version);
useDestory();
const { setHasUnsavedChanges, hasUnsavedChanges } = useLeaveWarning();
const { variableGroups, canEdit, saveHistory, getAllVariables } =
useVariableGroupsStore(
useShallow(state => ({
variableGroups: state.variableGroups,
canEdit: state.canEdit,
saveHistory: state.saveHistory,
getAllVariables: state.getAllVariables,
})),
);
const { isShowBanner, showBanner, hideBanner, hideBannerForever } =
useChangeWarning();
const isEmpty = !variableGroups.length;
const onVariableChange = (changeValue: Variable) => {
setHasUnsavedChanges(true);
if (changeValue.meta?.isHistory) {
showBanner();
}
};
const handleSubmit = async () => {
if (!checkProjectID(projectID)) {
return;
}
const formApi = formApiRef.current;
if (!formApi) {
return;
}
const isValid = await formApi.validate();
if (!isValid) {
return;
}
saveHistory();
await submit(projectID);
setHasUnsavedChanges(false);
};
const initValues = getAllVariables().reduce((acc, curr) => {
acc[curr.variableId] = { name: curr.name };
return acc;
}, {});
useEffect(() => {
if (loading) {
return;
}
formApiRef.current?.setValues(initValues);
}, [loading, initValues]);
return (
<VariableContext.Provider
value={{
variablePageCanEdit: canEdit,
groups: variableGroups,
}}
>
<div className="p-4 pb-[72px]">
{loading ? (
<div className="w-full h-full flex justify-center items-center">
<Spin />
</div>
) : isEmpty ? (
<div className="w-full h-full flex items-center justify-center">
<Empty
image={<IllustrationNoContent className="w-[140px] h-[140px]" />}
title={I18n.t('card_builder_varpanel_var_empty')}
/>
</div>
) : (
<>
{isShowBanner ? (
<div className="h-[36px] flex items-center justify-center coz-mg-hglt coz-fg-primary text-sm mb-4 mt-[-16px] mx-[-16px]">
<div className="flex items-center ml-auto">
{I18n.t('variable_config_change_banner')}
</div>
<div className="flex items-center ml-auto cursor-pointer">
<div
className="coz-fg-secondary text-xs"
onClick={hideBannerForever}
>
{I18n.t('do_not_remind_again')}
</div>
<IconButton
className="ml-2 !bg-transparent"
onClick={hideBanner}
icon={<IconCozCross />}
/>
</div>
</div>
) : null}
<Form<typeof initValues>
getFormApi={formApi => {
formApiRef.current = formApi;
}}
showValidateIcon={false}
autoScrollToError
initValues={initValues}
>
<div className="flex flex-col gap-2">
{variableGroups.map(item => (
<VariableGroupComponent
readonly={!canEdit || item.isReadOnly}
groupInfo={item}
onVariableChange={onVariableChange}
validateExistKeyword={item.channel === VariableChannel.APP}
/>
))}
</div>
</Form>
<div
className={cls(
'flex items-center justify-end',
'fixed bottom-[1px] right-[1px] left-[1px] pb-4 pt-6',
'bg-white mr-4 px-4',
)}
>
<Button
onClick={handleSubmit}
disabled={!canEdit || !hasUnsavedChanges}
>
{I18n.t('edit_variables_modal_ok_text')}
</Button>
</div>
</>
)}
</div>
</VariableContext.Provider>
);
};

View File

@@ -0,0 +1,169 @@
/*
* 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 dayjs from 'dayjs';
import classNames from 'classnames';
import { useRequest } from 'ahooks';
import { IllustrationNoContent } from '@douyinfe/semi-illustrations';
import { I18n } from '@coze-arch/i18n';
import { typeSafeJSONParse } from '@coze-arch/bot-utils';
import { EVENT_NAMES, sendTeaEvent } from '@coze-arch/bot-tea';
import { type KVItem } from '@coze-arch/bot-api/memory';
import { MemoryApi } from '@coze-arch/bot-api';
import {
IconCozRefresh,
IconCozCrossCircleFill,
} from '@coze-arch/coze-design/icons';
import { Table, Select, IconButton, Tooltip, Empty } from '@coze-arch/coze-design';
export interface VariablesValueProps {
projectID: string;
version?: string;
}
export function VariablesValue({ projectID, version }: VariablesValueProps) {
const { loading, data, refresh } = useRequest(async () => {
const res = await MemoryApi.GetPlayGroundMemory({
project_id: projectID,
...(version ? { version } : {}),
});
return res.memories ?? [];
});
const handleClear = async (item: KVItem) => {
if (!item.keyword) {
return;
}
sendTeaEvent(EVENT_NAMES.memory_click_front, {
project_id: projectID,
resource_type: 'variable',
action: 'reset',
source: 'app_detail_page',
source_detail: 'memory_preview',
});
await MemoryApi.DelProfileMemory({
project_id: projectID,
keywords: [item.keyword],
});
refresh();
};
const handleReset = async () => {
sendTeaEvent(EVENT_NAMES.memory_click_front, {
project_id: projectID,
resource_type: 'variable',
action: 'reset',
source: 'app_detail_page',
source_detail: 'memory_preview',
});
await MemoryApi.DelProfileMemory({ project_id: projectID });
refresh();
};
return (
<div
className={classNames(
'h-full p-4',
'[&_.semi-table-row]:!bg-transparent',
'[&_.semi-table-row-head]:!bg-transparent',
'[&_.semi-table-row-cell]:text-[14px]',
)}
>
<Table
useHoverStyle={false}
empty={
<Empty
image={<IllustrationNoContent className="w-[140px] h-[140px]" />}
title={I18n.t('variables_user_data_empty')}
/>
}
tableProps={{
loading,
dataSource: data,
columns: [
{
title: I18n.t('variable_Table_Title_name'),
dataIndex: 'keyword',
width: 300,
},
{
title: (
<div className={'flex items-center'}>
<span className={'mr-4px'}>
{I18n.t('variable_Table_Title_value')}
</span>
<Tooltip
theme={'dark'}
content={I18n.t('variable_Button_reset_variable')}
>
<IconButton
color={'primary'}
icon={<IconCozRefresh />}
size={'small'}
onClick={handleReset}
/>
</Tooltip>
</div>
),
dataIndex: 'value',
render: (value: string, item: KVItem) => {
const schema = typeSafeJSONParse(item?.schema) as
| { readonly?: boolean }
| undefined;
if (schema?.readonly) {
return value;
}
return (
<Select
className="w-full truncate"
value={value}
showArrow={false}
showClear={true}
emptyContent={null}
onClear={() => handleClear(item)}
clearIcon={
<IconButton
theme={'borderless'}
color={'secondary'}
icon={<IconCozCrossCircleFill />}
size={'large'}
/>
}
/>
);
},
},
{
title: I18n.t('variable_Table_Title_edit_time'),
align: 'left',
dataIndex: 'update_time',
width: 150,
render: (time: number, item: KVItem) =>
item.value && dayjs.unix(time).format('YYYY-MM-DD HH:mm'),
},
],
}}
/>
</div>
);
}

View File

@@ -0,0 +1,37 @@
/*
* 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 { DemoComponent } from '../src';
export default {
title: 'Example/Demo',
component: DemoComponent,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered',
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {},
};
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Base = {
args: {
name: 'tecvan',
},
};

View File

@@ -0,0 +1,34 @@
import { Meta } from "@storybook/blocks";
<Meta title="Hello world" />
<div className="sb-container">
<div className='sb-section-title'>
# Hello world
Hello world
</div>
</div>
<style>
{`
.sb-container {
margin-bottom: 48px;
}
.sb-section {
width: 100%;
display: flex;
flex-direction: row;
gap: 20px;
}
img {
object-fit: cover;
}
.sb-section-title {
margin-bottom: 32px;
}
`}
</style>

View File

@@ -0,0 +1,126 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "dist/tsconfig.build.tsbuildinfo"
},
"include": ["src"],
"exclude": ["node_modules", "dist"],
"references": [
{
"path": "../../../arch/bot-api/tsconfig.build.json"
},
{
"path": "../../../arch/bot-error/tsconfig.build.json"
},
{
"path": "../../../arch/bot-flags/tsconfig.build.json"
},
{
"path": "../../../arch/bot-hooks/tsconfig.build.json"
},
{
"path": "../../../arch/bot-monaco-editor/tsconfig.build.json"
},
{
"path": "../../../arch/bot-store/tsconfig.build.json"
},
{
"path": "../../../arch/bot-tea/tsconfig.build.json"
},
{
"path": "../../../arch/bot-typings/tsconfig.build.json"
},
{
"path": "../../../arch/bot-utils/tsconfig.build.json"
},
{
"path": "../../../arch/i18n/tsconfig.build.json"
},
{
"path": "../../../arch/logger/tsconfig.build.json"
},
{
"path": "../../../arch/report-events/tsconfig.build.json"
},
{
"path": "../../../arch/report-tti/tsconfig.build.json"
},
{
"path": "../../../common/chat-area/utils/tsconfig.build.json"
},
{
"path": "../../common/e2e/tsconfig.build.json"
},
{
"path": "../../common/reporter/tsconfig.build.json"
},
{
"path": "../../common/utils/tsconfig.build.json"
},
{
"path": "../../../components/bot-icons/tsconfig.build.json"
},
{
"path": "../../../components/bot-semi/tsconfig.build.json"
},
{
"path": "../../../../config/eslint-config/tsconfig.build.json"
},
{
"path": "../../../../config/stylelint-config/tsconfig.build.json"
},
{
"path": "../../../../config/ts-config/tsconfig.build.json"
},
{
"path": "../../../../config/vitest-config/tsconfig.build.json"
},
{
"path": "../../../foundation/local-storage/tsconfig.build.json"
},
{
"path": "../../knowledge/common/components/tsconfig.build.json"
},
{
"path": "../../knowledge/common/stores/tsconfig.build.json"
},
{
"path": "../../knowledge/knowledge-ide-base/tsconfig.build.json"
},
{
"path": "../../knowledge/knowledge-modal-adapter/tsconfig.build.json"
},
{
"path": "../../knowledge/knowledge-modal-base/tsconfig.build.json"
},
{
"path": "../../knowledge/knowledge-resource-processor-adapter/tsconfig.build.json"
},
{
"path": "../../knowledge/knowledge-resource-processor-base/tsconfig.build.json"
},
{
"path": "../../knowledge/knowledge-resource-processor-core/tsconfig.build.json"
},
{
"path": "../../../studio/components/tsconfig.build.json"
},
{
"path": "../../../studio/premium/premium-components-adapter/tsconfig.build.json"
},
{
"path": "../../../studio/premium/premium-store-adapter/tsconfig.build.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"exclude": ["**/*"],
"compilerOptions": {
"composite": true
},
"references": [
{
"path": "./tsconfig.build.json"
},
{
"path": "./tsconfig.misc.json"
}
]
}

View File

@@ -0,0 +1,23 @@
{
"extends": "@coze-arch/ts-config/tsconfig.web.json",
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"rootDir": "./",
"outDir": "./dist",
"jsx": "react-jsx",
"lib": ["DOM", "ESNext"],
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["__tests__", "vitest.config.ts", "stories"],
"exclude": ["./dist"],
"references": [
{
"path": "./tsconfig.build.json"
}
]
}

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.
*/
import { defineConfig } from '@coze-arch/vitest-config';
export default defineConfig({
dirname: __dirname,
preset: 'web',
});