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,83 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import { Tooltip } from '@coze-arch/coze-design';
import { AddButton } from '@/form';
export interface AddOptionButtonProps {
/** 是否展示标题行 */
showTitleRow?: boolean;
/** 是否展示选项标签 */
showOptionName?: boolean;
/** 选项 placeholder */
optionPlaceholder?: string;
/** 默认分支名称 */
defaultOptionText?: string;
/** 选项最大数量限制,默认值为整数最大值 */
maxItems?: number;
/** 展示禁止添加 Tooltip */
showDisableAddTooltip?: boolean;
customDisabledAddTooltip?: string;
className?: string;
dataTestId?: string;
value;
onClick;
readonly;
children;
}
export const AddOptionButton = ({
className,
showDisableAddTooltip = true,
maxItems = Number.MAX_SAFE_INTEGER,
customDisabledAddTooltip,
value,
onClick,
readonly,
children,
dataTestId,
}: AddOptionButtonProps) =>
showDisableAddTooltip && (value?.length as number) >= maxItems ? (
<Tooltip
content={
customDisabledAddTooltip ||
I18n.t('workflow_250117_05', { maxCount: maxItems })
}
>
<AddButton
className={className}
children={children}
dataTestId={dataTestId}
/>
</Tooltip>
) : (
<AddButton
className={className}
disabled={readonly}
children={children}
onClick={onClick}
dataTestId={dataTestId}
/>
);

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect } from 'react';
import { FormItemFeedback } from '@/nodes-v2/components/form-item-feedback';
import { ExpressionEditor } from '@/nodes-v2/components/expression-editor';
import {
calcPortId,
convertNumberToLetters,
} from '@/form-extensions/setters/answer-option/utils';
import { withField, useField, SortableItem, FieldArrayItem } from '@/form';
import styles from './index.module.less';
export const AnswerItemField = withField(
({
showOptionName,
optionPlaceholder,
optionIndex,
movePostion,
onItemDelete,
answerOptions,
}: {
showOptionName?: boolean;
optionPlaceholder?: string;
optionIndex: number;
movePostion: {
start?: number;
end?: number;
};
onItemDelete: (index: number) => void;
answerOptions: { name: string }[];
}) => {
const { value, onChange, onBlur, readonly, errors } = useField<string>();
useEffect(() => {
if (
movePostion?.start === optionIndex ||
movePostion?.end === optionIndex
) {
onBlur?.();
}
}, [movePostion]);
return (
<div className="w-full">
<SortableItem
key={optionIndex}
sortableID={calcPortId(optionIndex)}
index={optionIndex}
containerClassName="items-center"
hanlderClassName="!p-0"
>
<FieldArrayItem
className="!pt-[0px] items-center"
containerClassName="items-center"
removeIconClassName="!h-[24px] !min-w-[24px] !max-w-[24px] !p-[4px]"
disableRemove={answerOptions?.length <= 1 || readonly}
onRemove={() => {
if (answerOptions?.length <= 1 || readonly) {
return;
}
onItemDelete(optionIndex);
}}
>
{showOptionName ? (
<div className="break-keep w-[48px]">
{convertNumberToLetters(optionIndex)}
</div>
) : null}
<div className="items-center space-x-1 w-full min-h-[24px] leading-[24px]">
{!readonly ? (
<ExpressionEditor
name={'/questionParams/options'}
value={value as string}
onChangeTrigger="onChange"
onChange={val => onChange?.(val as string)}
onBlur={() => {
onBlur?.();
}}
isError={errors && errors?.length > 0}
minRows={1}
placeholder={optionPlaceholder}
disableSuggestion={false}
className="!px-[4px] !py-[2px]"
containerClassName={styles['answer-editor']}
/>
) : (
<div className="w-full">{value ?? ''}</div>
)}
</div>
</FieldArrayItem>
</SortableItem>
<FormItemFeedback className="pl-[68px]" errors={errors} />
</div>
);
},
);

View File

@@ -0,0 +1,37 @@
.parameters-title {
width: calc(100% - 12px);
}
.parameters-title-readonly {
width: calc(100% - 32px);
}
.parameters-wrapper {
background: var(--Bg-COZ-bg-max, #FFF);
border: 1px solid var(--Stroke-COZ-stroke-plus, rgba(84, 97, 156, 27%));
border-radius: var(--small, 6px);
:global {
.semi-radio-content{
width: 100%;
}
}
}
.answer-editor{
:global{
.cm-editor .cm-scroller{
scrollbar-width: none;
}
}
}
.option-radio-group {
width: 50%;
:global{
.semi-radio-content span{
color: var(--Fg-COZ-fg-primary, rgba(15, 21, 40, 82%));
}
}
}

View File

@@ -0,0 +1,270 @@
/*
* 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, { useMemo, useState } from 'react';
import update from 'immutability-helper';
import classNames from 'classnames';
import { ViewVariableType } from '@coze-workflow/variable';
import { useNodeTestId } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { Tooltip } from '@coze-arch/coze-design';
import { ValueExpressionInputField } from '@/node-registries/common/fields';
import { useUpdateSortedPortLines } from '@/hooks';
import { ColumnsTitleWithAction } from '@/form-extensions/components/columns-title-with-action';
import { RadioGroupField } from '@/form/fields';
import {
Section,
useFieldArray,
SortableList,
FieldArray,
useWatch,
} from '@/form';
import { OptionType } from '@/constants/question-settings';
import { generatePortId } from './utils';
import { AnswerItemField } from './answer-item';
import { AddOptionButton } from './add-option-button';
import styles from './index.module.less';
export interface AnswerOptionProps {
/** 是否展示标题行 */
showTitleRow?: boolean;
/** 是否展示选项标签 */
showOptionName?: boolean;
/** 选项 placeholder */
optionPlaceholder?: string;
/** 默认分支名称 */
defaultOptionText?: string;
}
const AnswerOption = ({
optionPlaceholder,
defaultOptionText,
showTitleRow = true,
showOptionName = true,
}: AnswerOptionProps) => {
const { value, move, name, readonly, onChange, append } = useFieldArray<{
name: string;
}>();
const answerType = useWatch({ name: 'questionParams.answer_type' });
const optionType = useWatch({ name: 'questionParams.option_type' });
const { getNodeSetterId } = useNodeTestId();
const updateSortedPortLines = useUpdateSortedPortLines(generatePortId);
const [movePostion, setMovePostion] = useState<{
start?: number;
end?: number;
}>({});
const isStaticOption = useMemo(
() => optionType === OptionType.Static,
[optionType],
);
if (answerType !== 'option') {
return;
}
const onItemDelete = (index: number) => {
// 将要被删除的端口移动到最后,这样删除时不会对其他连线顺序产生影响
updateSortedPortLines(index, value?.length as number);
const newVal = update(value, { $splice: [[index, 1]] });
onChange(newVal);
};
return (
<Section
title={
<div className="text-xs font-normal">
{I18n.t('workflow_ques_ans_type_option_label', {}, '设置选项内容')}
</div>
}
headerClassName="pt-[12px] !mb-[4px]"
noPadding
collapsible={false}
>
<div className={`w-full p-[8px] ${styles['parameters-wrapper']}`}>
<div className="w-full mb-[8px]">
<RadioGroupField
name="questionParams.option_type"
data-testid={getNodeSetterId('questionParams.option_type')}
type="button"
className="w-full"
buttonSize="middle"
defaultValue={OptionType.Static}
options={[
{
label: I18n.t(
'workflow_question_fixed_content',
{},
'固定内容',
),
value: OptionType.Static,
className: styles['option-radio-group'],
},
{
label: I18n.t(
'workflow_question_ dynamic_content',
{},
'动态内容',
),
value: OptionType.Dynamic,
className: styles['option-radio-group'],
},
]}
/>
</div>
{showTitleRow ? (
<ColumnsTitleWithAction
columns={[
{
title: I18n.t('workflow_ques_ans_type_option_title'),
style: {
width: isStaticOption ? '67px' : '56px',
},
},
{
title: I18n.t('workflow_ques_ans_type_option_content'),
style: {
flex: 1,
},
},
]}
readonly={readonly}
className={classNames(
'mb-[8px]',
readonly
? styles.parametersTitleReadonly
: styles.parametersTitle,
)}
/>
) : null}
{!isStaticOption ? (
<div className="flex items-center w-full text-xs mt-2">
<div
className="break-keep mr-[4px]"
style={{
minWidth: isStaticOption ? '48px' : '56px',
}}
>
<Tooltip
trigger="hover"
content={I18n.t(
'workflow_question_dynamic',
{},
'dynamicOption',
)}
>
<span>{I18n.t('workflow_question_az', {}, 'A~Z')}</span>
</Tooltip>
</div>
<div className="w-full items-center">
<ValueExpressionInputField
name={'questionParams.dynamic_option'}
availableFileTypes={[ViewVariableType.ArrayString]}
disabledTypes={ViewVariableType.getComplement([
ViewVariableType.ArrayString,
])}
/>
</div>
</div>
) : (
<>
<SortableList
onSortEnd={({ from, to }) => {
if (readonly) {
return;
}
updateSortedPortLines(from, to);
move(from, to);
setMovePostion({
start: from,
end: to,
});
}}
>
<div className="w-full flex flex-col gap-[8px] text-xs leading-[24px]">
{value?.map((_item, index) => (
<AnswerItemField
name={`${name}.${index}.name`}
answerOptions={value}
optionPlaceholder={optionPlaceholder}
optionIndex={index}
movePostion={movePostion}
showOptionName={showOptionName}
hasFeedback={false}
onItemDelete={onItemDelete}
/>
))}
</div>
</SortableList>
<AddOptionButton
className="w-full mt-[8px]"
dataTestId={getNodeSetterId('answer-option-add-btn')}
readonly={readonly}
onClick={() => {
append({
name: '',
});
}}
children={
<span className="text-[12px] font-medium">
{I18n.t('workflow_question_add_option', {}, '新增选项')}
</span>
}
value={value}
/>
</>
)}
<div className="flex items-center w-full text-xs mt-2">
<div
className="break-keep mr-[4px]"
style={{
minWidth: isStaticOption ? '48px' : '56px',
marginLeft: isStaticOption ? '16px' : '0px',
}}
>
{I18n.t('workflow_ques_ans_type_option_other', {}, 'other')}
</div>
<div className="space-x-1 w-full leading-[16px]">
{defaultOptionText}
</div>
</div>
</div>
</Section>
);
};
export const AnswerOptionField = ({
name,
optionPlaceholder,
defaultOptionText,
}) => (
<FieldArray name={name} defaultValue={[{ name: '' }, { name: '' }]}>
<AnswerOption
optionPlaceholder={optionPlaceholder}
defaultOptionText={defaultOptionText}
/>
</FieldArray>
);

View File

@@ -0,0 +1,28 @@
/*
* 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 ASCII_TO_A_INDEX = 65; // 字母A对应的ASCII序号
export function convertNumberToLetters(n) {
let result = '';
while (n >= 0) {
result = String.fromCharCode((n % 26) + ASCII_TO_A_INDEX) + result;
n = Math.floor(n / 26) - 1;
}
return result;
}
export const generatePortId = (index: number) => `branch_${index}`;

View File

@@ -0,0 +1,48 @@
.limit-wrapper {
background: var(--Bg-COZ-bg-plus, #fcfcff);
border: 0.67px solid
var(--Stroke-COZ-stroke-primary, rgba(82, 100, 154, 13%));
border-radius: 12px;
:global {
.semi-slider {
width: 100%;
padding: 0;
}
.semi-slider-handle {
transform: translateX(-50%) translateY(100%);
width: 8px;
height: 8px;
}
}
}
.sys-popover-content {
display: flex;
flex-direction: column;
width: 320px;
min-width: 320px;
max-width: 320px;
max-height: 800px;
padding: 8px 12px;
}
.content-title {
font-size: 14px;
font-weight: 500;
line-height: 20px;
color: var(--Fg-COZ-fg-plus, rgba(8, 13, 30, 90%));
}
.slider-marks {
top: 20px;
div {
font-size: 10px;
font-weight: 400;
line-height: 14px; /* 140% */
color: var(--Fg-COZ-fg-primary, rgba(15, 21, 41, 82%));
}
}

View File

@@ -0,0 +1,159 @@
/*
* 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 { useNodeTestId } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { IconCozSetting, IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import {
IconButton,
Tooltip,
Typography,
Popover,
Divider,
} from '@coze-arch/coze-design';
import { useReadonly } from '@/nodes-v2/hooks/use-readonly';
import { ExpressionEditorField } from '@/node-registries/common/fields';
import { useWatch } from '@/form';
import { CopyButton } from '@/components/copy-button';
import { SliderWithInputField } from './slider-with-input-field';
import styles from './question-limit.module.less';
const MAX_ROUND = 5;
const MIN_ROUND = 1;
export const QuestionLimit = () => {
const { getNodeSetterId } = useNodeTestId();
const readonly = useReadonly();
const systemPrompt = useWatch<string>({ name: 'llmParam.systemPrompt' });
const label = I18n.t(
'workflow_ques_ans_type_direct_exrtact_title',
{},
"Extract variables from user's response.",
);
return (
<div className="flex flex-col w-full mb-2">
<Divider margin="12px" />
<div className="flex w-full justify-between items-center">
<Typography.Text
className="mr-[6px] text-xs"
ellipsis={{
showTooltip: {
opts: {
content: label,
},
},
}}
style={{
maxWidth: 'calc(100% - 24px)',
color: '#1C1F23',
}}
>
{label}
</Typography.Text>
<Popover
autoAdjustOverflow={false}
className={styles['limit-wrapper']}
trigger="click"
position="topRight"
content={
<div className={styles['sys-popover-content']}>
<div className="flex items-center mb-[4px]">
<span className={styles['content-title']}>
{I18n.t(
'workflow_ques_ans_type_direct_exrtact_context_setting',
{},
'Maximum dialogue rounds',
)}
</span>
<Tooltip
content={I18n.t(
'workflow_ques_ans_type_direct_context_setting_tooltips',
{},
'允许用户回答该问题的最多次数,当从用户的多次回答中获取不到必填的关键字段时,该工作流将会终止运行。',
)}
>
<IconCozInfoCircle className="text-[#A7A9B0] text-[16px] ml-1" />
</Tooltip>
</div>
<div className="w-full relative mb-[16px]">
<SliderWithInputField
name="questionOutputs.limit"
defaultValue={3}
max={MAX_ROUND}
min={MIN_ROUND}
sliderStyle={{
width: '100%',
}}
/>
<div
className={`w-full flex justify-between absolute ${styles['slider-marks']}`}
>
<div>{MIN_ROUND}</div>
<div>{MAX_ROUND}</div>
</div>
</div>
<div className="flex items-center justify-between mb-[4px]">
<div className="flex items-center">
<span className={styles['content-title']}>
{I18n.t('workflow_question_sp', {}, '系统提示词')}
</span>
<Tooltip
content={I18n.t(
'workflow_question_sp_setting',
{},
'系统提示词设置',
)}
>
<IconCozInfoCircle className="text-[#A7A9B0] text-[16px] ml-1" />
</Tooltip>
</div>
{readonly ? <CopyButton value={systemPrompt ?? ''} /> : null}
</div>
<ExpressionEditorField
name="llmParam.systemPrompt"
defaultValue=""
placeholder={I18n.t(
'workflow_question_sp_placeholder',
{},
'支持额外的系统提示词,如设置人设和回复逻辑,使其追问语气更加自然',
)}
className="!p-[4px]"
containerClassName="!bg-transparent"
shouldUseContainerRef
/>
</div>
}
>
<IconButton
size="default"
color="secondary"
data-testid={getNodeSetterId('question-limit-setting')}
wrapperClass="flex justify-end"
className="!p-[4px] !max-w-[24px] !min-w-[24px] !h-[24px]"
icon={<IconCozSetting />}
/>
</Popover>
</div>
</div>
);
};

View File

@@ -0,0 +1,115 @@
/*
* 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 { FILE_TYPES, ViewVariableType } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { Section, useWatch } from '@/form';
import {
DEFAULT_EXTRACT_OUTPUT,
DEFAULT_ANSWER_OPTION_OUTPUT,
} from '../constants';
import {
CheckboxWithTipsField,
OutputsDisplayField,
OutputsField,
} from '../../common/fields';
import { QuestionLimit } from './question-limit';
export const QuestionOutputs = () => {
const answerType = useWatch({ name: 'questionParams.answer_type' });
const extraOutput = useWatch<Boolean>({
name: 'questionOutputs.extra_output',
});
const isOptionAnswer = answerType === 'option';
return (
<Section
title={I18n.t('workflow_detail_node_output')}
tooltip={I18n.t('workflow_ques_output_tooltips')}
actions={
isOptionAnswer
? []
: [
<CheckboxWithTipsField
name="questionOutputs.extra_output"
defaultValue={false}
text={I18n.t(
'workflow_ques_ans_type_direct_checkbox',
{},
'从回复中提取字段',
)}
itemTooltip={I18n.t(
'workflow_ques_ans_type_direct_checkbox_tooltips',
{},
'开启后将从用户输入中提取信息',
)}
/>,
]
}
>
{isOptionAnswer ? (
<OutputsDisplayField
id={'question-node-option-output'}
name={'questionOutputs.optionOutput'}
defaultValue={DEFAULT_ANSWER_OPTION_OUTPUT}
/>
) : (
<>
<OutputsField
title=""
id={'question-node-user-output'}
name={'questionOutputs.userOutput'}
noCard
jsonImport={false}
disabled={true}
allowAppendRootData={false}
topLevelReadonly
withRequired
hasFeedback={false}
/>
{extraOutput ? (
<>
<QuestionLimit />
<OutputsField
title=""
id={'question-node-extract-output'}
name={'questionOutputs.extractOutput'}
defaultValue={DEFAULT_EXTRACT_OUTPUT}
hiddenTypes={[
...FILE_TYPES,
ViewVariableType.ArrayBoolean,
ViewVariableType.ArrayInteger,
ViewVariableType.ArrayNumber,
ViewVariableType.ArrayObject,
ViewVariableType.ArrayString,
ViewVariableType.Object,
]}
noCard
withRequired
jsonImport={false}
hasFeedback={false}
/>
</>
) : null}
</>
)}
</Section>
);
};

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 React from 'react';
import { SliderWithInput as SliderWithInputLegacy } from '@/form-extensions/components/slider-with-input';
import { useField, withField } from '@/form';
const SliderWithInput = props => {
const { value, onChange, readonly } = useField<string | number>();
return (
<SliderWithInputLegacy
value={value}
onChange={onChange}
readonly={!!readonly}
{...props}
/>
);
};
export const SliderWithInputField = withField(SliderWithInput);

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 { nanoid } from '@flowgram-adapter/free-layout-editor';
import { ViewVariableType } from '@coze-workflow/nodes';
import { I18n } from '@coze-arch/i18n';
export const DEFAULT_USER_RESPONSE_PARAM_NAME = 'USER_RESPONSE';
export const DEFAULT_OPTION_ID_NAME = 'optionId';
export const DEFAULT_OPTION_CONTENT_NAME = 'optionContent';
export const DEFAULT_OUTPUT_NAMES = [
DEFAULT_USER_RESPONSE_PARAM_NAME,
DEFAULT_OPTION_ID_NAME,
DEFAULT_OPTION_CONTENT_NAME,
];
export const DEFAULT_USE_RESPONSE = [
{
key: nanoid(),
name: DEFAULT_USER_RESPONSE_PARAM_NAME,
type: ViewVariableType.String,
required: true,
description: I18n.t(
'workflow_ques_ans_type_direct_key_decr',
{},
'用户本轮对话输入内容',
),
},
];
export const DEFAULT_EXTRACT_OUTPUT = [
{
key: nanoid(),
name: 'output',
type: ViewVariableType.String,
required: true,
},
];
export const DEFAULT_ANSWER_OPTION_OUTPUT = [
{
key: nanoid(),
name: DEFAULT_OPTION_ID_NAME,
type: ViewVariableType.String,
required: false,
},
{
key: nanoid(),
name: DEFAULT_OPTION_CONTENT_NAME,
type: ViewVariableType.String,
required: false,
},
];

View File

@@ -0,0 +1,136 @@
/*
* 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 { get } from 'lodash-es';
import { variableUtils } from '@coze-workflow/variable';
import { getDefaultLLMParams, formatModelData } from '@coze-workflow/nodes';
import { OptionType } from '@/constants/question-settings';
import {
DEFAULT_USE_RESPONSE,
DEFAULT_USER_RESPONSE_PARAM_NAME,
DEFAULT_EXTRACT_OUTPUT,
DEFAULT_ANSWER_OPTION_OUTPUT,
DEFAULT_OUTPUT_NAMES,
} from './constants';
export function transformOnInit(value, context) {
const { playgroundContext } = context;
const { variableService } = playgroundContext;
const { models } = playgroundContext;
const { inputs = {}, outputs = DEFAULT_USE_RESPONSE, nodeMeta } = value || {};
const {
inputParameters = [],
answer_type = 'text',
dynamic_option,
option_type = OptionType.Static,
extra_output,
question,
options,
limit = 3,
} = inputs;
const isAnswerTypeOption = answer_type === 'option';
const userOutput = (outputs || []).filter(
item => item.name === DEFAULT_USER_RESPONSE_PARAM_NAME,
);
const extractOutput = (outputs || []).filter(item =>
isAnswerTypeOption
? !DEFAULT_OUTPUT_NAMES.includes(item.name)
: item.name !== DEFAULT_USER_RESPONSE_PARAM_NAME,
);
let llmParam = get(value, 'inputs.llmParam');
// 初次拖入画布时:从后端返回值里,解析出来默认值。
if (!llmParam) {
llmParam = getDefaultLLMParams(models);
}
return {
llmParam,
nodeMeta,
questionOutputs: {
limit,
extra_output: isAnswerTypeOption ? false : extra_output,
userOutput: userOutput.length > 0 ? userOutput : DEFAULT_USE_RESPONSE,
extractOutput:
extractOutput.length > 0 ? extractOutput : DEFAULT_EXTRACT_OUTPUT,
optionOutput: DEFAULT_ANSWER_OPTION_OUTPUT,
},
outputs,
inputParameters: inputParameters ?? [],
questionParams: {
answer_type,
question,
options,
option_type,
dynamic_option: variableUtils.valueExpressionToVO(
dynamic_option,
variableService,
),
},
};
}
export function transformOnSubmit(value, context) {
const { playgroundContext, node } = context;
const { variableService } = playgroundContext;
const { models } = playgroundContext;
const {
llmParam,
nodeMeta,
inputParameters,
questionOutputs,
outputs,
questionParams,
} = value;
const { limit, extra_output } = questionOutputs;
const { question, answer_type, options, dynamic_option, option_type } =
questionParams;
const modelMeta = models.find(m => m.model_type === llmParam?.modelType);
return {
inputs: {
llmParam: {
...formatModelData(llmParam, modelMeta),
systemPrompt: llmParam?.systemPrompt ?? '',
},
inputParameters: inputParameters ?? [],
extra_output,
answer_type,
option_type,
dynamic_option: !dynamic_option
? null
: variableUtils.valueExpressionToDTO(dynamic_option, variableService, {
node,
}),
question,
options,
limit,
},
nodeMeta,
outputs,
};
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { syncQuestionOutputsEffect } from './sync-question-outputs';
export { syncQuestionAnswerTypeEffect } from './sync-question-answer-type';
export { syncQuestionOptionTypeEffect } from './sync-question-option-type';

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isEqual, get } from 'lodash-es';
import {
type Effect,
FlowNodeFormData,
type FormModelV2,
} from '@flowgram-adapter/free-layout-editor';
import { WorkflowNodePortsData } from '@flowgram-adapter/free-layout-editor';
import { formatOutput } from '../utils';
export const syncQuestionAnswerTypeEffect: Effect = props => {
const { value, formValues, context } = props;
const { node } = context;
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const portsData = node.getData<WorkflowNodePortsData>(WorkflowNodePortsData);
const outputs = get(formValues, 'outputs');
if (value === 'text') {
portsData.updateStaticPorts([
{
type: 'input',
},
{
type: 'output',
},
]);
} else {
portsData.updateStaticPorts([
{
type: 'input',
},
]);
}
// 表单初始化时获取不到值,需要延时一会
setTimeout(() => {
let syncOutputValue: unknown = [];
if (value === 'text') {
if (outputs) {
const questionOutputs = get(formValues, 'questionOutputs');
syncOutputValue = formatOutput(questionOutputs);
}
} else {
const optionOutput = get(formValues, 'questionOutputs.optionOutput');
syncOutputValue = optionOutput;
}
// 将 questionOutput 的值同步到 output 上
if (outputs && !isEqual(outputs, syncOutputValue)) {
formModel.setValueIn('outputs', syncOutputValue);
}
}, 200);
};

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type Effect,
FlowNodeFormData,
type FormModelV2,
} from '@flowgram-adapter/free-layout-editor';
import { OptionType } from '@/constants/question-settings';
export const syncQuestionOptionTypeEffect: Effect = props => {
const { value, context } = props;
const { node } = context;
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
if (value === OptionType.Dynamic) {
return;
}
formModel.setValueIn('questionParams.dynamic_option', null);
};

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 { get } from 'lodash-es';
import {
type Effect,
FlowNodeFormData,
type FormModelV2,
} from '@flowgram-adapter/free-layout-editor';
import { formatOutput } from '../utils';
export const syncQuestionOutputsEffect: Effect = props => {
const { value, formValues, context } = props;
const { node } = context;
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const outputs = get(formValues, 'outputs');
// 将 questionOutputs 的值同步到outputs上
if (outputs) {
formModel.setValueIn('outputs', formatOutput(value));
}
};

View File

@@ -0,0 +1,134 @@
/*
* 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 { get } from 'lodash-es';
import { I18n } from '@coze-arch/i18n';
import {
ValidateTrigger,
type FormMetaV2,
DataEvent,
type EffectOptions,
} from '@flowgram-adapter/free-layout-editor';
import { provideNodeOutputVariablesEffect } from '@/nodes-v2/materials/provide-node-output-variables';
import { nodeMetaValidate } from '@/nodes-v2/materials/node-meta-validate';
import { fireNodeTitleChange } from '@/nodes-v2/materials/fire-node-title-change';
import { createValueExpressionInputValidate } from '@/nodes-v2/materials/create-value-expression-input-validate';
import { createNodeInputNameValidate } from '@/nodes-v2/components/node-input-name/validate';
import { valueExpressionValidator } from '@/form-extensions/validators';
import { OptionType } from '@/constants/question-settings';
import { outputTreeMetaValidator } from '../common/fields/outputs';
import FormRender from './form';
import {
syncQuestionAnswerTypeEffect,
syncQuestionOptionTypeEffect,
syncQuestionOutputsEffect,
} from './effects';
import { transformOnInit, transformOnSubmit } from './data-transformer';
const questionFieldName = 'questionParams.question';
const questionOptionsFieldName = 'questionParams.options.*.name';
const questionDynamicFieldName = 'questionParams.dynamic_option';
export const QUESTION_FORM_META: FormMetaV2<FormData> = {
// 节点表单渲染
render: () => <FormRender />,
// 验证触发时机
validateTrigger: ValidateTrigger.onBlur,
// 验证规则
validate: {
nodeMeta: nodeMetaValidate,
'inputParameters.*.name': createNodeInputNameValidate({
getNames: ({ formValues }) =>
(get(formValues, 'inputParameters') || []).map(item => item.name),
}),
'inputParameters.*.input': createValueExpressionInputValidate({
required: true,
}),
[questionFieldName]: ({ value }) =>
value
? undefined
: I18n.t('workflow_detail_node_error_empty', {}, '参数值不可为空'),
[questionOptionsFieldName]: ({ value, formValues }) => {
const anwserType = get(formValues, 'questionParams.answer_type');
const optionType = get(formValues, 'questionParams.option_type');
if (anwserType !== 'option' || optionType !== OptionType.Static) {
return undefined;
}
if (!value) {
return I18n.t('workflow_ques_option_notempty', {}, '选项内容不可为空');
}
const options = get(formValues, 'questionParams.options');
return options.filter(option => option?.name === value)?.length > 1
? I18n.t('workflow_ques_ans_testrun_dulpicate', {}, '选项内容不可重复')
: undefined;
},
[questionDynamicFieldName]: ({ value, formValues, context }) => {
const { node, playgroundContext } = context;
const anwserType = get(formValues, 'questionParams.answer_type');
const optionType = get(formValues, 'questionParams.option_type');
if (anwserType !== 'option' || optionType !== OptionType.Dynamic) {
return undefined;
}
return valueExpressionValidator({
value,
playgroundContext,
node,
required: true,
});
},
'questionOutputs.extractOutput': outputTreeMetaValidator,
},
// 副作用管理
effect: {
'questionParams.answer_type': [
{
effect: syncQuestionAnswerTypeEffect,
event: DataEvent.onValueChange,
},
{
effect: syncQuestionAnswerTypeEffect,
event: DataEvent.onValueInit,
},
] as unknown as EffectOptions[],
'questionParams.option_type': [
{
effect: syncQuestionOptionTypeEffect,
event: DataEvent.onValueChange,
},
],
questionOutputs: [
{
effect: syncQuestionOutputsEffect,
event: DataEvent.onValueChange,
},
] as unknown as EffectOptions[],
nodeMeta: fireNodeTitleChange,
outputs: provideNodeOutputVariablesEffect,
},
// 节点后端数据 -> 前端表单数据
formatOnInit: transformOnInit,
// 前端表单数据 -> 节点后端数据
formatOnSubmit: transformOnSubmit,
};

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { I18n } from '@coze-arch/i18n';
import { NodeConfigForm } from '@/node-registries/common/components';
import { Section } from '@/form';
import { AnswerType } from '@/constants/question-settings';
import {
ModelSelectField,
InputsParametersField,
ExpressionEditorField,
RadioSetterField,
} from '../common/fields';
import { QuestionOutputs } from './components/question-outputs';
import { AnswerOptionField } from './components/answer-option-field';
const Render = () => (
<NodeConfigForm>
<ModelSelectField
name="llmParam"
title={I18n.t('workflow_detail_llm_model')}
/>
<InputsParametersField
name="inputParameters"
tooltip={I18n.t(
'workflow_ques_input_tooltips',
{},
'输入需要添加到问题的参数,这些参数可以被下方的问题引用',
)}
/>
<Section
title={I18n.t('workflow_ques_content', {}, '提问内容')}
tooltip={I18n.t(
'workflow_ques_content_tooltips',
{},
'用于对用户发出提问的具体内容描述',
)}
>
<div className="w-full mb-[12px]">
<ExpressionEditorField
name="questionParams.question"
dataTestName="/questionParams/question"
placeholder={I18n.t('workflow_ques_content_placeholder')}
className="!p-[4px]"
containerClassName="!bg-transparent"
/>
</div>
<Section
headerClassName="!mb-0"
title={
<div className="text-xs font-normal">
{I18n.t('workflow_ques_ans_type', {}, '请选择回答类型')}
</div>
}
noPadding
collapsible={false}
>
<RadioSetterField
name="questionParams.answer_type"
defaultValue={AnswerType.Text}
options={{
key: 'questionParams.answer_type',
mode: 'card',
direction: 'vertical',
customClassName: 'pt-[4px] gap-y-[4px]',
options: [
{
value: AnswerType.Text,
label: I18n.t('workflow_ques_ans_type_direct', {}, '直接回答'),
},
{
value: AnswerType.Option,
label: I18n.t('workflow_ques_ans_type_option', {}, '选项回答'),
},
],
}}
/>
</Section>
<AnswerOptionField
name="questionParams.options"
optionPlaceholder={I18n.t(
'workflow_ans_content_placeholder',
{},
'可以使用{{变量名}}引入输入参数中的变量',
)}
defaultOptionText={I18n.t(
'workflow_ques_ans_type_option_other_placeholder',
{},
'此选项对用户不可见,当用户回复无关内容时,走此分支',
)}
/>
</Section>
<QuestionOutputs />
</NodeConfigForm>
);
export default Render;

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.
*/
export { QUESTION_NODE_REGISTRY } from './node-registry';

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 {
DEFAULT_NODE_META_PATH,
DEFAULT_NODE_SIZE,
DEFAULT_OUTPUTS_PATH,
type WorkflowNodeRegistry,
} from '@coze-workflow/nodes';
import { StandardNodeType } from '@coze-workflow/base';
import { type NodeTestMeta } from '@/test-run-kit';
import { test } from './node-test';
import { QUESTION_FORM_META } from './form-meta';
export const QUESTION_NODE_REGISTRY: WorkflowNodeRegistry<NodeTestMeta> = {
type: StandardNodeType.Question,
meta: {
nodeDTOType: StandardNodeType.Question,
size: { width: DEFAULT_NODE_SIZE.width, height: 156.7 },
nodeMetaPath: DEFAULT_NODE_META_PATH,
outputsPath: DEFAULT_OUTPUTS_PATH,
useDynamicPort: true,
inputParametersPath: '/inputParameters',
getLLMModelIdsByNodeJSON: nodeJSON =>
nodeJSON?.data?.inputs?.llmParam?.modelType,
test,
helpLink: '/open/docs/guides/question_node',
},
formMeta: QUESTION_FORM_META,
};

View File

@@ -0,0 +1,55 @@
/*
* 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 { FlowNodeFormData } from '@flowgram-adapter/free-layout-editor';
import { type IFormSchema } from '@coze-workflow/test-run-next';
import { generateParametersToProperties } from '@/test-run-kit';
import { type NodeTestMeta } from '@/test-run-kit';
import { AnswerType, OptionType } from '@/constants/question-settings';
export const test: NodeTestMeta = {
generateFormInputProperties(node) {
const formData = node
.getData(FlowNodeFormData)
.formModel.getFormItemValueByPath('/');
const inputParameters = formData?.inputParameters;
const inputProperties = generateParametersToProperties(inputParameters, {
node,
});
const answerType = formData?.questionParams?.answer_type;
const optionType = formData?.questionParams?.option_type;
let dynamicProperties: IFormSchema['properties'] = {};
if (answerType === AnswerType.Option && optionType === OptionType.Dynamic) {
const dynamicOption = formData?.questionParams?.dynamic_option;
dynamicProperties = generateParametersToProperties(
[
{
name: 'dynamic_option',
input: dynamicOption,
},
],
{ node },
);
}
return {
...inputProperties,
...dynamicProperties,
};
},
};

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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface QuestionOutputsValue {
userOutput: Array<any>;
extractOutput: Array<any>;
extra_output: boolean;
}
export const formatOutput = (value: QuestionOutputsValue) => {
const { userOutput, extractOutput, extra_output } = value;
if (extra_output) {
return [...userOutput, ...extractOutput];
}
return userOutput;
};